لنفرض أن لديك فريقاً صغيراً من أربعة مطورين يبني نظام طلبات لمتجر إلكتروني، ونجح الموقع وبدأ العملاء يطلبون تطبيقاً على الجوال، فالمندوب يريد تطبيقاً على Android يرى فيه طلبات اليوم، والعميل يريد تطبيقاً على iPhone يتابع فيه طلبه، وصاحب المتجر يريد التطبيقين خلال ثلاثة أشهر وبنفس الفريق. وفي أول اجتماع يقول أحد المطورين إن الحل الوحيد الصحيح هو كتابة تطبيقين أصليين Native بلغة Kotlin للأول وSwift للثاني، ويرد آخر بأن هذا مضاعفة للعمل وأن Flutter يكفي، ويقول ثالث إن الفريق يكتب JavaScript أصلاً فلماذا لا يكون React Native، وكل منهم يستشهد بمقال قرأه أو بشركة كبيرة اختارت ما يقوله.
والمشكلة في هذا النقاش أن أغلبه يدور حول الانطباعات: «Flutter أثقل»، «React Native أبطأ»، «الأصيل أفضل دائماً»، دون أن يعرف أحد ماذا يعني «أثقل» بالبايت، ولا من أين يأتي البطء إن وجد. لذلك سوف نفعل في هذا المقال ما يجب أن يفعله هذا الفريق قبل أي قرار: نبني نفس التطبيق الصغير ثلاث مرات، مرة بالطريقة الأصلية Kotlin مع Jetpack Compose، ومرة بإطار العمل Flutter، ومرة بإطار العمل React Native، ثم نفتح الملف الناتج من كل واحد ونقيس حجمه وزمن بنائه وزمن إقلاعه وذاكرته، ونفهم من أين يأتي كل رقم، لأن الرقم وحده لا يكفي لاتخاذ القرار، وإنما سببه هو الذي يقول لك هل يهمك أم لا.
وسوف نناقش في هذا المقال ما يلي:
- الخيارات المتاحة لبناء تطبيق على المنصتين، من الأصيل إلى تطبيقات الويب المغلفة، باختصار قبل المقارنة.
- كيف يرسم كل خيار الشاشة من الداخل: محرك Flutter الذي يرسم كل بكسل، وبنية React Native الجديدة مع Hermes وJSI وFabric، وKotlin Multiplatform الذي يشارك المنطق ويترك الواجهة.
- تجربة عملية تبني نفس التطبيق ثلاث مرات، مع زمن البناء وحجم APK وحجم التحميل الحقيقي وزمن الإقلاع والذاكرة، والمشكلات الحقيقية التي ظهرت أثناء البناء.
- الوصول إلى ميزات النظام والجسور الأصلية Native Bridges، والفريق والتوظيف، ومخاطر ترقية إطار العمل على المدى الطويل.
- الأخطاء الشائعة، وجدول قرار وقائمة تحقق قبل أن تختار.
ما الخيارات المتاحة Options قبل أي مقارنة؟
قبل أن ندخل في الأرقام، لنضع كل الخيارات على الطاولة، لأن النقاش في أغلب الفرق يبدأ مباشرة بالمقارنة بين Flutter وReact Native وينسى أن هناك طرقاً أخرى قد تكون أنسب لحالتك. والسؤال الذي يفرق بين هذه الخيارات ليس اللغة، وإنما سؤالان: ما الذي تشاركه بين المنصتين، المنطق Business Logic فقط أم الواجهة UI أيضاً؟ ومن الذي يرسم الواجهة على الشاشة، مكونات النظام نفسه أم محرك خاص يأتي مع تطبيقك؟ والصورة التالية تبين الخيارات الخمسة على هذين المحورين:

التطوير الأصيل Native: تطبيقان بلغة Kotlin ولغة Swift
التطوير الأصيل يعني أن تكتب تطبيق Android بلغة Kotlin مع مكتبة الواجهة Jetpack Compose، وتكتب تطبيق iOS بلغة Swift مع SwiftUI، أي مشروعين منفصلين بأدوات كل منصة: Android Studio وGradle من جهة، وXcode من جهة أخرى. وهذا هو الخيار الذي تبني عليه Google وApple كل توثيقهما، فكل ميزة جديدة في النظام تظهر أولاً في هذه الأدوات، وكل مكون في الواجهة هو مكون النظام نفسه، فيتصرف كما يتوقع المستخدم تماماً في التمرير ولوحة المفاتيح وقارئ الشاشة. وثمنه واضح: كل شاشة تكتب مرتين، وكل خطأ في المنطق قد يظهر في تطبيق ولا يظهر في الآخر، وتحتاج مطورين يعرفون المنصتين أو فريقين.
إطار العمل Flutter: كود واحد بلغة Dart ومحرك يرسم كل شيء
إطار العمل Flutter من Google تكتب فيه التطبيق مرة واحدة بلغة Dart، والفكرة المميزة فيه أنه لا يستخدم مكونات الواجهة في النظام أبداً، وإنما يأتي بمحرك رسم خاص داخل تطبيقك يرسم كل زر وكل نص وكل قائمة بنفسه على مساحة رسم Canvas يعطيها له النظام، تماماً كما تفعل محركات الألعاب. وهذا يعطيك واجهة متطابقة على المنصتين وتحكماً كاملاً في كل بكسل، ولكنه يعني أيضاً أن المحرك نفسه يسافر مع كل نسخة من تطبيقك، وسوف نرى بعد قليل كم يكلف ذلك بالميغابايت.
إطار العمل React Native: JavaScript يتحكم في مكونات النظام
إطار العمل React Native من Meta تكتب فيه التطبيق بلغة JavaScript أو TypeScript وبأسلوب مكتبة React المعروفة في الويب، ولكن الفرق الجوهري عن Flutter أن الواجهة النهائية مكونة من مكونات النظام الحقيقية: الكود يقول «أريد نصاً هنا وزراً هناك»، والإطار ينشئ View حقيقياً في Android وUIView حقيقياً في iOS. وهذا الخيار جذاب لفريق مثل فريقنا لأنه يكتب الويب أصلاً، فيستفيد من خبرته ومن مكتبات npm، وثمنه أنك تحمل محرك JavaScript داخل التطبيق، وأن كل حديث بين كودك ومكونات النظام يمر عبر طبقة وسيطة.
إطار Kotlin Multiplatform: تشارك المنطق وتترك الواجهة لكل منصة
وخيار رابع يقع بين الأصيل ومتعدد المنصات هو Kotlin Multiplatform واختصاراً KMP من JetBrains، وفيه تكتب طبقة المنطق مرة واحدة بلغة Kotlin: نماذج البيانات، والاتصال بال API، والتخزين المحلي، وقواعد التحقق Validation، ثم يترجمها المترجم إلى كود JVM لتطبيق Android وإلى مكتبة أصلية Native Framework يستخدمها تطبيق iOS من Swift. وتبقى الواجهة أصلية في كل منصة، أو تشاركها أيضاً عبر Compose Multiplatform الذي يرسم الواجهة بنفسه على iOS بطريقة تشبه Flutter. وهذا الخيار يناسب الفريق الذي يملك تطبيق Android بلغة Kotlin ويريد أن يضيف iOS دون أن يعيد كتابة المنطق، وتذكر Google في صفحة Kotlin Multiplatform على Android أنها تدعمه رسمياً لمشاركة المنطق.
التطبيقات المغلفة Capacitor وتطبيقات الويب التقدمية PWA
والخيار الخامس هو أن تأخذ موقعك الحالي وتضعه داخل تطبيق، إما بأداة مثل Capacitor التي تشغل صفحاتك داخل WebView وتعطيها جسراً للكاميرا والإشعارات وغيرها، أو دون متجر أصلاً عبر تطبيقات الويب التقدمية PWA التي يثبتها المستخدم من المتصفح. وهذا أرخص الخيارات لفريق يملك موقعاً جيداً، ولكنه الأضعف في الإحساس بأنه تطبيق حقيقي، وتذكر أن إرشادات مراجعة Apple ترفض صراحة التطبيق الذي لا يزيد عن موقع معاد تغليفه، وهذا ما سوف نفصله في مقال النشر في المتاجر من هذه المرحلة. ولذلك سوف نركز في التجربة على الخيارات الثلاثة التي يدور حولها نقاش فريقنا: الأصيل وFlutter وReact Native، ونعود لKMP وتطبيقات الويب في جدول القرار.
كيف يرسم كل إطار عمل Framework الشاشة من الداخل؟
أغلب الفروق التي سوف نقيسها في التجربة، من الحجم إلى الذاكرة إلى زمن الإقلاع، تأتي من إجابة سؤال واحد: ماذا يحدث بين السطر الذي تكتبه وبين البكسل الذي يظهر على الشاشة؟ ولذلك سوف نتتبع هذا المسار في كل خيار قبل أن نفتح أي ملف، والصورة التالية تضع المسارات الثلاثة جنباً إلى جنب:

