تسرب الذاكرة (memory leak) — حالة لا يحرر فيها التطبيق الذاكرة المشغولة بالكائنات التي لم تعد هناك حاجة إليها. في تطوير التطبيقات المحمولة، هذا أمر بالغ الأهمية: الكومة المحدودة وغياب المقايضة يؤديان إلى OutOfMemoryError وتعطل التطبيق. وفقًا لـ Purdue University (2022)، 35% من تطبيقات Android في Google Play تحتوي على تسرب ذاكرة واحد على الأقل. سنستعرض السيناريوهات النموذجية وأدوات التشخيص وطرق الإصلاح.
الملخص
تسرب الذاكرة — حالة لا يتم فيها إرجاع الذاكرة المخصصة إلى النظام بعد أن لم يعد الكائن مطلوبًا من قبل البرنامج. يعتبر جامع القمامة أن هذا الكائن حي لأن هناك سلسلة مراجع نشطة من GC Root تشير إليه.
في Java/Kotlin، يعمل جامع القمامة تلقائيًا، لكنه لا يستطيع تحديد أن الكائن غير ضروري منطقيًا إذا كان هناك مرجع تقني إليه. يجب على المطور قطع الاتصالات غير الضرورية بشكل صريح. في Swift/Objective-C، يقوم ARC تلقائيًا بعد المراجع، لكن دورات الاحتفاظ تمنع إنقاص العداد.
الخطر الرئيسي للتسربات هو التأثير التراكمي. كل تسرب يستهلك كمية صغيرة من الذاكرة، ولكن مع الانتقالات المتكررة بين الشاشات (تدوير الشاشة، فتح/إغلاق Activities) تتراكم التسربات حتى استنفاد حد الكومة.
التسرب — الكائن غير متاح للكود ولكن لم يتم إزالته بواسطة GC. الانتفاخ — الكائن مطلوب منطقيًا ولكنه مخزن بكمية زائدة. مثال على الانتفاخ: ذاكرة تخزين مؤقت للصور بحجم 100 MB مع مجموعة عمل بحجم 30 MB. كلتا المشكلتين تؤديان إلى OOM، لكن الأسباب وطرق العلاج مختلفة.
ART (Android Runtime) يستخدم جمع القمامة بالأجيال مع الضغط المتزامن. تنقسم الذاكرة إلى جيل الشباب (Young)، الجيل القديم (Old) والكائنات الكبيرة (Large). الكائنات التي تنجو من عدة دورات GC تنتقل إلى الجيل القديم، حيث يحدث الجمع بشكل أقل — وهذا يسرع الدورات العادية.
يبدأ GC عندما تصل الكومة إلى حد إشغال معين (عادة 75-85%). أثناء GC، يتم إيقاف جميع سلاسل التطبيق (STW — Stop The World). كلما زادت الكائنات الحية، زادت فترة التوقف. تزيد التسربات من عدد الكائنات الحية، مما يطيل فترات توقف GC.
يحدد المجمع الكائنات الحية عن طريق اجتياز الرسم البياني من GC Roots: الحقول الثابتة ومتغيرات المكدس للسلاسل النشطة ومراجع JNI. أي كائن يمكن الوصول إليه عبر المراجع من هذه الجذور يعتبر حيًا — حتى إذا كان المطور يعلم أنه لم يعد مطلوبًا.
// مثال: مجموعة ثابتة كـ GC Root — تسرب دائم
object GlobalHolder {
val listeners = mutableListOf<WeakReference<Any>>()
}
class LeakingFragment : Fragment() {
override fun onCreate(savedInstanceState: Bundle ?= null) {
super.onCreate(savedInstanceState)
GlobalHolder.listeners.add(WeakReference(this))
// WeakReference لا يمنع GC — سلوك صحيح
}
}
WeakReference يحل المشكلة: يتجاهل GC المراجع الضعيفة عند تحديد الكائنات الحية. إذا بقيت مراجع ضعيفة فقط لكائن، فسيتم جمعه في أقرب دورة GC.
Activity Context — السيناريو الأكثر انتشارًا للتسربات في Android. إذا كان المفرد أو الحقل الثابت أو الخدمة طويلة العمر تخزن مرجعًا إلى Activity Context، فلا يمكن جمع كل Activity مع جميع Views بواسطة GC. الحل: استخدم Application Context للكائنات طويلة العمر.
Handler والرسائل المرسلة — Handler.postDelayed(runnable, delay) يضع رسالة في قائمة Main Looper. إذا تم تدمير Activity قبل انتهاء التأخير، تظل الرسالة في القائمة وتحتفظ بمرجع عبر Runnable → فئة مجهولة → فئة خارجية (Activity).
class SafeActivity : AppCompatActivity() {
private val mainHandler = Handler(Looper.getMainLooper())
private val callback = Runnable { /* update UI */ }
override fun onResume() {
super.onResume()
mainHandler.postDelayed(callback, 5000)
}
override fun onPause() {
mainHandler.removeCallbacks(callback) // إلزامي: مسح قائمة الانتظار
super.onPause()
}
}
الفئات الداخلية — الفئة الداخلية غير الثابتة لها مرجع ضمني لمثيل الفئة الخارجية. إذا كانت الفئة الخارجية هي Activity وتم تمرير الفئة الداخلية إلى مكان خارجي (على سبيل المثال، إلى RecyclerView.Adapter)، فلا يمكن جمع Activity.
Android Studio Memory Profiler — أداة مدمجة لمراقبة الكومة في الوقت الفعلي. تعرض رسمًا بيانيًا للذاكرة المستخدمة وعدد التخصيصات والكائنات حسب النوع. تسمح بتسجيل heap dump وتصديره بتنسيق HPROF للتحليل في MAT.
Eclipse MAT (Memory Analyzer Tool) — محلل سطح مكتب لـ heap dump. يقوم تلقائيًا ببناء تقارير Leak Suspects التي تبرز الكائنات ذات الحجم المحتفظ به الأكبر وتقترح سلسلة GC Root المحتملة لكل كائن مشبوه.
Xcode Memory Graph Debugger — لنظام iOS. يوقف التطبيق ويصور الرسم البياني للكائنات. يتم تمييز دورات الاحتفاظ باللون الأحمر؛ يمكن النقر على أي كائن لرؤية عدد مرات الاحتفاظ به ومراجعه.
| الأداة | الإمكانيات | التعقيد |
|---|---|---|
| Memory Profiler | رسم بياني فوري، heap dump، تتبع تخصيص الكائنات | منخفض |
| Eclipse MAT | شجرة المسيطر، Leak Suspects، استعلامات OQL | متوسط |
| LeakCanary | كشف تلقائي، تتبع التسرب في الإشعار | أدنى |
| Xcode Memory Graph | رسم بياني مرئي لدورات الاحتفاظ، قائمة الكائنات الحية | منخفض |
وفقًا لـ Uber Engineering Blog، يؤدي دمج التنميط التلقائي للذاكرة (LeakCanary + تحليل heap dump) في خط أنابيب CI/CD إلى تقليل الحوادث المرتبطة بالذاكرة في الإنتاج بنسبة 60% خلال 3 أشهر.
استبدال Context — إذا كان الكائن يعيش أطول من Activity، استخدم applicationContext. يجب أن تتلقى جميع الكائنات طويلة العمر (المفردات، المستودعات، مساعدي قواعد البيانات) Application Context، وليس Activity Context. الاستثناء: مكونات واجهة المستخدم التي تحتاج إلى الوصول إلى السمة أو الموارد الخاصة بـ Activity.
المكونات الواعية لدورة الحياة — استخدام LifecycleObserver أو DefaultLifecycleObserver أو الامتدادات التفاعلية يلغي الاشتراكات تلقائيًا عند onDestroy. يوفر Android Jetpack lifecycleScope و viewModelScope، اللذين يتم تنظيفهما بواسطة حدث دورة الحياة المقابل.
فئة داخلية ثابتة — إذا كانت الفئة الداخلية لا تحتاج إلى الوصول إلى حقول الفئة الخارجية، اجعلها static. الفئة الداخلية الثابتة ليس لها مرجع ضمني للفئة الخارجية. إذا كان الوصول مطلوبًا، استخدم WeakReference للمرجع الصريح.
class MyActivity : AppCompatActivity() {
// ❌ فئة داخلية غير ثابتة — مرجع ضمني إلى MyActivity
inner class BadListener : SomeListener {
override fun onEvent() { /*...*/ }
}
// ✅ فئة داخلية ثابتة — بدون مرجع ضمني
class GoodListener(private val activityRef: WeakReference<MyActivity>) : SomeListener {
override fun onEvent() { /*...*/ }
}
}
في iOS، استخدم قوائم الالتقاط: [weak self] في الإغلاقات التي قد تعيش أطول من منشئها. للمفوضين، استخدم المراجع الضعيفة (weak var delegate). للإغلاقات المضمون استدعاؤها فقط أثناء عمر self، يمكن استخدام [unowned self]، ولكن بحذر — الوصول إلى كائن تم تحريره سيؤدي إلى تعطل.
الأسئلة الشائعة
في Android، قم بعدة انتقالات بين الشاشات (Activity A → B → A → B) وتحقق من adb shell dumpsys meminfo package_name. إذا كان Total PSS ينمو بثبات ولا يعود إلى القيمة الأصلية — فهناك تسرب. في iOS، بالمثل: استخدم Debug Memory Graph في Xcode للفحص البصري.
نعم، إذا لم يتم إلغاء CoroutineScope عند تدمير المكون. يستمر coroutine الذي تم تشغيله في GlobalScope في التنفيذ حتى بعد finish() لـ Activity. الحل: استخدم viewModelScope (يُلغى في onCleared) أو lifecycleScope (يُلغى في onDestroy). للنطاقات المخصصة، أنشئ نطاقات واعية بدورة الحياة عبر LifecycleOwner.
Bitmap يخزن بيانات البكسل في الكومة الأصلية (native heap)، وليس في كومة Java. هذا يعني أن GC لا يرى الحجم الفعلي لـ Bitmap. إذا لم يتم استدعاء recycle() على Bitmap أو لم يتم إلغاء المرجع، فلن يتم تحرير الذاكرة الأصلية. استخدم BitmapFactory مع inSampleSize لتحميل نسخ مصغرة و Glide/Coil للإدارة التلقائية للذاكرة المؤقتة.
الحقل الثابت — هو GC Root. يعيش طالما أن الفئة محملة (في Android — طالما أن العملية حية). إذا كان الحقل الثابت يشير إلى Activity أو Bitmap أو View أو أي كائن ثقيل آخر، فلن يتم جمع هذا الكائن أبدًا بواسطة GC. الحقل الثابت هو مرجع أبدي. الحل: قم بتخزين WeakReference فقط أو إلغاء الحقل الثابت في onDestroy.
ARC يحرر الكائنات تلقائيًا عندما ينخفض عداد المراجع القوية إلى الصفر. دورة الاحتفاظ هي الطريقة الوحيدة للتسرب مع ARC. استخدم دائمًا weak للمراجع من الأب إلى الابن حيث قد يعيش الابن أطول من الأب (المفوضون، مصادر البيانات). للإغلاقات، استخدم قائمة الالتقاط [weak self] وتحقق من أن self ليس nil داخل الإغلاق.
الخلاصة
سنقوم بتطوير تطبيق جوال جاهز
تقدم IT Sectr تطبيقات iOS وAndroid للشركات الناشئة والشركات منذ عام 2017. سوف نقدم لك النصح ونقترح أفضل حل.