تحسين استخدام الذاكرة في ألعاب Unity على Android

على الرغم من أنّ سعة ذاكرة الوصول العشوائي (RAM) الفعلية للأجهزة الجوّالة الحديثة تستمر في النمو، فإنّ استهلاك ذاكرة الألعاب يتجاوز هذا النمو بسبب مواد العرض العالية الدقة وعمليات العرض المعقّدة.

المشاكل الرئيسية الناتجة عن الاستخدام المفرط للذاكرة

  • عمليات إنهاء العمليات بسبب نقص الذاكرة (LMK): يغلق نظام التشغيل التطبيقات في الخلفية أو حتى في المقدّمة بشكل إجباري لحل مشاكل نقص الذاكرة على مستوى النظام.
  • انخفاض عدد اللقطات في الثانية والتقطُّع (jank): تؤدي الارتفاعات المتكررة في عملية جمع البيانات المُهمَلة في Managed Heap أو تبديل الذاكرة على مستوى نظام التشغيل إلى حدوث اختناقات في المعالجة.
  • الحدّ من سرعة المعالجة بسبب الحرارة واستنزاف البطارية: يؤدي التخصيص المستمر للذاكرة وإلغاء تخصيصها وعمليات نقل صفحات الذاكرة إلى زيادة كبيرة في الحمل الزائد لوحدة المعالجة المركزية، ما يؤدي إلى ارتفاع درجة الحرارة بشكل كبير واستنزاف البطارية بشكل أسرع.

تحديثات إدارة الذاكرة في Android

  • ‫Android 17: تقديم MemoryLimiter: يقدّم نظام التشغيل Android 17 MemoryLimiter الذي يراقب بشكل نشط استهلاك التطبيق لذاكرة الجهاز مقارنةً بالحدود القصوى الخاصة بالجهاز. يتم إيقاف التطبيقات التي تتجاوز الحد الأقصى المسموح به للذاكرة على الفور على مستوى النظام. وبما أنّ هذه الآلية أكثر صرامة من آلية LMK التقليدية، أصبحت إدارة الحد الأقصى لاستخدام الذاكرة أكثر أهمية من أي وقت مضى.

تقليل استخدام الذاكرة في Unity

في Unity، بعد أن يوسّع المحرك مجموعات الذاكرة الداخلية (Native Block Allocators وManaged Heap) لاستيعاب ذروة الحمل العالية، يحتفظ بصفحات الذاكرة هذه بدلاً من إعادتها على الفور إلى نظام التشغيل. نتيجةً لذلك، حتى بعد إلغاء تحميل مواد العرض الكبيرة، يظل حجم الذاكرة المقيمة الأساسي مرتفعًا، ما يجعل التطبيق عرضة بشكل كبير لعمليات إنهاء نظام التشغيل Android (مثل LMK أو MemoryLimiter).

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

  • الفئة (أ): تقليل الحد الأقصى لاستخدام الذاكرة
  • الفئة (ب): إزالة آثار الأصول والأنظمة غير الضرورية
  • الفئة ج: التخلص من عمليات تخصيص غير ضرورية لجمع البيانات غير الصالحة

الفئة (أ): تقليل الحد الأقصى لاستخدام الذاكرة

تحتفظ أدوات التخصيص في Unity بصفحات الذاكرة التي تم إخلاؤها لإعادة استخدامها بدلاً من إرجاعها إلى نظام التشغيل على الفور، لذا يميل استخدام الذاكرة الأساسي إلى عرض أعلى معدل استخدام تم الوصول إليه بدلاً من الاستخدام الحالي. لذلك، يُعدّ منع حدوث ارتفاعات مفاجئة في المقام الأول أكثر فعالية من الاعتماد على التنظيف بعد حدوثها.

1. تجنُّب حِزم AssetBundle الكبيرة جدًا

بسبب آلية تحميل الحِزم في Unity، تؤدي حِزم AssetBundle الضخمة إلى تضخّم كبير في الذاكرة وتعطُّل التطبيق بسبب خطأ نفاد الذاكرة. احرص على أن تكون الحِزم معيارية لضمان إدارة الموارد بكفاءة.