المسار الأصيل: Compose يتحدث مع نظام الرسم Rendering مباشرة
في التطبيق الأصيل يترجم كود Kotlin إلى Bytecode ثم إلى ملف classes.dex الذي تشغله بيئة Android Runtime واختصاراً ART، وعندما تكتب Text("طلباتي") في Compose فإن المكتبة تحسب مكان النص وحجمه ثم تطلب من نظام الرسم في Android أن يرسمه، ونظام الرسم موجود أصلاً في كل جهاز. لاحظ أن Compose نفسها مكتبة تأتي داخل تطبيقك وليست جزءاً من النظام، فهي ليست مجانية بالكامل في الحجم، ولكنها كود Kotlin عادي يستطيع المصغر R8 أن يحذف منه كل ما لا تستخدمه، وهذه النقطة تحديداً هي سبب الفرق الكبير في الحجم كما سيأتي.
محرك Flutter Engine: لغة Dart المترجمة مسبقاً AOT ومحرك الرسم Impeller
في Flutter يحدث شيء مختلف تماماً، فكود Dart في نسخة الإصدار Release لا يعمل على آلة افتراضية تفسره، وإنما يترجمه المترجم مسبقاً Ahead-of-Time واختصاراً AOT إلى كود آلة حقيقي للمعالج، ويضعه في مكتبة اسمها libapp.so، وهي كودك أنت مع أجزاء إطار العمل التي استخدمتها. وبجوارها مكتبة ثانية اسمها libflutter.so وهي المحرك نفسه: تخطيط النصوص، ومعالجة اللمس، ومحرك الرسم. ومحرك الرسم الحالي اسمه Impeller، ويذكر توثيقه أنه أصبح المحرك الافتراضي في iOS وفي Android من الإصدار 29 فما فوق منذ Flutter 3.27، وأنه يعتمد على واجهة Vulkan في Android، فإذا لم يدعمها الجهاز يرجع إلى محرك OpenGL القديم دون أي تدخل منك. وهذه النقطة سوف تهمنا عندما نقيس زمن الإقلاع على المحاكي Emulator، لأن المحاكي في تجربتنا يرسم بمعالج رسومي برمجي وليس بمعالج رسومي حقيقي.
والنتيجة أن تطبيق Flutter يحمل معه محركاً كاملاً لكل معمارية معالج Architecture يدعمها، ويطلب من النظام مساحة رسم واحدة فقط، ثم يرسم عليها كل شيء بنفسه. ومن هنا جاءت قوته في تطابق الواجهة بين المنصتين، ومن هنا أيضاً جاء أنه يحتاج أن يعيد بنفسه ما يقدمه النظام مجاناً للتطبيقات الأصلية، مثل الاتجاه من اليمين لليسار وقارئ الشاشة، وهي أمور يقدمها Flutter فعلاً ولكن على مسؤوليته هو وليس مسؤولية النظام.
البنية الجديدة New Architecture في React Native: محرك Hermes ثم JSI ثم Fabric
في React Native يكتب الفريق JavaScript، وفي نسخة الإصدار يحول الإطار هذا الكود إلى ملف واحد اسمه index.android.bundle، ثم يحوله إلى Bytecode جاهز لمحرك Hermes، وهو محرك JavaScript بنته Meta خصيصاً للجوال، ويذكر توثيقه أنه الافتراضي في React Native وأنه يحسن زمن الإقلاع والذاكرة مقارنة بمحرك JavaScriptCore. وفي البنية القديمة كان كود JavaScript يتحدث مع كود Android عبر جسر Bridge غير متزامن يحول كل رسالة إلى JSON ثم يعيد قراءتها في الطرف الآخر، وهذا الجسر كان مصدر أغلب شكاوى البطء.
والبنية الجديدة التي يقول توثيق البنية إنها أصبحت مفعلة افتراضياً في كل المشاريع منذ الإصدار 0.76 أزالت هذا الجسر، ووضعت مكانه واجهة JavaScript Interface واختصاراً JSI، وهي واجهة بلغة C++ تسمح لكود JavaScript أن يحمل مرجعاً لكائن C++ ويستدعي دواله مباشرة دون تحويل إلى JSON. وفوق JSI يعمل محرك العرض Fabric الذي يحسب شجرة الواجهة بلغة C++ وينشئ منها مكونات النظام الحقيقية، ووحدات Turbo Native Modules التي تعطي JavaScript وصولاً لميزات النظام مثل الكاميرا والتخزين. والنتيجة أن تطبيق React Native يحمل محرك Hermes ومكتبة libreactnative.so الكبيرة لكل معمارية معالج، بالإضافة إلى كود Java/Kotlin للمكونات، ثم ملف JavaScript الخاص بك.
ماذا يشارك Kotlin Multiplatform فعلاً؟
في KMP لا يوجد محرك يسافر مع التطبيق بالمعنى السابق، فطبقة المنطق المشتركة تترجم في Android إلى نفس كود JVM الذي يكتبه أي مطور Kotlin، فلا يختلف التطبيق عن الأصيل، وفي iOS تترجم عبر Kotlin/Native إلى كود آلة في Framework عادي يستدعيه Swift كأي مكتبة. ولذلك فإن تكلفة KMP ليست في الحجم وإنما في الفريق: يجب أن يعرف أحد ما Kotlin جيداً، وأن يتعامل مطورو iOS مع واجهة مولدة من Kotlin، وأن يبني الفريق خط بناء Build Pipeline يجمع Gradle وXcode معاً. ولم نشمله في التجربة لأن تطبيقنا الصغير لا منطق فيه يستحق المشاركة، وأي رقم نقيسه لتطبيق Android منه سيكون نفس رقم التطبيق الأصيل.
تجربة عملية Lab: نفس التطبيق بثلاث طرق
الآن سوف نبني نفس الشاشة ثلاث مرات ونقيسها. والشاشة بسيطة عمداً حتى لا تؤثر كمية الكود على المقارنة: عنوان «طلباتي»، وزر «تحديث» يزيد عداداً، وقائمة من 200 طلب يمكن تمريرها، وكل سطر فيها «طلب رقم N: X ريال». وهذا يعني أن الأرقام التي سوف نراها هي في أغلبها تكلفة إطار العمل نفسه وليست تكلفة كودنا، وهو بالضبط ما يحتاج فريقنا أن يعرفه: كم يدفع قبل أن يكتب أول شاشة حقيقية.
التطبيق Sample App والإصدارات Versions التي استخدمناها
بنينا التطبيقات الثلاثة على Windows 11 بنفس الجهاز ونفس Android SDK ونفس JDK، وهو JetBrains Runtime 21.0.5 الذي يأتي مع Android Studio:
| التطبيق | الإصدارات | إعدادات الإصدار Release |
|---|---|---|
| الأصيل Native | Kotlin مع Jetpack Compose من Compose BOM 2026.09.00، وAndroid Gradle Plugin 9.4.1 وGradle 9.8.2 | R8 مفعل مع حذف الموارد غير المستخدمة، minSdk 26 وtargetSdk 36 |
| Flutter | Flutter 3.41.1 القناة المستقرة، وDart 3.11.0 | الإعدادات الافتراضية لأمر flutter build، ثلاث معماريات |
| React Native | React Native 0.88.1 مع React 19.3 وHermes والبنية الجديدة، وأداة السطر 20.2.0 | إعدادات القالب كما هي: R8 معطل، وأربع معماريات |
والكود الكامل للتطبيقات الثلاثة موجود في مجلد ال lab مع المقال، وهذا هو قلب النسخة الأصلية بلغة Kotlin حتى ترى حجم ما نقارنه:
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val orders = (1..200).map { Order(it, 25 + it * 3) }
setContent {
MaterialTheme {
var refreshed by remember { mutableIntStateOf(0) }
Column(Modifier.fillMaxSize().padding(16.dp)) {
Text("طلباتي", style = MaterialTheme.typography.headlineMedium)
Button(onClick = { refreshed++ }) { Text("تحديث ($refreshed)") }
LazyColumn {
items(orders) { o ->
Text("طلب رقم ${o.id}: ${o.total} ريال", Modifier.padding(vertical = 8.dp))
}
}
}
}
}
}
}والنسخة بلغة Dart تستخدم ListView.builder لنفس القائمة، ونسخة React Native تستخدم FlatList، وكلاهما يرسم فقط الأسطر الظاهرة على الشاشة كما تفعل LazyColumn، فالمقارنة عادلة من هذه الجهة. أما إنشاء المشروعين الآخرين فكان بالأوامر الرسمية لكل إطار عمل:
flutter create --org io.example --project-name orders_flutter --platforms android flutter_app
npx @react-native-community/[email protected] init OrdersRN --version 0.88.1 --pm npmمشكلات حقيقية Real Problems قبل أول بناء ناجح Build
قبل أن نصل لأي رقم واجهتنا ثلاث مشكلات، ونذكرها هنا لأن كل فريق سوف يقابل مثلها في أول أسبوع، وهي جزء من تكلفة كل خيار لا تظهر في المقارنات المنشورة.
المشكلة الأولى كانت في التطبيق الأصيل نفسه، فأول بناء بإصدار Compose BOM 2026.09.00 مع compileSdk = 36 فشل في مرحلة فحص بيانات المكتبات، والرسالة كانت كما يلي:
Execution failed for task ':app:checkReleaseAarMetadata'
> 9 issues were found when checking AAR metadata:
1. Dependency 'androidx.compose.material:material-ripple-android:1.12.1' requires libraries and applications that
depend on it to compile against version 37 or later of the
Android APIs.
:app is currently compiled against android-36.والمعنى أن مكتبات Compose الحديثة تفترض أنك تترجم مقابل أحدث واجهة برمجية API من Android، فإذا كان SDK عندك قديماً بإصدار واحد فلن يبني المشروع. والحل كان تثبيت المنصة android-37.2 ثم كتابة compileSdk { version = release(37) { minorApiLevel = 2 } } في ملف البناء، ولاحظ أن الرسالة نفسها تشرح أن رفع compileSdk لا يغير سلوك التطبيق على الأجهزة، لأن ذلك يتحكم فيه targetSdk وليس compileSdk. والدرس هنا أن الأصيل ليس معفى من مشكلات الترقية، فمكتبات Jetpack تتحرك بسرعة أيضاً.
والمشكلة الثانية كانت في React Native على Windows، فأول بناء استمر قرابة سبع دقائق ثم فشل في مرحلة ترجمة كود C++ بهذه الرسالة:
ninja: error: rebuilding 'build.ninja': Stat(C:/Users/.../OrdersRN/android/app/.cxx/RelWithDebInfo/5o5o1052/prefab/arm64-v8a/prefab/lib/aarch64-linux-android/cmake/ReactAndroid/ReactAndroidConfig.cmake): Filename longer than 260 charactersوالسبب أن البنية الجديدة في React Native تترجم كود C++ لكل معمارية معالج عبر CMake وNinja، وهذه الأدوات تنشئ مسارات عميقة جداً، وWindows يحد طول المسار افتراضياً ب 260 حرفاً كما يشرح توثيق Microsoft عن حد طول المسار. وكان مشروعنا داخل مجلد مؤقت طويل المسار، فالحل كان نقل المشروع إلى مسار قصير مثل C:\smb\rn وحذف مجلد android/app/.cxx ثم البناء من جديد. ولاحظ أن هذه المشكلة لا تظهر في Flutter ولا في الأصيل لأنهما لا يترجمان كود C++ في مشروعك أصلاً، فالمحرك يأتي مترجماً جاهزاً.
والمشكلة الثالثة هي وزن الأدوات في أول مرة، فأول بناء Flutter استغرق 8 دقائق و13 ثانية لأن Gradle نزل إصداره ومكتباته، وأمر إنشاء مشروع React Native استغرق 7 دقائق و31 ثانية أغلبها في تنزيل مجلد node_modules الذي وصل إلى 296 MB قبل أي بناء Android. وهذه أرقام تدفعها مرة على كل جهاز جديد وكل خادم بناء CI جديد، فإذا كان خط البناء عندك لا يحتفظ بذاكرة مؤقتة Cache بين التشغيلات فسوف تدفعها في كل مرة.
زمن البناء Build Time بعد أن تصبح الأدوات جاهزة
بعد أن نزلت كل الأدوات، قسنا زمن بناء نسخة الإصدار ثلاث مرات لكل خيار: بناء نظيف Clean Build بعد حذف ملفات البناء السابقة، ثم بناء ثان دون أي تغيير، ثم بناء حزمة التطبيق App Bundle التي ترفعها إلى Google Play. والسكربت الكامل في مجلد ال lab، وهذا جزء التطبيق الأصيل منه، والجزءان الآخران بنفس الشكل مع flutter build و./gradlew داخل مجلد android في React Native:
t() { local s=$(date +%s%N); "$@" > /dev/null 2>&1; local rc=$?; echo "$(( ($(date +%s%N) - s)/1000000 )) ms rc=$rc"; }
cd native
./gradlew -q clean
echo "clean assembleRelease: $(t ./gradlew -q assembleRelease)"
echo "no-change assembleRelease: $(t ./gradlew -q assembleRelease)"
echo "bundleRelease: $(t ./gradlew -q bundleRelease)"والمخرج سوف يكون كما يلي، وقد شغلناه والمحاكي مغلق ولا شيء آخر يعمل على الجهاز:
== native
clean assembleRelease: 120869 ms rc=0
no-change assembleRelease: 9601 ms rc=0
bundleRelease: 10035 ms rc=0
== flutter
clean build apk: 105251 ms rc=0
no-change build apk: 15722 ms rc=0
build appbundle: 13825 ms rc=1
== rn
clean assembleRelease: 209558 ms rc=0
no-change assembleRelease: 7368 ms rc=0
bundleRelease: 10167 ms rc=0في المخرج أعلاه لاحظ التالي:
- البناء النظيف في الأصيل أخذ دقيقتين تقريباً، وأغلب هذا الوقت في المصغر R8 الذي يحلل كل مكتبات Compose ليحذف منها ما لا نستخدمه، وهذا عمل لا يفعله القالب الافتراضي في React Native.
- Flutter كان الأسرع في البناء النظيف بحوالي 105 ثوان، والسبب أن المحرك يأتي مترجماً جاهزاً، فالعمل الفعلي هو ترجمة كود Dart إلى
libapp.soلثلاث معماريات وتجميع ملف APK. - React Native كان الأبطأ في البناء النظيف بحوالي 210 ثوان، لأنه يترجم كود C++ الخاص بالبنية الجديدة والوحدات المولدة لأربع معماريات عبر CMake، ويجمع ملف JavaScript ويحوله إلى Bytecode لمحرك Hermes.
- البناء الثاني دون تغيير كان بين 7 و16 ثانية في الثلاثة، وهو الرقم الذي يعيشه المطور طوال اليوم أكثر من البناء النظيف، ولاحظ أن React Native كان الأسرع فيه لأن Gradle يتعرف على أن كل مخرجات C++ لم تتغير.
- السطر
build appbundleفي Flutter انتهى بالرمزrc=1أي بفشل، مع أن الملف موجود، وسوف نشرح السبب في قسم مستقل بعد قليل لأنه مثال جيد على رسالة تخدعك.
وقبل هذا التشغيل كنا قد شغلنا نفس السكربت والمحاكي يعمل وتجارب أخرى تبني في نفس الوقت، فكان البناء النظيف 191 ثانية للأصيل و152 ثانية لFlutter و282 ثانية لReact Native، أي أن الضغط Load على الجهاز وحده غير الأرقام بين 35% و60%. ولذلك لا تقارن أبداً زمن بناء قسته على جهازك بزمن قرأته في مقال، ولا تقارن خيارين قستهما في ظروف مختلفة، وإنما قس الخيارات كلها على نفس الجهاز في نفس الحالة، وكرر القياس إذا كان القرار يعتمد عليه.

