> ## 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.

# تقارير الأعطال Crash Reporting في تطبيقات الجوال
- URL: https://arabroot.io/articles/تقارير-الاعطال-crash-reporting-في-تطبيقات-الجوال/
- Published: 2026-10-22T22:00:00.000Z
- Updated: 2026-10-23T10:37:31.000Z
- Description: كيف يصلك عطل حدث في جوال مستخدم لا تعرفه، وكيف تقرأ ال Stack Trace المشفر، ومتى توقف إصداراً جديداً قبل أن يصل إلى الجميع.
- Author: فريق عرب رووت
- Tags: الأدلة التقنية, الاستضافة الذاتية والتطبيقات, DevOps وCI/CD, السيادة الرقمية والامتثال

تنشر إصداراً جديداً من تطبيقك في المتجر، وبعد يومين يصلك تقييم بنجمة واحدة: «التطبيق يقفل عند الدفع»، ولا شيء غير ذلك، فلا تعرف نوع الجوال ولا نسخة النظام ولا الخطوة التي سبقت المشكلة، وعند تجربتك على جوالك يعمل كل شيء. وفي تطبيقات الويب كان الحل أداة تتبع الأخطاء Error Tracking التي شرحناها في [دليل GlitchTip](https://arabroot.io/articles/%D8%AA%D8%AA%D8%A8%D8%B9-%D8%A7%D9%84%D8%A7%D8%AE%D8%B7%D8%A7%D8%A1-error-tracking-%D9%85%D8%B9-glitchtip/)، لأن الخطأ يحدث على سيرفرك أو في متصفح يستطيع أن يرسله فوراً، وأما في الجوال فالعطل يحدث على جهاز مستخدم لا تعرفه، والتطبيق نفسه يتوقف في لحظة العطل، والكود الذي وصله المستخدم مضغوط ومغيرة أسماؤه، فيصلك ال Stack Trace بأسماء مثل `a.b.a` لا تدل على شيء.

وهذا هو عمل تقارير الأعطال Crash Reporting في تطبيقات الجوال: أن يصل العطل من جهاز المستخدم إليك، وأن يصبح مقروءاً، وأن تعرف كم مستخدماً تأثر به وفي أي إصدار، وأن تقرر بناءً على ذلك هل توقف الإصدار الجديد قبل أن يصل إلى الجميع. وفي هذا المقال سوف نتناول:

- كيف يصل العطل من جوال المستخدم إليك، ولماذا يرسل التقرير في التشغيل التالي وليس لحظة العطل.
- أنواع الأعطال: ال Crash وال ANR وتعليق التطبيق App Hang والأخطاء غير القاتلة Non-fatal، وإنهاء التطبيق بسبب الذاكرة Out of Memory أو ال Watchdog في iOS، وأعطال الكود الأصلي Native في NDK.
- استعادة الأسماء والأسطر Symbolication: ملف ال Mapping في Android، وال dSYM في iOS، ورموز مكتبات NDK، مع تجربة حقيقية في Docker تعيد Stack Trace مشفراً إلى أصله، وماذا يحدث إذا استخدمت ملف Mapping من بناء آخر.
- مقاييس الاستقرار: نسبة المستخدمين دون أعطال Crash-free Users ونسبة الجلسات Crash-free Sessions، وحدود Android vitals في Google Play، وXcode Organizer.
- السياق الذي يجعل التقرير مفيداً: ال Breadcrumbs ورقم الإصدار، ثم الإطلاق التدريجي Staged Rollout ومتى توقفه.
- ما الذي لا يجب أن يخرج من التطبيق في تقرير العطل، وما يقوله نظام حماية البيانات الشخصية في المملكة.

والمواضيع في هذه السلسلة:

1. مفاهيم تقارير الأعطال في تطبيقات الجوال (الموضوع الحالي).
2. أدوات تقارير أعطال الجوال: Firebase Crashlytics والخدمات التجارية والبدائل مفتوحة المصدر.
3. مختبر عملي: أعطال تطبيق Android تصل إلى سيرفرك مع قراءة ال Stack Trace.

## كيف يصل العطل Crash من جوال المستخدم إليك؟

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

![مسار تقرير العطل في خمس خطوات: جوال المستخدم فيه التطبيق ومكتبة ال SDK تلتقط العطل وتكتب تقريراً على القرص، ثم يتوقف التطبيق، وفي التشغيل التالي يرسل التقرير إلى خدمة تقارير الأعطال، والخدمة تجمع الأعطال المتشابهة في مشكلة واحدة وتعيد الأسماء والأسطر بملف ال Mapping أو ال dSYM الذي رفعته عند البناء، ثم تعرض لوحة بنسبة المستخدمين دون أعطال وترسل تنبيهاً للمطور](https://arabroot.io/content/images/2026/10/mobile-crash-basics-01-how-it-works.webp)

العطل يكتب على القرص لحظة حدوثه، ويرسل في التشغيل التالي

وفيما يلي الخطوات بالتفصيل:

1. **تسجيل معالج العطل Crash Handler:** عند تشغيل التطبيق تسجل المكتبة نفسها لتستقبل كل استثناء Exception لم يعالجه الكود، ففي Java وKotlin تستخدم `Thread.setDefaultUncaughtExceptionHandler`، وفي الكود الأصلي C/C++ تسجل معالجاً للإشارات Signals مثل `SIGSEGV` و`SIGABRT`، وفي iOS تلتقط استثناءات Mach والإشارات واستثناءات Objective-C وSwift.
2. **كتابة التقرير على القرص:** عند العطل تجمع المكتبة ال Stack Trace لكل Thread ومعلومات الجهاز والإصدار وآخر الأحداث، وتكتبها في ملف داخل مساحة التطبيق.
3. **توقف التطبيق:** النظام ينهي العملية Process كما كان سيحدث دون المكتبة، ويرى المستخدم التطبيق يغلق.
4. **الإرسال في التشغيل التالي:** عندما يفتح المستخدم التطبيق مرة أخرى تجد المكتبة التقرير المحفوظ وترسله إلى الخدمة ثم تحذفه.
5. **التجميع واستعادة الأسماء:** الخدمة تجمع التقارير المتشابهة في مشكلة واحدة Issue، وتحول العناوين والأسماء المشفرة إلى أسماء الدوال والملفات والأسطر الأصلية بملفات الرموز التي رفعتها عند البناء، ثم تحسب المقاييس وترسل التنبيهات.

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

## ليس كل خروج للتطبيق Crash: أنواع الأعطال

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

![خمس بطاقات لأنواع الأعطال: العطل Crash باستثناء Java أو إشارة SIGSEGV ويصل مع Stack Trace كامل، وعدم الاستجابة ANR في Android وتعليق التطبيق App Hang في iOS عندما يتوقف ال Main Thread، والإنهاء بسبب الذاكرة Out of Memory الذي ينفذه النظام دون Stack Trace، وإنهاء ال Watchdog في iOS بالرمز 0x8badf00d، والخطأ غير القاتل Non-fatal الذي يعالجه الكود ويسجله دون أن يغلق التطبيق](https://arabroot.io/content/images/2026/10/mobile-crash-basics-02-crash-types.webp)

ما يراه المستخدم واحد، وما يصلك منه يختلف بحسب النوع

| النوع                               | ماذا يحدث                                             | هل يصلك Stack Trace؟                    | من يلتقطه                                      |
| ----------------------------------- | ----------------------------------------------------- | --------------------------------------- | ---------------------------------------------- |
| العطل Crash في Java/Kotlin أو Swift | استثناء لم يعالجه الكود فأنهى العملية                 | نعم، كاملاً                             | مكتبة تقارير الأعطال، ونظام التشغيل            |
| عطل الكود الأصلي Native Crash       | إشارة مثل SIGSEGV في مكتبة C/C++                      | نعم، عناوين تحتاج ملفات رموز            | المكتبة إذا كان فيها جزء Native، ونظام التشغيل |
| عدم الاستجابة ANR في Android        | ال Main Thread متوقف 5 ثوان عن الرد على لمسة أو زر    | نعم، Stack Trace لل Threads وقت الحادثة | النظام، والمكتبة في التشغيل التالي             |
| تعليق التطبيق App Hang في iOS       | ال Main Thread متوقف فلا تستجيب الواجهة               | بحسب الأداة والمدة                      | MetricKit، والمكتبة                            |
| إنهاء بسبب الذاكرة Out of Memory    | النظام ينهي التطبيق لأنه استهلك ذاكرة أكثر من المسموح | لا                                      | النظام فقط، والمكتبة تستنتجه في التشغيل التالي |
| إنهاء ال Watchdog في iOS            | التطبيق تأخر في التشغيل أو في الاستجابة فأنهاه النظام | يصلك تقرير من Apple، والمكتبة تستنتجه   | النظام، والمكتبة بالاستنتاج                    |
| الخطأ غير القاتل Non-fatal          | استثناء عالجه الكود ثم سجله المطور                    | نعم                                     | المكتبة، عندما تستدعيها أنت                    |

### عدم الاستجابة ANR وتعليق التطبيق App Hang

كل ما تراه على الشاشة يرسمه Thread واحد هو ال Main Thread، وهو نفسه الذي يستقبل اللمسات، فإذا وضعت عليه طلب شبكة متزامناً Synchronous، أو قراءة ملف JSON كبير، أو استعلاماً ثقيلاً من قاعدة البيانات، فإن الواجهة تتجمد حتى ينتهي. وفي Android يسمي النظام هذه الحالة «التطبيق لا يستجيب» Application Not Responding واختصاراً ANR، وبحسب [صفحة ANR في توثيق Android](https://developer.android.com/topic/performance/vitals/anr?ref=arabroot.io) فإن أشهر أسبابها أن التطبيق لم يرد على حدث إدخال مثل لمسة أو زر خلال 5 ثوان، ومن أسبابها أيضاً BroadcastReceiver لم ينته في وقته، وخدمة Service بدأت في المقدمة Foreground ولم تستدع `startForeground` خلال 5 ثوان. وعند حدوثها في المقدمة يعرض النظام نافذة تسمح للمستخدم بإغلاق التطبيق، وأغلب المستخدمين يغلقونه فعلاً، ولذلك يحسبها Google Play عطلاً موازياً لل Crash كما سوف نرى في [Android vitals](#vitals).

وفي iOS لا توجد نافذة ANR، ولكن المشكلة نفسها تسمى تعليق التطبيق App Hang، وإذا طالت أثناء التشغيل أو العودة من الخلفية ينهي النظام التطبيق، وهذا ما يسمى إنهاء ال Watchdog كما في الفقرة التالية.

### إنهاء التطبيق بسبب الذاكرة Out of Memory وال Watchdog في iOS

هناك حالات يغلق فيها التطبيق دون أن يتعطل الكود نفسه، لأن نظام التشغيل هو الذي قرر إنهاءه، وأشهرها حالتان:

- **الذاكرة Out of Memory:** عندما تنخفض الذاكرة المتاحة يطلب iOS من التطبيقات تحرير ما تستطيع، فإذا لم يكف ذلك أنهى بعضها، وتسمي Apple هذا الحدث Jetsam. وبحسب [صفحة تقارير Jetsam](https://developer.apple.com/documentation/xcode/identifying-high-memory-use-with-jetsam-event-reports?ref=arabroot.io) فإن تقريره بصيغة JSON ويحتوي على استهلاك الذاكرة لكل العمليات في الجهاز، ولا يحتوي على Stack Trace لأي Thread في تطبيقك، وإذا أنهى النظام تطبيقك وهو ظاهر على الشاشة فسوف يبدو للمستخدم كأنه Crash. وفي التقرير حقل `reason` يبين السبب، مثل `per-process-limit` عندما يتجاوز تطبيقك الحد المسموح لعملية واحدة. وفي Android يقوم بالعمل نفسه ال Low Memory Killer.
- **ال Watchdog:** يراقب النظام زمن التشغيل وسرعة الاستجابة، وينهي التطبيق الذي يحجز ال Main Thread مدة طويلة، ويظهر ذلك في التقرير بالرمز `0x8badf00d` كما في [صفحة إنهاء ال Watchdog](https://developer.apple.com/documentation/xcode/addressing-watchdog-terminations?ref=arabroot.io). والمثال الذي تعرضه Apple في الصفحة نفسها لتطبيق لم يرسم أول شاشة بعد التشغيل:

```text
Termination Reason: Namespace SPRINGBOARD, Code 0x8badf00d
Termination Description: SPRINGBOARD,
    scene-create watchdog transgression: application<com.example.MyCoolApp>:667
    exhausted real (wall clock) time allowance of 19.97 seconds
```

في التقرير أعلاه لاحظ التالي:

- `scene-create` تعني أن التطبيق لم يرسم أول إطار من واجهته في الوقت المسموح، وأما `scene-update` فتعني أن الواجهة لم تتحدث لأن ال Main Thread مشغول.
- الوقت المسموح هنا قرابة 20 ثانية لأول شاشة، والسبب الأشهر عمل متزامن عند التشغيل مثل طلب شبكة أو ترحيل Migration لقاعدة بيانات كبيرة.
- ال Stack Trace في هذا التقرير يبين ما كان يفعله التطبيق لحظة إنهائه، وليس بالضرورة الكود الذي استهلك الوقت، فقد تكون دالة أخذت 4 ثوان وانتهت قبل ذلك.

والنتيجة المهمة لهذين النوعين أن مكتبة تقارير الأعطال داخل التطبيق لا تستطيع التقاطهما لحظة حدوثهما، لأن النظام ينهي العملية بإشارة `SIGKILL` التي لا يمكن اعتراضها، فتلجأ المكتبات إلى الاستنتاج في التشغيل التالي: إذا كان التطبيق في المقدمة آخر مرة، ولم يحفظ تقرير Crash، ولم يغلقه المستخدم، ولم يتحدث النظام أو التطبيق، فالأرجح أن النظام أنهاه. ولذلك يختلف عدد هذه الحالات بين أداة وأخرى، ولا تقارن رقم OOM بين أداتين مختلفتين.

وفي Android منذ الإصدار 11 (API 30) أصبح هذا الاستنتاج أدق، لأن الواجهة [ApplicationExitInfo](https://developer.android.com/reference/android/app/ApplicationExitInfo?ref=arabroot.io) تعطي التطبيق في التشغيل التالي سبب انتهاء العملية السابقة كما سجله النظام، ومن قيمها:

- `REASON_CRASH`: استثناء لم يعالج في كود Java.
- `REASON_CRASH_NATIVE`: عطل في الكود الأصلي.
- `REASON_ANR`: أنهى النظام التطبيق لأنه لا يستجيب.
- `REASON_LOW_MEMORY`: أنهاه ال Low Memory Killer لأن الجهاز تحت ضغط الذاكرة، والتوثيق ينبه إلى أن بعض الأجهزة لا تدعم هذا السبب.
- `REASON_USER_REQUESTED`: المستخدم أوقف التطبيق من الإعدادات أو أزاله من قائمة التطبيقات الأخيرة، وهذا ليس عطلاً.

وفي iOS يقدم إطار [MetricKit](https://developer.apple.com/documentation/metrickit?ref=arabroot.io) شيئاً قريباً، فهو يسلم التطبيق تقارير تشخيص Diagnostics عن الأعطال وتعليق التطبيق واستهلاك المعالج والكتابة على القرص، وتصل هذه التقارير فوراً منذ iOS 15، وأما المقاييس فتصل مرة في اليوم على الأكثر عن الساعات الأربع والعشرين السابقة.

### الأخطاء غير القاتلة Non-fatal

عندما يعالج الكود استثناءً بـ `try/catch` ويعرض للمستخدم رسالة «حدث خطأ، حاول مرة أخرى»، فالتطبيق لم يغلق، ولكن المستخدم لم يكمل الدفع، وهذه الحالة لا تصل إلى أي أداة إلا إذا سجلتها بنفسك، وكل الأدوات توفر دالة لذلك مثل `recordException`. والقاعدة هنا أن تسجل الاستثناء الذي يعني أن المستخدم لم يحصل على ما يريد، مثل فشل الدفع أو فشل تحميل السلة، ولا تقم بتسجيل كل استثناء متوقع مثل انقطاع الشبكة في المترو، والسبب أن الأخطاء المتوقعة تغطي على الأخطاء الحقيقية في اللوحة، وبعض الأدوات تحتفظ بعدد محدود منها في كل جلسة.

### أعطال الكود الأصلي Native في NDK

إذا كان تطبيقك أو إحدى مكتباته تستخدم كود C/C++ عبر ال NDK، وهذا أكثر مما تتوقع، فمكتبات معالجة الصور والفيديو وقواعد البيانات المحلية والألعاب وبعض SDK الدفع فيها كود أصلي، فإن العطل هنا لا يكون استثناءً بل إشارة من النظام مثل `SIGSEGV` عند قراءة عنوان غير صالح في الذاكرة، أو `SIGABRT` عند استدعاء `abort()`. وبحسب [صفحة الأعطال في توثيق Android](https://developer.android.com/topic/performance/vitals/crash?ref=arabroot.io) يكتب النظام في هذه الحالة تقريراً يبدأ بالإشارة وعنوان الخطأ ثم قائمة `backtrace` فيها لكل إطار Frame عنوان `pc` داخل ملف المكتبة `.so`. ومعالج الاستثناءات في Java لا يرى هذه الأعطال إطلاقاً، ولذلك تحتاج الأداة إلى جزء Native خاص في ال SDK، فإذا لم تفعله فإن هذه الأعطال لا تظهر في لوحتك، مع أنها تظهر في Android vitals وتحسب عليك.

## لماذا يصلك ال Stack Trace غير مقروء؟ استعادة الأسماء Symbolication

الكود الذي تكتبه ليس الكود الذي يصل إلى المستخدم، ففي Android يقوم ال R8 عند بناء نسخة الإصدار Release بتصغير الكود Shrinking وتغيير أسماء الكلاسات والدوال Obfuscation ودمج الدوال الصغيرة في من يستدعيها Inlining، وهذا يقلل حجم التطبيق ويصعب الهندسة العكسية Reverse Engineering، وفي iOS وفي مكتبات NDK يخرج المترجم Compiler كوداً آلياً Machine Code لا يحمل أسماء الدوال ولا أرقام الأسطر، وتوضع هذه المعلومات في ملف منفصل. لذلك فإن ال Stack Trace الذي يصلك من جهاز المستخدم يحتوي على أسماء مختصرة أو عناوين في الذاكرة، وتحويلها إلى أسماء الدوال والملفات والأسطر الأصلية يسمى Symbolication، وفي Android يسمى أيضاً إزالة التشفير Deobfuscation أو Retrace.

![عند البناء يخرج ملفان: التطبيق الذي يذهب إلى المتجر ثم إلى جوال المستخدم، وملف الرموز الذي يبقى عندك أو يرفع إلى أداة تقارير الأعطال، وهو mapping.txt في Android أو dSYM في iOS أو مكتبات so غير المجردة في NDK أو ملفات symbols في Flutter. ومن الجوال يصل Stack Trace مشفر فيه a.b.a(SourceFile:1)، وأداة Retrace تجمعه مع ملف الرموز من البناء نفسه فيخرج CouponRules.discountPercent(CouponRules.java:5) ومعه الإطارات التي دمجها R8. والربط بين الاثنين بمعرف البناء Build ID أو UUID](https://arabroot.io/content/images/2026/10/mobile-crash-basics-03-symbolication.webp)

ملف الرموز من البناء نفسه هو المفتاح الوحيد لقراءة ال Stack Trace

ولكل منصة ملف رموز Symbol File خاص بها، وفيما يلي أشهرها:

| المنصة                      | ملف الرموز                      | أين تجده                                 | الأداة الرسمية لقراءته                                                                                                                                  |
| --------------------------- | ------------------------------- | ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Android (Java/Kotlin مع R8) | mapping.txt                     | app/build/outputs/mapping/<variant>/     | [retrace](https://developer.android.com/topic/performance/app-optimization/test-and-troubleshoot-the-optimization?ref=arabroot.io) في أدوات سطر الأوامر |
| Android NDK (C/C++)         | مكتبات .so غير مجردة Unstripped | build/intermediates/cxx/…/obj/<abi>      | [ndk-stack](https://developer.android.com/ndk/guides/ndk-stack?ref=arabroot.io)                                                                         |
| iOS                         | ملف dSYM لكل Binary             | داخل ال Xcode Archive                    | Xcode وأدوات atos                                                                                                                                       |
| Flutter                     | ملفات .symbols لكل معمارية      | المجلد الذي تحدده في \--split-debug-info | [flutter symbolize](https://docs.flutter.dev/deployment/obfuscate?ref=arabroot.io)                                                                      |

وفي iOS تقول [صفحة بناء التطبيق مع معلومات التصحيح](https://developer.apple.com/documentation/xcode/building-your-app-to-include-debugging-information?ref=arabroot.io) إن كل ملف Binary في التطبيق، أي التطبيق نفسه وكل Framework وكل Extension، له ملف dSYM خاص به، وإن الربط بينهما بمعرف البناء Build UUID، فإذا بنيت الكود نفسه مرتين بإصدار Xcode مختلف أو إعدادات مختلفة فلن يتطابق المعرف، ولن يقرأ ملف dSYM من البناء الأول أعطال البناء الثاني. ولذلك تطلب Apple أن تتأكد من أن الإعداد `DEBUG_INFORMATION_FORMAT` قيمته DWARF with dSYM File قبل بناء نسخة التوزيع، وأن تحتفظ بال Xcode Archive لكل إصدار توزعه. وفي Flutter يكون التصغير بالأمر `flutter build` مع `--obfuscate` و`--split-debug-info`، وملفات الرموز تخرج لكل معمارية مثل `app.android-arm64.symbols`، وفي React Native تحتاج إلى ملفين معاً: خريطة المصدر Source Map لكود JavaScript، وملفات الرموز للجزء الأصلي في Android وiOS.

والآن سوف نرى ذلك عملياً. التجربة التالية تعمل على أي جهاز فيه Docker، دون Android SDK ودون جهاز أو محاكي Emulator، والسبب أن R8 نفسه برنامج Java يستطيع أن يعالج ملفات Java العادية بالخيار `--classfile`، فنحصل على الأثر نفسه الذي يحدث لتطبيق Android: أسماء مغيرة، ودوال مدمجة، وملف `mapping.txt`، ثم نشغل البرنامج على ال JVM ليتعطل فعلاً، ونقرأ ال Stack Trace المشفر بأداة Retrace التي تأتي مع R8.

### تجربة: Stack Trace مشفر Obfuscated من R8 وإعادته إلى أصله

لنفرض أن لديك شاشة دفع تحسب المجموع بعد الخصم، والكود الذي يحسب نسبة الخصم لا يتوقع أن يكون رمز الخصم فارغاً، والملفات التالية هي البرنامج كاملاً، وكلها في المجلد `src/com/example/shop/`:

```java
// Main.java
package com.example.shop;

public class Main {
    public static void main(String[] args) {
        CheckoutScreen screen = new CheckoutScreen();
        screen.onPayClicked(args.length > 0 ? args[0] : null);
    }
}

// CheckoutScreen.java
package com.example.shop;

public class CheckoutScreen {
    private final CartRepository repository = new CartRepository();

    public void onPayClicked(String couponCode) {
        Cart cart = repository.loadCart();
        String total = PriceFormatter.format(cart.totalAfterCoupon(couponCode));
        System.out.println("Total: " + total);
    }
}

// Cart.java
package com.example.shop;

public class Cart {
    private final long subtotalHalalas;

    public Cart(long subtotalHalalas) {
        this.subtotalHalalas = subtotalHalalas;
    }

    public long totalAfterCoupon(String couponCode) {
        int percent = CouponRules.discountPercent(couponCode);
        return subtotalHalalas - (subtotalHalalas * percent / 100);
    }
}

// CouponRules.java
package com.example.shop;

public class CouponRules {
    public static int discountPercent(String couponCode) {
        if (couponCode.startsWith("VIP")) {
            return 20;
        }
        return 0;
    }
}

// CartRepository.java
package com.example.shop;

public class CartRepository {
    public Cart loadCart() {
        return new Cart(15000);
    }
}

// PriceFormatter.java
package com.example.shop;

public class PriceFormatter {
    public static String format(long halalas) {
        return (halalas / 100) + "." + String.format("%02d", halalas % 100) + " SAR";
    }
}
```

وملف قواعد R8 بصيغة ProGuard، وهو نفسه الذي تضعه في `proguard-rules.pro` في مشروع Android:

```text
-keep class com.example.shop.Main {
    public static void main(java.lang.String[]);
}
-keepattributes SourceFile,LineNumberTable
-renamesourcefileattribute SourceFile
```

وسكربت البناء `build.sh`، ويحتاج إلى ملف `r8.jar` من [مستودع Google Maven](https://maven.google.com/web/index.html?ref=arabroot.io#com.android.tools:r8)، وقد استخدمنا الإصدار 9.5.28:

```bash
set -e
cd /work
rm -rf classes out && mkdir classes out
javac --release 17 -d classes src/com/example/shop/*.java
jar --create --file out/app.jar -C classes .
java -cp r8.jar com.android.tools.r8.R8 --classfile --release \
  --lib "$JAVA_HOME" --pg-conf rules.pro \
  --pg-map-output out/mapping.txt --output out/app-release.jar out/app.jar
```

الآن نقوم بتشغيل البناء داخل Container فيه JDK 21، وفي Git Bash على Windows أضف `MSYS_NO_PATHCONV=1` قبل الأمر واستخدم `"$(pwd -W)"` بدلاً من `"$(pwd)"`:

```bash
curl -fLo r8.jar https://dl.google.com/android/maven2/com/android/tools/r8/9.5.28/r8-9.5.28.jar
docker run --rm -v "$(pwd)":/work eclipse-temurin:21.0.8_9-jdk bash /work/build.sh
```

ثم نشغل النسخة العادية، ثم نسخة الإصدار التي خرجت من R8، دون رمز خصم في الحالتين:

```bash
docker run --rm -v "$(pwd)":/work -w /work eclipse-temurin:21.0.8_9-jdk java -cp out/app.jar com.example.shop.Main
docker run --rm -v "$(pwd)":/work -w /work eclipse-temurin:21.0.8_9-jdk sh -c 'java -cp out/app-release.jar com.example.shop.Main 2> out/trace.txt; cat out/trace.txt'
```

والمخرج سوف يكون كما يلي، الأول من النسخة العادية:

```text
Exception in thread "main" java.lang.NullPointerException: Cannot invoke "String.startsWith(String)" because "<parameter1>" is null
	at com.example.shop.CouponRules.discountPercent(CouponRules.java:5)
	at com.example.shop.Cart.totalAfterCoupon(Cart.java:11)
	at com.example.shop.CheckoutScreen.onPayClicked(CheckoutScreen.java:8)
	at com.example.shop.Main.main(Main.java:6)
```

والثاني من نسخة الإصدار، وهو ما سوف يصلك من جهاز المستخدم:

```text
Exception in thread "main" java.lang.NullPointerException: Cannot invoke "String.startsWith(String)" because "<parameter1>" is null
	at a.b.a(SourceFile:1)
	at com.example.shop.Main.main(SourceFile:2)
```

في المخرج أعلاه لاحظ التالي:

- أربعة إطارات في النسخة العادية أصبحت إطارين، والسبب أن R8 دمج `discountPercent` و`totalAfterCoupon` داخل `onPayClicked`، ثم سمى الكلاس `CheckoutScreen` باسم `a.b` والدالة باسم `a`، فالسطر الذي يحتوي على الخطأ الحقيقي غير موجود في ال Stack Trace أصلاً.
- اسم الملف أصبح `SourceFile` بسبب `-renamesourcefileattribute`، وأرقام الأسطر 1 و2 ليست أسطر الكود، وإنما أرقام يحولها ملف ال Mapping إلى الأسطر الأصلية.
- الكلاس `Main` بقي باسمه لأننا طلبنا ذلك بقاعدة `-keep`، ولو لم نفعل لما وجد البرنامج نقطة البداية.

وملف `mapping.txt` الذي خرج مع البناء يبين كيف حدث ذلك، وهذا جزء منه:

```text
# compiler: R8
# compiler_version: 9.5.28
# pg_map_id: fad30d88441bb062d8014ffb99a2d2e04ecc67278cc87d864d208aea8e82a32e
com.example.shop.Cart -> R8$$REMOVED$$CLASS$$0:
com.example.shop.CheckoutScreen -> a.b:
    1:1:void <init>():3:3 -> <init>
    1:1:int com.example.shop.CouponRules.discountPercent(java.lang.String):5:5 -> a
    1:1:long com.example.shop.Cart.totalAfterCoupon(java.lang.String):11 -> a
    1:1:void onPayClicked(java.lang.String):8 -> a
    2:2:long com.example.shop.Cart.totalAfterCoupon(java.lang.String):12:12 -> a
    2:2:void onPayClicked(java.lang.String):8 -> a
    3:4:void onPayClicked(java.lang.String):8:9 -> a
com.example.shop.CouponRules -> R8$$REMOVED$$CLASS$$1:
com.example.shop.PriceFormatter -> a.c:
    1:1:java.lang.String format(long):5:5 -> a
```

لاحظ أن السطر 1 في الدالة `a` يقابل ثلاثة أسطر أصلية متداخلة: السطر 5 في `discountPercent`، داخل السطر 11 في `totalAfterCoupon`، داخل السطر 8 في `onPayClicked`، وأن الكلاسين `Cart` و`CouponRules` حذفا بالكامل لأن كل كودهما دمج في غيرهما. الآن نعطي أداة Retrace الملف وال Stack Trace:

```bash
docker run --rm -v "$(pwd)":/work -w /work eclipse-temurin:21.0.8_9-jdk java -cp r8.jar com.android.tools.r8.retrace.Retrace out/mapping.txt out/trace.txt
```

والمخرج:

```text
Exception in thread "main" java.lang.NullPointerException: Cannot invoke "String.startsWith(String)" because "<parameter1>" is null
	at com.example.shop.CouponRules.discountPercent(CouponRules.java:5)
	at com.example.shop.Cart.totalAfterCoupon(Cart.java:11)
	at com.example.shop.CheckoutScreen.onPayClicked(CheckoutScreen.java:8)
	at com.example.shop.Main.main(Main.java:6)
```

وهو مطابق تماماً لل Stack Trace من النسخة العادية، بما في ذلك الإطاران اللذان دمجهما R8\. وهذا ما تفعله كل أدوات تقارير الأعطال عندما ترفع لها ملف ال Mapping، والأداة `retrace` في Android SDK هي نفسها هذه الأداة. ولاحظ أن الرسالة نفسها تقول `<parameter1>` وليس `couponCode`، والسبب أن أسماء المعاملات Parameters لا تحفظ في ال Bytecode إلا بخيار خاص، وملف ال Mapping لا يعيد النص داخل الرسائل، فاكتب رسائل الأخطاء بحيث تفهم دون أسماء المتغيرات.

### ماذا يحدث إذا استخدمت ملف Mapping من بناء آخر؟

هذا هو الخطأ الأشهر في هذا الموضوع، فملف ال Mapping يتغير مع كل بناء، و[توثيق Android](https://developer.android.com/topic/performance/app-optimization/test-and-troubleshoot-the-optimization?ref=arabroot.io) ينبه إلى أن البناء يكتب فوق الملف في كل مرة، فإذا لم ترفعه أو تحفظه مع كل إصدار ثم احتجته لاحقاً فسوف تجد ملف البناء الأخير فقط. لنجرب ذلك: نضيف إلى الكود كلاساً جديداً `ReceiptPrinter` يطبع المجموع بدلاً من `System.out`، أي تعديل صغير كالذي يحدث بين إصدارين، ثم نبني الإصدار الثاني ونستخدم ملف ال Mapping الجديد لقراءة ال Stack Trace القديم الذي جاء من الإصدار الأول:

```bash
docker run --rm -v "$(pwd)":/work -w /work eclipse-temurin:21.0.8_9-jdk java -cp r8.jar com.android.tools.r8.retrace.Retrace v2/out/mapping.txt out/trace.txt
```

والمخرج:

```text
Exception in thread "main" java.lang.NullPointerException: Cannot invoke "String.startsWith(String)" because "<parameter1>" is null
	at com.example.shop.ReceiptPrinter.print(ReceiptPrinter.java:5)
	at com.example.shop.CouponRules.discountPercent(CouponRules.java:5)
	at com.example.shop.Cart.totalAfterCoupon(Cart.java:11)
	at com.example.shop.CheckoutScreen.onPayClicked(CheckoutScreen.java:9)
	at com.example.shop.Main.main(Main.java:6)
```

لاحظ أن الأداة لم تعرض أي خطأ أو تحذير، وإنما أعطت Stack Trace يبدو سليماً ولكنه غير صحيح، ففيه إطار `ReceiptPrinter.print` لم يكن موجوداً في الإصدار الذي تعطل أصلاً، لأن الاسم `a.b` في البناء الثاني أصبح يعني `ReceiptPrinter`، والسطر في `onPayClicked` أصبح 9 بدلاً من 8\. وهذا أسوأ من Stack Trace مشفر، لأن المشفر تعرف أنك لا تفهمه، وأما هذا فقد يرسلك للبحث في كلاس لا علاقة له بالعطل. ولذلك ارفع ملف ال Mapping أو احفظه مع كل بناء يذهب إلى المستخدمين، واربطه برقم الإصدار، والأدوات الحديثة تفعل ذلك تلقائياً بإضافة Gradle، وتستخدم المعرف `pg_map_id` الذي رأيته في رأس الملف لتربط كل عطل بملفه الصحيح.

### تجربة: تحويل عناوين مكتبة C إلى أسماء الدوال Symbols والأسطر

الفكرة نفسها في الكود الأصلي، ولكن بعناوين بدلاً من أسماء مختصرة. المثال التالي مكتبة C صغيرة `prices.c` تحسب المجموع، وفيها الخطأ نفسه: رمز خصم فارغ NULL:

```c
#include <stddef.h>

struct cart { long subtotal; const char *coupon; };

static int coupon_percent(const char *coupon) {
    return coupon[0] == 'V' ? 20 : 0;
}

long cart_total(const struct cart *c) {
    int percent = coupon_percent(c->coupon);
    return c->subtotal - c->subtotal * percent / 100;
}
```

والبرنامج `main.c` يستدعي المكتبة، ويسجل معالجاً للإشارة `SIGSEGV` يطبع العناوين بالشكل الذي تطبعه بها تقارير Android، أي عنوان `pc` نسبة إلى بداية كل مكتبة، وهذا تبسيط لما تفعله مكتبات مثل Crashpad وBreakpad:

```c
#define _GNU_SOURCE
#include <dlfcn.h>
#include <execinfo.h>
#include <signal.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>

struct cart { long subtotal; const char *coupon; };
long cart_total(const struct cart *c);

static void on_crash(int sig, siginfo_t *info, void *ctx) {
    void *frames[16];
    int n = backtrace(frames, 16);
    fprintf(stderr, "signal %d (%s), fault addr %p\nbacktrace:\n", sig, sig == SIGSEGV ? "SIGSEGV" : "?", info->si_addr);
    for (int i = 0; i < n; i++) {
        Dl_info d;
        if (dladdr(frames[i], &d) && d.dli_fname) {
            fprintf(stderr, "  #%02d pc %016lx  %s\n", i, (unsigned long)((char *)frames[i] - (char *)d.dli_fbase), d.dli_fname);
        }
    }
    _exit(128 + sig);
}

int main(void) {
    struct sigaction sa = {0};
    sa.sa_sigaction = on_crash;
    sa.sa_flags = SA_SIGINFO;
    sigaction(SIGSEGV, &sa, NULL);
    struct cart c = { 15000, NULL };
    printf("total=%ld\n", cart_total(&c));
    return 0;
}
```

نبني المكتبة بمعلومات التصحيح `-g`، ثم نحتفظ بنسخة منها غير مجردة Unstripped، ونجرد النسخة التي سوف نوزعها بالأمر `strip` كما يفعل ال NDK مع المكتبات داخل ملف APK أو AAB، ثم نشغل البرنامج ليتعطل، وهذه هي الأوامر داخل Container من ال Image `eclipse-temurin:21.0.8_9-jdk` المبنية على Ubuntu 24.04، بعد تثبيت `gcc` و`binutils`:

```bash
gcc -g -O2 -fPIC -shared -o libprices.so prices.c
gcc -g -O2 -o shop main.c -L. -lprices -Wl,-rpath,'$ORIGIN'
cp libprices.so libprices-unstripped.so
strip --strip-unneeded libprices.so
ls -l libprices.so libprices-unstripped.so
./shop 2> trace.txt; cat trace.txt
```

والمخرج:

```text
-rwxr-xr-x 1 root root 14016 Oct 23 08:33 libprices.so
-rwxr-xr-x 1 root root 16696 Oct 23 08:33 libprices-unstripped.so
signal 11 (SIGSEGV), fault addr (nil)
backtrace:
  #00 pc 0000000000001320  ./shop
  #01 pc 0000000000045330  /lib/x86_64-linux-gnu/libc.so.6
  #02 pc 000000000000110d  /work/libprices.so
  #03 pc 00000000000011ac  ./shop
  #04 pc 000000000002a1ca  /lib/x86_64-linux-gnu/libc.so.6
  #05 pc 000000000002a28b  /lib/x86_64-linux-gnu/libc.so.6
  #06 pc 0000000000001215  ./shop
```

الإطاران الأولان هما معالج الإشارة نفسه ودالة النظام التي استدعته، والعطل الحقيقي في الإطار `#02`: العنوان `0x110d` داخل `libprices.so`، وعنوان الخطأ `nil` أي أن الكود قرأ من العنوان صفر. الآن نحاول تحويل العنوان بالأداة `addr2line`، مرة بالمكتبة المجردة التي وزعناها، ومرة بالنسخة غير المجردة التي احتفظنا بها:

```bash
addr2line -f -i -e libprices.so 0x110d
addr2line -f -i -C -e libprices-unstripped.so 0x110d
readelf -n libprices.so | grep "Build ID"
readelf -n libprices-unstripped.so | grep "Build ID"
```

والمخرج:

```text
cart_total
??:?
coupon_percent
/work/prices.c:6
cart_total
/work/prices.c:10
    Build ID: 351f31074a2de9e0313e12fc87cf2d6cb7def6e4
    Build ID: 351f31074a2de9e0313e12fc87cf2d6cb7def6e4
```

في المخرج أعلاه لاحظ التالي:

- المكتبة المجردة عرفت اسم `cart_total` فقط، لأنها دالة مصدرة Exported يحتاجها الربط الديناميكي، ولكنها لم تعرف الملف ولا السطر (`??:?`)، ولم تعرف أن العطل وقع فعلياً في الدالة `coupon_percent` التي دمجها المترجم داخل `cart_total`.
- النسخة غير المجردة أعطت الصورة كاملة: السطر 6 في `coupon_percent` حيث نقرأ `coupon[0]`، وقد استدعيت من السطر 10 في `cart_total`.
- النسختان تحملان معرف البناء نفسه Build ID، وبهذا المعرف تعرف أداة تقارير الأعطال أي ملف رموز يخص أي مكتبة، كما يفعل ال UUID مع ملفات dSYM في iOS. والأداة الرسمية في Android لهذا العمل هي `ndk-stack`، وتعطيها مجلد المكتبات غير المجردة مع ال Stack Trace من `adb logcat` أو من ملف ال Tombstone.

وفي Google Play تستطيع رفع رموز NDK مع التطبيق، فبحسب [صفحة إزالة تشفير الأعطال في Play Console](https://support.google.com/googleplay/android-developer/answer/9848633?ref=arabroot.io) يكفي أن تضيف `ndk.debugSymbolLevel = 'FULL'` في إعداد Gradle ليضم ال App Bundle ملف الرموز، أو `SYMBOL_TABLE` إذا كان الملف كبيراً، والحد 800 MB، وملف ال Mapping يرفع تلقائياً مع ال App Bundle. ولاحظ أن الصفحة نفسها تقول إن Play Console لا يعيد قراءة الأعطال التي وصلت قبل رفع الملف، وإنما يقرأ ما يصل بعده فقط، وهذا سبب آخر لترفع ملفات الرموز مع البناء نفسه وليس عندما تحتاجها.

## كيف تقيس استقرار التطبيق Stability؟

عدد الأعطال وحده لا يعني شيئاً، فمئة عطل في يوم لتطبيق عنده مليون مستخدم تختلف عن مئة عطل لتطبيق عنده ألف، ولذلك تقيس الأدوات الاستقرار بنسبتين، وتعرفهما [صفحة Release Health في Sentry](https://docs.sentry.io/product/releases/health/?ref=arabroot.io) كما يلي:

- **نسبة الجلسات دون أعطال Crash-free Sessions:** نسبة الجلسات التي لم تنته بعطل، والجلسة Session في تطبيقات الجوال تبدأ عند فتح التطبيق أو عودته من الخلفية، وتنتهي عند إغلاقه أو ذهابه إلى الخلفية، وإذا عاد خلال 30 ثانية فهي الجلسة نفسها.
- **نسبة المستخدمين دون أعطال Crash-free Users:** نسبة المستخدمين الذين لم يواجهوا أي عطل في المدة المحددة.

![مثال بعشرة مستخدمين وأربعين جلسة: ثمانية مستخدمين كل جلساتهم سليمة، ومستخدم واحد فتح التطبيق أربع مرات وتعطل في جلسة واحدة، ومستخدم ثقيل الاستخدام فتح التطبيق اثنتي عشرة مرة وتعطل في جلستين. ثلاث جلسات من أربعين تعطلت فنسبة الجلسات دون أعطال 92.5 بالمئة، ومستخدمان من عشرة تأثرا فنسبة المستخدمين دون أعطال 80 بالمئة](https://arabroot.io/content/images/2026/10/mobile-crash-basics-04-crash-free.webp)

الرقمان من البيانات نفسها، وكل منهما يجيب عن سؤال مختلف

لنأخذ المثال الذي في الصورة: عشرة مستخدمين فتحوا التطبيق 40 مرة في يوم، وتعطلت 3 جلسات، اثنتان منها لمستخدم واحد يستخدم التطبيق كثيراً، والثالثة لمستخدم آخر. فنسبة الجلسات دون أعطال (40 - 3) / 40 = 92.5%، ونسبة المستخدمين دون أعطال (10 - 2) / 10 = 80%. ونسبة المستخدمين هي الأقرب لما يشعر به جمهورك، فخمس المستخدمين رأوا التطبيق يغلق اليوم، وأما نسبة الجلسات فهي أدق في متابعة إصلاح عطل معين، لأنها تتحرك مع كل جلسة. وعلى أي حال انظر دائماً إلى الرقمين لكل إصدار على حدة وليس للتطبيق كله، لأن الإصدار القديم الذي يستخدمه أغلب الناس يخفي مشكلة الإصدار الجديد الذي لم يصل إلا إلى 5% منهم، وهذا ما تسميه الأدوات صحة الإصدار Release Health، ومعه نسبة الاعتماد Adoption، أي كم من الجلسات أو المستخدمين في آخر 24 ساعة على هذا الإصدار.

### حدود Google Play في Android vitals

Google Play نفسه يقيس استقرار تطبيقك دون أي SDK، من الأجهزة التي وافق أصحابها على مشاركة بيانات الاستخدام والتشخيص، ويعرضها في Play Console تحت Android vitals. والفرق المهم هنا أن هذه الأرقام تؤثر في ظهور تطبيقك، فبحسب [صفحة Android vitals](https://developer.android.com/topic/performance/vitals?ref=arabroot.io) إذا تجاوز التطبيق حد السلوك السيئ Bad Behavior Threshold فقد يقلل Play ظهوره في المتجر، وقد يعرض للمستخدمين تحذيراً في صفحة التطبيق، والتقييم على متوسط آخر 28 يوماً، والحدود كما ظهرت في 23 أكتوبر 2026:

| المقياس                                                      | الحد لكل الأجهزة Overall | الحد لطراز جوال واحد |
| ------------------------------------------------------------ | ------------------------ | -------------------- |
| نسبة الأعطال التي يلاحظها المستخدم User-perceived Crash Rate | 1.09%                    | 8%                   |
| نسبة ال ANR التي يلاحظها المستخدم User-perceived ANR Rate    | 0.47%                    | 8%                   |
| الاستهلاك الزائد للبطارية Excessive Battery Usage            | 1%                       | لا يوجد              |
| Partial Wake Locks الزائدة                                   | 5%                       | لا يوجد              |

والنسبة هنا بحسب [تعريف Play Console](https://support.google.com/googleplay/android-developer/answer/9844486?ref=arabroot.io) هي نسبة المستخدمين اليوميين الذين واجهوا عطلاً واحداً على الأقل يلاحظه المستخدم، أي أنها أقرب إلى عكس Crash-free Users، فحد 1.09% يعني تقريباً أن يكون 98.91% من مستخدميك اليوميين دون أعطال ملحوظة. ولاحظ أن ال ANR المحسوبة هنا هي من نوع «انتهاء مهلة الإدخال» Input Dispatching Timed Out فقط، وأن الحد لطراز جوال واحد مهم، فقد يكون تطبيقك مستقراً في المجمل ولكنه يتعطل في 9% من جلسات جوال معين شائع عند جمهورك.

وقد يتساءل البعض: إذا كان Play Console يعطيني هذه الأرقام مجاناً فلماذا أحتاج أداة أخرى؟ والإجابة أن Android vitals يعطيك المقياس والمجموعات العامة، ولكنه لا يعرف ماذا فعل المستخدم قبل العطل، ولا يعرف الأخطاء غير القاتلة، ولا يرسل تنبيهاً عند ظهور عطل جديد، ولا يغطي إلا التطبيقات المثبتة من Google Play على أجهزة معتمدة. فهو المرجع الذي يحاسبك به المتجر، والأداة هي التي تساعدك على الإصلاح، ولذلك راقب الاثنين.

### Xcode Organizer في iOS

وفي iOS تعرض Apple الأعطال في نافذة Organizer داخل Xcode، من المستخدمين الذين وافقوا على مشاركة بيانات التشخيص مع المطورين، ومن مستخدمي TestFlight دائماً بغض النظر عن إعداداتهم، كما تقول [صفحة الحصول على تقارير الأعطال](https://developer.apple.com/documentation/xcode/acquiring-crash-reports-and-diagnostic-logs?ref=arabroot.io). وإذا رفعت ملفات dSYM مع البناء إلى App Store Connect فإن التقارير تصلك مقروءة. والنافذة نفسها تعرض مقاييس الأداء من أجهزة المستخدمين، مثل زمن التشغيل والذاكرة وسرعة الاستجابة والبطارية، بحسب [صفحة تحليل أداء التطبيق المنشور](https://developer.apple.com/documentation/xcode/analyzing-the-performance-of-your-shipping-app?ref=arabroot.io). ولكن الصفحة الأولى تنبه إلى أن بعض الأنواع لا تظهر في Organizer، ومنها أحداث ال Watchdog عند بطء التشغيل وأحداث Jetsam بسبب الذاكرة، وهذا سبب آخر لأداة داخل التطبيق.

## ما الذي يجعل تقرير العطل Crash Report مفيداً؟

ال Stack Trace المقروء يخبرك أين تعطل الكود، ولكنه لا يخبرك دائماً لماذا، فقيمة `null` في رمز الخصم قد تكون لأن المستخدم ضغط زر الدفع قبل أن تحمل الشاشة، أو لأن السيرفر أرجع استجابة غير متوقعة، ولذلك تجمع الأدوات مع العطل سياقاً Context، وفيما يلي أهمه:

- **ال Breadcrumbs:** قائمة بآخر الأحداث قبل العطل، مثل فتح شاشة، والضغط على زر، وطلب HTTP مع رمز الاستجابة، وتغير حالة الشبكة. والمكتبات تسجل كثيراً منها تلقائياً، وتستطيع إضافة ما يخص تطبيقك، مثل «تم تطبيق رمز الخصم».
- **الإصدار Release:** اسم الإصدار ورقم البناء، أي `versionName` و`versionCode` في Android، و`CFBundleShortVersionString` و`CFBundleVersion` في iOS. ولا تقم بإعادة استخدام رقم البناء نفسه لبناءين مختلفين، والسبب أن الأداة تربط ملف ال Mapping أو ال dSYM وصحة الإصدار بهذا الرقم، فإذا تكرر اختلطت ملفات الرموز كما رأينا في التجربة.
- **البيئة Environment:** production أو staging أو نسخة TestFlight، حتى لا تختلط أعطال الفريق الداخلي بأعطال المستخدمين.
- **الجهاز والنظام:** الطراز ونسخة النظام والذاكرة المتاحة واتجاه الشاشة واللغة، وتجمعها المكتبة تلقائياً، وهي التي تكشف أن العطل في جوال واحد أو في نسخة نظام واحدة.
- **المفاتيح المخصصة Custom Keys:** قيم من حالة التطبيق تساعد في الفهم، مثل نوع الاشتراك أو عدد العناصر في السلة أو مزود الدفع المختار.
- **معرف المستخدم User ID:** لمعرفة عدد المستخدمين المتأثرين وللرد على بلاغ عميل محدد، ويكفي فيه معرف داخلي عشوائي، وليس البريد ولا رقم الجوال كما في [فقرة الخصوصية](#privacy).

## الإطلاق التدريجي Staged Rollout: متى توقف الإصدار؟

في تطبيقات الويب إذا نشرت إصداراً فيه عطل فإنك ترجع للإصدار السابق Rollback في دقائق، وأما في الجوال فالإصدار الذي وصل إلى جهاز المستخدم يبقى عليه حتى يحدثه بنفسه، ولا توجد طريقة لسحبه، ولذلك فإن أهم أداة لحماية المستخدمين هي ألا يصل الإصدار الجديد إلى الجميع مرة واحدة، وأن تراقب الأعطال في النسبة الأولى قبل أن توسعها. وهي الفكرة نفسها التي شرحناها للخوادم في [مقال Feature Flags والإطلاق التدريجي](https://arabroot.io/articles/%D9%85%D9%81%D9%87%D9%88%D9%85-feature-flags-%D9%88%D8%A7%D9%84%D8%A7%D8%B7%D9%84%D8%A7%D9%82-%D8%A7%D9%84%D8%AA%D8%AF%D8%B1%D9%8A%D8%AC%D9%8A-gradual-rollout/)، ولكن المتجر هنا هو من يتحكم في التوزيع:

- **Google Play:** بحسب [صفحة الإطلاق التدريجي](https://support.google.com/googleplay/android-developer/answer/6346149?ref=arabroot.io) تختار نسبة المستخدمين الذين يصلهم الإصدار، ويختارهم Play عشوائياً، والنسبة لا تزيد تلقائياً وإنما ترفعها أنت. وتستطيع إيقاف الإطلاق Halt، فلا يصل الإصدار إلى مستخدمين جدد، ولكن من وصلهم يبقون عليه، ثم تستأنفه Resume إذا تبين أن المشكلة ليست في التطبيق.
- **App Store:** بحسب [صفحة الإصدار على مراحل](https://developer.apple.com/help/app-store-connect/update-your-app/release-a-version-update-in-phases/?ref=arabroot.io) يصل التحديث خلال 7 أيام بالنسب 1% ثم 2% ثم 5% ثم 10% ثم 20% ثم 50% ثم 100%، لمن فعلوا التحديث التلقائي، وتستطيع إيقافه مؤقتاً 30 يوماً بالمجموع. ولاحظ أن الصفحة نفسها تقول إن أي مستخدم يستطيع تنزيل التحديث يدوياً من المتجر في أي وقت، فالنسبة ليست حداً صارماً.

![مثال بأرقام توضيحية: الإصدار الجديد 2.4.0 يصل إلى 1 ثم 2 ثم 5 بالمئة من المستخدمين، ونسبة المستخدمين دون أعطال تنزل من 99.5 إلى 98.7 بالمئة بينما كان الإصدار السابق عند 99.6، فيأتي قرار الإيقاف Halt بسبب عطل جديد في شاشة الدفع. ثم إصدار تصحيحي Hotfix رقمه 2.4.1 يبدأ من جديد على جدول App Store لسبعة أيام بالنسب 1 و2 و5 و10 و20 و50 و100 بالمئة ونسبته ثابتة عند 99.6. وفي الأسفل تنبيه بأن من وصلهم 2.4.0 يبقون عليه حتى يحدثوا، فلا يوجد Rollback في الجوال](https://arabroot.io/content/images/2026/10/mobile-crash-basics-05-staged-rollout.webp)

توقف عند أول نسبة يظهر فيها الفرق، ثم أصدر إصلاحاً يبدأ من نسبة صغيرة من جديد

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

- **قارن الإصدار الجديد بالإصدار السابق وليس برقم مطلق:** إذا كانت نسبة المستخدمين دون أعطال في الإصدار السابق 99.6% وهبطت في الجديد إلى 98.7%، فهذا تضاعف في عدد المتأثرين تقريباً، حتى لو بدا الرقم مرتفعاً.
- **عطل جديد في مسار المال أو الدخول:** أي عطل جديد في تسجيل الدخول أو الدفع أو التسجيل يوقف الإطلاق مهما كان عدده، لأن المستخدم هنا يخسر الخدمة كلها.
- **عطل عند بدء التشغيل:** يوقف الإطلاق فوراً، لأن المستخدم لا يستطيع حتى فتح التطبيق ليصله الإصلاح عبر إعدادات عن بعد Remote Config.
- **الاقتراب من حدود Android vitals:** إذا اقتربت نسبة الإصدار الجديد من 1.09% للأعطال أو 0.47% لل ANR، فالتوسيع سوف يرفع متوسط التطبيق كله.
- **انتظر عدداً كافياً من الجلسات:** نسبة 1% من مستخدمين قليلين في ساعة واحدة لا تكفي لقرار، فحدد عدداً أدنى من الجلسات أو مدة دنيا لكل مرحلة قبل أن ترفع النسبة.

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

## الخصوصية Privacy: ما الذي لا يجب أن يخرج في تقرير العطل؟

تقرير العطل يخرج من جهاز المستخدم إلى سيرفر غالباً خارج المملكة، وهو يحمل أكثر مما تتوقع: معرف الجهاز أو التثبيت، وعنوان IP في طلب الإرسال، وال Breadcrumbs التي قد تحتوي على روابط فيها توكن Token أو رقم طلب، والمفاتيح المخصصة، ورسائل الاستثناءات التي قد تحتوي على مدخلات المستخدم، وفي بعض الأدوات لقطات الشاشة Screenshots وتسجيل الجلسة Session Replay. ولذلك فإن القاعدة أن ترسل ما يساعدك على الإصلاح فقط، والجدول التالي يبين الفرق:

| أرسله                                            | لا ترسله                                          |
| ------------------------------------------------ | ------------------------------------------------- |
| معرف داخلي عشوائي للمستخدم                       | البريد ورقم الجوال ورقم الهوية والاسم             |
| اسم الشاشة ونوع الإجراء وكود حالة HTTP           | رابط الطلب كاملاً مع ال Query String والتوكن      |
| نوع الاشتراك وعدد العناصر في السلة               | رقم البطاقة والعنوان والموقع الجغرافي الدقيق      |
| رسالة خطأ تصف الحالة                             | رسالة تحتوي على ما كتبه المستخدم في حقل           |
| Session Replay مع إخفاء النصوص والصور، إن احتجته | لقطات شاشة دون إخفاء في شاشات الدفع والملف الشخصي |

وكل الأدوات توفر دالة تمر عليها كل الأحداث قبل الإرسال، مثل `beforeSend`، لتحذف الحقول الحساسة أو تخفيها، وأغلبها يسمح بأن تؤجل جمع التقارير حتى يوافق المستخدم، وهذا ما سوف نراه لكل أداة في المقال التالي. وتذكر أن ما ترسله الأداة يجب أن تصرح به في المتجر، ففي Google Play تحت [قسم أمان البيانات Data Safety](https://support.google.com/googleplay/android-developer/answer/10787469?ref=arabroot.io) يوجد نوع «سجلات الأعطال» Crash Logs ضمن فئة معلومات التطبيق وأدائه، وفي App Store تحت [تفاصيل خصوصية التطبيق](https://developer.apple.com/app-store/app-privacy-details/?ref=arabroot.io) يوجد نوع Crash Data ضمن Diagnostics، وApple تنص على أن تصرح أيضاً بما تجمعه ال SDK من جهات خارجية في تطبيقك.

وفي المملكة يضع [نظام حماية البيانات الشخصية](https://dgp.sdaia.gov.sa/wps/portal/pdp/?ref=arabroot.io) و[لائحة نقل البيانات الشخصية خارج المملكة](https://dgp.sdaia.gov.sa/wps/portal/pdp/knowledgecenter/details/RegulationonPersonalDataTransferOutsidetheKingdom?ref=arabroot.io) شروطاً على نقل البيانات الشخصية إلى الخارج، وتقرير العطل الذي يحمل معرف المستخدم أو بريده أو موقعه بيانات شخصية تنتقل إلى مزود في الولايات المتحدة أو أوروبا، وقد ناقشنا سؤال مكان البيانات في [مقال ال Backend as a Service وبدائل Firebase](https://arabroot.io/articles/%D9%85%D8%A7-%D9%87%D9%88-backend-as-a-service-%D9%88%D8%A8%D8%AF%D8%A7%D8%A6%D9%84-firebase/). والحل الأبسط هنا أن تقلل البيانات الشخصية في التقرير إلى ما لا يمكن ربطه بشخص، فإذا كانت جهتك حكومية أو صحية أو مالية، أو كانت سياستها أن تبقى البيانات داخل المملكة، فالأنسب أداة تستضيفها بنفسك على سيرفر داخل المملكة، وهذا ما سوف نقارنه في المقال التالي.

## أخطاء شائعة

- **عدم رفع ملف ال Mapping أو ال dSYM مع كل بناء:** فتصل الأعطال مشفرة، وكثير من الأدوات لا تعيد قراءة ما وصل قبل الرفع.
- **استخدام ملف رموز من بناء آخر:** فيعطيك Stack Trace يبدو سليماً وهو غير صحيح، كما رأينا في التجربة.
- **تهيئة مكتبة الأعطال متأخرة:** بعد مكتبات أخرى أو بعد طلب شبكة، فلا ترى أعطال بدء التشغيل وهي أخطرها.
- **نسيان الجزء الأصلي Native:** التطبيق فيه مكتبة C/C++ عبر مكتبة خارجية، والأداة لا تلتقط إلا أعطال Java، فترى في Android vitals أعطالاً لا تجدها في لوحتك.
- **قراءة نسبة التطبيق كله:** بدلاً من نسبة كل إصدار، فلا ترى مشكلة الإصدار الجديد إلا بعد أن يصل إلى الجميع.
- **تسجيل كل استثناء متوقع Non-fatal:** حتى تمتلئ اللوحة بانقطاعات الشبكة وتختفي الأخطاء الحقيقية.
- **إرسال البريد أو رقم الجوال معرفاً للمستخدم:** والمعرف الداخلي العشوائي يكفي لكل أغراض التحقيق.
- **الإطلاق إلى 100% مباشرة:** وفي الجوال لا يوجد Rollback، فالإطلاق التدريجي هو خط الدفاع الوحيد.

## الخلاصة

- مكتبة تقارير الأعطال تكتب التقرير على القرص لحظة العطل وترسله في التشغيل التالي، ولذلك هيئها أول شيء عند تشغيل التطبيق.
- الأعطال أنواع: Crash وNative Crash وANR وApp Hang وOut of Memory وWatchdog وNon-fatal، وبعضها ينفذه النظام دون Stack Trace فتستنتجه الأدوات، وفي Android 11 وما بعده تعطيك `ApplicationExitInfo` السبب الفعلي.
- ال Stack Trace من الجوال مشفر أو بعناوين، وتعيده إلى أصله بملف الرموز من البناء نفسه: `mapping.txt` أو dSYM أو مكتبات `.so` غير مجردة أو ملفات Flutter، وفي التجربة أعاد Retrace إطارين إلى أربعة إطارات أصلية، وأعطى ملف من بناء آخر نتيجة خاطئة دون أي تحذير.
- راقب Crash-free Users وCrash-free Sessions لكل إصدار، وحدود Android vitals هي 1.09% للأعطال و0.47% لل ANR لكل الأجهزة، و8% لطراز جوال واحد.
- أطلق كل إصدار تدريجياً، وحدد قاعدة الإيقاف قبل النشر، وتذكر أن الإيقاف لا يعيد من وصلهم الإصدار.
- أرسل في التقرير ما يساعد على الإصلاح فقط، دون بريد أو رقم جوال أو توكن، وصرح به في المتجر، وانتبه لنقل البيانات خارج المملكة.

وفي المقال التالي نقارن الأدوات التي تنفذ كل ما سبق، من Firebase Crashlytics المجاني إلى البدائل التي تستضيفها بنفسك، ثم نبني في المختبر تطبيق Android يرسل أعطاله إلى سيرفرك:

[![](https://arabroot.io/content/images/2026/10/mobile-crash-tools-cover-cover.webp)دليلأدوات تقارير أعطال الجوال: Crashlytics والبدائل المفتوحةمقارنة Firebase Crashlytics بالخدمات التجارية والأدوات التي تستضيفها بنفسك، بالأسعار ومكان البيانات ودعم Android وiOS وFlutter.](https://arabroot.io/articles/%D8%A7%D8%AF%D9%88%D8%A7%D8%AA-%D8%AA%D9%82%D8%A7%D8%B1%D9%8A%D8%B1-%D8%A7%D8%B9%D8%B7%D8%A7%D9%84-%D8%A7%D9%84%D8%AC%D9%88%D8%A7%D9%84-crashlytics-%D9%88%D8%A7%D9%84%D8%A8%D8%AF%D8%A7%D8%A6%D9%84/)[![](https://arabroot.io/content/images/2026/10/mobile-crash-lab-cover-cover.webp)دليلتقارير الأعطال Crash Reports لتطبيق Android مع GlitchTipتطبيق Android يتعطل عمداً على المحاكي ويرسل أعطاله إلى GlitchTip على سيرفرك، وما الذي يعمل فعلاً عند رفع ملف ال Mapping وما الذي لا يعمل.](https://arabroot.io/articles/%D9%85%D8%AE%D8%AA%D8%A8%D8%B1-%D8%A7%D8%B9%D8%B7%D8%A7%D9%84-%D8%AA%D8%B7%D8%A8%D9%8A%D9%82-android-%D9%85%D8%B9-glitchtip/)[![](https://arabroot.io/content/images/2026/10/error-tracking-cover-cover.webp)دليلتتبع الأخطاء Error Tracking للمطورين مع GlitchTipماذا يضيف تتبع الأخطاء فوق السجلات، وكيف تشغل GlitchTip بديلاً خفيفاً لـ Sentry يستقبل أخطاء تطبيقك بالـ SDK نفسه.](https://arabroot.io/articles/%D8%AA%D8%AA%D8%A8%D8%B9-%D8%A7%D9%84%D8%A7%D8%AE%D8%B7%D8%A7%D8%A1-error-tracking-%D9%85%D8%B9-glitchtip/)

## تدريب عملي

1. في مشروع Android لديك، فعل `isMinifyEnabled = true` في نسخة ال Release، ثم ابحث عن `mapping.txt` بعد البناء، وجرب أداة `retrace` على Stack Trace من التطبيق نفسه.
2. افتح Android vitals في Play Console أو Organizer في Xcode لتطبيقك، وقارن نسبة الأعطال لآخر إصدارين، وحدد الرقم الذي كنت سوف توقف عنده الإطلاق.

## سجل التحديثات

- أكتوبر 2026: كتابة المقال، وتجربة إعادة Stack Trace مشفر بأداة R8 Retrace 9.5.28، وتحويل عناوين مكتبة C إلى أسطر بالأداة `addr2line`، في Docker.