المشاكل الرئيسية

  • زيادة كبيرة في استخدام الذاكرة: يؤدي طلب مادة عرض صغيرة إلى إجبار Unity على تحميل ملف الحزمة بأكمله (بما في ذلك العناوين وبيانات التعريف ومخازن البث المؤقتة) في ذاكرة الوصول العشوائي.
  • فخ إلغاء التحميل: إذا كان أي مادة عرض ضمن حزمة قيد الاستخدام النشط، لا يمكن إلغاء تحميل الحزمة بأكملها، ما يؤدي إلى تخزين البيانات غير المستخدَمة في ذاكرة الوصول العشوائي.

أفضل الممارسات

  • الحفاظ على تصميم الحِزم كوحدات مستقلة: يمكنك تجميع مواد العرض بشكل منطقي حسب المشهد أو دورة الحياة.
  • نصيحة بشأن Unity 6.6 والإصدارات الأحدث: استخدِم أدلة المحتوى لمنع التبعيات غير المقصودة بين الحِزم.

2. تحسين مراجع مواد العرض في ScriptableObjects

تؤدي حقول UnityEngine.Object المتسلسلة المباشرة في عملية إنشاء ScriptableObject إلى إنشاء مراجع ثابتة مباشرة، ما يجبر جميع مواد العرض المشار إليها على التحميل في ذاكرة الوصول العشوائي (RAM) بمجرد تحميل ScriptableObject أو إنشاء مثيل لها.

// BEFORE: Loading SceneRequiredAssets forces _worldAsset and _spawnSettings into RAM immediately
public class SceneRequiredAssets : ScriptableObject
{
    public string sceneName;
    public Object _worldAsset;
    public Object _spawnSettings;
}

// AFTER: Use AssetReference to enable asynchronous, on-demand loading using Addressables
public class SceneRequiredAssets : ScriptableObject
{
    public string sceneName;
    public AssetReference _worldAsset;
    public AssetReference _spawnSettings;
}

3- ضبط أنواع تحميل المقاطع الصوتية

يؤدي تحميل جميع مقاطع الصوت غير المضغوطة مباشرةً إلى الذاكرة إلى إنشاء ارتفاعات كبيرة ودائمة في الذاكرة. يجب ضبط إعدادات تحميل الصوت استنادًا إلى ملف الاستخدام الشخصي:

فئة الصوت نوع التحميل السبب
موسيقى الخلفية (BGM) البث يبث الصوت من القرص في مخازن مؤقتة صغيرة للتخلص من الارتفاعات المفاجئة في استخدام الذاكرة.
Long SFX مضغوطة في الذاكرة تحافظ هذه الميزة على انخفاض الحمل الزائد لذاكرة الوصول العشوائي (RAM) وتزيل ضغط الصوت أثناء التشغيل.
المؤثرات الصوتية القصيرة والمتكررة فك الضغط عند التحميل يتم فك ضغط الصوت في ذاكرة الوصول العشوائي عند التحميل لتجنُّب زيادة استخدام وحدة المعالجة المركزية أثناء التشغيل.

4. تنفيذ استراتيجيات تجميع العناصر وإصدارها

تظل النسخ غير المُحرَّرة المتبقية داخل "مجموعات العناصر" (Object Pools) أثناء عمليات انتقال المشهد محتفظة بـ "الذاكرة المحجوزة" (Reserved Memory) إلى أجل غير مسمى، ما يؤدي إلى تضخّم استهلاك الذاكرة الأساسي بلا داعٍ.

  • الإجراء: يمكنك بشكل دوري محو أو تقليل حجم العناصر المجمّعة غير المستخدَمة أثناء عمليات انتقال المشاهد أو فترات النشاط المنخفض، ما يتيح لـ Unity إعادة استخدام المساحة المحجوزة أو استخدامها مرة أخرى في عمليات تخصيص أخرى.

الفئة (ب): إزالة استخدام الذاكرة غير الضروري

يؤدي التخلص من أصول الرسومات المكرّرة وعرض المخازن المؤقتة المستهدَفة مباشرةً إلى تقليل استهلاك الذاكرة الأساسي.

