> ## Content Index
> Fetch the complete content index at: https://arabroot.io/llms.txt
> Use this file to discover other available public pages before exploring further.

# دورة حياة تطبيق الجوال Lifecycle والمهام الخلفية
- URL: https://arabroot.io/articles/دورة-حياة-تطبيق-الجوال-lifecycle-الذاكرة-والمهام-الخلفية/
- Published: 2026-10-30T18:43:41.000Z
- Updated: 2026-10-30T22:38:59.000Z
- Description: لماذا يجد المستخدم السلة فارغة بعد مكالمة، وما الذي يبقى فعلاً بعد تدوير الشاشة وموت العملية والسبات Doze، بتجربة حقيقية على Android وشرح ما يقابلها في iOS.
- Author: فريق عرب رووت
- Tags: الأدلة التقنية, تطوير الواجهات والجوال Frontend & Mobile

تفتح تطبيق متجر على جوالك وتضيف ثلاثة منتجات إلى السلة، ثم يرن الهاتف فترد على المكالمة، وبعدها تفتح تطبيق البنك لتتأكد من الرصيد، وتنسخ رمز التحقق من الرسائل، ثم ترجع إلى المتجر بعد خمس دقائق فتجد السلة فارغة والشاشة في أول خطوة من الطلب. وفي تطبيق آخر تكتب رسالة طويلة في نموذج الدعم الفني، وتدير الجوال على جانبه لترى النص أوضح، فيختفي كل ما كتبته. وقد تكون أنت المطور الذي وصلته هذه الشكوى، ثم جربت على جهازك عشر مرات ولم يحدث شيء من ذلك، فالتطبيق يرجع في كل مرة كما تركته، والمشكلة لا تظهر إلا عند المستخدمين.