ماذا يوجد داخل ملف APK؟ الحجم Size بالبايت
بعد البناء كان حجم ملف APK الشامل Universal APK، وهو الملف الواحد الذي يعمل على أي جهاز، كما يلي: 927,869 بايت للأصيل، و42,887,570 بايت لFlutter، و55,481,825 بايت لReact Native. أي أن نفس الشاشة البسيطة كانت أقل من 1 MB في الأصيل، وحوالي 43 MB في Flutter، وحوالي 55 MB في React Native، وهذا الفرق الضخم هو أول ما يستشهد به من يقول إن متعدد المنصات «ثقيل». ولكن قبل أن نحكم لنفتح الملفات، لأن ملف APK ما هو إلا ملف ZIP، والأمر unzip -l يعرض ما بداخله مع حجم كل ملف:
unzip -l app-release.apk | sort -k1 -n -r | headوهذه أكبر الملفات في تطبيق Flutter:
## flutter fat APK (3 ABIs)
43303050 68 files
12563376 1981-01-01 01:01 lib/x86_64/libflutter.so
11316480 1981-01-01 01:01 lib/arm64-v8a/libflutter.so
8275708 1981-01-01 01:01 lib/armeabi-v7a/libflutter.so
3588700 1981-01-01 01:01 lib/armeabi-v7a/libapp.so
3277744 1981-01-01 01:01 lib/x86_64/libapp.so
3277744 1981-01-01 01:01 lib/arm64-v8a/libapp.so
525992 1981-01-01 01:01 classes.dex
257628 1981-01-01 01:01 assets/flutter_assets/packages/cupertino_icons/assets/CupertinoIcons.ttf
104646 1981-01-01 01:01 assets/flutter_assets/NOTICES.Zوفي المخرج أعلاه لاحظ التالي:
- المحرك
libflutter.soموجود ثلاث مرات، مرة لكل معمارية معالج ABI: معالجات ARM بنظام 64 بت في أغلب الهواتف الحاليةarm64-v8a، ومعالجات ARM القديمة بنظام 32 بتarmeabi-v7a، ومعالجات Intel بنظام 64 بتx86_64التي تستخدمها المحاكيات وبعض الأجهزة اللوحية. وكل نسخة بين 8 و12.5 MB. - الملف
libapp.soهو كودنا مع أجزاء إطار العمل التي استخدمناها مترجمة مسبقاً إلى كود آلة، وحجمه حوالي 3.3 MB لكل معمارية لشاشة واحدة، لأنه يشمل مكتبة Material وطبقة الواجهات Widgets كلها وليس سطورنا فقط. - ملف
classes.dexهنا صغير، حوالي 0.5 MB، لأن كود Android في تطبيق Flutter مجرد غلاف يشغل المحرك. - العمود الأول يقول إن مجموع الملفات قبل الضغط 43,303,050 بايت والملف النهائي 42,887,570، أي أن الضغط لم يوفر شيئاً تقريباً، والسبب أن Android يفضل أن تكون مكتبات
.soمخزنة دون ضغط داخل APK حتى يقرأها النظام مباشرة من الملف دون فكها.
وهذه أكبر الملفات في تطبيق React Native:
## react native APK (4 ABIs)
62697861 515 files
9958052 1981-01-01 01:01 classes.dex
7536760 1981-01-01 01:01 lib/x86/libreactnative.so
7257672 1981-01-01 01:01 lib/x86_64/libreactnative.so
7048592 1981-01-01 01:01 lib/arm64-v8a/libreactnative.so
4980904 1981-01-01 01:01 lib/armeabi-v7a/libreactnative.so
3270440 1981-01-01 01:01 lib/x86/libhermesvm.so
2832176 1981-01-01 01:01 lib/x86_64/libhermesvm.so
2705536 1981-01-01 01:01 lib/arm64-v8a/libhermesvm.so
1883936 1981-01-01 01:01 lib/armeabi-v7a/libhermesvm.so
1694012 1981-01-01 01:01 classes2.dex
1292904 1981-01-01 01:01 lib/arm64-v8a/libc++_shared.so
1254988 1981-01-01 01:01 lib/x86/libc++_shared.so
1252080 1981-01-01 01:01 lib/x86_64/libc++_shared.so
1006112 1981-01-01 01:01 assets/index.android.bundle
872872 1981-01-01 01:01 lib/armeabi-v7a/libc++_shared.soوهنا الصورة مختلفة في ثلاث نقاط:
- القالب يبني لأربع معماريات وليس ثلاثاً، فهناك أيضاً
x86بنظام 32 بت، وهي معمارية لا يكاد يستخدمها أي هاتف حالي، ولكنها تضيف نسخة رابعة من كل مكتبة. - لكل معمارية ثلاث مكتبات كبيرة:
libreactnative.soوهي البنية الجديدة كلها بلغة C++ بين 5 و7.5 MB، ومحركlibhermesvm.soبين 1.9 و3.3 MB، ومكتبة C++ القياسيةlibc++_shared.soحوالي 1.3 MB، بالإضافة إلى عدد كبير من المكتبات الصغيرة، ولذلك وصل عدد الملفات إلى 515 ملفاً. - ملف
classes.dexحوالي 10 MB ومعهclasses2.dexحوالي 1.7 MB، وهذا رقم كبير جداً لشاشة واحدة، والسبب أن القالب الافتراضي لا يشغل المصغر R8، فكل كود Java/Kotlin في React Native ومكتباته يدخل كما هو. وكودنا الفعلي بلغة JavaScript هوindex.android.bundleبحجم 1 MB فقط.
أما التطبيق الأصيل فأكبر ما فيه هو هذا:
## native compose APK (release, R8 on, unsigned)
1678782 77 files
1457828 1981-01-01 01:01 classes.dex
69156 1981-01-01 01:01 resources.arsc
29399 1981-01-01 01:01 kotlin/kotlin.kotlin_builtins
10760 1981-01-01 01:01 lib/x86_64/libandroidx.graphics.path.soملف classes.dex واحد بحجم 1.46 MB قبل الضغط فيه Compose وMaterial 3 وكودنا بعد أن حذف R8 كل ما لا نستخدمه، ولا توجد مكتبات أصلية كبيرة لأن نظام الرسم موجود في الجهاز أصلاً. والمكتبة الصغيرة libandroidx.graphics.path.so بحجم 10 KB تأتي من إحدى مكتبات androidx الرسومية، وهي الكود الأصلي الوحيد في التطبيق.
والخلاصة من هذه القوائم أن الفرق في الحجم ليس سببه أن كود Flutter أو React Native أكبر من كود Kotlin، وإنما أن كلاً منهما يحمل منصة تشغيل كاملة داخل التطبيق، مرة لكل معمارية معالج. والصورة التالية تبين من أين يأتي حجم كل تطبيق:

