تسرب الذاكرة (Memory Leak) هو حالة يحتفظ فيها التطبيق بمراجع لكائنات لم تعد هناك حاجة إليها، مما يمنع جامع القمامة من تحرير الذاكرة المشغولة. وفقًا لـ LeakCanary، حتى في التطبيقات مكتوبة بشكل جيد تظهر 3–5 تسريبات لكل 10,000 سطر من الكود. كل تسرب يقلل تدريجيًا من الذاكرة المتاحة، مما يؤدي إلى التباطؤ و OutOfMemoryError.
الخلاصة
تسرب الذاكرة (Memory Leak) هو حالة يظل فيها الكائن قابلاً للوصول عبر سلسلة من المراجع القوية (Strong Reference)، على الرغم من أنه لم يعد ضروريًا منطقيًا للتطبيق. يعتبر جامع القمامة (GC) مثل هذا الكائن حيًا ولا يحرر الذاكرة التي يشغلها. ونتيجة لذلك، تقل الذاكرة المتاحة للكومة (Heap) باستمرار، ويزداد تواتر توقفات GC.
على عكس اللغات ذات الإدارة اليدوية للذاكرة (C, C++)، في Java/Kotlin التسرب ليس free() منسيًا، بل مرجع منسي. طالما يوجد مرجع قوي من جذر GC (GC Root) إلى الكائن المسرب، يعتبره GC ضروريًا. جذور GC النموذجية: الحقول الثابتة، الخيوط النشطة، مكدس الاستدعاءات، المراجع العامة JNI.
خطر التسريبات هو تأثيرها التراكمي. تسرب واحد بحجم 100 كيلوبايت غير ملحوظ، لكن 100 تسرب من هذا القبيل تشغل 10 ميغابايت، ويبدأ التطبيق في التباطؤ بسبب GC المتكرر. تؤدي الكتلة الحرجة من التسريبات إلى OutOfMemoryError وتعطل التطبيق. أعراض التسرب: نمو مستمر في استهلاك الذاكرة على رسم Profiler، توقفات GC متكررة مع STW (Stop The World)، وتدهور أداء واجهة المستخدم.
خمسة أنواع من التسريبات تغطي 95% من الحالات في تطوير التطبيقات المحمولة. لكل منها سببها ونمط الكود المميز الخاص بها.
أشهر تسرب في Android هو تخزين مرجع ثابت إلى Activity أو Context. كود نموذجي: حقل ثابت من نوع Activity لا يتم تصفيره عند onDestroy(). طالما أن الحقل الثابت موجود، فإن Activity بأكملها مع شجرة View الخاصة بها تبقى حية، والتي قد تشغل 1–10 ميغابايت. هذا هو التسرب الكلاسيكي الذي يجده LeakCanary في المقام الأول.
الحل: لا تخزن أبدًا Activity أو Context في حقول ثابتة. استخدم Application Context للـ singletons التي تعيش بعد Activity. إذا كنت بحاجة إلى مرجع إلى Activity، استخدم WeakReference<Activity>.
object MySingleton {
private var weakActivity: WeakReference<Activity>? = null
fun attach(activity: Activity) {
weakActivity = WeakReference(activity)
}
}
الفئات المجهولة والفئات الداخلية غير الثابتة تحتفظ ضمنيًا بمرجع للفئة الحاوية. Runnable يتم تمريره إلى Handler ويتم تنفيذه بعد onDestroy() يحتفظ بـ Activity بأكمله. استدعاء Retrofit الذي يغلق على Activity يفعل الشيء نفسه. هذا هو أكثر أنواع التسريبات غدرًا — المرجع الضمني غير مرئي في الكود.
تعابير object في Kotlin واللامبدات تلتقط أيضًا مراجع للفئة الخارجية. اجعل الفئات الداخلية ثابتة (أو من المستوى الأعلى في Kotlin) ومرر المراجع الخارجية عبر WeakReference. بالنسبة للامبدات، استخدم نهج Lifecycle-aware مع viewLifecycleOwner.
الاشتراك في خدمات النظام دون إلغاء الاشتراك هو تسرب مباشر. SensorManager و LocationManager و NotificationListener المسجلة في onResume() دون استدعاء unregister في onPause() تحتفظ بـ Activity. وبالمثل: Disposable في RxJava غير المضاف إلى CompositeDisposable، و coroutine التي تم إطلاقها عبر GlobalScope.
استخدم المكونات الواعية لدورة الحياة: observe() مع LifecycleOwner تلغي الاشتراك تلقائيًا عند onDestroy(). لـ RxJava — viewLifecycleOwner.lifecycle.addObserver مع DisposableObserver. للـ coroutines — lifecycleScope.launch() مرتبط بدورة الحياة.
// إلغاء الاشتراك التلقائي عبر Lifecycle
viewModel.userData.observe(viewLifecycleOwner) { data ->
updateUI(data)
}
// coroutines مع lifecycleScope
lifecycleScope.launch {
viewModel.loadData().collect { render(it) }
}
Bitmap يستهلك قدرًا كبيرًا من ذاكرة الكومة: صورة FullHD واحدة هي 1920 × 1080 × 4 بايت = 8.3 ميغابايت. إذا تم إنشاء Bitmap لكل عنصر في القائمة ولم يتم استدعاء recycle() عند الإخفاء، تستنفد الذاكرة بسرعة. في الإصدارات القديمة من Android (قبل 3.0)، كان Bitmap يُخزن في الذاكرة الأصلية، لكن في الإصدارات الحديثة هو في كومة Dalvik/ART، ويمكن لـ GC تحريره فقط إذا لم يكن هناك مرجع قوي.
استخدم Glide أو Coil لتحميل الصور — هذه المكتبات تدير التخزين المؤقت وإعادة التدوير تلقائيًا. إذا كنت تعمل مع Bitmap مباشرة، استدعِ bitmap.recycle() للصور الكبيرة التي لم تعد معروضة، واستخدم inSampleSize لتحميل نسخ مصغرة.
Fragment له دورتا حياة: دورة حياة Fragment نفسه ودورة حياة View الخاصة به. بعد onDestroyView()، يتم تدمير شجرة View، لكن Fragment نفسه قد يبقى في الذاكرة إذا كان هناك مرجع خارجي. الخطأ النموذجي هو تخزين مرجع إلى Fragment في محول ViewPager أو في رسم بياني للتنقل لا يتم مسحه عند التدمير.
لا تخزن أبدًا مرجعًا إلى Fragment في حقول الكائنات طويلة العمر. استخدم childFragmentManager للـ fragments المتداخلة و observe() مع LifecycleOwner لنقل البيانات بينها. حل ViewPager2 هذه المشكلة على مستوى API: FragmentTransactionAdapter يدير دورة الحياة بشكل صحيح.
اكتشاف التسرب يتطلب التحقق من حقيقتين: الذاكرة لا تعود بعد العمر المتوقع، وعدد الكائنات من نوع معين ينمو دون انخفاض. تتضمن عملية التشخيص ثلاث مراحل.
المرحلة الأولى — فحص بصري عبر Memory Profiler في Android Studio. افتح علامة التبويب Memory، وقم بالإجراء المستهدف (افتح وأغلق الشاشة)، اضغط GC (Garbage Collection) وانظر ما إذا عادت الذاكرة إلى المستوى الأولي. إذا كانت الذاكرة تنمو باستمرار بعد 3–4 دورات من الفتح والإغلاق — فهناك تسرب.
المرحلة الثانية — أخذ Heap Dump. في Memory Profiler، اضغط Dump Java Heap. افتح ملف .hprof الناتج في Android Studio: سترى جميع الكائنات في الكومة مع الأحجام والمراجع. ابحث عن الفئات التي يجب أن يكون عددها صفرًا بعد إغلاق الشاشة. على سبيل المثال، MainActivity بعدد 2 بعد الإغلاق هو تسرب واضح.
المرحلة الثالثة — تحليل Retained Size و GC Root. في Android Studio، حلل Retained Size: كم من الذاكرة سيتم تحريرها إذا قمت بإزالة هذا الكائن. المسار من GC Root إلى الكائن يظهر ما يحتفظ به: Static field → HashMap → Activity — وترى نقطة التسرب. لوحة Reference تظهر جميع حاملي الكائن.
أربع أدوات تغطي البحث عن التسريبات من الكشف التلقائي إلى التحليل العميق لـ Heap Dump.
| الأداة | الطريقة | تنسيق النتائج |
|---|---|---|
| LeakCanary | مراقبة تلقائية | Heap Dump + stack trace للتسرب |
| Android Memory Profiler | مراقبة يدوية | رسم بياني للذاكرة + Heap Dump |
| MAT (Eclipse) | تحليل عميق | تقرير Dominator Tree + مسار GC Root |
| Perfetto | تتبع على مستوى النظام | خط زمني + ذاكرة أصلية |
LeakCanary — أمر لا غنى عنه لأي مشروع Android. يكتشف تلقائيًا التسريبات عند نهاية دورة حياة Activity/Fragment ويظهر الموقع الدقيق للتسرب مع stack trace. التكامل: سطر واحد في build.gradle. LeakCanary 2.x لا يتطلب تهيئة يدوية — يسجل تلقائيًا Application Watcher.
الوقاية من التسريبات تُدمج في عملية التطوير من خلال مجموعة من القواعد والأدوات التي تتحقق من الكود في كل مرحلة.
لا تخزن أبدًا مرجعًا إلى Activity أو Fragment أو View في حقل ثابت أو singleton أو كائن طويل العمر. إذا كان لا بد من المرجع، استخدم WeakReference أو خزن البيانات عبر ViewModel، التي تعيش بالضبط للمدة المطلوبة ولا تحتفظ بـ View مباشرة.
ViewModel و LiveData من Android Architecture Components يحلان مشكلة دورة الحياة على المستوى المعماري. ViewModel ينجو من تدوير الشاشة ولا يحتوي على مراجع لـ View. LiveData تلغي اشتراك المراقب تلقائيًا عند onDestroy(). استخدمها بدلاً من الاشتراك اليدوي في خدمات النظام.
في مراجعة الكود، انتبه إلى: الحقول الثابتة بأنواع Context/View، الفئات المجهولة، اللامبدات التي تغلق على Activity، الاشتراكات اليدوية، RxJava disposable بدون composite، تخزين Fragment عبر Bundle. في Kotlin، تحقق بالإضافة إلى ذلك من الـ coroutines مع launch بدون ربط بدورة الحياة.
LeakCanary يمكن أن يعمل كجزء من خط أنابيب الاختبار: قم بتشغيل اختبارات القبول مع LeakCanary وافشل البناء إذا تم العثور على تسرب. هذا يمنع وصول التسريبات إلى الإنتاج. استكمل التحقق بقاعدة StaticFieldLeak من Android Lint — فهي تجد التسريبات المحتملة على مستوى التحليل الثابت.
// LeakCanary في الاختبارات
class LeakTest {
@Test
fun activityShouldNotLeak() {
ActivityScenario.launch(MainActivity::class.java)
.close()
LeakAssertions.assertNoLeak() // فشل إذا كان هناك تسرب
}
}
الأسئلة الشائعة
التسرب هو السبب، و OutOfMemoryError هو النتيجة. تسرب واحد لا يؤدي إلى OOM، لكن تراكم عشرات التسريبات يستنفد Heap. OOM هو استثناء قاتل، بينما التسرب هو نمط يؤدي إليه بمرور الوقت.
عبر Android Memory Profiler: افتح وأغلق الشاشة 5 مرات، بعد كل إغلاق استدعِ GC. إذا لم تعد الذاكرة إلى المستوى الأساسي — فهناك تسرب. خذ Heap Dump وابحث في القائمة عن فئة Activity التي يكون عددها أكبر من 0 بعد الإغلاق.
جزئيًا. Kotlin يحل مشكلة null-safety لكنه لا يدير strong references. الـ coroutines مع lifecycleScope و viewModelScope تمنع التسريبات من المهام الخلفية، بينما sealed class و data class يقللان من عدد الحالات المؤدية للتسريبات. الحماية الرئيسية هي الأنماط المعمارية، وليس ميزات اللغة.
LeakCanary أحيانًا يعطي نتائج إيجابية خاطئة: قد يتم احتجاز الكائن مؤقتًا بواسطة النظام (على سبيل المثال، InputMethodManager يحتفظ بـ View الأخيرة). تحقق يدويًا: إذا كان Retained Size < 1 كيلوبايت وكان GC Root خدمة نظام، فمن المحتمل أنه إنذار خاطئ.
لا. التسريبات ممكنة على أي منصة بها GC: iOS (Swift/Objective-C), Flutter (Dart), متصفحات الويب (JavaScript). الآليات هي نفسها — strong reference من GC Root. على iOS، يدير ARC الذاكرة تلقائيًا، لكن دورات الاحتفاظ (retain cycles) بين الكائنات تخلق نفس التسرب.
الملخص
سنقوم بتطوير تطبيق جوال جاهز
تقدم IT Sectr تطبيقات iOS وAndroid للشركات الناشئة والشركات منذ عام 2017. سوف نقدم لك النصح ونقترح أفضل حل.
اقرأ أيضًا