والسبب في أغلب هذه الحالات ليس خطأ في الشبكة ولا في الخادم (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 خاصة به، ويضع النظام كل عملية في درجة من الأهمية بحسب ما تفعله الآن. ويذكر توثيق [العمليات ودورة حياة التطبيق](https://developer.android.com/guide/components/activities/process-lifecycle?ref=arabroot.io) أن العملية في المقدمة Foreground هي التي تعرض الشاشة التي يلمسها المستخدم الآن، أو تشغل Receiver يعالج رسالة في هذه اللحظة، أو تشغل خدمة أمامية Foreground Service، ثم تأتي العملية المرئية Visible التي تظهر شاشتها ولكن ليست في التركيز، ثم عملية الخدمة Service التي تشغل خدمة بدأت بالأمر `startService`، وفي الأسفل العملية المخزنة Cached وهي تطبيق في الخلفية لا يحتاجه أحد الآن. والصورة التالية تبين هذا الترتيب:

![أربع درجات لأهمية العمليات في Android من الأعلى: المقدمة ثم المرئية ثم الخدمة ثم المخزنة، وعلى اليسار قاتل الذاكرة المنخفضة lmkd يوجه سهماً قوياً إلى العمليات المخزنة وسهماً متقطعاً إلى عمليات الخدمة](https://arabroot.io/content/images/2026/10/mobile-app-lifecycle-01-process-importance.webp)

النظام يرتب العمليات بحسب أهميتها، والإنهاء يبدأ من العمليات المخزنة

ولاحظ أن تطبيقك لا يبقى في درجة واحدة، فعندما يضغط المستخدم على زر الرجوع إلى الشاشة الرئيسية Home ينتقل تطبيقك خلال ثوان من المقدمة إلى العمليات المخزنة، ويقول التوثيق نفسه إن النظام السليم يحتفظ دائماً بعدد من العمليات المخزنة ليسرع التنقل بين التطبيقات، وإنه ينهي هذه العمليات بانتظام كلما احتاج الذاكرة. أي أن إنهاء تطبيقك وهو في الخلفية ليس حادثاً نادراً، وإنما جزء من التشغيل الطبيعي للنظام، وبالتالي يجب أن يكون التطبيق مستعداً له في كل مرة يخرج فيها المستخدم.

### قاتل الذاكرة المنخفضة Low Memory Killer

الجهة التي تنفذ الإنهاء في Android هي خدمة في النظام اسمها `lmkd`، أي Low Memory Killer Daemon، ويشرح توثيق [lmkd في مشروع Android المفتوح](https://source.android.com/docs/core/perf/lmkd?ref=arabroot.io) أنها تراقب ضغط الذاكرة، وأنها في 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.

والكود التالي هو الجزء المهم من الشاشة:

```kotlin
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](https://developer.android.com/reference/androidx/lifecycle/ProcessLifecycleOwner?ref=arabroot.io)، ويطبع مستوى `onTrimMemory` عندما يصل.

وشغلنا التطبيق على محاكي Android Emulator بنظام Android 15 (API 35) على جهاز Windows 11، والمحاكي يرسم الواجهة بالمعالج دون بطاقة رسوميات، والجهاز كان مشغولاً في نفس الوقت ببناء مشاريع أخرى، لذلك أزمنة الفتح في المخرجات (بين 10 و16 ثانية) لا تمثل هاتفاً حقيقياً إطلاقاً، وما يهمنا هنا هو ترتيب الأحداث وما بقي من القيم وليس الزمن. والسكربت `run1.sh` يقوم بالخطوات بأوامر [adb](https://developer.android.com/tools/adb?ref=arabroot.io) بدلاً من اللمس باليد، حتى تستطيع إعادتها كما هي، وهذه أهم أوامره:

```bash
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](https://developer.android.com/guide/components/activities/activity-lifecycle?ref=arabroot.io): الدالة `onCreate` عند إنشاء الكائن، ثم `onStart` عندما تصبح الشاشة مرئية، ثم `onResume` عندما تصبح في المقدمة وتستقبل اللمس، ثم في الاتجاه المعاكس `onPause` عندما تفقد التركيز، و`onStop` عندما لا تعود مرئية، و`onDestroy` عندما ينتهي الكائن. والصورة التالية تجمع هذا الترتيب مع ما سجلناه فعلاً:

![خمسة مربعات ملونة من الأعلى للأسفل onCreate وonStart وonResume وonPause وonStop مع شرح كل منها، وسهم متقطع من onStop إلى onStart للرجوع من الخلفية، وصندوق بالاحتمالات الثلاثة بعد onStop، وصندوق بما سجله ال Logcat عند الضغط على Home](https://arabroot.io/content/images/2026/10/mobile-app-lifecycle-02-activity-lifecycle.webp)

الاستدعاءات بترتيبها، وما سجلته التجربة عند الضغط على Home

### التشغيل الأول Cold Launch: ماذا يقول ال Logcat؟

بعد فتح التطبيق والضغط على الزر ثلاث مرات كان المخرج كما يلي:

```text
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

الآن سوف نقوم بتدوير الشاشة أفقياً والتطبيق في المقدمة، وهذا ما يحدث كل يوم عندما يدير المستخدم جواله ليرى صورة أكبر، والمخرج كان كما يلي:

```text
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`. وهذا السلوك مقصود، ويشرح توثيق [التعامل مع تغيير الإعدادات](https://developer.android.com/guide/topics/resources/runtime-changes?ref=arabroot.io) أن النظام يعيد إنشاء ال Activity عند تغيير اتجاه الشاشة أو اللغة أو حجم الخط أو الوضع الليلي أو حجم النافذة، والسبب أن كل هذه التغييرات قد تحتاج ملفات موارد Resources مختلفة، مثل تصميم خاص للوضع الأفقي أو نصوص بلغة أخرى، والطريقة المضمونة لتحميلها هي بناء الشاشة من جديد.

والآن انظر إلى القيم في سطر `onCreate` الجديد: الحقل العادي رجع إلى 0 لأنه جزء من الكائن القديم الذي انتهى، وال ViewModel بقي 3 لأن النظام يحتفظ به في مخزن خاص أثناء إعادة الإنشاء ويعطيه للكائن الجديد، والحالة المحفوظة بقيت 3 لأنها كتبت في `onSaveInstanceState` قبل الهدم مباشرة، والقرص بقي 3 بالطبع. وأما متغيرات Compose فلا تظهر في هذا السطر لأنها تعيش داخل الواجهة، لذلك التقطنا صورة الشاشة قبل التدوير وبعده:

![شاشة التطبيق عمودية وعنوانها السلة، وفيها السطر field=3 remember=3 rememberSaveable=3 vm=3 savedState=3 disk=3 t=3 وتحته زرا أضف منتجاً ومزامنة لاحقاً](https://arabroot.io/content/images/2026/10/mobile-app-lifecycle-03-before-rotation.webp)

قبل التدوير: كل الأماكن الستة تقول 3

![نفس الشاشة في الوضع الأفقي وفيها السطر field=0 remember=0 rememberSaveable=3 vm=3 savedState=3 disk=3 t=0](https://arabroot.io/content/images/2026/10/mobile-app-lifecycle-04-after-rotation.webp)

بعد التدوير: `remember` والحقل العادي رجعا إلى صفر، والباقي بقي 3

وهذا بالضبط ما حدث للمستخدم الذي كتب رسالة الدعم الفني ثم أدار جواله: النص كان في متغير `remember` أو في حقل عادي، فضاع مع الكائن القديم. والحل هنا بسيط، فإما `rememberSaveable` للقيم الصغيرة في الواجهة مثل نص في حقل إدخال، وإما ViewModel لبيانات الشاشة، ويشرح توثيق [حفظ حالة الواجهة في Compose](https://developer.android.com/develop/ui/compose/state-saving?ref=arabroot.io) متى تختار كل منهما. وقد ناقشنا هذا السؤال من زاوية المقابلة في سؤال [المستخدم أدار الشاشة فاختفى ما كتبه](https://arabroot.io/articles/%D8%A7%D8%B3%D8%A6%D9%84%D8%A9-%D9%85%D9%82%D8%A7%D8%A8%D9%84%D8%A9-%D9%85%D8%B7%D9%88%D8%B1-%D8%AA%D8%B7%D8%A8%D9%8A%D9%82%D8%A7%D8%AA-%D8%A7%D9%84%D8%AC%D9%88%D8%A7%D9%84-%D9%85%D9%86-%D8%A7%D9%84%D9%85%D8%A8%D8%AA%D8%AF%D8%A6-%D8%A7%D9%84%D9%89-%D8%A7%D9%84%D8%AE%D8%A8%D9%8A%D8%B1/#mo-rotation)، أما هنا فالمهم أن ترى أن ال ViewModel يحل مشكلة التدوير فقط، كما سوف نرى في القسم التالي.

وقد يسأل البعض: لماذا لا أمنع إعادة الإنشاء بالخاصية `android:configChanges` في ملف ال Manifest وأرتاح من كل هذا؟ والإجابة أن هذا الحل يخفي المشكلة ولا يحلها، فهو يمنع إعادة الإنشاء عند التدوير، ولكن موت العملية في الخلفية سوف يحدث في كل الأحوال ويضيع نفس البيانات، والتوثيق نفسه ينصح باستخدامه فقط عندما تكون الشاشة قادرة على التعامل مع التغيير بنفسها، مثل مشغل فيديو لا يريد أن يتوقف عند التدوير.

## الانتقال إلى الخلفية Background والدالة onTrimMemory

بعد ذلك ضغطنا على زر Home، وهي اللحظة التي يغادر فيها المستخدم المتجر ليرد على المكالمة أو يفتح تطبيق البنك، والمخرج:

```text
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](https://developer.android.com/reference/android/content/ComponentCallbacks2?ref=arabroot.io) أن المستوى 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 كما هي. ثم رجعنا إلى التطبيق، والمخرج كان كما يلي:

```text
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`.

والصورة التالية تلخص هذه الحالة:

![أربع مراحل من اليمين لليسار: السلة فيها ثلاثة منتجات وpid 5925، ثم النظام ينهي العملية بالرسالة Killing 5925 دون استدعاء onDestroy، ثم المستخدم يرجع وتبدأ العملية 6525 ومعها ال Bundle المحفوظ، ثم ما رجع: savedState وdisk بقيمة 3، وما ضاع: vm وfield بقيمة صفر](https://arabroot.io/content/images/2026/10/mobile-app-lifecycle-05-process-death.webp)

موت العملية في الخلفية ثم الرجوع: الشاشة نفسها، والبيانات التي في الذاكرة ضاعت

وهنا تظهر خطورة هذه الحالة، فالمستخدم يرى نفس الشاشة التي تركها، في نفس المكان من التطبيق، ولا يعرف أن التطبيق مات ورجع، ولكن إذا كانت السلة في ال ViewModel فقط فسوف يجدها فارغة، وإذا كان التطبيق يفترض أن بيانات المستخدم محملة في الذاكرة من شاشة تسجيل الدخول فقد يتعطل التطبيق بالخطأ `NullPointerException` عند أول لمسة. وهذا النوع من الأعطال يظهر في تقارير الأعطال Crash Reports دون أي خطوات واضحة لإعادته، وقد شرحنا كيف تقرأ هذه التقارير في مقال [تقارير الأعطال في تطبيقات الجوال](https://arabroot.io/articles/%D8%AA%D9%82%D8%A7%D8%B1%D9%8A%D8%B1-%D8%A7%D9%84%D8%A7%D8%B9%D8%B7%D8%A7%D9%84-crash-reporting-%D9%81%D9%8A-%D8%AA%D8%B7%D8%A8%D9%8A%D9%82%D8%A7%D8%AA-%D8%A7%D9%84%D8%AC%D9%88%D8%A7%D9%84/).

ولماذا لا يرى المطور هذه الحالة؟ لأن جهاز المطور فيه ذاكرة كبيرة غالباً، والتطبيق تحت التطوير يبقى في الذاكرة طوال الوقت، والمطور لا يفتح عشرة تطبيقات بين كل اختبار وآخر. لذلك عليك أن تجبر هذه الحالة بنفسك: أرسل التطبيق إلى الخلفية ثم نفذ `adb shell am kill` ثم ارجع إليه من قائمة التطبيقات الأخيرة، أو فعل الخيار «Don't keep activities» من خيارات المطور، وهو خيار يهدم كل Activity بمجرد أن تخرج منها، ولكن لاحظ أن هذا الخيار يهدم الشاشة وتبقى العملية نفسها حية، أي أن الكائنات العامة في العملية لا تضيع، لذلك الأمر `am kill` أقرب لما يحدث فعلاً عند المستخدم.

### الإيقاف الإجباري Force Stop والسحب من قائمة التطبيقات الأخيرة Recents

بعد ذلك نفذنا الإيقاف الإجباري، وهو ما يحدث عندما يضغط المستخدم «Force stop» في صفحة التطبيق في الإعدادات، ثم فتحنا التطبيق من جديد:

```text
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؟ جدول من نتائج التجربة

الصورة التالية تجمع نتائج الخطوات الثلاث لكل مكان من الأماكن الستة:

![جدول بستة صفوف وثلاثة أعمدة: الحقل العادي وremember لا تبقى في أي حالة، وrememberSaveable وSavedStateHandle تبقى بعد التدوير وموت العملية وتضيع بعد الإيقاف الإجباري، وViewModel يبقى بعد التدوير فقط، والملف على القرص يبقى في الحالات الثلاث](https://arabroot.io/content/images/2026/10/mobile-app-lifecycle-06-state-survival.webp)

نتيجة التجربة: ما الذي بقي بعد التدوير وموت العملية والإيقاف الإجباري

وهذا يطابق الجدول في توثيق [حفظ حالة الواجهة](https://developer.android.com/topic/libraries/architecture/saving-states?ref=arabroot.io)، وفيه أن ال ViewModel يبقى بعد تغيير الإعدادات ولا يبقى بعد موت العملية الذي يقوم به النظام، والحالة المحفوظة تبقى بعد الاثنين ولا تبقى إذا أغلق المستخدم الشاشة بنفسه، والتخزين الدائم يبقى في كل الحالات. ومن هذا نبني جدول القرار التالي:

| نوع البيانات                 | مثال من المتجر                           | المكان المناسب                              | والسبب                                                                       |
| ---------------------------- | ---------------------------------------- | ------------------------------------------- | ---------------------------------------------------------------------------- |
| حالة مؤقتة في الواجهة        | هل القائمة المنسدلة مفتوحة، موضع التمرير | rememberSaveable أو تتركها تضيع             | صغيرة، وضياعها لا يضر المستخدم كثيراً                                        |
| مدخلات المستخدم التي لم ترسل | نص رسالة الدعم، رقم الصفحة، كلمة البحث   | SavedStateHandle أو rememberSaveable        | يجب أن تبقى بعد موت العملية، وهي صغيرة                                       |
| بيانات الشاشة المحملة        | قائمة المنتجات من الخادم                 | ViewModel، ومصدرها الشبكة أو قاعدة البيانات | كبيرة، ويمكن تحميلها من جديد بعد موت العملية                                 |
| بيانات يملكها المستخدم       | السلة، المسودات، الإعدادات               | قاعدة بيانات Room أو DataStore أو الخادم    | يجب أن تبقى حتى بعد الإيقاف الإجباري أو إعادة تشغيل الجهاز                   |
| بيانات الجلسة                | توكن تسجيل الدخول                        | تخزين مشفر على القرص                        | إذا كانت في الذاكرة فقط فسوف يظهر المستخدم كأنه خرج من حسابه بعد موت العملية |

### حدود الحالة المحفوظة Saved State

قد تسأل: إذا كانت الحالة المحفوظة تبقى بعد موت العملية، فلماذا لا أضع فيها كل شيء، حتى قائمة المنتجات؟ والإجابة أن هذه الحالة تمر عبر آلية Binder في النظام إلى خدمة ActivityManager، وتحفظ هناك خارج تطبيقك، ويقول التوثيق إنها مناسبة للأنواع البسيطة والكائنات الصغيرة مثل النصوص، وإن الكتابة فيها تحتاج تحويل البيانات إلى صيغة قابلة للحفظ Serialization على الخيط الرئيسي Main Thread أثناء تغيير الإعدادات، وبالتالي الكائنات الكبيرة تسبب تقطعاً في الواجهة، وإذا تجاوز حجمها حد ال Binder يتعطل التطبيق بالخطأ `TransactionTooLargeException`. لذلك ضع في الحالة المحفوظة المعرفات والمدخلات فقط، مثل رقم المنتج وكلمة البحث، ثم حمل البيانات الكاملة من قاعدة البيانات أو الشبكة بهذه المعرفات.

## وماذا عن iOS؟ حالات التطبيق App States والمشاهد Scenes

نفس المشكلة موجودة في iOS بأسماء مختلفة، ولم نشغل تطبيق iOS في هذا المقال، لذلك هذا القسم مبني على توثيق Apple فقط. ويشرح توثيق [إدارة دورة حياة التطبيق](https://developer.apple.com/documentation/uikit/managing-your-app-s-life-cycle?ref=arabroot.io) أن iOS منذ الإصدار 13 يتعامل مع كل مشهد Scene على حدة، والمشهد هو نسخة واحدة من واجهة التطبيق، ويمكن أن يكون للتطبيق عدة مشاهد على iPad. ويمر المشهد بحالات: غير متصل Unattached، ثم في المقدمة ونشط Foreground Active، أو في المقدمة وغير نشط Foreground Inactive، مثل لحظة وصول مكالمة أو سحب مركز التحكم، ثم في الخلفية Background، ثم معلق Suspended، وهي حالة يبقى فيها التطبيق في الذاكرة ولكن لا ينفذ أي كود. وفي SwiftUI تقرأ هذه الحالات من القيمة [ScenePhase](https://developer.apple.com/documentation/swiftui/scenephase?ref=arabroot.io) بثلاث قيم: `active` و`inactive` و`background`. والصورة التالية تبين الانتقالات:

![حالات مشهد iOS من اليمين لليسار: غير متصل ثم في المقدمة ونشط مع scenePhase active، وتحته غير نشط عند مكالمة، ثم في الخلفية مع وقت قصير لإنهاء العمل، ثم معلق في الذاكرة دون تنفيذ، ومن المعلق سهم أحمر إلى إنهاء بسبب الذاكرة Jetsam دون أي استدعاء، وصندوق بمهام الخلفية beginBackgroundTask وBGAppRefreshTask وBGProcessingTask](https://arabroot.io/content/images/2026/10/mobile-app-lifecycle-07-ios-states.webp)

حالات المشهد في iOS، والإنهاء من حالة التعليق لا يرسل أي استدعاء

ويقول التوثيق إن على التطبيق عند مغادرة الحالة النشطة أن يحفظ البيانات ويهدئ نشاطه، وعند دخول الخلفية أن ينهي المهام المهمة ويحرر أكبر قدر من الذاكرة، والسبب نفسه الذي رأيناه في Android: UIKit يمكن أن يفصل أي مشهد في الخلفية أو معلق في أي وقت ليستعيد موارده. ولاستعادة الواجهة بعد ذلك يوفر iOS آلية [استعادة حالة التطبيق](https://developer.apple.com/documentation/uikit/restoring-your-app-s-state?ref=arabroot.io)، وفي SwiftUI يقوم `@SceneStorage` بدور قريب من `rememberSaveable`.

### التنفيذ في الخلفية Background Execution والأداة BGTaskScheduler

التطبيق في iOS لا يعمل في الخلفية إلا في حالات محددة، والفكرة الأساسية أنه يعلق بعد وقت قصير من خروجه. فإذا كان هناك عمل بدأ في المقدمة ويجب أن ينتهي، مثل رفع صورة، يطلب التطبيق وقتاً إضافياً بالدالة `beginBackgroundTask` كما في توثيق [تمديد وقت التنفيذ في الخلفية](https://developer.apple.com/documentation/uikit/extending-your-app-s-background-execution-time?ref=arabroot.io)، ويجب أن ينهي العمل ويبلغ النظام قبل انتهاء الوقت، وإلا أنهاه النظام. وللأعمال الدورية يوفر إطار [BackgroundTasks](https://developer.apple.com/documentation/backgroundtasks?ref=arabroot.io) الكلاس [BGTaskScheduler](https://developer.apple.com/documentation/backgroundtasks/bgtaskscheduler?ref=arabroot.io)، وفيه نوعان أساسيان: `BGAppRefreshTask` لتحديث سريع للمحتوى، و`BGProcessingTask` لأعمال أطول يمكن أن تشترط أن يكون الجهاز على الشاحن أو متصلاً بالشبكة. ولاحظ أنك تطلب الجدولة ولا تحدد الموعد، فالنظام هو الذي يختار متى يشغل المهمة بحسب استخدام المستخدم للتطبيق وحالة البطارية، وهي نفس فلسفة WorkManager التي سوف نراها بعد قليل.

### تحذيرات الذاكرة Memory Warnings وأحداث Jetsam

وما يقابل `onTrimMemory` في iOS هو تحذير الذاكرة، ويشرح توثيق [الاستجابة لتحذيرات الذاكرة](https://developer.apple.com/documentation/uikit/responding-to-memory-warnings?ref=arabroot.io) أن UIKit يرسله للتطبيقات العاملة إذا لم يستطع النظام استعادة الذاكرة بإنهاء التطبيقات المعلقة، وأن على التطبيق أن يحرر الصور والملفات التي يمكن تحميلها لاحقاً، وأن يكتب البيانات غير المحفوظة على القرص، لأن عدم تقليل الاستهلاك قد يؤدي إلى إنهائه. والإنهاء بسبب الذاكرة في iOS اسمه Jetsam، ويشرح توثيق [تقارير أحداث Jetsam](https://developer.apple.com/documentation/xcode/identifying-high-memory-use-with-jetsam-event-reports?ref=arabroot.io) أنها تختلف عن تقارير الأعطال، فهي بصيغة JSON وتحتوي على استهلاك الذاكرة لكل التطبيقات ولا تحتوي على Stack Trace لتطبيقك، وأن التطبيق إذا أنهي وهو ظاهر فسوف يبدو للمستخدم كأنه تعطل. وقد شرحنا هذا النوع من الإنهاء من جهة تقارير الأعطال في قسم [إنهاء التطبيق بسبب الذاكرة وال Watchdog](https://arabroot.io/articles/%D8%AA%D9%82%D8%A7%D8%B1%D9%8A%D8%B1-%D8%A7%D9%84%D8%A7%D8%B9%D8%B7%D8%A7%D9%84-crash-reporting-%D9%81%D9%8A-%D8%AA%D8%B7%D8%A8%D9%8A%D9%82%D8%A7%D8%AA-%D8%A7%D9%84%D8%AC%D9%88%D8%A7%D9%84/#oom-watchdog).

## العمل في الخلفية Background Work على Android

نرجع إلى متجرنا، فالمستخدم أضاف المنتجات وخرج من التطبيق، والتطبيق يريد أن يرسل السلة إلى الخادم حتى يجدها المستخدم على جهاز آخر، أو يريد أن يزامن الطلبات كل ساعة. والسؤال: أين يعمل هذا الكود إذا كان التطبيق في الخلفية ويمكن أن يموت في أي لحظة؟ والحل البسيط الذي يكتبه كثير من المطورين هو خدمة Service تعمل في الخلفية، وهو حل كان يعمل قبل Android 8، أما اليوم فسوف نرى ماذا يحدث له.

### حدود الخدمات Background Service Limits منذ Android 8

أضفنا إلى التطبيق خدمة بسيطة `UploadService` ومستقبلاً للرسائل `Kick` نرسل إليه أمراً من adb فيحاول بدء الخدمة ويسجل النتيجة:

```kotlin
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 ثانية في الخلفية، بالأمر:

```bash
adb shell am broadcast -n io.example.life/.Kick --es what service
```

والمخرج من ملف `run2.out` كان كما يلي:

```text
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=-------
```

والصورة التالية تضع هذه الأحداث على خط الزمن:

![خط زمني من اليمين لليسار: في المقدمة عند 21:50:26 قبول، ثم Home عند 21:50:30، ثم قبول بعد 3 ثوان عند 21:50:33، ثم نافذة سماح، ثم رفض عند 21:51:44 بعد دقيقة و13 ثانية بالخطأ BackgroundServiceStartNotAllowedException](https://arabroot.io/content/images/2026/10/mobile-app-lifecycle-08-background-service.webp)

نفس الأمر قبل ونهاية نافذة السماح: قبول ثم رفض

ولاحظ التالي في المخرج:

- في المرتين الأوليين قبل النظام بدء الخدمة، حتى بعد الخروج بثلاث ثوان، والسبب ما يشرحه توثيق [حدود التنفيذ في الخلفية في Android 8.0](https://developer.android.com/about/versions/oreo/background?ref=arabroot.io): التطبيق الذي يذهب إلى الخلفية تبقى له نافذة من عدة دقائق يستطيع فيها أن ينشئ الخدمات ويستخدمها، وبعدها يعد خاملاً 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 أغلق هذا الباب أيضاً، ويقول توثيق [قيود بدء الخدمات الأمامية من الخلفية](https://developer.android.com/develop/background-work/services/fgs/restrictions-bg-start?ref=arabroot.io) إن التطبيقات التي تستهدف API 31 وما بعده لا تستطيع بدء خدمة أمامية وهي في الخلفية إلا في حالات استثنائية، وإلا رمى النظام الخطأ `ForegroundServiceStartNotAllowedException`. وفي التطبيقات التي تستهدف Android 14 وما بعده يجب أن تعلن كل خدمة أمامية نوعها في ال Manifest، مثل `location` أو `mediaPlayback` أو `dataSync`، كما في صفحة [أنواع الخدمات الأمامية](https://developer.android.com/develop/background-work/services/fgs/service-types?ref=arabroot.io)، ولكل نوع شروط وصلاحيات، وهذا يدخل أيضاً في مراجعة Google Play. والخلاصة أن الخدمة الأمامية لعمل يراه المستخدم ويعرف أنه يحدث الآن، وليست طريقة لإبقاء التطبيق حياً.

### أداة WorkManager والشروط Constraints

الطريقة التي يوصي بها توثيق [العمل المستمر Persistent Work](https://developer.android.com/develop/background-work/background-tasks/persistent?ref=arabroot.io) للعمل الذي يجب أن يكتمل حتى لو خرج المستخدم أو أعيد تشغيل الجهاز هي مكتبة WorkManager. وأنت لا تحدد متى يعمل الكود، وإنما تصف العمل وشروطه، والمكتبة تحفظ الطلب في قاعدة بيانات داخل التطبيق ثم تسلمه لخدمة JobScheduler في النظام، والنظام يشغله عندما تتحقق الشروط. وفي تطبيقنا عرفنا مهمة مزامنة بشرط واحد: أن تكون هناك شبكة.

```kotlin
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:

```bash
adb shell dumpsys jobscheduler io.example.life
```

```text
    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` أي أن التطبيق لم يتجاوز حصته من وقت التشغيل في الخلفية. ثم أعدنا الشبكة:

```text
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 عندما يبقى دون شحن وثابتاً وشاشته مطفأة لمدة من الوقت، ويقول توثيق [التحسين للسبات ووضع الاستعداد](https://developer.android.com/training/monitoring-device-state/doze-standby?ref=arabroot.io) إن النظام في هذا الوضع يوقف وصول التطبيقات إلى الشبكة، ويتجاهل أقفال الاستيقاظ Wake Locks، ويؤجل المنبهات العادية، ولا يشغل JobScheduler، وإن WorkManager يستخدم JobScheduler داخلياً وبالتالي لا تعمل مهامه، ثم يفتح النظام نوافذ صيانة Maintenance Windows قصيرة بين فترات السبات تعمل فيها الأعمال المؤجلة. ولأن الانتظار حتى يدخل المحاكي السبات وحده يستغرق وقتاً طويلاً، أجبرناه عليه بالأوامر التالية من التوثيق نفسه:

```bash
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`) كانت النتيجة غير ما توقعناه:

```text
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` بعد إضافة المهمة وقبل السبات، وهي الحالة الأقرب للواقع بعد ساعة من آخر استخدام للتطبيق:

```text
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 لأنها عملية في الخلفية من أول لحظة، ثم عملت المهمة. والصورة التالية تضع الحالتين معاً:

![خطان زمنيان: الأول والعملية حية، المهمة أضيفت 22:10:09 والسبات 22:10:12 والمهمة عملت في موعدها 22:11:09 والجهاز في السبات العميق، والثاني والعملية منتهية، المهمة أضيفت 22:13:24 وam kill وforce-idle 22:13:27، وموعدها 22:14:24 ولم تعمل، ثم unforce 22:15:31 ثم عملية جديدة وتعمل المهمة 22:15:41](https://arabroot.io/content/images/2026/10/mobile-app-lifecycle-09-doze.webp)

نفس المهمة بتأخير 60 ثانية: في موعدها والعملية حية، ومؤجلة حتى الخروج من السبات بعد إنهاء العملية

والدرس من التجربتين أن نتيجة الاختبار تعتمد على حالة العملية، فإذا اختبرت المهام المؤجلة والتطبيق مفتوح أو خرجت منه للتو فسوف تعمل في موعدها وتظن أن السبات لا يؤثر عليك، بينما عند المستخدم بعد ساعة من آخر استخدام تكون العملية قد ماتت، والمهمة تنتظر نافذة الصيانة التالية، والتي قد تأتي بعد دقائق أو ساعات بحسب مدة السبات. لذلك صمم المزامنة على أنها سوف تحدث «لاحقاً» وليس «بعد دقيقة»، وإذا احتاج المستخدم إشعاراً في وقت محدد فالطريقة الصحيحة رسالة من الخادم عبر [الإشعارات الفورية Push Notifications](https://arabroot.io/articles/%D9%83%D9%8A%D9%81-%D8%AA%D8%B9%D9%85%D9%84-%D8%A7%D9%84%D8%A7%D8%B4%D8%B9%D8%A7%D8%B1%D8%A7%D8%AA-%D8%A7%D9%84%D9%81%D9%88%D8%B1%D9%8A%D8%A9-push-notifications/)، فرسائل FCM ذات الأولوية العالية تستطيع إيقاظ التطبيق حتى في السبات.

### مجموعات الاستعداد App Standby Buckets

وحتى خارج السبات لا تعامل كل التطبيقات بنفس الطريقة، فمنذ Android 9 يضع النظام كل تطبيق في مجموعة بحسب استخدامه، ويشرح توثيق [مجموعات الاستعداد](https://developer.android.com/topic/performance/appstandby?ref=arabroot.io) خمس مجموعات: نشط Active للتطبيق المستخدم الآن أو قبل قليل، ومجموعة العمل Working Set للتطبيق المستخدم بانتظام، ومتكرر Frequent للتطبيق المستخدم كثيراً ولكن ليس يومياً، ونادر Rare، ومقيد Restricted للتطبيق الذي يستهلك موارد كثيرة أو يتصرف بشكل غير مرغوب. وكلما نزل التطبيق في المجموعات قلت حصته من المهام والمنبهات ووقت الشبكة في الخلفية. وفي التجربة قرأنا مجموعة التطبيق ثم غيرناها:

```bash
adb shell am get-standby-bucket io.example.life
adb shell am set-standby-bucket io.example.life restricted
```

```text
standby bucket: 10
bucket now: 45
```

والرقم 10 هو مجموعة Active لأننا استخدمنا التطبيق قبل قليل، والرقم 45 هو المجموعة المقيدة. وهذا الأمر مفيد جداً في الاختبار، لأنك تستطيع أن ترى كيف يتصرف تطبيقك عند مستخدم لم يفتحه منذ أسبوع دون أن تنتظر أسبوعاً، ونصيحتي أن تشغل مزامنتك مرة واحدة على الأقل والتطبيق في المجموعة `rare` و`restricted` قبل كل إصدار مهم.

### المنبهات الدقيقة Exact Alarms

وماذا لو احتجت وقتاً دقيقاً فعلاً، مثل تذكير بموعد استلام الطلب؟ هنا تأتي المنبهات من AlarmManager، ويشرح توثيق [جدولة المنبهات](https://developer.android.com/develop/background-work/services/alarms/schedule?ref=arabroot.io) أنواعها، ومنها `setExactAndAllowWhileIdle` الذي يعمل حتى في السبات. ولكن الصلاحية `SCHEDULE_EXACT_ALARM` لم تعد تعطى تلقائياً لأغلب التطبيقات الجديدة التي تستهدف Android 13 وما بعده عند تثبيتها على Android 14، كما في صفحة [المنبهات الدقيقة مرفوضة افتراضياً](https://developer.android.com/about/versions/14/changes/schedule-exact-alarms?ref=arabroot.io)، والسبب أن المنبه الدقيق يوقظ الجهاز ويستهلك البطارية، والنظام يريده فقط لما يطلبه المستخدم بنفسه في وقت محدد، مثل المنبه أو التقويم. لذلك لا تستخدمه للمزامنة إطلاقاً، فهذا عمل WorkManager.

## البدء البارد Cold Start والدافئ Warm Start والساخن Hot Start

كل ما رأيناه يحدد أيضاً كم ينتظر المستخدم عند فتح التطبيق. ويفرق توثيق [زمن بدء التطبيق](https://developer.android.com/topic/performance/vitals/launch-time?ref=arabroot.io) بين ثلاثة أنواع: البدء البارد عندما لا توجد عملية للتطبيق ويجب أن ينشئها النظام من الصفر، مثل أول فتح بعد تشغيل الجهاز أو بعد أن أنهى النظام التطبيق، والبدء الدافئ عندما تكون العملية موجودة ولكن يجب بناء ال Activity من جديد، أو عندما ترجع العملية بعد موتها مستفيدة من الحالة المحفوظة، والبدء الساخن عندما تكون الشاشة كلها في الذاكرة ويكفي أن يعيدها النظام إلى المقدمة. وفي تجربتنا كان كل فتح بعد `am kill` أو `force-stop` بدءاً بارداً، والمخرج من الأمر `am start -W` قال ذلك صراحة:

```text
LaunchState: COLD
TotalTime: 14005
```

والرقم 14005 ميلي ثانية هنا لا معنى له خارج هذا المحاكي البطيء كما ذكرنا، والمهم هو كلمة `COLD`: كل موت للعملية في الخلفية يحول رجوع المستخدم من بدء ساخن سريع إلى بدء بارد يعيد تحميل كل شيء، وبالتالي كل ميغابايت توفره في الخلفية عند `onTrimMemory` يزيد احتمال أن يبقى تطبيقك في الذاكرة ويرجع إليه المستخدم بسرعة. وأما تحسين زمن البدء نفسه، وكيف يرتبط بعدم الاستجابة ANR، فقد ذكرنا جزءاً منه في سؤال [التطبيق يأخذ أربع ثوان ليفتح](https://arabroot.io/articles/%D8%A7%D8%B3%D8%A6%D9%84%D8%A9-%D9%85%D9%82%D8%A7%D8%A8%D9%84%D8%A9-%D9%85%D8%B7%D9%88%D8%B1-%D8%AA%D8%B7%D8%A8%D9%8A%D9%82%D8%A7%D8%AA-%D8%A7%D9%84%D8%AC%D9%88%D8%A7%D9%84-%D9%85%D9%86-%D8%A7%D9%84%D9%85%D8%A8%D8%AA%D8%AF%D8%A6-%D8%A7%D9%84%D9%89-%D8%A7%D9%84%D8%AE%D8%A8%D9%8A%D8%B1/#mo-startup)، وفي قسم [عدم الاستجابة ANR](https://arabroot.io/articles/%D8%AA%D9%82%D8%A7%D8%B1%D9%8A%D8%B1-%D8%A7%D9%84%D8%A7%D8%B9%D8%B7%D8%A7%D9%84-crash-reporting-%D9%81%D9%8A-%D8%AA%D8%B7%D8%A8%D9%8A%D9%82%D8%A7%D8%AA-%D8%A7%D9%84%D8%AC%D9%88%D8%A7%D9%84/#anr-hang).

## كيف تختبر هذا كله بأوامر adb؟

أغلب مشكلات دورة الحياة لا تظهر في الاختبار اليدوي العادي، لذلك اجمع هذه الأوامر في سكربت واحد، أو أضفها إلى اختبارات الواجهة التي شرحناها في مقال [اختبار تطبيقات الجوال مع Appium وMaestro](https://arabroot.io/articles/%D8%A7%D8%AE%D8%AA%D8%A8%D8%A7%D8%B1-%D8%AA%D8%B7%D8%A8%D9%8A%D9%82%D8%A7%D8%AA-%D8%A7%D9%84%D8%AC%D9%88%D8%A7%D9%84-%D9%85%D8%B9-appium-%D9%88-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، فاحفظ عند مغادرة الحالة النشطة.

[![](https://arabroot.io/content/images/2026/10/mobile-native-vs-cross-cover-cover.webp)دليلتطبيق الجوال: أصيل Native أم Flutter أم React Native؟بنينا نفس تطبيق الطلبات ثلاث مرات بلغة Kotlin وبإطاري Flutter وReact Native، وقسنا الحجم وزمن البناء والإقلاع والذاكرة لنعرف من أين يأتي كل فرق وكيف تختار.](https://arabroot.io/articles/%D8%AA%D8%B7%D8%A8%D9%8A%D9%82-%D8%A7%D9%84%D8%AC%D9%88%D8%A7%D9%84-%D8%A7%D8%B5%D9%8A%D9%84-native-%D8%A7%D9%85-flutter-%D8%A7%D9%85-react-native/)[![](https://arabroot.io/content/images/2026/10/mobile-offline-first-cover-cover.webp)دليلتطبيق يعمل دون إنترنت Offline-first: التخزين والمزامنةنبني سيرفراً وجوالين بقواعد SQLite ونرى بأرقام حقيقية كيف يضيع التسليم أو يسجل المبلغ مرتين أو يعود الطلب المحذوف، وكيف يمنع ذلك Outbox ومفتاح منع التكرار ودمج الحقول.](https://arabroot.io/articles/%D8%AA%D8%B7%D8%A8%D9%8A%D9%82-%D9%8A%D8%B9%D9%85%D9%84-%D8%AF%D9%88%D9%86-%D8%A7%D9%86%D8%AA%D8%B1%D9%86%D8%AA-offline-first-%D8%A7%D9%84%D8%AA%D8%AE%D8%B2%D9%8A%D9%86-%D9%88%D8%A7%D9%84%D9%85%D8%B2%D8%A7%D9%85%D9%86%D8%A9/)[![](https://arabroot.io/content/images/2026/10/mobile-deep-links-push-cover-cover.webp)دليلالروابط العميقة Deep Links والإشعارات والشراء داخل التطبيقلماذا يفتح رابط الطلب المتصفح بدلاً من تطبيقك، وكيف يثبت نطاقك ملكيته للتطبيق في Android وiOS، وكيف يفتح الإشعار الشاشة الصحيحة، ومتى يلزمك الشراء داخل التطبيق.](https://arabroot.io/articles/%D8%A7%D9%84%D8%B1%D9%88%D8%A7%D8%A8%D8%B7-%D8%A7%D9%84%D8%B9%D9%85%D9%8A%D9%82%D8%A9-deep-links-%D9%88%D8%A7%D9%84%D8%A7%D8%B4%D8%B9%D8%A7%D8%B1%D8%A7%D8%AA-%D9%88%D8%A7%D9%84%D8%B4%D8%B1%D8%A7%D8%A1-%D8%AF%D8%A7%D8%AE%D9%84-%D8%A7%D9%84%D8%AA%D8%B7%D8%A8%D9%8A%D9%82/)[![](https://arabroot.io/content/images/2026/10/mobile-store-publishing-cover-cover.webp)دليلالنشر في App Store و Google Play: التوقيع Signing والمراجعةنوقع إصداراً حقيقياً لتطبيق Android ونرى ماذا يرفض الجهاز ولماذا، ثم نمر على ما تطلبه Google وApple في 2026 وأسباب الرفض الشائعة وكيف تتجنبها قبل أول إرسال.](https://arabroot.io/articles/%D9%86%D8%B4%D8%B1-%D8%AA%D8%B7%D8%A8%D9%8A%D9%82%D8%A7%D8%AA-%D8%A7%D9%84%D8%AC%D9%88%D8%A7%D9%84-%D9%81%D9%8A-app-store-%D9%88-google-play/)[![](https://arabroot.io/content/images/2026/10/interview-mobile-cover-cover.webp)دليلأسئلة مقابلة مطور تطبيقات الجوال من المبتدئ إلى الخبيربنك أسئلة لمقابلة مطور تطبيقات الجوال على Android وiOS، لكل سؤال إجابة المبتدئ وإجابة الخبير، مع تمارين عملية شغلناها.](https://arabroot.io/articles/%D8%A7%D8%B3%D8%A6%D9%84%D8%A9-%D9%85%D9%82%D8%A7%D8%A8%D9%84%D8%A9-%D9%85%D8%B7%D9%88%D8%B1-%D8%AA%D8%B7%D8%A8%D9%8A%D9%82%D8%A7%D8%AA-%D8%A7%D9%84%D8%AC%D9%88%D8%A7%D9%84-%D9%85%D9%86-%D8%A7%D9%84%D9%85%D8%A8%D8%AA%D8%AF%D8%A6-%D8%A7%D9%84%D9%89-%D8%A7%D9%84%D8%AE%D8%A8%D9%8A%D8%B1/)[![](https://arabroot.io/content/images/2026/10/mobile-crash-basics-cover-cover.webp)دليلتقارير الأعطال Crash Reporting في تطبيقات الجوالكيف يصلك عطل حدث في جوال مستخدم لا تعرفه، وكيف تقرأ ال Stack Trace المشفر، ومتى توقف إصداراً جديداً قبل أن يصل إلى الجميع.](https://arabroot.io/articles/%D8%AA%D9%82%D8%A7%D8%B1%D9%8A%D8%B1-%D8%A7%D9%84%D8%A7%D8%B9%D8%B7%D8%A7%D9%84-crash-reporting-%D9%81%D9%8A-%D8%AA%D8%B7%D8%A8%D9%8A%D9%82%D8%A7%D8%AA-%D8%A7%D9%84%D8%AC%D9%88%D8%A7%D9%84/)[![](https://arabroot.io/content/images/2026/10/mobile-testing-cover-cover.webp)دليلاختبار تطبيقات الجوال مع Appium وMaestroكيف تختار أداة اختبار تطبيقات الجوال، وأين تشغل الاختبارات، ولماذا تفشل بعض الاختبارات عندما تصبح الواجهة عربية.](https://arabroot.io/articles/%D8%A7%D8%AE%D8%AA%D8%A8%D8%A7%D8%B1-%D8%AA%D8%B7%D8%A8%D9%8A%D9%82%D8%A7%D8%AA-%D8%A7%D9%84%D8%AC%D9%88%D8%A7%D9%84-%D9%85%D8%B9-appium-%D9%88-maestro/)[![](https://arabroot.io/content/images/2026/10/push-basics-cover-cover.webp)دليلكيف تعمل الإشعارات الفورية Push Notifications؟كيف يصل الإشعار إلى تطبيق مغلق عبر خدمات Apple وGoogle، وما الذي تراه هذه الخدمات من بيانات مستخدميك.](https://arabroot.io/articles/%D9%83%D9%8A%D9%81-%D8%AA%D8%B9%D9%85%D9%84-%D8%A7%D9%84%D8%A7%D8%B4%D8%B9%D8%A7%D8%B1%D8%A7%D8%AA-%D8%A7%D9%84%D9%81%D9%88%D8%B1%D9%8A%D8%A9-push-notifications/)

## تدريب عملي

- خذ أي شاشة نموذج في تطبيقك، واكتب فيها نصاً، ثم نفذ 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.