1. تحسين مواد العرض وعمق الكاميرا

  • إزالة مخازن العمق/الاستنسل: اضبط "تنسيق استنسل العمق" على بلا لـ "نسخ العرض" التي تتطلّب بيانات الألوان فقط.
  • إيقاف نسيج العمق في كاميرا واجهة المستخدم: بالنسبة إلى كاميرات واجهة المستخدم التي لا تحتاج إلى بيانات العمق، أوقِف إنشاء نسيج العمق في إعدادات كاميرا URP لإزالة CopyDepth Pass وذاكرة نسيج وحدة معالجة الرسومات المرتبطة بها.

2. تحسين الأنسجة والشبكات

الفئة إرشادات التحسين
ضغط النسيج يجب دائمًا تطبيق تنسيقات الضغط الخاصة بالمنصة المستهدفة (مثل ASTC لنظام التشغيل Android).
تفعيل إذن القراءة والكتابة يجب إبقاء الميزة غير مفعَّلة إلا إذا كان ذلك ضروريًا. يؤدي تفعيل هذا الخيار إلى مضاعفة ذاكرة الزخرفة في ذاكرة الوصول العشوائي لوحدة المعالجة المركزية ووحدة معالجة الرسومات.
خرائط MIP يمكنك إيقاف Mipmaps لزخارف واجهة المستخدم أو العناصر الثابتة على مسافة ثابتة من الكاميرا، ما يؤدي إلى توفير% 33 تقريبًا من ذاكرة الزخارف.
تعقيد الشبكة المتداخلة قلِّل عدد المضلّعات غير الضرورية وتدفّقات الرؤوس لتقليل استهلاك الذاكرة لوحدة معالجة الرسومات والذاكرة الأصلية.

3- إزالة أشكال برنامج التظليل المتشابهة وتحسين الذاكرة

تتضمّن برامج التظليل الفائقة (مثل URP Lit Shader) العديد من الميزات باستخدام الكلمتَين الرئيسيتَين #multi_compile وshader_feature. بدون تحسين، يؤدي الانفجار التوافقي إلى إنشاء عشرات الآلاف من متغيرات التظليل الفريدة، ما يؤدي إلى زيادة أحجام الإصدارات، واستهلاك كبير للذاكرة الأصلية، وحدوث مشاكل في تجميع برامج تشغيل وحدة معالجة الرسومات أثناء اللعب.

(أ). آلية عمل الحمل الزائد لذاكرة صيغ التظليل

  • الانفجار التوافقي: يزداد إجمالي عدد الصيغ المحتملة بشكل كبير مع كل مجموعة كلمات رئيسية تتم إضافتها.
  • بنية تخصيص الأجزاء: تجمع Unity بين صيغ ثنائية مترجمة في وحدات ذاكرة مضغوطة تُسمى الأجزاء (الإعداد التلقائي: 4 ميغابايت).
  • تضخّم الذاكرة الأصلية: عندما يطلب رمز وقت التشغيل صيغة واحدة على الأقل داخل جزء، يتم فك ضغط الجزء الكامل الذي يبلغ حجمه 4 ميغابايت في ذاكرة الوصول العشوائي. في حال عدم تحسينها، ستشغل آلاف الصيغ غير المستخدَمة المضمّنة في تلك الأجزاء مساحة في الذاكرة الأصلية بشكل دائم.

(ب). مسار المعالجة المضمّن في Unity لإزالة المراحل المتعددة: يزيل Unity تلقائيًا المتغيرات غير الضرورية في وقت الإنشاء استنادًا إلى إعدادات الرسومات للنظام الأساسي المستهدف وميزات المحرك غير المستخدَمة (مثل الضباب وخرائط الإضاءة وإعدادات الواقع الممتد). بالإضافة إلى ذلك، يتم تلقائيًا استبعاد صيغ shader_feature إذا لم يتم استخدام كلماتها الرئيسية بشكل نشط في أي مواد ضمن المشروع، بينما يتم تضمين صيغ #multi_compile بشكل إلزامي بغض النظر عن الاستخدام.