حجم التحميل Download Size الحقيقي مع حزمة App Bundle
ولكن المستخدم لا يحمل الملف الشامل أصلاً، فعند النشر في Google Play ترفع حزمة Android App Bundle بامتداد .aab، ثم يقسمها المتجر ويرسل لكل جهاز فقط ما يحتاجه: معماريته، وكثافة شاشته، ولغته. ويذكر توثيق حجم تطبيقات Flutter صراحة أن حجم ملف flutter build apk لا يمثل حجم التحميل عند المستخدم لهذا السبب. ولكي نعرف الرقم الحقيقي استخدمنا أداة bundletool الرسمية بالإصدار 1.18.3، وهي نفس المنطق الذي يقسم به المتجر الحزمة، مع وصف لجهاز نموذجي: معالج arm64 ولغة عربية وكثافة 420 وAndroid 15:
cat > arm64.json <<'EOF'
{ "supportedAbis": ["arm64-v8a"], "supportedLocales": ["ar"], "screenDensity": 420, "sdkVersion": 35 }
EOF
java -jar bundletool.jar build-apks --bundle=app-release.aab --output=app.apks
java -jar bundletool.jar get-size total --apks=app.apks --device-spec=arm64.jsonوالمخرج للتطبيقات الثلاثة:
native aab bytes: 2247031
native arm64 device: 779840,779840
flutter aab bytes: 38829608
flutter arm64 device: 7083929,7083929
rn aab bytes: 39061116
rn arm64 device: 9530121,9530121في المخرج أعلاه لاحظ التالي:
- الأمر
get-sizeيطبع رقمين هما أقل حجم وأكبر حجم للتحميل، وهما متساويان هنا لأننا حددنا جهازاً واحداً بالضبط. - الهاتف النموذجي يحمل 0.78 MB من التطبيق الأصيل، و7.1 MB من Flutter، و9.5 MB من React Native. أي أن الفرق الحقيقي عند المستخدم ليس 43 MB مقابل 1 MB، وإنما حوالي 6 إلى 9 MB إضافية يدفعها مرة واحدة عند التثبيت.
- حزمة
.aabنفسها أكبر من حجم التحميل بكثير، فحزمة Flutter 38.8 MB، وحزمة React Native 39 MB، والسبب أنها تحتوي كل المعماريات، وفي Flutter تحتوي أيضاً ملفات رموز التصحيح Debug Symbols التي سوف نراها في القسم التالي.
وهل 7 أو 9 MB رقم يهم؟ يعتمد على جمهورك. إذا كان عملاؤك على باقات بيانات محدودة أو على هواتف بمساحة تخزين صغيرة، فكل ميغابايت يقلل نسبة من يكمل التثبيت، وتجد تفصيل أثر الحجم وطرق تقليله في سؤال لماذا يهم حجم التطبيق من مقال أسئلة المقابلة. أما إذا كان التطبيق داخلياً لمندوبين على أجهزة الشركة، فهذا الفرق لا يغير أي قرار.
لماذا فشل أمر flutter build appbundle مع أن الملف موجود؟
عودة إلى السطر rc=1 في زمن البناء، فعند تشغيل الأمر مباشرة ظهرت هذه الرسالة:
Running Gradle task 'bundleRelease'... 2,976ms
Release app bundle failed to strip debug symbols from native libraries.
Please run flutter doctor and ensure that the Android toolchain does not
report any issues.والرسالة توحي بأن مكتبات .so داخل الحزمة ما زالت تحمل رموز التصحيح، وأن الحزمة أكبر مما يجب. وعند تشغيل flutter doctor -v كان السطر المهم هو ✗ cmdline-tools component is missing، أي أن مجلد أدوات السطر في Android SDK غير مثبت، وFlutter يستخدم إحدى أدواته ليتحقق من الحزمة بعد بنائها، فلما لم يجدها اعتبر التحقق فاشلاً. ولكي نعرف هل المشكلة حقيقية فتحنا الحزمة نفسها:
18072976 1981-01-01 01:01 BUNDLE-METADATA/com.android.tools.build.debugsymbols/arm64-v8a/libflutter.so.sym
17176264 1981-01-01 01:01 BUNDLE-METADATA/com.android.tools.build.debugsymbols/x86_64/libflutter.so.sym
13325340 1981-01-01 01:01 BUNDLE-METADATA/com.android.tools.build.debugsymbols/armeabi-v7a/libflutter.so.sym
12563376 1981-01-01 01:01 base/lib/x86_64/libflutter.so
11316480 1981-01-01 01:01 base/lib/arm64-v8a/libflutter.so
8275708 1981-01-01 01:01 base/lib/armeabi-v7a/libflutter.so
3688550 1981-01-01 01:01 BUNDLE-METADATA/com.android.tools.build.obfuscation/proguard.mapولاحظ هنا أن مكتبة libflutter.so في مجلد base/lib بنفس حجمها في ملف APK بالضبط، 11,316,480 بايت لمعمارية arm64، أي أنها مجردة من الرموز فعلاً. أما الملفات الكبيرة بامتداد .sym فهي رموز التصحيح موضوعة عمداً في مجلد BUNDLE-METADATA، وهذا المجلد لا يصل لأي جهاز، وإنما يستخدمه Google Play ليحول عناوين الذاكرة في تقارير الأعطال إلى أسماء دوال، وهي نفس فكرة ملف proguard.map الذي بجوارها لكود Java. وهذا يفسر لماذا حجم الحزمة 38.8 MB بينما التحميل 7.1 MB. فالرسالة كانت إنذاراً كاذباً سببه أداة ناقصة، والحل الصحيح هو تثبيت أدوات السطر cmdline-tools في SDK حتى يكتمل التحقق، وليس تجاهل الرمز rc=1 في خط البناء، لأن نفس الرمز سيظهر يوماً لمشكلة حقيقية. وإذا أردت معرفة كيف تستخدم رموز التصحيح وملف ال Mapping في قراءة الأعطال، فقد شرحنا ذلك في مقال تقارير الأعطال Crash Reporting.
ماذا يحدث إذا فعلنا المصغر R8 في React Native؟
رأينا أن ملفات dex في React Native حوالي 11.7 MB لأن القالب لا يشغل المصغر R8، والتوثيق الرسمي في صفحة نشر تطبيق Android يذكر أن تفعيله اختياري، ويحذر أنه يحتاج اختباراً جيداً وقد يحتاج قواعد خاصة لبعض المكتبات. فماذا يحدث إذا فعلناه؟ غيرنا سطراً واحداً في android/app/build.gradle ثم بنينا مرتين، مرة بالمعماريات الأربع، ومرة لمعمارية arm64 فقط:
sed -i 's/def enableProguardInReleaseBuilds = false/def enableProguardInReleaseBuilds = true/' app/build.gradle
./gradlew -q assembleRelease
./gradlew -q assembleRelease -PreactNativeArchitectures=arm64-v8aوالمخرج كان كما يلي:
R8 on, 4 ABIs: rc=0 99 s
apk bytes: 52319418
2188284 1981-01-01 01:01 classes.dex
R8 on, arm64 only: rc=0 15 s
apk bytes: 15768111- مع R8 أصبح ملف
dexواحداً بحجم 2.19 MB بدلاً من 11.65 MB في ملفين، أي أن R8 حذف حوالي 80% من كود Java/Kotlin الذي لا يستخدمه تطبيقنا. - الملف الشامل نزل من 55.5 MB إلى 52.3 MB فقط، والسبب أن أغلب الحجم في المكتبات الأصلية لأربع معماريات، وR8 لا يلمس كود C++ أبداً.
- عندما بنينا لمعمارية arm64 وحدها نزل الملف إلى 15.8 MB، وهذا أقرب لما يحمله المستخدم من المتجر، والبناء نفسه أخذ 15 ثانية فقط لأن مخرجات C++ لهذه المعمارية كانت جاهزة من البناء السابق.
والدرس هنا أن إعدادات القالب الافتراضية ليست إعدادات الإنتاج، فلا تقارن خياراً بإعداداته الافتراضية بخيار آخر بعد ضبطه. وفي الإنتاج فعل R8 ثم شغل كل الاختبارات، وسوف تجد في مقال اختبار تطبيقات الجوال مع Appium وMaestro كيف تختبر نسخة الإصدار نفسها وليس نسخة التطوير، لأن R8 يحذف أحياناً كوداً تستخدمه مكتبة عبر Reflection فيظهر العطل في نسخة الإصدار فقط.
زمن الإقلاع Startup Time والذاكرة Memory على المحاكي Emulator
بعد الحجم ننتقل إلى ما يشعر به المستخدم مباشرة: كم يأخذ التطبيق ليفتح، وكم يستهلك من الذاكرة. ثبتنا التطبيقات الثلاثة على محاكي Android 15 بمعالج x86_64، وشغلنا كل واحد خمس مرات تشغيلاً بارداً Cold Start، أي بعد إيقاف عملية التطبيق تماماً، ثم قسنا الذاكرة بعد ثماني ثوان من فتحه، ثم أرسلناه للخلفية وأعدناه. والأوامر الأساسية هي أوامر adb المعروفة:
adb shell am force-stop io.example.orders.native
adb shell am start -W -n io.example.orders.native/io.example.orders.MainActivity | grep TotalTime
adb shell dumpsys meminfo io.example.orders.native | grep -E "TOTAL PSS|Native Heap:|Java Heap:|Code:"والأمر am start -W ينتظر حتى يرسم التطبيق أول إطار ويطبع TotalTime، وهو الزمن من طلب التشغيل حتى ظهور أول إطار، وهو نفس المقياس الذي يشرحه توثيق زمن إقلاع التطبيق. والمخرج كان كما يلي:
== io.example.orders.native (io.example.orders.MainActivity)
cold 1: 1202 ms
cold 2: 1039 ms
cold 3: 1083 ms
cold 4: 1494 ms
cold 5: 1175 ms
Java Heap: 3372 28044
Native Heap: 8452 9452
Code: 1708 81680
TOTAL PSS: 20478 TOTAL RSS: 123724 TOTAL SWAP PSS: 479
from background: LaunchState: HOT TotalTime: 389
== io.example.orders_flutter (io.example.orders_flutter.MainActivity)
cold 1: 2744 ms
cold 2: 3105 ms
cold 3: 2392 ms
cold 4: 3106 ms
cold 5: 5127 ms
Java Heap: 1500 26152
Native Heap: 24304 25312
Code: 23132 99388
TOTAL PSS: 64571 TOTAL RSS: 164248 TOTAL SWAP PSS: 506
from background: LaunchState: HOT TotalTime: 421
== com.ordersrn (com.ordersrn.MainActivity)
cold 1: 1116 ms
cold 2: 924 ms
cold 3: 922 ms
cold 4: 2052 ms
cold 5: 2151 ms
Java Heap: 11600 36196
Native Heap: 18964 19956
Code: 21772 105184
TOTAL PSS: 66727 TOTAL RSS: 172816 TOTAL SWAP PSS: 506
from background: LaunchState: HOT TotalTime: 363في المخرج أعلاه لاحظ التالي:
- التشغيل البارد للأصيل بين 1039 و1494 ms، ولReact Native بين 922 و2151 ms، ولFlutter بين 2392 و5127 ms. أي أن React Native كان في بعض المحاولات أسرع من الأصيل، وكان Flutter الأبطأ بوضوح في هذا المحاكي.
- التذبذب كبير داخل الخيار الواحد، فReact Native تضاعف زمنه بين المحاولة الثالثة والرابعة دون أي تغيير، ولهذا قسنا خمس مرات ولا نعتمد على رقم واحد.
- العودة من الخلفية
HOTكانت بين 363 و421 ms في الثلاثة، لأن العملية ما زالت في الذاكرة والمحرك جاهز، فالفرق بين الخيارات يكاد يختفي هنا. - عمود الذاكرة الأول هو PSS، أي الذاكرة الحقيقية التي يتحمل التطبيق مسؤوليتها بعد توزيع الصفحات المشتركة على العمليات التي تستخدمها، وهو المقياس الذي يشرحه توثيق إدارة الذاكرة في Android. والأصيل استهلك 20.5 MB، بينما Flutter 64.6 MB وReact Native 66.7 MB.
- سطر
Codeيفسر جزءاً كبيراً من الفرق، فهو 1.7 MB في الأصيل مقابل 23.1 MB في Flutter و21.8 MB في React Native، وهذه هي المكتبات الأصلية للمحرك التي تحمل في الذاكرة. وسطرNative Heapفي Flutter 24.3 MB هو ذاكرة المحرك وبيئة Dart، وفي React Native يظهرJava Heapأكبر بسبب طبقة المكونات بلغة Java.
والآن السؤال المهم: هل هذه أرقام هاتف حقيقي؟ لا، والتفصيل فيما يلي. المحاكي في تجربتنا يعمل على Windows 11 بتسريع WHPX للمعالج، ولكنه يرسم بمعالج رسومي برمجي Software GPU اسمه SwiftShader وليس بمعالج رسومي حقيقي. وFlutter يرسم كل بكسل بنفسه عبر المعالج الرسومي، وتوثيق Impeller يذكر أنه يحتاج Vulkan ويرجع لمحرك OpenGL إذا لم يجده، فكل إطار في Flutter يمر على هذا المعالج البرمجي البطيء، بينما التطبيق الأصيل وReact Native يرسمان عبر نظام الرسم في Android الذي يتعامل مع هذه البيئة بشكل مختلف. ولذلك فإن زمن إقلاع Flutter في هذا المحاكي يظلمه على الأرجح، ولا ننصح أبداً بالاعتماد على أرقام المحاكي في قرار مثل هذا. أما الذاكرة وسطر Code فهي أقرب للحقيقة لأنها تعتمد على حجم المكتبات المحملة وليس على سرعة الرسم.

