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

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

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

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

تعديلات على إدارة الذاكرة في Android

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

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

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

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

  • الفئة "أ": تقليل ذروة استخدام الذاكرة
  • الفئة "ب": التخلص من آثار الأصول والأنظمة غير الضرورية
  • الفئة "ج": إلغاء عمليات تخصيص الذاكرة غير الضرورية لجمع المهملات

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

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

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

بسبب آلية تحميل الحِزم في Unity، تؤدي حِزم AssetBundles الضخمة إلى تضخّم الذاكرة بشكل كبير وتعطُّل الألعاب بسبب نفاد الذاكرة (OOM). يجب الحفاظ على وحدات الحِزم لضمان إدارة الموارد بكفاءة.

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

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

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

  • الحفاظ على تصميم الحِزم كوحدات مستقلة: يمكنك تجميع مواد العرض بشكل منطقي حسب المشهد أو دورة الحياة.
  • نصيحة بشأن 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- ضبط المقاطع الصوتية والقنوات

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

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

بالإضافة إلى أنواع التحميل، طبِّق إعدادات الصوت التالية لتقليل استهلاك الذاكرة بشكل أكبر:

  • تفعيل ميزة "فرض الصوت الأحادي" (Force To Mono) للصوت غير الاتجاهي: لا تتطلّب العديد من مواد العرض الصوتية، مثل المؤثرات الصوتية لواجهة المستخدم والتعليقات الصوتية والمؤثرات الصوتية المكانية الثلاثية الأبعاد، فصل القنوات الاستيريو. فعِّل Force To Mono في إعدادات استيراد AudioClip لمواد العرض هذه لتقليل استهلاك الذاكرة وحجم ذاكرة التخزين المؤقت إلى النصف (انخفاض بنسبة% 50).
  • تقييد قنوات الصوت النشطة حسب فئة الجهاز: يخصّص كل صوت نشط مخازن مؤقتة لضغط البيانات ومزج معالِج الإشارات الرقمية (DSP) الأصلي. على الأجهزة ذات الذاكرة المنخفضة، قلِّل الحد الأقصى لعدد القنوات النشطة المتزامنة، مثلاً من خلال خفض قيمة Max Real Voices (مثلًا من 32 إلى 16) باستخدام AudioSettings.Reset في Unity، أو من خلال تقييد قنوات التشغيل البرمجية في محركات الصوت التابعة لجهات خارجية.

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

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

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

الفئة ب: التخلص من استخدام الذاكرة غير الضروري

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

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

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

2. تحسين الزخارف والشبكات

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

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

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

(أ). آليات استهلاك الذاكرة الإضافية لمتغيّرات أدوات التظليل

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

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

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

(د) دمج برامج التظليل المكرّرة: تحتوي برامج التظليل الشاملة، مثل Lit وSimple Lit، على العديد من توجيهات #multi_compile وتستهلك قدرًا كبيرًا من الذاكرة عند إنشاء العديد من النُسخ:

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

الفئة ج: التخلص من عمليات تخصيص الذاكرة غير الضرورية في GC

تؤدي عمليات تخصيص "جمع البيانات المُهمَلة" (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

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

// 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- تحسين تكرار عملية الجمع

تجنَّب عمليات التخصيص في الذاكرة المؤقتة (heap) من نوع Enumerator وBoxing الناتجة عن إضافات 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. قواعد إضافية لمنع تخصيص أرصدة Google Cloud

  • تجنَّب استخدام Camera.allCameras لأنّه ينشئ مصفوفة Camera[] جديدة في الذاكرة الديناميكية عند كل طلب. بدلاً من ذلك، خزِّن مجموعة من الكاميرات مؤقتًا ومرِّرها إلى Camera.GetAllCameras(_allCameras).
  • تجنُّب استخدام foreach في IReadOnlyList<T>: يؤدي التكرار في واجهة إلى تحويل العدادات إلى كائنات، ما يؤدي إلى تخصيصات في GC. استخدِم حلقة تكرار for عادية بدلاً من ذلك.
  • تخزين عناصر الكوروتين مؤقتًا: يمكنك تخزين مثيلات WaitForSeconds مؤقتًا بدلاً من إنشاء مثيلات yield return new WaitForSeconds(time); بشكل متكرر.
  • مفاتيح البنية المخصّصة في القواميس: يؤدي استخدام البُنى المخصّصة كمفاتيح قاموس إلى استدعاء الدالة التلقائية Equals، ما يؤدي إلى تفعيل عملية تحويل أنواع البيانات (boxing). نفِّذ 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 (Intermediate Language to C++) الخلفية.

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

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

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

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

7. استخدام الفهارس الثنائية لـ Addressables

في Unity 2022 والإصدارات الأقدم، يتم ضبط Addressables تلقائيًا على كتالوجات JSON بدلاً من الكتالوجات الثنائية (وهي الإعدادات التلقائية في Unity 2023 والإصدارات الأحدث). إذا كان مشروعك يستخدم كتالوجات JSON، فإنّ تحميل كتالوج كبير عند بدء التشغيل يقرأ الملف بأكمله في سلسلة C# ‎ ضخمة واحدة ويخصّص آلاف السلاسل المُدارة ومصفوفات byte[] المؤقتة أثناء تحليل JSON وفك ترميز Base64. يؤدي ذلك إلى ارتفاع كبير في تخصيص GC يؤدي إلى توسيع Managed Heap بشكل دائم، لذا عليك التبديل إلى كتالوج ثنائي لبث البيانات المضغوطة مباشرةً.