تفتح تطبيق متجر على جوالك وتضيف ثلاثة منتجات إلى السلة، ثم يرن الهاتف فترد على المكالمة، وبعدها تفتح تطبيق البنك لتتأكد من الرصيد، وتنسخ رمز التحقق من الرسائل، ثم ترجع إلى المتجر بعد خمس دقائق فتجد السلة فارغة والشاشة في أول خطوة من الطلب. وفي تطبيق آخر تكتب رسالة طويلة في نموذج الدعم الفني، وتدير الجوال على جانبه لترى النص أوضح، فيختفي كل ما كتبته. وقد تكون أنت المطور الذي وصلته هذه الشكوى، ثم جربت على جهازك عشر مرات ولم يحدث شيء من ذلك، فالتطبيق يرجع في كل مرة كما تركته، والمشكلة لا تظهر إلا عند المستخدمين.
والسبب في أغلب هذه الحالات ليس خطأ في الشبكة ولا في الخادم (Server)، وإنما أن تطبيق الجوال لا يملك حياته، فالنظام هو الذي يقرر متى يعمل ومتى يتوقف ومتى ينتهي تماماً، ويقرر ذلك بحسب الذاكرة والبطارية وما يفعله المستخدم الآن، ثم يعيد بناء الشاشة عندما يرجع المستخدم وكأن شيئاً لم يحدث، ويترك لك أنت مسؤولية أن تكون البيانات في مكانها. وهذا ما نسميه دورة حياة التطبيق Lifecycle: سلسلة الحالات التي يمر بها التطبيق من لحظة فتحه إلى لحظة إنهائه، والاستدعاءات التي يرسلها النظام لكودك عند كل انتقال، والحالات التي ينتقل فيها النظام دون أن يرسل لك شيئاً.
وفي هذا المقال لن نكتفي بشرح الرسم المعروف للاستدعاءات، وإنما بنينا تطبيق Android صغيراً يحفظ عدد المنتجات في السلة في ستة أماكن مختلفة في وقت واحد، ثم شغلناه على محاكي Android 15 وقمنا بتدوير الشاشة وإرسال التطبيق إلى الخلفية وإنهاء عمليته كما يفعل النظام، وسجلنا ما بقي وما ضاع في كل حالة، ثم انتقلنا إلى العمل في الخلفية: متى يرفض النظام بدء خدمة، ومتى تنتظر المهمة المجدولة، وماذا يحدث لها أثناء السبات Doze. وكل سطر من المخرجات في المقال خرج من التشغيل الحقيقي، والملفات كلها في مجلد lab من حزمة المقال.
وسوف نناقش في هذا المقال ما يلي:
- لماذا ينهي النظام التطبيقات أصلاً، وكيف يرتب Android العمليات بحسب أهميتها، ومن يخرج من الذاكرة أولاً.
- تطبيق التجربة: سلة تحفظ العدد في حقل عادي وفي Compose وفي ViewModel وفي SavedStateHandle وعلى القرص.
- دورة حياة الشاشة Activity Lifecycle كما يسجلها ال Logcat سطراً بسطر.
- تدوير الشاشة وتغيير الإعدادات Configuration Change، ولماذا تختفي بعض القيم.
- موت العملية Process Death، الحالة التي لا يراها المطور أثناء التطوير، والفرق بينها وبين الإيقاف الإجباري Force Stop.
- أين تحفظ كل نوع من الحالة State، في جدول مبني على نتائج التجربة.
- كيف يتعامل iOS مع نفس المشكلة: حالات التطبيق والمشاهد Scenes وحدود التنفيذ في الخلفية وأحداث Jetsam.
- العمل في الخلفية على Android: حدود الخدمات Services، وأداة WorkManager مع الشروط Constraints، والسبات Doze، ومجموعات الاستعداد App Standby Buckets، والمنبهات الدقيقة Exact Alarms.
- البدء البارد والدافئ والساخن، ثم الأخطاء الشائعة وقائمة تحقق قبل الإطلاق.
لماذا ينهي النظام تطبيقك؟ الذاكرة Memory والبطارية Battery
في جهاز الحاسب المكتبي يفتح المستخدم برنامجاً ويغلقه بنفسه، والنظام لا يغلق البرنامج إلا في حالات نادرة، لأن الذاكرة كبيرة وهناك ذاكرة تبديل Swap على القرص، والجهاز موصول بالكهرباء. أما الجوال فالوضع مختلف تماماً: الذاكرة محدودة ويتقاسمها عشرات التطبيقات، ولا توجد ذاكرة تبديل بالمعنى المعروف في أغلب الأجهزة لأن الكتابة المستمرة تستهلك شريحة التخزين وتستنزف البطارية، والمستخدم لا يغلق التطبيقات أصلاً، وإنما ينتقل من تطبيق إلى آخر ويترك كل شيء مفتوحاً. لذلك صمم Android وiOS على فكرة واحدة: النظام هو الذي يدير الذاكرة، ويحتفظ بالتطبيقات في الخلفية ما دامت الذاكرة تكفي لأن الرجوع إلى تطبيق في الذاكرة أسرع بكثير من تشغيله من البداية، فإذا احتاج ذاكرة لتطبيق في المقدمة أنهى أقل التطبيقات أهمية دون أن يسألها.
والبطارية هي السبب الثاني، فتطبيق في الخلفية يوقظ المعالج كل دقيقة ليسأل الخادم عن جديد قد يستهلك بطارية الجهاز كله خلال يوم، والمستخدم سوف يلوم الهاتف وليس التطبيق. لذلك أضاف Android منذ الإصدار 6.0 وضع السبات Doze، وأضاف في الإصدار 8.0 حدوداً على الخدمات في الخلفية، ثم أضاف في الإصدار 12 حدوداً على الخدمات الأمامية، وكل إصدار تقريباً يضيف قيداً جديداً، وسوف نرى بعضها يعمل أمامنا في التجربة.
ترتيب أهمية العمليات Process Importance
كل تطبيق Android يعمل في عملية Process خاصة به، ويضع النظام كل عملية في درجة من الأهمية بحسب ما تفعله الآن. ويذكر توثيق العمليات ودورة حياة التطبيق أن العملية في المقدمة Foreground هي التي تعرض الشاشة التي يلمسها المستخدم الآن، أو تشغل Receiver يعالج رسالة في هذه اللحظة، أو تشغل خدمة أمامية Foreground Service، ثم تأتي العملية المرئية Visible التي تظهر شاشتها ولكن ليست في التركيز، ثم عملية الخدمة Service التي تشغل خدمة بدأت بالأمر startService، وفي الأسفل العملية المخزنة Cached وهي تطبيق في الخلفية لا يحتاجه أحد الآن. والصورة التالية تبين هذا الترتيب:

ولاحظ أن تطبيقك لا يبقى في درجة واحدة، فعندما يضغط المستخدم على زر الرجوع إلى الشاشة الرئيسية Home ينتقل تطبيقك خلال ثوان من المقدمة إلى العمليات المخزنة، ويقول التوثيق نفسه إن النظام السليم يحتفظ دائماً بعدد من العمليات المخزنة ليسرع التنقل بين التطبيقات، وإنه ينهي هذه العمليات بانتظام كلما احتاج الذاكرة. أي أن إنهاء تطبيقك وهو في الخلفية ليس حادثاً نادراً، وإنما جزء من التشغيل الطبيعي للنظام، وبالتالي يجب أن يكون التطبيق مستعداً له في كل مرة يخرج فيها المستخدم.
قاتل الذاكرة المنخفضة Low Memory Killer
الجهة التي تنفذ الإنهاء في Android هي خدمة في النظام اسمها lmkd، أي Low Memory Killer Daemon، ويشرح توثيق lmkd في مشروع Android المفتوح أنها تراقب ضغط الذاكرة، وأنها في Android 10 وما بعده تستخدم مقياس معلومات تأخر الضغط PSI من نواة Linux، وهو مقياس يحسب كم من الوقت تأخرت المهام بسبب نقص الذاكرة، فإذا ارتفع هذا التأخر اختارت عملية من الدرجات الأقل أهمية وأنهتها. والإنهاء هنا يكون بإشارة SIGKILL، وهي إشارة لا يستطيع البرنامج أن يلتقطها أو يؤجلها، وبالتالي لا يصل إلى كودك أي استدعاء، فلا onDestroy ولا onStop ولا غيره، والعملية تختفي في لحظة واحدة.
وقد يتساءل البعض: إذا كان الإنهاء لا يرسل أي استدعاء، فكيف أحفظ البيانات قبله؟ والإجابة على ذلك أنك لا تحفظها قبله، وإنما تحفظها قبل أن تصبح عملية مخزنة أصلاً، أي في اللحظة التي يغادر فيها المستخدم الشاشة، لأن هذه آخر لحظة مضمونة يصلك فيها استدعاء من النظام. وهذا ما سوف نراه بوضوح في التجربة.
تطبيق التجربة Lab: سلة تحفظ العدد في ستة أماكن
بنينا تطبيقاً بلغة Kotlin وواجهة Jetpack Compose فيه شاشة واحدة اسمها «السلة» وزر «أضف منتجاً»، وعند كل ضغطة يزيد العدد في ستة أماكن في وقت واحد، ثم يكتب كل استدعاء من استدعاءات دورة الحياة في ال Logcat تحت الوسم LIFE ومعه قيمة كل مكان، وبهذا الشكل نعرف بعد كل حدث ما الذي بقي وما الذي ضاع. والمشروع مبني بإضافة Android Gradle Plugin 9.4.1 وGradle 9.8.2 ومكتبات Compose من BOM الإصدار 2026.09.00 وWorkManager 2.12.0، والتطبيق يستهدف targetSdk 36. والأماكن الستة هي:
- حقل عادي في الكلاس
field: متغير داخل ال Activity نفسها. - المتغير
rememberفي Compose: قيمة يحتفظ بها Compose ما دامت الواجهة نفسها موجودة. - المتغير
rememberSaveable: مثل السابق، ولكنه يكتب قيمته في الحالة المحفوظة Saved Instance State. - الكلاس ViewModel: كائن من مكتبة Jetpack يعيش أطول من ال Activity عند تدوير الشاشة.
- الكائن SavedStateHandle: حالة محفوظة يقرأها ال ViewModel ويكتبها في نفس آلية الحفظ التي يستخدمها
rememberSaveable. - ملف على القرص عبر SharedPreferences، ويمثل هنا أي تخزين دائم مثل قاعدة البيانات Database.
والكود التالي هو الجزء المهم من الشاشة:
class CartViewModel(private val state: SavedStateHandle) : ViewModel() {
var inMemory by mutableIntStateOf(0)
var saved: Int
get() = state["saved"] ?: 0
set(v) { state["saved"] = v }
}
class MainActivity : ComponentActivity() {
private var field = 0
private val vm: CartViewModel by viewModels()
private fun log(event: String) = Log.i(TAG, "${this.hashCode().toString(16)} $event")
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val prefs = getSharedPreferences("cart", Context.MODE_PRIVATE)
log("onCreate restored=${savedInstanceState != null} field=$field vm=${vm.inMemory} saved=${vm.saved} disk=${prefs.getInt("disk", 0)}")
setContent {
var remembered by remember { mutableIntStateOf(0) }
var saveable by rememberSaveable { mutableIntStateOf(0) }
Button(onClick = {
field++; remembered++; saveable++; vm.inMemory++; vm.saved = vm.saved + 1
prefs.edit().putInt("disk", prefs.getInt("disk", 0) + 1).apply()
log("add field=$field remember=$remembered saveable=$saveable vm=${vm.inMemory} saved=${vm.saved}")
}) { Text("أضف منتجاً") }
}
}
override fun onStart() { super.onStart(); log("onStart") }
override fun onResume() { super.onResume(); log("onResume") }
override fun onPause() { super.onPause(); log("onPause") }
override fun onStop() { super.onStop(); log("onStop") }
override fun onSaveInstanceState(outState: Bundle) { super.onSaveInstanceState(outState); log("onSaveInstanceState") }
override fun onDestroy() { super.onDestroy(); log("onDestroy changingConfig=$isChangingConfigurations finishing=$isFinishing") }
}في الكود أعلاه لاحظ التالي:
- كل سطر في ال Logcat يبدأ ب
hashCodeالخاص بكائن ال Activity بالنظام الست عشري، والسبب أننا نريد أن نعرف هل الكائن الذي يستقبل الاستدعاء هو نفس الكائن السابق أم كائن جديد بناه النظام، وهذا أهم سؤال في دورة الحياة كلها. - السطر في
onCreateيطبعrestored، أي هل وصلsavedInstanceStateبقيمة أم كان فارغاً، وهذه هي العلامة على أن النظام يعيد بناء شاشة سابقة. - الكلاس
LifeApp(في ملف مستقل) يطبعprocess started pid=…عند بدء العملية، ويسجلapp foregroundوapp backgroundمن ProcessLifecycleOwner، ويطبع مستوىonTrimMemoryعندما يصل.
وشغلنا التطبيق على محاكي Android Emulator بنظام Android 15 (API 35) على جهاز Windows 11، والمحاكي يرسم الواجهة بالمعالج دون بطاقة رسوميات، والجهاز كان مشغولاً في نفس الوقت ببناء مشاريع أخرى، لذلك أزمنة الفتح في المخرجات (بين 10 و16 ثانية) لا تمثل هاتفاً حقيقياً إطلاقاً، وما يهمنا هنا هو ترتيب الأحداث وما بقي من القيم وليس الزمن. والسكربت run1.sh يقوم بالخطوات بأوامر adb بدلاً من اللمس باليد، حتى تستطيع إعادتها كما هي، وهذه أهم أوامره:
adb shell am start -W -n io.example.life/.MainActivity # فتح التطبيق
adb shell input tap 200 378 # الضغط على «أضف منتجاً»
adb shell settings put system user_rotation 1 # تدوير الشاشة أفقياً
adb shell input keyevent KEYCODE_HOME # الخروج إلى الشاشة الرئيسية
adb shell am kill io.example.life # إنهاء العملية كما يفعل النظام
adb shell am force-stop io.example.life # الإيقاف الإجباري من الإعداداتدورة حياة الشاشة Activity Lifecycle
الشاشة في Android كائن من الكلاس Activity، والنظام يستدعي فيه ست دوال رئيسية بترتيب ثابت يشرحه توثيق دورة حياة ال Activity: الدالة onCreate عند إنشاء الكائن، ثم onStart عندما تصبح الشاشة مرئية، ثم onResume عندما تصبح في المقدمة وتستقبل اللمس، ثم في الاتجاه المعاكس onPause عندما تفقد التركيز، وonStop عندما لا تعود مرئية، وonDestroy عندما ينتهي الكائن. والصورة التالية تجمع هذا الترتيب مع ما سجلناه فعلاً:

التشغيل الأول Cold Launch: ماذا يقول ال Logcat؟
بعد فتح التطبيق والضغط على الزر ثلاث مرات كان المخرج كما يلي:
10-30 21:48:06.504 5906 5906 I LIFE : STEP launch
10-30 21:48:08.389 601 637 I ActivityManager: Start proc 5925:io.example.life/u0a209 for next-top-activity {io.example.life/io.example.life.MainActivity}
10-30 21:48:14.401 5925 5925 I LIFE : process started pid=5925
10-30 21:48:15.625 5925 5925 I LIFE : 367f090 onCreate restored=false field=0 vm=0 saved=0 disk=0
10-30 21:48:15.748 5925 5925 I LIFE : 367f090 onStart
10-30 21:48:15.755 5925 5925 I LIFE : app foreground
10-30 21:48:15.759 5925 5925 I LIFE : 367f090 onResume
10-30 21:48:27.077 6280 6280 I LIFE : STEP tap add x3
10-30 21:48:28.031 5925 5925 I LIFE : 367f090 add field=1 remember=1 saveable=1 vm=1 saved=1
10-30 21:48:29.743 5925 5925 I LIFE : 367f090 add field=2 remember=2 saveable=2 vm=2 saved=2
10-30 21:48:31.729 5925 5925 I LIFE : 367f090 add field=3 remember=3 saveable=3 vm=3 saved=3وفي المخرج أعلاه لاحظ التالي:
- السطر الثاني من خدمة
ActivityManagerفي النظام وليس من تطبيقنا، وهو يقول إن النظام بدأ عملية جديدة رقمها 5925 للتطبيق، والسببnext-top-activity، أي أن هناك شاشة يجب أن تظهر في الأعلى وليس لها عملية. - السطر
process started pid=5925هو أول كود يعمل في تطبيقنا، وهوApplication.onCreate، ويأتي قبل أي شاشة، لذلك أي عمل ثقيل تضعه فيه يؤخر ظهور الشاشة الأولى في كل تشغيل بارد. - الكائن
367f090استقبلonCreateمعrestored=false، أي لا توجد حالة سابقة، ثمonStartوonResumeبالترتيب، وبين الاثنين سجل ProcessLifecycleOwner أن التطبيق كله أصبح في المقدمة. - الضغطات الثلاث زادت الأماكن الستة معاً حتى وصلت إلى 3، وكلها على نفس الكائن
367f090.
تدوير الشاشة: تغيير الإعدادات Configuration Change
الآن سوف نقوم بتدوير الشاشة أفقياً والتطبيق في المقدمة، وهذا ما يحدث كل يوم عندما يدير المستخدم جواله ليرى صورة أكبر، والمخرج كان كما يلي:
10-30 21:48:33.476 6339 6339 I LIFE : STEP rotate to landscape
10-30 21:48:34.365 5925 5925 I LIFE : 367f090 onPause
10-30 21:48:34.396 5925 5925 I LIFE : 367f090 onStop
10-30 21:48:34.398 5925 5925 I LIFE : 367f090 onSaveInstanceState
10-30 21:48:34.481 5925 5925 I LIFE : 367f090 onDestroy changingConfig=true finishing=false
10-30 21:48:34.563 5925 5925 I LIFE : c0e52aa onCreate restored=true field=0 vm=3 saved=3 disk=3
10-30 21:48:34.587 5925 5925 I LIFE : c0e52aa onStart
10-30 21:48:34.631 5925 5925 I LIFE : c0e52aa onResumeولاحظ هنا أن رقم العملية بقي 5925، أي أن التطبيق لم يمت، ولكن الكائن 367f090 مر بكل الاستدعاءات حتى onDestroy، ومعه changingConfig=true، أي أن النظام يهدم الشاشة لأن الإعدادات تغيرت وليس لأن المستخدم أغلقها، ثم بنى كائناً جديداً تماماً c0e52aa بدأ من onCreate ومعه restored=true. وهذا السلوك مقصود، ويشرح توثيق التعامل مع تغيير الإعدادات أن النظام يعيد إنشاء ال Activity عند تغيير اتجاه الشاشة أو اللغة أو حجم الخط أو الوضع الليلي أو حجم النافذة، والسبب أن كل هذه التغييرات قد تحتاج ملفات موارد Resources مختلفة، مثل تصميم خاص للوضع الأفقي أو نصوص بلغة أخرى، والطريقة المضمونة لتحميلها هي بناء الشاشة من جديد.
والآن انظر إلى القيم في سطر onCreate الجديد: الحقل العادي رجع إلى 0 لأنه جزء من الكائن القديم الذي انتهى، وال ViewModel بقي 3 لأن النظام يحتفظ به في مخزن خاص أثناء إعادة الإنشاء ويعطيه للكائن الجديد، والحالة المحفوظة بقيت 3 لأنها كتبت في onSaveInstanceState قبل الهدم مباشرة، والقرص بقي 3 بالطبع. وأما متغيرات Compose فلا تظهر في هذا السطر لأنها تعيش داخل الواجهة، لذلك التقطنا صورة الشاشة قبل التدوير وبعده:


remember والحقل العادي رجعا إلى صفر، والباقي بقي 3وهذا بالضبط ما حدث للمستخدم الذي كتب رسالة الدعم الفني ثم أدار جواله: النص كان في متغير remember أو في حقل عادي، فضاع مع الكائن القديم. والحل هنا بسيط، فإما rememberSaveable للقيم الصغيرة في الواجهة مثل نص في حقل إدخال، وإما ViewModel لبيانات الشاشة، ويشرح توثيق حفظ حالة الواجهة في Compose متى تختار كل منهما. وقد ناقشنا هذا السؤال من زاوية المقابلة في سؤال المستخدم أدار الشاشة فاختفى ما كتبه، أما هنا فالمهم أن ترى أن ال ViewModel يحل مشكلة التدوير فقط، كما سوف نرى في القسم التالي.
وقد يسأل البعض: لماذا لا أمنع إعادة الإنشاء بالخاصية android:configChanges في ملف ال Manifest وأرتاح من كل هذا؟ والإجابة أن هذا الحل يخفي المشكلة ولا يحلها، فهو يمنع إعادة الإنشاء عند التدوير، ولكن موت العملية في الخلفية سوف يحدث في كل الأحوال ويضيع نفس البيانات، والتوثيق نفسه ينصح باستخدامه فقط عندما تكون الشاشة قادرة على التعامل مع التغيير بنفسها، مثل مشغل فيديو لا يريد أن يتوقف عند التدوير.
الانتقال إلى الخلفية Background والدالة onTrimMemory
بعد ذلك ضغطنا على زر Home، وهي اللحظة التي يغادر فيها المستخدم المتجر ليرد على المكالمة أو يفتح تطبيق البنك، والمخرج:
10-30 21:48:40.663 6393 6393 I LIFE : STEP home
10-30 21:48:41.712 5925 5925 I LIFE : c0e52aa onPause
10-30 21:48:46.487 5925 5925 I LIFE : app background
10-30 21:48:46.488 5925 5925 I LIFE : c0e52aa onStop
10-30 21:48:46.488 5925 5925 I LIFE : c0e52aa onSaveInstanceState
10-30 21:48:46.545 5925 5925 I LIFE : onTrimMemory level=20وفي هذا المخرج ثلاث ملاحظات مهمة. الأولى أن الكائن نفسه c0e52aa استقبل onPause ثم onStop، ولم يستقبل onDestroy، أي أن الشاشة ما زالت في الذاكرة وجاهزة للرجوع. والثانية أن onSaveInstanceState جاء بعد onStop وليس قبله، وهذا هو الترتيب في التطبيقات التي تستهدف Android 9 وما بعده، لذلك لا تكتب كوداً يفترض أن الحالة حفظت عند onPause. والثالثة أن التطبيق استقبل onTrimMemory بالمستوى 20، وهو الثابت TRIM_MEMORY_UI_HIDDEN، أي أن واجهتك لم تعد ظاهرة.
والدالة onTrimMemory هي الطريقة التي يخبرك بها النظام أنك أصبحت مرشحاً للإنهاء، ويشرح توثيق ComponentCallbacks2 أن المستوى 20 هو الوقت المناسب لتحرير الذاكرة التي تخص الواجهة فقط، مثل الصور الكبيرة Bitmaps، وأن المستوى 40 TRIM_MEMORY_BACKGROUND يعني أن العملية أصبحت في الخلفية ومؤهلة للإنهاء، وكلما حررت ذاكرة أكثر بقيت مدة أطول في الذاكرة. ويذكر التوثيق أيضاً أن المستويات الأخرى مثل TRIM_MEMORY_RUNNING_LOW وTRIM_MEMORY_COMPLETE لم تعد تصل إلى التطبيقات منذ API 34، لذلك لا تبن منطقاً يعتمد على أنها سوف تصلك. وقد وصلنا المستوى 40 فعلاً في تجربة السبات لاحقاً، عندما بدأ النظام عملية التطبيق في الخلفية لتشغيل مهمة مجدولة.
ولاحظ الفرق الزمني بين onPause عند الثانية 41.7 وapp background عند الثانية 46.5، والسبب أن ProcessLifecycleOwner يؤخر حدث الخلفية قليلاً عن قصد، حتى لا يعد تدوير الشاشة أو الانتقال بين شاشتين في نفس التطبيق خروجاً من التطبيق، وأما الخمس ثوان هنا فهي بطء المحاكي، والفارق على هاتف حقيقي أقل بكثير.
موت العملية Process Death: الحالة التي لا تراها أثناء التطوير
الآن نصل إلى الحالة التي تسبب شكوى السلة الفارغة، فالتطبيق في الخلفية، والنظام يحتاج الذاكرة، فينهي العملية. والأمر adb shell am kill يفعل نفس الشيء بالضبط، فهو ينهي العملية فقط إذا كانت في الخلفية ويمكن إنهاؤها بأمان، كما يفعل النظام عند نقص الذاكرة، ويترك المهمة Task في قائمة التطبيقات الأخيرة Recents كما هي. ثم رجعنا إلى التطبيق، والمخرج كان كما يلي:
pid before kill: 5925
10-30 21:48:51.154 6472 6472 I LIFE : STEP am kill process death in background
10-30 21:48:51.918 601 2736 I ActivityManager: Killing 5925:io.example.life/u0a209 (adj 700): kill background
pid after kill:
10-30 21:48:55.406 6506 6506 I LIFE : STEP back to the app from recents
10-30 21:48:56.448 601 637 I ActivityManager: Start proc 6525:io.example.life/u0a209 for next-top-activity {io.example.life/io.example.life.MainActivity}
10-30 21:49:03.334 6525 6525 I LIFE : process started pid=6525
10-30 21:49:04.525 6525 6525 I LIFE : b54618e onCreate restored=true field=0 vm=0 saved=3 disk=3
10-30 21:49:04.703 6525 6525 I LIFE : b54618e onStart
10-30 21:49:04.711 6525 6525 I LIFE : app foreground
10-30 21:49:04.717 6525 6525 I LIFE : b54618e onResume
text="field=0 remember=0 rememberSaveable=3 vm=0 savedState=3 disk=3 t=0"ولنقرأ هذا المخرج سطراً بسطر:
- السطر
Killing 5925 … (adj 700): kill backgroundيقول إن النظام أنهى العملية وقيمةadjلها 700، وهي درجة الأهمية التي يستخدمها النظام عند اختيار من ينهي، والرقم الأكبر يعني أهمية أقل وإنهاء أسرع، والقيمة 700 في كود Android هي درجة آخر تطبيق غادره المستخدم، والعمليات المخزنة الأقدم تأخذ أرقاماً أكبر منها. ولاحظ أنه لا يوجد أي سطر من تطبيقنا بين STEP والإنهاء، فلاonDestroyولا غيره، وبعدها رجع الأمرpidofفارغاً، أي أن العملية لم تعد موجودة. - عند الرجوع بدأ النظام عملية جديدة رقمها 6525، ونفذ
Application.onCreateمن جديد، ثم بنى كائن Activity جديداًb54618e، ولكنه أعطاهrestored=true، أي أن النظام احتفظ بالحالة المحفوظة خارج العملية التي ماتت، وأعادها إلى العملية الجديدة. - القيم في
onCreateتقول القصة كلها: الحقل 0، وال ViewModel صفر، والحالة المحفوظة 3، والقرص 3. والسطر الأخير مأخوذ من الشاشة نفسها بالأداةuiautomator، وفيهremember=0وrememberSaveable=3.
والصورة التالية تلخص هذه الحالة:

وهنا تظهر خطورة هذه الحالة، فالمستخدم يرى نفس الشاشة التي تركها، في نفس المكان من التطبيق، ولا يعرف أن التطبيق مات ورجع، ولكن إذا كانت السلة في ال ViewModel فقط فسوف يجدها فارغة، وإذا كان التطبيق يفترض أن بيانات المستخدم محملة في الذاكرة من شاشة تسجيل الدخول فقد يتعطل التطبيق بالخطأ NullPointerException عند أول لمسة. وهذا النوع من الأعطال يظهر في تقارير الأعطال Crash Reports دون أي خطوات واضحة لإعادته، وقد شرحنا كيف تقرأ هذه التقارير في مقال تقارير الأعطال في تطبيقات الجوال.
ولماذا لا يرى المطور هذه الحالة؟ لأن جهاز المطور فيه ذاكرة كبيرة غالباً، والتطبيق تحت التطوير يبقى في الذاكرة طوال الوقت، والمطور لا يفتح عشرة تطبيقات بين كل اختبار وآخر. لذلك عليك أن تجبر هذه الحالة بنفسك: أرسل التطبيق إلى الخلفية ثم نفذ adb shell am kill ثم ارجع إليه من قائمة التطبيقات الأخيرة، أو فعل الخيار «Don't keep activities» من خيارات المطور، وهو خيار يهدم كل Activity بمجرد أن تخرج منها، ولكن لاحظ أن هذا الخيار يهدم الشاشة وتبقى العملية نفسها حية، أي أن الكائنات العامة في العملية لا تضيع، لذلك الأمر am kill أقرب لما يحدث فعلاً عند المستخدم.
الإيقاف الإجباري Force Stop والسحب من قائمة التطبيقات الأخيرة Recents
بعد ذلك نفذنا الإيقاف الإجباري، وهو ما يحدث عندما يضغط المستخدم «Force stop» في صفحة التطبيق في الإعدادات، ثم فتحنا التطبيق من جديد:
10-30 21:49:18.899 601 2244 I ActivityManager: Force stopping io.example.life appid=10209 user=0: from pid 6769
10-30 21:49:18.903 601 2244 I ActivityManager: Killing 6525:io.example.life/u0a209 (adj 0): stop io.example.life due to from pid 6769
10-30 21:49:22.348 601 637 I ActivityManager: Start proc 6796:io.example.life/u0a209 for next-top-activity {io.example.life/io.example.life.MainActivity}
10-30 21:49:25.713 6796 6796 I LIFE : process started pid=6796
10-30 21:49:26.163 6796 6796 I LIFE : ded7d89 onCreate restored=false field=0 vm=0 saved=0 disk=3
text="field=0 remember=0 rememberSaveable=0 vm=0 savedState=0 disk=3 t=0"ولاحظ أن الإنهاء هنا كان والتطبيق في المقدمة (adj 0)، وأن الشاشة الجديدة بدأت مع restored=false، أي أن النظام رمى الحالة المحفوظة مع المهمة كلها، ولم يبق إلا القرص بقيمة 3. والسبب أن الإيقاف الإجباري قرار صريح من المستخدم بإنهاء التطبيق، فالنظام لا يحاول أن يعيده كما كان، بل ويلغي أيضاً المهام المجدولة والمنبهات الخاصة بالتطبيق، ولا يسمح له باستقبال أي رسالة من النظام حتى يفتحه المستخدم بنفسه مرة أخرى. وأما سحب التطبيق من قائمة التطبيقات الأخيرة فهو يزيل المهمة ويعني أن المستخدم انتهى من الشاشة، فالحالة المحفوظة لا ترجع، وسلوك العملية نفسها يختلف بين الشركات المصنعة، لذلك لا تبن عليه أي افتراض.
والقاعدة التي نخرج بها من القسمين: الحالة المحفوظة Saved State مصممة لموت العملية الذي لم يطلبه المستخدم، والتخزين الدائم مصمم لكل ما يجب أن يبقى حتى لو أغلق المستخدم التطبيق بنفسه. والسلة في متجر حقيقي من النوع الثاني، فالمستخدم يتوقع أن يجد السلة غداً، لذلك مكانها قاعدة البيانات أو الخادم، وأما رقم الصفحة في قائمة المنتجات أو نص البحث فمكانه الحالة المحفوظة. وإذا أردت أن يعمل التطبيق كاملاً دون شبكة ويزامن السلة لاحقاً فهذا موضوع مقال التخزين المحلي والمزامنة من هذه المرحلة.
أين تحفظ الحالة State؟ جدول من نتائج التجربة
الصورة التالية تجمع نتائج الخطوات الثلاث لكل مكان من الأماكن الستة:

وهذا يطابق الجدول في توثيق حفظ حالة الواجهة، وفيه أن ال ViewModel يبقى بعد تغيير الإعدادات ولا يبقى بعد موت العملية الذي يقوم به النظام، والحالة المحفوظة تبقى بعد الاثنين ولا تبقى إذا أغلق المستخدم الشاشة بنفسه، والتخزين الدائم يبقى في كل الحالات. ومن هذا نبني جدول القرار التالي:
| نوع البيانات | مثال من المتجر | المكان المناسب | والسبب |
|---|---|---|---|
| حالة مؤقتة في الواجهة | هل القائمة المنسدلة مفتوحة، موضع التمرير | rememberSaveable أو تتركها تضيع | صغيرة، وضياعها لا يضر المستخدم كثيراً |
| مدخلات المستخدم التي لم ترسل | نص رسالة الدعم، رقم الصفحة، كلمة البحث | SavedStateHandle أو rememberSaveable | يجب أن تبقى بعد موت العملية، وهي صغيرة |
| بيانات الشاشة المحملة | قائمة المنتجات من الخادم | ViewModel، ومصدرها الشبكة أو قاعدة البيانات | كبيرة، ويمكن تحميلها من جديد بعد موت العملية |
| بيانات يملكها المستخدم | السلة، المسودات، الإعدادات | قاعدة بيانات Room أو DataStore أو الخادم | يجب أن تبقى حتى بعد الإيقاف الإجباري أو إعادة تشغيل الجهاز |
| بيانات الجلسة | توكن تسجيل الدخول | تخزين مشفر على القرص | إذا كانت في الذاكرة فقط فسوف يظهر المستخدم كأنه خرج من حسابه بعد موت العملية |
حدود الحالة المحفوظة Saved State
قد تسأل: إذا كانت الحالة المحفوظة تبقى بعد موت العملية، فلماذا لا أضع فيها كل شيء، حتى قائمة المنتجات؟ والإجابة أن هذه الحالة تمر عبر آلية Binder في النظام إلى خدمة ActivityManager، وتحفظ هناك خارج تطبيقك، ويقول التوثيق إنها مناسبة للأنواع البسيطة والكائنات الصغيرة مثل النصوص، وإن الكتابة فيها تحتاج تحويل البيانات إلى صيغة قابلة للحفظ Serialization على الخيط الرئيسي Main Thread أثناء تغيير الإعدادات، وبالتالي الكائنات الكبيرة تسبب تقطعاً في الواجهة، وإذا تجاوز حجمها حد ال Binder يتعطل التطبيق بالخطأ TransactionTooLargeException. لذلك ضع في الحالة المحفوظة المعرفات والمدخلات فقط، مثل رقم المنتج وكلمة البحث، ثم حمل البيانات الكاملة من قاعدة البيانات أو الشبكة بهذه المعرفات.
وماذا عن iOS؟ حالات التطبيق App States والمشاهد Scenes
نفس المشكلة موجودة في iOS بأسماء مختلفة، ولم نشغل تطبيق iOS في هذا المقال، لذلك هذا القسم مبني على توثيق Apple فقط. ويشرح توثيق إدارة دورة حياة التطبيق أن iOS منذ الإصدار 13 يتعامل مع كل مشهد Scene على حدة، والمشهد هو نسخة واحدة من واجهة التطبيق، ويمكن أن يكون للتطبيق عدة مشاهد على iPad. ويمر المشهد بحالات: غير متصل Unattached، ثم في المقدمة ونشط Foreground Active، أو في المقدمة وغير نشط Foreground Inactive، مثل لحظة وصول مكالمة أو سحب مركز التحكم، ثم في الخلفية Background، ثم معلق Suspended، وهي حالة يبقى فيها التطبيق في الذاكرة ولكن لا ينفذ أي كود. وفي SwiftUI تقرأ هذه الحالات من القيمة ScenePhase بثلاث قيم: active وinactive وbackground. والصورة التالية تبين الانتقالات:

ويقول التوثيق إن على التطبيق عند مغادرة الحالة النشطة أن يحفظ البيانات ويهدئ نشاطه، وعند دخول الخلفية أن ينهي المهام المهمة ويحرر أكبر قدر من الذاكرة، والسبب نفسه الذي رأيناه في Android: UIKit يمكن أن يفصل أي مشهد في الخلفية أو معلق في أي وقت ليستعيد موارده. ولاستعادة الواجهة بعد ذلك يوفر iOS آلية استعادة حالة التطبيق، وفي SwiftUI يقوم @SceneStorage بدور قريب من rememberSaveable.
التنفيذ في الخلفية Background Execution والأداة BGTaskScheduler
التطبيق في iOS لا يعمل في الخلفية إلا في حالات محددة، والفكرة الأساسية أنه يعلق بعد وقت قصير من خروجه. فإذا كان هناك عمل بدأ في المقدمة ويجب أن ينتهي، مثل رفع صورة، يطلب التطبيق وقتاً إضافياً بالدالة beginBackgroundTask كما في توثيق تمديد وقت التنفيذ في الخلفية، ويجب أن ينهي العمل ويبلغ النظام قبل انتهاء الوقت، وإلا أنهاه النظام. وللأعمال الدورية يوفر إطار BackgroundTasks الكلاس BGTaskScheduler، وفيه نوعان أساسيان: BGAppRefreshTask لتحديث سريع للمحتوى، وBGProcessingTask لأعمال أطول يمكن أن تشترط أن يكون الجهاز على الشاحن أو متصلاً بالشبكة. ولاحظ أنك تطلب الجدولة ولا تحدد الموعد، فالنظام هو الذي يختار متى يشغل المهمة بحسب استخدام المستخدم للتطبيق وحالة البطارية، وهي نفس فلسفة WorkManager التي سوف نراها بعد قليل.
تحذيرات الذاكرة Memory Warnings وأحداث Jetsam
وما يقابل onTrimMemory في iOS هو تحذير الذاكرة، ويشرح توثيق الاستجابة لتحذيرات الذاكرة أن UIKit يرسله للتطبيقات العاملة إذا لم يستطع النظام استعادة الذاكرة بإنهاء التطبيقات المعلقة، وأن على التطبيق أن يحرر الصور والملفات التي يمكن تحميلها لاحقاً، وأن يكتب البيانات غير المحفوظة على القرص، لأن عدم تقليل الاستهلاك قد يؤدي إلى إنهائه. والإنهاء بسبب الذاكرة في iOS اسمه Jetsam، ويشرح توثيق تقارير أحداث Jetsam أنها تختلف عن تقارير الأعطال، فهي بصيغة JSON وتحتوي على استهلاك الذاكرة لكل التطبيقات ولا تحتوي على Stack Trace لتطبيقك، وأن التطبيق إذا أنهي وهو ظاهر فسوف يبدو للمستخدم كأنه تعطل. وقد شرحنا هذا النوع من الإنهاء من جهة تقارير الأعطال في قسم إنهاء التطبيق بسبب الذاكرة وال Watchdog.
العمل في الخلفية Background Work على Android
نرجع إلى متجرنا، فالمستخدم أضاف المنتجات وخرج من التطبيق، والتطبيق يريد أن يرسل السلة إلى الخادم حتى يجدها المستخدم على جهاز آخر، أو يريد أن يزامن الطلبات كل ساعة. والسؤال: أين يعمل هذا الكود إذا كان التطبيق في الخلفية ويمكن أن يموت في أي لحظة؟ والحل البسيط الذي يكتبه كثير من المطورين هو خدمة Service تعمل في الخلفية، وهو حل كان يعمل قبل Android 8، أما اليوم فسوف نرى ماذا يحدث له.
حدود الخدمات Background Service Limits منذ Android 8
أضفنا إلى التطبيق خدمة بسيطة UploadService ومستقبلاً للرسائل Kick نرسل إليه أمراً من adb فيحاول بدء الخدمة ويسجل النتيجة:
class Kick : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
when (intent.getStringExtra("what")) {
"service" -> try {
context.startService(Intent(context, UploadService::class.java))
Log.i(TAG, "startService accepted")
} catch (e: Exception) {
Log.e(TAG, "startService refused: ${e.javaClass.simpleName}: ${e.message}")
}
"work" -> enqueueSync(context)
}
}
}ثم أرسلنا الأمر ثلاث مرات: والتطبيق في المقدمة، وبعد 3 ثوان من الضغط على Home، وبعد حوالي 75 ثانية في الخلفية، بالأمر:
adb shell am broadcast -n io.example.life/.Kick --es what serviceوالمخرج من ملف run2.out كان كما يلي:
10-30 21:50:26.498 7090 7090 I LIFE : STEP service while foreground
10-30 21:50:26.813 6996 6996 I LIFE : startService accepted
10-30 21:50:26.817 6996 6996 I LIFE : UploadService started
10-30 21:50:29.196 7117 7117 I LIFE : STEP home
10-30 21:50:30.866 6996 6996 I LIFE : app background
10-30 21:50:32.764 7134 7134 I LIFE : STEP service 3 s after going to background
10-30 21:50:33.105 6996 6996 I LIFE : startService accepted
10-30 21:50:33.109 6996 6996 I LIFE : UploadService started
10-30 21:51:44.004 7265 7265 I LIFE : STEP service 75 s after going to background
10-30 21:51:44.739 601 727 W ActivityManager: Background start not allowed: service Intent { cmp=io.example.life/.UploadService } to io.example.life/.UploadService from pid=6996 uid=10209 pkg=io.example.life startFg?=false
10-30 21:51:44.745 6996 6996 E LIFE : startService refused: BackgroundServiceStartNotAllowedException: Not allowed to start service Intent { cmp=io.example.life/.UploadService }: app is in background uid UidRecord{1d4af76 u0a209 RCVR bg:+1m13s771ms idle change:uncachedprocstateprocadj procs:0 seq(13348,13348)} caps=-------والصورة التالية تضع هذه الأحداث على خط الزمن:

ولاحظ التالي في المخرج:
- في المرتين الأوليين قبل النظام بدء الخدمة، حتى بعد الخروج بثلاث ثوان، والسبب ما يشرحه توثيق حدود التنفيذ في الخلفية في Android 8.0: التطبيق الذي يذهب إلى الخلفية تبقى له نافذة من عدة دقائق يستطيع فيها أن ينشئ الخدمات ويستخدمها، وبعدها يعد خاملاً Idle، ويوقف النظام خدماته كأنها استدعت
stopSelf. - في المرة الثالثة رفض النظام برسالة
Background start not allowedمن خدمة ActivityManager، ووصل إلى تطبيقنا الخطأBackgroundServiceStartNotAllowedException. والجزءbg:+1m13s771ms idleداخل الرسالة يقول إن التطبيق في الخلفية منذ دقيقة و13 ثانية وإنه مصنف خاملاً، أي أن النافذة في هذا المحاكي انتهت في أقل من دقيقة وربع. - والجزء
RCVRيعني أن العملية كانت تعمل لأنها تستقبل رسالة Broadcast، وهذا لا يكفي لبدء خدمة، فاستقبال رسالة لا يجعل التطبيق في المقدمة بالنسبة لهذا القيد.
وهذه بالضبط المشكلة التي يقع فيها من يختبر الكود والتطبيق مفتوح: الكود يعمل في كل مرة على جهازه، ثم يصل تقرير بخطأ IllegalStateException أو BackgroundServiceStartNotAllowedException من المستخدمين، لأن الرسالة التي تبدأ الخدمة تصل عندهم بعد ساعة من آخر استخدام. لذلك لا تبدأ خدمة عادية من الخلفية لعمل مؤجل، واستخدم WorkManager كما في القسم التالي.
الخدمات الأمامية Foreground Services منذ Android 12
والحل الذي انتقل إليه المطورون بعد Android 8 كان الخدمة الأمامية Foreground Service، وهي خدمة تعرض إشعاراً دائماً للمستخدم وتبقى أولويتها عالية، مثل مشغل الموسيقى أو تتبع المندوب على الخريطة أثناء التوصيل. ولكن Android 12 أغلق هذا الباب أيضاً، ويقول توثيق قيود بدء الخدمات الأمامية من الخلفية إن التطبيقات التي تستهدف API 31 وما بعده لا تستطيع بدء خدمة أمامية وهي في الخلفية إلا في حالات استثنائية، وإلا رمى النظام الخطأ ForegroundServiceStartNotAllowedException. وفي التطبيقات التي تستهدف Android 14 وما بعده يجب أن تعلن كل خدمة أمامية نوعها في ال Manifest، مثل location أو mediaPlayback أو dataSync، كما في صفحة أنواع الخدمات الأمامية، ولكل نوع شروط وصلاحيات، وهذا يدخل أيضاً في مراجعة Google Play. والخلاصة أن الخدمة الأمامية لعمل يراه المستخدم ويعرف أنه يحدث الآن، وليست طريقة لإبقاء التطبيق حياً.
أداة WorkManager والشروط Constraints
الطريقة التي يوصي بها توثيق العمل المستمر Persistent Work للعمل الذي يجب أن يكتمل حتى لو خرج المستخدم أو أعيد تشغيل الجهاز هي مكتبة WorkManager. وأنت لا تحدد متى يعمل الكود، وإنما تصف العمل وشروطه، والمكتبة تحفظ الطلب في قاعدة بيانات داخل التطبيق ثم تسلمه لخدمة JobScheduler في النظام، والنظام يشغله عندما تتحقق الشروط. وفي تطبيقنا عرفنا مهمة مزامنة بشرط واحد: أن تكون هناك شبكة.
fun enqueueSync(context: Context) {
val request = OneTimeWorkRequestBuilder<SyncWorker>()
.setConstraints(Constraints(requiredNetworkType = NetworkType.CONNECTED))
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 30, TimeUnit.SECONDS)
.build()
WorkManager.getInstance(context).enqueueUniqueWork("sync", ExistingWorkPolicy.KEEP, request)
Log.i(TAG, "sync enqueued id=${request.id}")
}
class SyncWorker(ctx: Context, params: WorkerParameters) : CoroutineWorker(ctx, params) {
override suspend fun doWork(): Result {
Log.i(TAG, "SyncWorker running attempt=$runAttemptCount")
return Result.success()
}
}في الكود أعلاه لاحظ أن enqueueUniqueWork مع KEEP يمنع تكرار المزامنة إذا ضغط المستخدم الزر عشر مرات، فتبقى مهمة واحدة فقط، وأن setBackoffCriteria يحدد كيف تعاد المحاولة إذا رجعت الدالة Result.retry()، بفواصل تتضاعف تبدأ من 30 ثانية. ثم قطعنا الشبكة عن المحاكي بالأمرين svc wifi disable وsvc data disable، وأضفنا المهمة، وقرأنا حالتها من JobScheduler:
adb shell dumpsys jobscheduler io.example.life Required constraints: CONNECTIVITY FLEXIBILITY [0x10200000]
Satisfied constraints: FLEXIBILITY DEVICE_NOT_DOZING BACKGROUND_NOT_RESTRICTED WITHIN_QUOTA [0x3600000]
Unsatisfied constraints: CONNECTIVITY [0x10000000]وهذا المخرج يشرح بالضبط لماذا لم تعمل المهمة: الشرط المطلوب CONNECTIVITY غير متحقق، بينما بقية الشروط التي يضيفها النظام بنفسه متحققة، مثل DEVICE_NOT_DOZING أي أن الجهاز ليس في السبات، وWITHIN_QUOTA أي أن التطبيق لم يتجاوز حصته من وقت التشغيل في الخلفية. ثم أعدنا الشبكة:
10-30 21:51:56.115 6996 6996 I LIFE : sync enqueued id=0e7e4f8e-d052-47c5-93f4-c5962e49dbdb
10-30 21:52:18.674 7417 7417 I LIFE : STEP network on
10-30 21:52:22.214 6996 7012 I LIFE : SyncWorker running attempt=0
10-30 21:52:22.227 6996 7403 I WM-WorkerWrapper: Worker result SUCCESS for Work [ id=0e7e4f8e-d052-47c5-93f4-c5962e49dbdb, class=io.example.life.SyncWorker, tags={ io.example.life.SyncWorker } ]والمهمة انتظرت 22 ثانية دون شبكة، ثم عملت بعد أقل من 4 ثوان من رجوع الشبكة، على خيط غير الخيط الرئيسي (رقم الخيط 7012 وليس 6996)، وسجلت المكتبة النتيجة SUCCESS. وهذا ما تريده تماماً لمزامنة السلة: تكتب الطلب مرة واحدة والمستخدم في المترو دون تغطية، والنظام يرسله عندما تعود الشبكة، حتى لو كان التطبيق مغلقاً في تلك اللحظة.
وإذا كان العمل يجب أن يبدأ فوراً لأن المستخدم ينتظره، مثل إرسال طلب دفع، فهناك العمل المستعجل Expedited Work بالدالة setExpedited، وإذا كان طويلاً ويجب أن يراه المستخدم يمكن أن يتحول العمل إلى خدمة أمامية من داخل ال Worker. وكل هذا موضح في نفس صفحة التوثيق.
وضع السبات Doze: هل تعمل المهمة في موعدها؟
يدخل الجهاز في وضع السبات Doze عندما يبقى دون شحن وثابتاً وشاشته مطفأة لمدة من الوقت، ويقول توثيق التحسين للسبات ووضع الاستعداد إن النظام في هذا الوضع يوقف وصول التطبيقات إلى الشبكة، ويتجاهل أقفال الاستيقاظ Wake Locks، ويؤجل المنبهات العادية، ولا يشغل JobScheduler، وإن WorkManager يستخدم JobScheduler داخلياً وبالتالي لا تعمل مهامه، ثم يفتح النظام نوافذ صيانة Maintenance Windows قصيرة بين فترات السبات تعمل فيها الأعمال المؤجلة. ولأن الانتظار حتى يدخل المحاكي السبات وحده يستغرق وقتاً طويلاً، أجبرناه عليه بالأوامر التالية من التوثيق نفسه:
adb shell dumpsys battery unplug
adb shell dumpsys deviceidle force-idle
adb shell dumpsys deviceidle get deep
adb shell dumpsys deviceidle unforce
adb shell dumpsys battery resetوالتجربة هنا مهمة بتأخير 60 ثانية دون أي شرط، نضيفها والتطبيق في الخلفية، ثم ندخل الجهاز في السبات العميق فوراً، وننتظر دقيقتين. وفي المحاولة الأولى (run3.sh) كانت النتيجة غير ما توقعناه:
10-30 22:10:09.215 9785 9785 I LIFE : delayed sync enqueued id=f8e5e5b0-8164-423d-aeb9-2a411bb7923c (60 s)
10-30 22:10:12.595 9892 9892 I LIFE : STEP screen off, unplug, force deep idle
10-30 22:11:09.285 9785 9854 I LIFE : SyncWorker running attempt=0
10-30 22:11:09.290 9785 9948 I WM-WorkerWrapper: Worker result SUCCESS for Work [ id=f8e5e5b0-8164-423d-aeb9-2a411bb7923c, class=io.example.life.SyncWorker, tags={ io.example.life.SyncWorker } ]
deep state after 120 s: IDLEالمهمة عملت بعد 60 ثانية بالضبط، والجهاز في حالة السبات العميق IDLE طوال الوقت، وهذا يخالف ما يقوله توثيق السبات. ولاحظ أن رقم العملية في سطر التشغيل 9785 هو نفس رقم العملية التي أضافت المهمة، أي أن عملية التطبيق ما زالت حية لأن النظام لم يحتج ذاكرتها، والمهمة نفذت داخلها. والتفسير الأقرب لما رأيناه أن WorkManager لا يعتمد على JobScheduler وحده ما دامت عملية التطبيق حية، فهو يتابع المهام المؤجلة دون شروط داخل العملية أيضاً، ولم نجد صفحة في التوثيق الرسمي تشرح هذا السلوك، لذلك نذكره كما رأيناه فقط ولا تبن عليه أي شيء، لأن العملية المخزنة يمكن أن تموت في أي لحظة.
لذلك أعدنا التجربة في run3b.sh مع تغيير واحد: أنهينا العملية بالأمر am kill بعد إضافة المهمة وقبل السبات، وهي الحالة الأقرب للواقع بعد ساعة من آخر استخدام للتطبيق:
10-30 22:13:24.517 10078 10078 I LIFE : delayed sync enqueued id=1d55f515-f84b-4bc3-bd62-303dad757085 (60 s)
10-30 22:13:27.916 10178 10178 I LIFE : STEP kill the process, screen off, unplug, force deep idle
pid after kill: []
deep state after 120 s: IDLE
Satisfied constraints: FLEXIBILITY BACKGROUND_NOT_RESTRICTED WITHIN_QUOTA [0x1600000]
Unsatisfied constraints: TIMING_DELAY [0x80000000]
10-30 22:15:31.166 10221 10221 I LIFE : STEP leave doze
10-30 22:15:40.993 10286 10286 I LIFE : process started pid=10286
10-30 22:15:41.108 10286 10286 I LIFE : onTrimMemory level=40
10-30 22:15:41.411 10286 10311 I LIFE : SyncWorker running attempt=0
10-30 22:15:41.773 10286 10307 I WM-WorkerWrapper: Worker result SUCCESS for Work [ id=1d55f515-f84b-4bc3-bd62-303dad757085, class=io.example.life.SyncWorker, tags={ io.example.life.SyncWorker } ]وهنا السلوك الذي يصفه التوثيق: موعد المهمة كان 22:14:24، ومرت عليه أكثر من دقيقة والمهمة لم تعمل، وحالة JobScheduler بعد دقيقتين تقول إن شرط التوقيت TIMING_DELAY غير متحقق، ولاحظ أن DEVICE_NOT_DOZING اختفى من قائمة الشروط المتحققة، أي أن النظام يعرف أن الجهاز في السبات. وبعد الخروج من السبات بعشر ثوان بدأ النظام عملية جديدة رقمها 10286 في الخلفية دون أي شاشة، ووصلها onTrimMemory بالمستوى 40 لأنها عملية في الخلفية من أول لحظة، ثم عملت المهمة. والصورة التالية تضع الحالتين معاً:

والدرس من التجربتين أن نتيجة الاختبار تعتمد على حالة العملية، فإذا اختبرت المهام المؤجلة والتطبيق مفتوح أو خرجت منه للتو فسوف تعمل في موعدها وتظن أن السبات لا يؤثر عليك، بينما عند المستخدم بعد ساعة من آخر استخدام تكون العملية قد ماتت، والمهمة تنتظر نافذة الصيانة التالية، والتي قد تأتي بعد دقائق أو ساعات بحسب مدة السبات. لذلك صمم المزامنة على أنها سوف تحدث «لاحقاً» وليس «بعد دقيقة»، وإذا احتاج المستخدم إشعاراً في وقت محدد فالطريقة الصحيحة رسالة من الخادم عبر الإشعارات الفورية Push Notifications، فرسائل FCM ذات الأولوية العالية تستطيع إيقاظ التطبيق حتى في السبات.
مجموعات الاستعداد App Standby Buckets
وحتى خارج السبات لا تعامل كل التطبيقات بنفس الطريقة، فمنذ Android 9 يضع النظام كل تطبيق في مجموعة بحسب استخدامه، ويشرح توثيق مجموعات الاستعداد خمس مجموعات: نشط Active للتطبيق المستخدم الآن أو قبل قليل، ومجموعة العمل Working Set للتطبيق المستخدم بانتظام، ومتكرر Frequent للتطبيق المستخدم كثيراً ولكن ليس يومياً، ونادر Rare، ومقيد Restricted للتطبيق الذي يستهلك موارد كثيرة أو يتصرف بشكل غير مرغوب. وكلما نزل التطبيق في المجموعات قلت حصته من المهام والمنبهات ووقت الشبكة في الخلفية. وفي التجربة قرأنا مجموعة التطبيق ثم غيرناها:
adb shell am get-standby-bucket io.example.life
adb shell am set-standby-bucket io.example.life restrictedstandby bucket: 10
bucket now: 45والرقم 10 هو مجموعة Active لأننا استخدمنا التطبيق قبل قليل، والرقم 45 هو المجموعة المقيدة. وهذا الأمر مفيد جداً في الاختبار، لأنك تستطيع أن ترى كيف يتصرف تطبيقك عند مستخدم لم يفتحه منذ أسبوع دون أن تنتظر أسبوعاً، ونصيحتي أن تشغل مزامنتك مرة واحدة على الأقل والتطبيق في المجموعة rare وrestricted قبل كل إصدار مهم.
المنبهات الدقيقة Exact Alarms
وماذا لو احتجت وقتاً دقيقاً فعلاً، مثل تذكير بموعد استلام الطلب؟ هنا تأتي المنبهات من AlarmManager، ويشرح توثيق جدولة المنبهات أنواعها، ومنها setExactAndAllowWhileIdle الذي يعمل حتى في السبات. ولكن الصلاحية SCHEDULE_EXACT_ALARM لم تعد تعطى تلقائياً لأغلب التطبيقات الجديدة التي تستهدف Android 13 وما بعده عند تثبيتها على Android 14، كما في صفحة المنبهات الدقيقة مرفوضة افتراضياً، والسبب أن المنبه الدقيق يوقظ الجهاز ويستهلك البطارية، والنظام يريده فقط لما يطلبه المستخدم بنفسه في وقت محدد، مثل المنبه أو التقويم. لذلك لا تستخدمه للمزامنة إطلاقاً، فهذا عمل WorkManager.
البدء البارد Cold Start والدافئ Warm Start والساخن Hot Start
كل ما رأيناه يحدد أيضاً كم ينتظر المستخدم عند فتح التطبيق. ويفرق توثيق زمن بدء التطبيق بين ثلاثة أنواع: البدء البارد عندما لا توجد عملية للتطبيق ويجب أن ينشئها النظام من الصفر، مثل أول فتح بعد تشغيل الجهاز أو بعد أن أنهى النظام التطبيق، والبدء الدافئ عندما تكون العملية موجودة ولكن يجب بناء ال Activity من جديد، أو عندما ترجع العملية بعد موتها مستفيدة من الحالة المحفوظة، والبدء الساخن عندما تكون الشاشة كلها في الذاكرة ويكفي أن يعيدها النظام إلى المقدمة. وفي تجربتنا كان كل فتح بعد am kill أو force-stop بدءاً بارداً، والمخرج من الأمر am start -W قال ذلك صراحة:
LaunchState: COLD
TotalTime: 14005والرقم 14005 ميلي ثانية هنا لا معنى له خارج هذا المحاكي البطيء كما ذكرنا، والمهم هو كلمة COLD: كل موت للعملية في الخلفية يحول رجوع المستخدم من بدء ساخن سريع إلى بدء بارد يعيد تحميل كل شيء، وبالتالي كل ميغابايت توفره في الخلفية عند onTrimMemory يزيد احتمال أن يبقى تطبيقك في الذاكرة ويرجع إليه المستخدم بسرعة. وأما تحسين زمن البدء نفسه، وكيف يرتبط بعدم الاستجابة ANR، فقد ذكرنا جزءاً منه في سؤال التطبيق يأخذ أربع ثوان ليفتح، وفي قسم عدم الاستجابة ANR.
كيف تختبر هذا كله بأوامر adb؟
أغلب مشكلات دورة الحياة لا تظهر في الاختبار اليدوي العادي، لذلك اجمع هذه الأوامر في سكربت واحد، أو أضفها إلى اختبارات الواجهة التي شرحناها في مقال اختبار تطبيقات الجوال مع Appium وMaestro:
| ما تريد اختباره | الأمر | ما يجب أن تراه |
|---|---|---|
| تغيير الإعدادات | adb shell settings put system user_rotation 1 | onDestroy changingConfig=true ثم كائن جديد، والبيانات في مكانها |
| موت العملية | Home ثم adb shell am kill <package> ثم الرجوع من Recents | restored=true في عملية جديدة، والشاشة كما كانت |
| الإيقاف الإجباري | adb shell am force-stop <package> | بدء من الصفر، والبيانات الدائمة فقط |
| حدود الخدمات | انتظر دقيقتين في الخلفية ثم أرسل الحدث الذي يبدأ الخدمة | لا يوجد BackgroundServiceStartNotAllowedException |
| السبات | dumpsys deviceidle force-idle بعد am kill | المهام تنتظر ثم تعمل بعد unforce |
| مجموعات الاستعداد | am set-standby-bucket <package> restricted | المزامنة تتأخر ولا تفشل |
| حالة المهام | dumpsys jobscheduler <package> | الشروط غير المتحققة Unsatisfied constraints |
أخطاء شائعة Common Mistakes
- وضع بيانات المستخدم في ال ViewModel وحده، ظناً أنه «يحفظ الحالة»: رأينا أنه يبقى بعد التدوير فقط، وبعد موت العملية رجع صفراً بينما الشاشة نفسها رجعت.
- الحفظ في
onDestroy: هذه الدالة لم تستدع إطلاقاً عند موت العملية ولا عند الإيقاف الإجباري في التجربة، فآخر مكان مضمون هوonStop، والأفضل أن تحفظ عند كل تغيير مهم دون انتظار أي استدعاء. - منع إعادة الإنشاء ب
android:configChangesلإسكات مشكلة التدوير: موت العملية سوف يضيع نفس البيانات لاحقاً، والمشكلة باقية. - وضع قوائم كبيرة أو صور في الحالة المحفوظة: يبطئ الواجهة عند الحفظ وقد يسبب
TransactionTooLargeException، والصحيح أن تحفظ المعرفات فقط. - افتراض أن الكائنات العامة Singletons أو الكلاس Application تحتفظ بالبيانات طوال الجلسة: بعد موت العملية تبدأ كلها من الصفر، بينما يرجع المستخدم إلى شاشة في منتصف التطبيق.
- بدء خدمة عادية من الخلفية لعمل مؤجل: يعمل على جهاز المطور ويفشل عند المستخدم بعد دقيقة من خروجه، كما رأينا في السطر
bg:+1m13s771ms idle. - استخدام خدمة أمامية أو منبه دقيق لإبقاء التطبيق حياً أو للمزامنة: النظام يقيدهما بصلاحيات وأنواع، وGoogle Play يراجع استخدامهما، والبطارية تدفع الثمن.
- اختبار المهام المؤجلة والتطبيق ما زال حياً: رأينا مهمة تعمل في موعدها في السبات لأن العملية كانت حية، ونفس المهمة تأخرت أكثر من دقيقة عندما ماتت العملية.
- الاعتماد على
onTrimMemoryبمستويات لم تعد تصل منذ API 34، مثلTRIM_MEMORY_RUNNING_LOW. - تجاهل iOS لأن التطبيق «يعمل» على Android: التعليق في iOS يحدث بعد ثوان من الخروج، وأي عمل لم ينته ولم يطلب وقتاً إضافياً يتوقف مكانه.
قائمة تحقق Checklist قبل الإطلاق
- جربت كل شاشة فيها مدخلات بعد التدوير، وبعد تغيير اللغة والوضع الليلي، ولم يضع أي شيء.
- جربت كل شاشة بعد Home ثم
am killثم الرجوع، والتطبيق لم يتعطل ولم يظهر فارغاً. - بيانات المستخدم المهمة (السلة، المسودات، الجلسة) على القرص أو الخادم، وليست في الذاكرة فقط.
- الحالة المحفوظة فيها معرفات ومدخلات صغيرة فقط.
- لا يوجد عمل مهم في
onDestroy، والحفظ يحدث عند التغيير أو فيonStopعلى الأكثر. - العمل في الخلفية عبر WorkManager مع الشروط المناسبة، والمهام الفريدة لا تتكرر.
- لا توجد خدمة عادية تبدأ من الخلفية، والخدمات الأمامية معلنة بنوعها ولها سبب يراه المستخدم.
- جربت المزامنة في السبات بعد إنهاء العملية، وفي المجموعة
restricted. - التطبيق يحرر الذاكرة الخاصة بالواجهة عند
TRIM_MEMORY_UI_HIDDEN. - في iOS: الحفظ عند مغادرة الحالة النشطة، والأعمال الطويلة عبر
beginBackgroundTaskأو BGTaskScheduler.
الخلاصة Summary
وصلنا لنهاية الموضوع، وأهم ما فيه:
- النظام وليس المستخدم هو الذي يدير حياة تطبيق الجوال، وينهي العمليات المخزنة في الخلفية كجزء طبيعي من عمله، بإشارة لا تصل إلى كودك.
- تدوير الشاشة يهدم ال Activity ويبني كائناً جديداً في نفس العملية: الحقل العادي و
rememberضاعا، وال ViewModel والحالة المحفوظة بقيا. - موت العملية في الخلفية يعيد نفس الشاشة في عملية جديدة: الحالة المحفوظة والقرص رجعا، وال ViewModel رجع صفراً، ولم يستدع
onDestroyإطلاقاً. - الإيقاف الإجباري يرمي حتى الحالة المحفوظة، ولا يبقى إلا التخزين الدائم.
- بدء خدمة عادية نجح بعد 3 ثوان من الخروج، ورفض بعد دقيقة و13 ثانية بالخطأ
BackgroundServiceStartNotAllowedException. - مهمة WorkManager بشرط الشبكة انتظرت ثم عملت بعد 4 ثوان من رجوع الشبكة، والمهمة المؤجلة في السبات عملت في موعدها والعملية حية، وتأخرت حتى الخروج من السبات بعد إنهاء العملية.
- iOS يتبع نفس الفلسفة بحالات المشاهد والتعليق وأحداث Jetsam، فاحفظ عند مغادرة الحالة النشطة.
دليلتطبيق الجوال: أصيل Native أم Flutter أم React Native؟بنينا نفس تطبيق الطلبات ثلاث مرات بلغة Kotlin وبإطاري Flutter وReact Native، وقسنا الحجم وزمن البناء والإقلاع والذاكرة لنعرف من أين يأتي كل فرق وكيف تختار.
دليلتطبيق يعمل دون إنترنت Offline-first: التخزين والمزامنةنبني سيرفراً وجوالين بقواعد SQLite ونرى بأرقام حقيقية كيف يضيع التسليم أو يسجل المبلغ مرتين أو يعود الطلب المحذوف، وكيف يمنع ذلك Outbox ومفتاح منع التكرار ودمج الحقول.
دليلالروابط العميقة Deep Links والإشعارات والشراء داخل التطبيقلماذا يفتح رابط الطلب المتصفح بدلاً من تطبيقك، وكيف يثبت نطاقك ملكيته للتطبيق في Android وiOS، وكيف يفتح الإشعار الشاشة الصحيحة، ومتى يلزمك الشراء داخل التطبيق.
دليلالنشر في App Store و Google Play: التوقيع Signing والمراجعةنوقع إصداراً حقيقياً لتطبيق Android ونرى ماذا يرفض الجهاز ولماذا، ثم نمر على ما تطلبه Google وApple في 2026 وأسباب الرفض الشائعة وكيف تتجنبها قبل أول إرسال.
دليلأسئلة مقابلة مطور تطبيقات الجوال من المبتدئ إلى الخبيربنك أسئلة لمقابلة مطور تطبيقات الجوال على Android وiOS، لكل سؤال إجابة المبتدئ وإجابة الخبير، مع تمارين عملية شغلناها.
دليلتقارير الأعطال Crash Reporting في تطبيقات الجوالكيف يصلك عطل حدث في جوال مستخدم لا تعرفه، وكيف تقرأ ال Stack Trace المشفر، ومتى توقف إصداراً جديداً قبل أن يصل إلى الجميع.
دليلاختبار تطبيقات الجوال مع Appium وMaestroكيف تختار أداة اختبار تطبيقات الجوال، وأين تشغل الاختبارات، ولماذا تفشل بعض الاختبارات عندما تصبح الواجهة عربية.
دليلكيف تعمل الإشعارات الفورية Push Notifications؟كيف يصل الإشعار إلى تطبيق مغلق عبر خدمات Apple وGoogle، وما الذي تراه هذه الخدمات من بيانات مستخدميك.تدريب عملي
- خذ أي شاشة نموذج في تطبيقك، واكتب فيها نصاً، ثم نفذ Home و
adb shell am killوارجع إليها. هل بقي النص؟ إذا لم يبق فانقله إلى SavedStateHandle وأعد التجربة. - أضف مهمة WorkManager بتأخير دقيقة، ثم أنه العملية وأدخل المحاكي في السبات بالأوامر أعلاه، وسجل متى عملت المهمة فعلاً.
سجل التحديثات
- أكتوبر 2026: كتابة المقال وتنفيذ التجارب على محاكي Android Emulator 35.4.9 بنظام Android 15 (API 35) على Windows 11، بتطبيق مبني بإضافة Android Gradle Plugin 9.4.1 وGradle 9.8.2 وCompose BOM 2026.09.00 وWorkManager 2.12.0 وLifecycle 2.11.0.