فما الذي يفعله فريقنا إذاً؟ يقيس على هاتف حقيقي متوسط، وليس على أحدث هاتف في جيب المطور، ويقيس نسخة الإصدار وليس نسخة التطوير، ويكرر القياس عدة مرات. والأمر am start -W نفسه يعمل على أي هاتف متصل عبر USB، فهذه التجربة تكلف ربع ساعة على جهاز حقيقي، وهي أرخص بكثير من قرار خاطئ يبقى معك سنوات.
الاتجاه من اليمين لليسار RTL في الصور الثلاث
وأخذنا صورة شاشة من كل تطبيق على نفس المحاكي، ولغة المحاكي هي الإنجليزية:

لاحظ أن التطبيق الأصيل وتطبيق React Native ظهرا من اليسار لليمين، مع أن النصوص عربية، بينما ظهر تطبيق Flutter من اليمين لليسار. والسبب ليس في إطار العمل، وإنما في قرار كتبناه: في Flutter وضعنا الصفحة داخل Directionality(textDirection: TextDirection.rtl) ففرضنا الاتجاه، أما الآخران فيتبعان لغة الجهاز كما يجب، فإذا كانت لغة الهاتف عربية انقلبت الواجهة تلقائياً. وهذا درس مهم لأي تطبيق عربي: قرر من البداية هل يتبع التطبيق لغة الجهاز أم لغة يختارها المستخدم داخل التطبيق، ثم اختبر الحالتين، لأن كل إطار عمل يعطيك أدوات مختلفة لذلك: موارد اللغة والدعم supportsRtl في التطبيق الأصيل، وملفات الترجمة والاتجاه في Flutter، والوحدة I18nManager في React Native التي تحتاج إعادة تشغيل التطبيق لتغيير الاتجاه. وفي سؤال ما الذي يتعطل عندما تشغل التطبيق بالعربية قائمة بما يجب أن تختبره.
الوصول لميزات النظام Platform APIs والجسور الأصلية Native Bridges
بعد الأرقام نأتي لسؤال يظهر عادة في الشهر الثالث وليس في الأسبوع الأول: ماذا تفعل عندما تحتاج ميزة في النظام لا يغطيها إطار العمل؟ لنفرض أن صاحب المتجر طلب أن يمسح المندوب رمز الطلب بالكاميرا، ثم أن يعمل التطبيق مع طابعة إيصالات بلوتوث يملكها المتجر. في التطبيق الأصيل أنت تستخدم واجهة النظام مباشرة، وكل توثيق Google وApple مكتوب لك. وفي Flutter وReact Native تبحث أولاً عن مكتبة جاهزة، فإذا وجدت مكتبة مشهورة يصونها فريق نشط فالأمر سهل، وإذا لم تجد فعليك أن تكتب الجسر بنفسك.
والجسر في Flutter اسمه قناة المنصة Platform Channel، وهو قناة رسائل باسم ثابت يرسل فيها كود Dart طلباً يحوله الإطار إلى بايتات، ويستقبله كود Kotlin في Android وكود Swift في iOS ويرد عليه. وإذا احتجت أن تضع مكوناً أصلياً كاملاً داخل شاشة Flutter، مثل خريطة أو مشغل فيديو، فهناك Platform Views، وهي أثقل لأن المحرك يجب أن يدمج ما يرسمه هو مع ما يرسمه النظام. وفي React Native تكتب Turbo Native Module تصف واجهته في TypeScript، ثم يولد الإطار كود C++ يربطه عبر JSI، وتكتب التنفيذ بلغة Kotlin وSwift.
والنقطة التي يغفل عنها الفريق هنا أن كتابة الجسر تعني أنك تكتب كوداً أصلياً بلغتين على أي حال، فإذا كان التطبيق يعتمد على الكثير من ميزات الجهاز، مثل البلوتوث والموقع في الخلفية والكاميرا المتقدمة، فإن الوعد بكود واحد يتقلص مع كل ميزة، ويصبح الفريق محتاجاً لمطور يعرف المنصتين بجوار مطوري Flutter أو React Native. وتجد هذا السيناريو بالتفصيل في سؤال ميزة تحتاج واجهة أصلية لا تدعمها المكتبة من أسئلة المقابلة.
الفريق Team والتوظيف Hiring والصيانة Maintenance
أغلب المقارنات تتحدث عن الأداء، ولكن في فريق من أربعة أشخاص فإن العامل الأكبر في الغالب هو الناس وليس الميغابايت. فإذا كان الفريق يكتب React وTypeScript كل يوم، فإن React Native يسمح له أن يبدأ في الأسبوع الأول ويشارك أنواع البيانات وقواعد التحقق مع الموقع، وهذه قيمة حقيقية. وإذا كان في الفريق مطور Android قوي بلغة Kotlin، فإن الأصيل مع KMP يسمح له أن يبني المنطق مرة واحدة ويضيف iOS بواجهة SwiftUI. وإذا لم يكن في الفريق أي خبرة جوال، فإن Flutter يعطي تجربة متسقة: لغة واحدة وأداة واحدة وتوثيق واحد، مع ميزة إعادة التحميل السريع Hot Reload التي تختصر وقت التطوير.
وفي التوظيف اسأل سؤالين: من تستطيع أن توظف في سوقك بميزانيتك؟ ومن يصون الكود إذا غادر الشخص الذي اختار التقنية؟ فإطار عمل لا يعرفه إلا شخص واحد في الفريق هو مخاطرة، بغض النظر عن جودته. وفي سؤال شركة ناشئة تريد تطبيقاً على المنصتين من أسئلة المقابلة تجد كيف يناقش المرشح الجيد هذا القرار، وفي سؤال ما الذي تشاركه بين تطبيقي Android وiOS حدود المشاركة في الأصيل.
ترقية إطار العمل Framework Upgrades على المدى الطويل
والتكلفة التي لا تظهر في الشهر الأول هي الترقية. فالتطبيق يجب أن يرفع targetSdk كل سنة ليقبله Google Play، وأن يبنى بأحدث Xcode ليقبله App Store، وهذا يعني ترقية إطار العمل ومكتباته معه. وفي الأصيل رأينا بأنفسنا أن مكتبات Compose الحديثة رفضت البناء حتى رفعنا compileSdk، وفي React Native تمر كل ترقية رئيسية بتغييرات في ملفات Gradle وXcode والوحدات الأصلية، وتحتاج أن تتأكد أن كل مكتبة من مكتبات npm التي تستخدم كوداً أصلياً تدعم الإصدار الجديد، وقد كان الانتقال للبنية الجديدة مثالاً كبيراً على ذلك. وفي Flutter تأتي الترقية عادة أبسط لأن الإطار يملك المحرك كله، ولكن الإضافات Plugins الخارجية تبقى نقطة ضعف بنفس الطريقة. والقاعدة هنا: كلما زادت المكتبات الخارجية ذات الكود الأصلي في تطبيقك، زادت تكلفة كل ترقية، لذلك راجع قائمة المكتبات قبل أن تضيف أي واحدة، وفضل المكتبات الرسمية أو التي تصونها جهة معروفة.
ماذا فعلت Shopify، ولماذا؟
وفي يناير 2020 نشرت Shopify في مدونتها الهندسية مقال React Native is the Future of Mobile at Shopify، وقالت فيه إنها بعد سنوات من التطوير الأصيل قررت أن تبني كل تطبيقاتها الجديدة بإطار React Native، مع أنها لن تعيد كتابة التطبيقات القديمة تلقائياً. وذكرت أنها أعادت كتابة تطبيق Arrive، الذي أصبح لاحقاً Shop، بإطار React Native في نهاية 2018، وكان سبباً مناسباً أنه لم تكن له نسخة Android أصلاً، وأنها توقعت مشاركة حوالي 80% من الكود بين المنصتين فوصلت فعلياً إلى 95% في هذا التطبيق و99% في تطبيق آخر. والدرس من هذه الحالة ليس أن React Native هو الأفضل، وإنما أن القرار جاء من وضع محدد: شركة تكتب React بكثافة، وتطبيق ليس له نسخة على إحدى المنصتين، وفريق قادر على كتابة الكود الأصلي عند الحاجة. فإذا كان وضعك مختلفاً، فالنتيجة قد تكون مختلفة.
أخطاء شائعة Common Mistakes في هذا القرار
- مقارنة حجم ملف APK الشامل بدلاً من حجم التحميل: رأينا 43 MB مقابل 1 MB في الملف الشامل، بينما الفرق عند المستخدم حوالي 6 MB، فقس دائماً بأداة bundletool أو بتقرير المتجر.
- مقارنة الإعدادات الافتراضية: قالب React Native يأتي دون R8 وبأربع معماريات، وتفعيل R8 وحده حذف حوالي 80% من ملفات dex، فلا تحكم على خيار قبل ضبطه للإنتاج.
- القياس على المحاكي واعتبار النتيجة حقيقة: رأينا Flutter أبطأ بثلاث مرات في الإقلاع على محاك يرسم بمعالج رسومي برمجي، وهذا لا يقول شيئاً عن هاتف حقيقي.
- القياس مرة واحدة: زمن إقلاع React Native تضاعف بين محاولتين متتاليتين، وزمن البناء تغير حتى 60% مع الضغط على الجهاز.
- تجاهل الرمز
rc=1لأن الملف موجود: في حالتنا كان إنذاراً كاذباً من أداة ناقصة، ولكن خط البناء الذي يتجاهل الأخطاء سيتجاهل يوماً خطأ حقيقياً. - اختيار التقنية لأن شركة كبيرة اختارتها، دون النظر إلى الظروف التي جعلتها مناسبة لتلك الشركة.
- افتراض أن متعدد المنصات يعني ألا تحتاج أي خبرة أصلية: الإشعارات والمدفوعات والبلوتوث والنشر في المتاجر كلها تحتاج من يفهم Android وiOS.
- نسيان الاتجاه من اليمين لليسار حتى آخر أسبوع: قرر من البداية من يحدد الاتجاه، واختبر التطبيق بلغة عربية وإنجليزية على الجهاز.
جدول القرار Decision Table
والآن نجمع كل ما سبق في جدول يساعد فريقنا وأي فريق في وضع مشابه، والصورة التالية تلخص نفس المنطق كمسار أسئلة:

| وضعك | الخيار الأنسب غالباً | والسبب |
|---|---|---|
| التطبيق يعتمد بشدة على ميزات الجهاز: كاميرا متقدمة، بلوتوث، عمل في الخلفية، أداء رسومي عال | الأصيل Native | كل ميزة متاحة مباشرة، ولن تكتب جسراً لكل واحدة |
| لديك تطبيق Android بلغة Kotlin وتريد iOS دون إعادة كتابة المنطق | Kotlin Multiplatform | تشارك المنطق وتبقى الواجهة أصلية، والحجم مثل الأصيل |
| الفريق يكتب React وTypeScript، والتطبيق شاشات بيانات ونماذج | React Native | الفريق يبدأ فوراً، ومكونات النظام الحقيقية في الواجهة، مع حوالي 9.5 MB في تجربتنا |
| لا توجد خبرة جوال، وتريد واجهة متطابقة بتصميم خاص على المنصتين | Flutter | أداة واحدة ولغة واحدة، وتحكم كامل في كل بكسل، مع حوالي 7 MB في تجربتنا |
| الموقع جيد، والتطبيق مطلوب للحضور في المتجر أو لإشعارات بسيطة | Capacitor أو PWA | أرخص خيار، مع مخاطرة الرفض في App Store إذا لم يقدم أكثر من الموقع |
| جمهور على هواتف قديمة ومساحة صغيرة وباقات محدودة | الأصيل أو KMP | أقل من 1 MB تحميل وحوالي 20 MB ذاكرة في تجربتنا |
أما فريقنا: أربعة مطورين يكتبون React كل يوم، وتطبيق للطلبات أغلبه قوائم ونماذج، وميزة الكاميرا لها مكتبات معروفة، فالأقرب لهم React Native مع تفعيل R8 وقياس الإقلاع على هاتف حقيقي متوسط قبل الالتزام، مع شخص واحد على الأقل يتعلم كود Android وiOS الأصلي من اليوم الأول، لأنه سيحتاجه في الإشعارات والنشر.
قائمة تحقق Checklist قبل أن تختار
- اكتب ما يجب أن يفعله التطبيق في أول سنة، وحدد كل ميزة تحتاج واجهة من النظام، وابحث لكل منها عن مكتبة في الخيار الذي تميل إليه، وانظر متى صدر آخر إصدار لها ومن يصونها.
- ابن نفس الشاشة النموذجية بالخيارين الأقرب لك، كما فعلنا هنا، فهذا يكلف يومين ويعطيك أرقامك أنت.
- قس حجم التحميل بحزمة App Bundle وأداة bundletool لجهاز نموذجي من جمهورك، وليس حجم الملف الشامل.
- فعل R8 في نسخة الإصدار، وابن فقط المعماريات التي تحتاجها إذا كنت توزع خارج Google Play.
- قس الإقلاع البارد والذاكرة على هاتف حقيقي متوسط، خمس مرات على الأقل، بنسخة الإصدار.
- جرب أول بناء على جهاز نظيف وعلى خادم البناء CI، واكتب كم يأخذ وما المشكلات، مثل حد طول المسار في Windows.
- قرر سياسة اللغة والاتجاه من اليمين لليسار من البداية، واختبرها بلغة الجهاز العربية والإنجليزية.
- تأكد أن في الفريق شخصين على الأقل يفهمان التقنية المختارة، وشخصاً يستطيع كتابة كود أصلي عند الحاجة.
- خطط للترقية السنوية: من يرفع
targetSdkوإصدار Xcode وإطار العمل، وكم يوماً يأخذ ذلك.
الخلاصة Summary
وصلنا لنهاية الموضوع، وأهم ما فيه:
- الخيارات خمسة وليست اثنين: الأصيل، وKotlin Multiplatform، وReact Native، وFlutter، وتطبيقات الويب المغلفة، والفرق بينها في ما تشاركه ومن يرسم الواجهة.
- Flutter يرسم كل بكسل بمحركه الخاص ويترجم Dart مسبقاً إلى
libapp.so، وReact Native يشغل JavaScript على Hermes ويتحدث مع مكونات النظام عبر JSI وFabric، والأصيل يستخدم ما في الجهاز أصلاً. - نفس الشاشة كانت 0.93 MB في الأصيل و42.9 MB في Flutter و55.5 MB في React Native كملف شامل، ولكن حجم التحميل لهاتف arm64 كان 0.78 و7.1 و9.5 MB، والفرق كله تقريباً منصة التشغيل المحمولة مع التطبيق.
- البناء النظيف كان 121 و105 و210 ثانية، والبناء دون تغيير بين 7 و16 ثانية، والضغط على الجهاز وحده غير الأرقام حتى 60%.
- تفعيل R8 في React Native حذف حوالي 80% من ملفات dex، ولكن الملف الشامل لم ينزل إلا 3 MB لأن أغلب الحجم كود C++.
- على المحاكي استهلك الأصيل حوالي 20 MB من الذاكرة مقابل حوالي 65 MB للخيارين الآخرين، وأرقام الإقلاع على محاك برسم برمجي لا تصلح للحكم على Flutter.
- المشكلات الحقيقية كانت في الطريق: مكتبات Compose تطلب compileSdk 37، وحد 260 حرفاً للمسار في Windows أوقف React Native، وFlutter أعلن فشلاً كاذباً لأن cmdline-tools غير مثبتة.
- القرار يعتمد على ميزات الجهاز التي تحتاجها، وخبرة فريقك، وجمهورك، أكثر مما يعتمد على أي رقم من هذه الأرقام.
دليلدورة حياة تطبيق الجوال Lifecycle والمهام الخلفيةلماذا يجد المستخدم السلة فارغة بعد مكالمة، وما الذي يبقى فعلاً بعد تدوير الشاشة وموت العملية والسبات Doze، بتجربة حقيقية على Android وشرح ما يقابلها في iOS.
دليلتطبيق يعمل دون إنترنت Offline-first: التخزين والمزامنةنبني سيرفراً وجوالين بقواعد SQLite ونرى بأرقام حقيقية كيف يضيع التسليم أو يسجل المبلغ مرتين أو يعود الطلب المحذوف، وكيف يمنع ذلك Outbox ومفتاح منع التكرار ودمج الحقول.
دليلالروابط العميقة Deep Links والإشعارات والشراء داخل التطبيقلماذا يفتح رابط الطلب المتصفح بدلاً من تطبيقك، وكيف يثبت نطاقك ملكيته للتطبيق في Android وiOS، وكيف يفتح الإشعار الشاشة الصحيحة، ومتى يلزمك الشراء داخل التطبيق.
دليلالنشر في App Store و Google Play: التوقيع Signing والمراجعةنوقع إصداراً حقيقياً لتطبيق Android ونرى ماذا يرفض الجهاز ولماذا، ثم نمر على ما تطلبه Google وApple في 2026 وأسباب الرفض الشائعة وكيف تتجنبها قبل أول إرسال.
دليلاختبار تطبيقات الجوال مع Appium وMaestroكيف تختار أداة اختبار تطبيقات الجوال، وأين تشغل الاختبارات، ولماذا تفشل بعض الاختبارات عندما تصبح الواجهة عربية.
دليلتقارير الأعطال Crash Reporting في تطبيقات الجوالكيف يصلك عطل حدث في جوال مستخدم لا تعرفه، وكيف تقرأ ال Stack Trace المشفر، ومتى توقف إصداراً جديداً قبل أن يصل إلى الجميع.
دليلأسئلة مقابلة مطور تطبيقات الجوال من المبتدئ إلى الخبيربنك أسئلة لمقابلة مطور تطبيقات الجوال على Android وiOS، لكل سؤال إجابة المبتدئ وإجابة الخبير، مع تمارين عملية شغلناها.وإذا أردت أن ترى تطبيقاً أصلياً كاملاً يرسل أعطاله إلى سيرفرك مع ملف ال Mapping الذي ينتجه R8، فقد بنيناه في مقال تقارير الأعطال لتطبيق Android مع GlitchTip.
سجل التحديثات
- أكتوبر 2026: كتابة المقال، وبناء نفس التطبيق بثلاث طرق على Windows 11: Kotlin مع Compose BOM 2026.09.00 وAGP 9.4.1 وGradle 9.8.2، وFlutter 3.41.1 مع Dart 3.11.0، وReact Native 0.88.1 مع Hermes والبنية الجديدة، وقياس الحجم بأداة bundletool 1.18.3، وزمن الإقلاع والذاكرة على محاكي Android 15 (API 35).