(ج) بنية التجريد المبرمَج المخصّص (IPreprocessShaders): بما أنّ التحليل الثابت لا يمكنه رصد الكلمات الرئيسية التي تم تعديلها ديناميكيًا باستخدام نصوص C# البرمجية في وقت التشغيل (Material.EnableKeyword)، غالبًا ما يكون التجريد العادي غير كافٍ. لضمان تضمين المتغيرات المستخدَمة فقط، يمكنك جمع المتغيرات أثناء مجموعات اختبار ضمان الجودة باستخدام Player.log (تفعيل Log Shader Compilation في إعدادات المحرّر) أو Profiler Traces (علامات Shader.CreateGPUProgram). بعد ذلك، نفِّذ IPreprocessShaders.OnProcessShader في نص برمجي في "المحرّر" لاستبعاد المتغيرات التي لم يتم تنفيذها مطلقًا أثناء وقت التشغيل، مع الاحتفاظ بالمتغيرات الضرورية فقط في الإصدار.

الفئة C: إزالة عمليات تخصيص غير ضرورية لجامع البيانات

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

1. منع تخصيصات إغلاق lambda

عندما يلتقط تعبير lambda متغيرات محلية خارجية، ينشئ C# فئة عرض ضمنية على Heap. يؤدي تنفيذ هذا الرمز البرمجي داخل Update إلى تخصيص مثيلات إغلاق في كل إطار.

// Bad: Capturing local variable 'targetId' allocates a new closure object on the Heap every frame
void Update()
{
    int targetId = 100;
    Monster target = monsterList.Find(m => m.Id == targetId);
}

// Good 1: Replace with a standard 'for' loop (Recommended: 0 B allocation)
void Update()
{
    int targetId = 100;
    Monster target = null;
    for (int i = 0; i < monsterList.Count; i++)
    {
        if (monsterList[i].Id == targetId)
        {
            target = monsterList[i];
            break;
        }
    }
}

// Good 2: Use a static lambda (C# 9.0+) if no outer variables are captured
Monster target = monsterList.Find(static m => m.Id == 100);

2. استخدام stackalloc وSpan

تجنَّب تخصيص الذاكرة في Heap للمصفوفات المؤقتة القصيرة الأمد باستخدام ذاكرة Stack.

// Before: Allocates an array on the Heap every call (GC Target)
Vector2[] pos = new Vector2[4];

// After: Utilizes Stack memory using System.Span (0 B Heap Allocation)
System.Span<Vector2> pos = stackalloc Vector2[4];

3- تحسين تكرار عملية الجمع

تجنَّب عمليات تخصيص الذاكرة المؤقتة لعمليات التجميع والتعداد الناتجة عن إضافات LINQ أو أدوات الوصول إلى ReadOnlyCollection.

// Before: LINQ Count() causes internal GetEnumerator() heap allocations
bool hasData = component != null && component.parameters.Count(parameter => parameter.overrideState) > 0;

// After: Replaced with indexer and direct loop iteration
bool hasData = HasDataOptimized(component);

private bool HasDataOptimized(TestComponent component)
{
    if (component == null) return false;

    var count = component.parameters.Count;
    for (var i = 0; i < count; ++i)
    {
        if (component.parameters[i].overrideState)
            return true;
    }
    return false;
}

4. قواعد إضافية لمنع تخصيص مساحة إضافية في ذاكرة التخزين المؤقت

  • تجنَّب استخدام Camera.allCameras لأنّه ينشئ مصفوفة Camera[] جديدة في الذاكرة المؤقتة في كل مرة يتم فيها استدعاء الدالة. بدلاً من ذلك، خزِّن مجموعة من الكاميرات مؤقتًا ومرِّرها إلى Camera.GetAllCameras(_allCameras).
  • تجنَّب foreach على IReadOnlyList<T>: يؤدي تكرار واجهة إلى إنشاء مربّع تعداد بنية، ما يؤدي إلى إنشاء عمليات تخصيص في "جامع البيانات غير الصالحة". استخدِم حلقة for عادية بدلاً من ذلك.
  • تخزين عناصر روتينية مؤقتة في ذاكرة التخزين المؤقت: خزِّن مثيلات WaitForSeconds في ذاكرة التخزين المؤقت بدلاً من إنشاء مثيلات yield return new WaitForSeconds(time); بشكل متكرر.
  • مفاتيح البنية المخصّصة في القواميس: يؤدي استخدام البِنى المخصّصة كمفاتيح في Dictionary إلى استدعاء Equals التلقائي، ما يؤدي إلى تضمين الكائن. نفِّذ IEqualityComparer<T> ومرِّره إلى الدالة الإنشائية Dictionary.
