تسرب الذاكرة: ما هو، السيناريوهات النموذجية والتشخيص

المؤلف: IT Sectr نُشر: 2026-07-29 وقت القراءة: 10 دق

تسرب الذاكرة (memory leak) — حالة لا يحرر فيها التطبيق الذاكرة المشغولة بالكائنات التي لم تعد هناك حاجة إليها. في تطوير التطبيقات المحمولة، هذا أمر بالغ الأهمية: الكومة المحدودة وغياب المقايضة يؤديان إلى OutOfMemoryError وتعطل التطبيق. وفقًا لـ Purdue University (2022)، 35% من تطبيقات Android في Google Play تحتوي على تسرب ذاكرة واحد على الأقل. سنستعرض السيناريوهات النموذجية وأدوات التشخيص وطرق الإصلاح.

الملخص

  • GC Root — نقطة الدخول التي يحدد من خلالها جامع القمامة الكائنات الحية
  • تسرب Context — تمرير Activity Context إلى مفرد يؤدي إلى الاحتفاظ بالتسلسل الهرمي للعرض بالكامل
  • Handler مع postDelayed — إذا تم تدمير Activity، يمنعها Handler من الوصول إلى GC
  • Heap dump — الطريقة الأساسية لتحليل التسربات عبر MAT أو Android Profiler
  • SoftReference — بديل لـ WeakReference للتخزين المؤقت مع التنظيف التلقائي عند نقص الذاكرة

ما هو تسرب الذاكرة في التطبيقات المحمولة?

تسرب الذاكرة — حالة لا يتم فيها إرجاع الذاكرة المخصصة إلى النظام بعد أن لم يعد الكائن مطلوبًا من قبل البرنامج. يعتبر جامع القمامة أن هذا الكائن حي لأن هناك سلسلة مراجع نشطة من 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. أي كائن يمكن الوصول إليه عبر المراجع من هذه الجذور يعتبر حيًا — حتى إذا كان المطور يعلم أنه لم يعد مطلوبًا.

kotlin
// مثال: مجموعة ثابتة كـ 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.

السيناريوهات النموذجية للتسربات في Android و iOS

Activity Context — السيناريو الأكثر انتشارًا للتسربات في Android. إذا كان المفرد أو الحقل الثابت أو الخدمة طويلة العمر تخزن مرجعًا إلى Activity Context، فلا يمكن جمع كل Activity مع جميع Views بواسطة GC. الحل: استخدم Application Context للكائنات طويلة العمر.

Handler والرسائل المرسلة — Handler.postDelayed(runnable, delay) يضع رسالة في قائمة Main Looper. إذا تم تدمير Activity قبل انتهاء التأخير، تظل الرسالة في القائمة وتحتفظ بمرجع عبر Runnable → فئة مجهولة → فئة خارجية (Activity).

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

  • TimerTask و ScheduledExecutorService — المهام المجدولة قبل تدمير Activity
  • BroadcastReceiver — غير المسجل في onPause/onDestroy يستمر في الاحتفاظ بـ Context
  • ViewModel مع مرجع إلى View — ViewModel يعيش أطول من Activity، المرجع إلى View يؤدي إلى تسرب
  • Retrofit Call — إذا لم يتم إلغاء Call، يصل الرد إلى Fragment مدمر

أدوات تشخيص تسرب الذاكرة

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 للمرجع الصريح.

kotlin
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 للفحص البصري.

هل يمكن لـ Kotlin coroutine أن تسبب تسربًا?

نعم، إذا لم يتم إلغاء CoroutineScope عند تدمير المكون. يستمر coroutine الذي تم تشغيله في GlobalScope في التنفيذ حتى بعد finish() لـ Activity. الحل: استخدم viewModelScope (يُلغى في onCleared) أو lifecycleScope (يُلغى في onDestroy). للنطاقات المخصصة، أنشئ نطاقات واعية بدورة الحياة عبر LifecycleOwner.

كيف يؤثر Bitmap على التسربات?

Bitmap يخزن بيانات البكسل في الكومة الأصلية (native heap)، وليس في كومة Java. هذا يعني أن GC لا يرى الحجم الفعلي لـ Bitmap. إذا لم يتم استدعاء recycle() على Bitmap أو لم يتم إلغاء المرجع، فلن يتم تحرير الذاكرة الأصلية. استخدم BitmapFactory مع inSampleSize لتحميل نسخ مصغرة و Glide/Coil للإدارة التلقائية للذاكرة المؤقتة.

ما هو التسرب عبر حقل ثابت?

الحقل الثابت — هو GC Root. يعيش طالما أن الفئة محملة (في Android — طالما أن العملية حية). إذا كان الحقل الثابت يشير إلى Activity أو Bitmap أو View أو أي كائن ثقيل آخر، فلن يتم جمع هذا الكائن أبدًا بواسطة GC. الحقل الثابت هو مرجع أبدي. الحل: قم بتخزين WeakReference فقط أو إلغاء الحقل الثابت في onDestroy.

كيف تتجنب التسربات في iOS مع ARC?

ARC يحرر الكائنات تلقائيًا عندما ينخفض عداد المراجع القوية إلى الصفر. دورة الاحتفاظ هي الطريقة الوحيدة للتسرب مع ARC. استخدم دائمًا weak للمراجع من الأب إلى الابن حيث قد يعيش الابن أطول من الأب (المفوضون، مصادر البيانات). للإغلاقات، استخدم قائمة الالتقاط [weak self] وتحقق من أن self ليس nil داخل الإغلاق.

الخلاصة

  • تسرب الذاكرة — كائن غير متاح للكود ولكن لم تتم إزالته بواسطة GC بسبب وجود مرجع نشط من GC Root
  • GC Roots تشمل الحقول الثابتة ومتغيرات المكدس ومراجع JNI؛ أي كائن يمكن الوصول إليه منها يكون حيًا
  • تسرب Context — المشكلة الأكثر انتشارًا في Android: تمرير Activity Context إلى مفرد أو حقل ثابت
  • Handler والفئة الداخلية — السبب الثاني الأكثر شيوعًا: الرسائل غير الملغاة في قائمة Looper تحتفظ بمرجع إلى Activity
  • LeakCanary — الأداة القياسية للكشف التلقائي؛ تأخذ heap dump وتظهر سلسلة GC Root الدقيقة
  • lifecycleScope و viewModelScope يحلان مشكلة التسربات عبر coroutines — الإلغاء التلقائي عند التدمير
  • قم بتنميط الذاكرة في CI/CD: LeakCanary في وضع التصحيح + تحليل heap dump في تشغيل الاختبارات يجب أن يمنعا الدمج عند وجود تسربات جديدة

سنقوم بتطوير تطبيق جوال جاهز

تقدم IT Sectr تطبيقات iOS وAndroid للشركات الناشئة والشركات منذ عام 2017. سوف نقدم لك النصح ونقترح أفضل حل.

مناقشة المشروع

اقرأ أيضًا