public struct TypeKey
{
    public int v1;
    public int v2;

    public class TypeKeyComparer : IEqualityComparer<TypeKey>
    {
        public bool Equals(TypeKey x, TypeKey y) => x.v1 == y.v1 && x.v2 == y.v2;
        public int GetHashCode(TypeKey obj) => obj.v1.GetHashCode() ^ obj.v2.GetHashCode();
    }
}

// Pass custom comparer during Dictionary initialization to prevent boxing
public readonly Dictionary<TypeKey, int> _typeKeyDictionary = new(new TypeKey.TypeKeyComparer());

5. تقليل استخدام الأنواع العامة والانعكاس

في حين أنّ الطرق العامة توفّر إمكانية ممتازة لإعادة استخدام الرموز البرمجية والحفاظ عليها، فإنّ الإفراط في استخدامها يمكن أن يؤثّر سلبًا على مشروعك في سياق Unity IL2CPP (الرمز الوسيط إلى C++) الخلفي.

  • تضخّم رمز IL2CPP: لكل مجموعة فريدة من الأنواع العامة، ينشئ IL2CPP إصدارًا متخصصًا من الرمز. يمكن أن يؤدي الاستخدام المفرط للأنواع العامة المعقّدة إلى "انفجار توافقي" في رمز C++ الذي تم إنشاؤه، ما يزيد بشكل كبير من حجم التطبيق الثنائي ومساحة الذاكرة الأصلية.
  • تكلفة الأداء المرتبطة بالانعكاس: إنّ الطرق التي تستخدم الانعكاس، مثل واجهات برمجة التطبيقات System.Reflection، تكون بطيئة بطبيعتها وتتسبّب غالبًا في عمليات تخصيص الذاكرة المجمّعة أثناء وقت التشغيل.
  • أفضل الممارسات: استخدِم الأنواع العامة بحذر، مع إعطاء الأولوية لها لتحقيق وضوح معماري بدلاً من التطبيق الواسع النطاق وغير المميز. عندما يكون الأداء مهمًا، ننصحك باستخدام أنواع ملموسة أو تعدد الأشكال المستند إلى الواجهات. لتحسين الأداء، خزِّن نتائج مثل MethodInfo أو FieldInfo مؤقتًا أثناء عملية التهيئة بدلاً من طلبها في حلقة التعديل.

6. تجنُّب تسريب الأغلفة المُدارة

يحتوي كل UnityEngine.Object، مثل MonoBehaviour أو Texture أو GameObject، على برنامج تضمين "Managed Shell" بلغة C# يتواصل مع محرك C++ الأصلي.

  • المشكلة: إذا تم الاحتفاظ بـ Managed Shell في الذاكرة من خلال مرجع ثابت أو اشتراك دائم في الأحداث أو إغلاق غير منظَّف، لن يتمكّن جامع البيانات المهملة من استرداد الذاكرة. وحتى إذا تم إتلاف العنصر الأصلي، يظل الغلاف المُدار موجودًا، ما يؤدي إلى حدوث تسريبات ذاكرة "وهمية" تؤدي إلى تضخّم الذاكرة المخصّصة.
  • الحل: عليك دائمًا تنفيذ أنماط تنظيف قوية. عند إتلاف الكائنات أو الانتقال بين المشاهد، عليك إلغاء الاشتراك في الأحداث بشكل صريح باستخدام عامل التشغيل -= وإبطال المراجع الثابتة لأنواع UnityEngine.Object. يضمن هذا التنظيف أنّه يمكن لجامع البيانات غير الضرورية جمع برنامج تضمين البيانات بنجاح بعد أن يحرر المحرك الأصلي معرّفه.