بهینه‌سازی حافظه بازی Unity در اندروید

اگرچه ظرفیت رم فیزیکی دستگاه‌های تلفن همراه مدرن همچنان در حال افزایش است، اما مصرف حافظه بازی به دلیل دارایی‌های با وضوح بالا و خطوط رندر پیچیده، از این رشد پیشی می‌گیرد.

مشکلات کلیدی ناشی از استفاده بیش از حد از حافظه

  • خاتمه‌های قاتل حافظه کم (LMK) : سیستم عامل به اجبار برنامه‌های پس‌زمینه یا حتی برنامه‌های پیش‌زمینه را می‌بندد تا کمبود حافظه در کل سیستم را برطرف کند.
  • افت فریم و وقفه (Jank) : افزایش مکرر سرعت جمع‌آوری زباله (GC) در Managed Heap یا تعویض حافظه در سطح سیستم عامل، باعث ایجاد گلوگاه‌های پردازشی می‌شود.
  • کاهش دما و تخلیه باتری : تخصیص، آزادسازی و پردازش مداوم حافظه، سربار قابل توجهی برای پردازنده ایجاد می‌کند و منجر به افزایش گرما و تخلیه سریع‌تر باتری می‌شود.

به‌روزرسانی‌های مدیریت حافظه اندروید

  • اندروید ۱۷: معرفی MemoryLimiter : اندروید ۱۷ MemoryLimiter معرفی می‌کند که به طور فعال مصرف حافظه برنامه را در برابر آستانه‌های خاص دستگاه نظارت می‌کند. برنامه‌هایی که از حد حافظه خود فراتر روند، بلافاصله در سطح سیستم خاتمه می‌یابند. از آنجا که این مکانیسم سختگیرانه‌تر از LMK سنتی است، مدیریت اوج استفاده از حافظه اکنون بیش از هر زمان دیگری حیاتی است .

کاهش مصرف حافظه در یونیتی

در یونیتی، هنگامی که موتور، حافظه داخلی خود ( تخصیص‌دهندگان بلوک بومی و حافظه مدیریت‌شده ) را برای تطبیق با بار اوج بالا گسترش می‌دهد، آن صفحات حافظه را به جای اینکه بلافاصله به سیستم‌عامل بازگرداند، حفظ می‌کند. در نتیجه، حتی پس از تخلیه فایل‌های سنگین، حافظه ساکن پایه همچنان متورم باقی می‌ماند و برنامه را در برابر خاتمه فرآیند سیستم عامل اندروید (مانند LMK یا MemoryLimiter ) بسیار آسیب‌پذیر می‌کند.

برای جلوگیری از اعمال قانون در سطح سیستم عامل، بهینه‌سازی حافظه باید از طریق سه رکن کلیدی انجام شود:

  • دسته الف: کاهش مصرف اوج حافظه
  • دسته ب: حذف ردپاهای غیرضروری دارایی‌ها و سیستم‌ها
  • دسته C: حذف تخصیص‌های غیرضروری GC

دسته الف: کاهش مصرف اوج حافظه

تخصیص‌دهنده‌های یونیتی صفحات حافظه آزاد شده را برای استفاده مجدد نگه می‌دارند، به جای اینکه آنها را فوراً به سیستم عامل برگردانند، بنابراین حافظه پایه تمایل دارد بالاترین اوج مصرف رسیده را منعکس کند، نه میزان استفاده فعلی. بنابراین جلوگیری از افزایش ناگهانی مصرف در وهله اول مؤثرتر از تکیه بر پاکسازی پس از وقوع آن است.

۱. از AssetBundle های خیلی بزرگ اجتناب کنید

به دلیل مکانیزم بارگذاری بسته‌های نرم‌افزاری یونیتی، AssetBundleهای غول‌پیکر منجر به افزایش شدید حجم حافظه و از کار افتادن OOM می‌شوند. برای اطمینان از مدیریت کارآمد منابع، بسته‌ها را ماژولار نگه دارید.

مسائل کلیدی

  • سربار بالای حافظه : درخواست یک فایل کوچک، یونیتی را مجبور می‌کند کل فایل بسته (شامل هدرها، متادیتاها و بافرهای استریمینگ) را در رم بارگذاری کند.
  • تله تخلیه : اگر هر یک از دارایی‌های درون یک بسته نرم‌افزاری به طور فعال در حال استفاده باشد، کل بسته نرم‌افزاری قابل تخلیه نیست و داده‌های استفاده نشده در RAM به دام می‌افتند.

بهترین شیوه‌ها

  • ماژولار نگه داشتن بسته‌ها : دارایی‌ها را به صورت منطقی بر اساس صحنه یا چرخه حیات گروه‌بندی کنید.
  • نکته‌ای در مورد یونیتی ۶.۶ به بالا : از دایرکتوری‌های محتوا برای جلوگیری از وابستگی‌های ناخواسته بین بسته‌های نرم‌افزاری استفاده کنید.

۲. بهینه‌سازی ارجاعات به منابع در ScriptableObjects

فیلدهای سریالیزه شده UnityEngine.Object در یک ScriptableObject ارجاعات مستقیم ایجاد می‌کنند و تمام فایل‌های ارجاع شده را مجبور می‌کنند به محض بارگذاری یا نمونه‌سازی خود ScriptableObject در RAM بارگذاری شوند.

// 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;
}

۳. پیکربندی انواع بارگذاری کلیپ صوتی

بارگذاری تمام کلیپ‌های صوتی فشرده نشده مستقیماً در حافظه، باعث ایجاد پیک‌های حافظه بزرگ و دائمی می‌شود. تنظیمات بارگذاری صدا باید بر اساس مشخصات استفاده آنها پیکربندی شود:

دسته بندی صوتی نوع بار دلیل
موسیقی پس‌زمینه (BGM) پخش جریانی صدا را از دیسک در بافرهای کوچک پخش می‌کند تا از افزایش ناگهانی حافظه جلوگیری شود.
SFX طولانی فشرده شده در حافظه سربار رم را پایین نگه می‌دارد و صدا را در حین پخش، فشرده‌سازی می‌کند.
جلوه‌های ویژه کوتاه و مکرر رفع فشار در هنگام بارگذاری صدا را پس از بارگذاری در RAM از حالت فشرده خارج می‌کند تا از سربار CPU در زمان اجرا در حین پخش جلوگیری شود.

۴. پیاده‌سازی استراتژی‌های object pooling و release

نمونه‌های منتشر نشده‌ای که در طول انتقال صحنه‌ها درون Object Poolها باقی می‌مانند، به طور نامحدود در Reserved Memory باقی می‌مانند و باعث می‌شوند که فضای حافظه پایه به طور غیرضروری افزایش یابد.

  • اقدام : اشیاء استفاده نشده را به صورت دوره‌ای در طول انتقال صحنه یا دوره‌های کم‌فعالیت پاک یا مرتب کنید، که به یونیتی اجازه می‌دهد آن فضای رزرو شده را برای تخصیص‌های دیگر بازگرداند یا دوباره استفاده کند.

دسته ب: حذف استفاده غیرضروری از حافظه

حذف فایل‌های گرافیکی اضافی و بافرهای رندرینگ هدف، مستقیماً میزان اشغال حافظه پایه را کاهش می‌دهد.

۱. بافت‌های رندر و عمق دوربین را بهینه کنید

  • حذف بافرهای عمق/استنسیل : برای رندر تکسچرهایی که فقط به داده‌های رنگی نیاز دارند، فرمت استنسیل عمق را روی None تنظیم کنید.
  • غیرفعال کردن بافت عمق دوربین رابط کاربری : برای دوربین‌های رابط کاربری که داده‌های عمق غیرضروری هستند، تولید بافت عمق را در تنظیمات دوربین URP غیرفعال کنید تا CopyDepth Pass و حافظه بافت GPU مرتبط حذف شوند.

۲. بهینه‌سازی بافت‌ها و مش‌ها

دسته بندی دستورالعمل بهینه‌سازی
فشرده‌سازی بافت همیشه از فرمت‌های فشرده‌سازی پلتفرم هدف (مثلاً ASTC برای اندروید) استفاده کنید.
خواندن/نوشتن فعال است مگر اینکه لازم باشد، غیرفعال نگه دارید. فعال کردن این گزینه، حافظه بافت را در RAM پردازنده و پردازنده گرافیکی کپی می‌کند.
میپ‌مپ‌ها غیرفعال کردن Mipmaps برای بافت‌های رابط کاربری یا اشیاء ثابت در فاصله ثابت دوربین، حدود ۳۳٪ در حافظه بافت صرفه‌جویی می‌کند.
پیچیدگی مش تعداد چندضلعی‌های غیرضروری و جریان‌های رأس را کاهش دهید تا حجم اشغال‌شده توسط پردازنده گرافیکی و حافظه اصلی کمتر شود.

۳. انواع سایه‌زن را حذف کنید و حافظه را بهینه کنید

اوبر-شیدرها (برای مثال، URP Lit Shader) ویژگی‌های متعددی را با استفاده از کلمات کلیدی #multi_compile و shader_feature کپسوله می‌کنند. بدون بهینه‌سازی، انفجار ترکیبی ده‌ها هزار نوع شیدر منحصر به فرد ایجاد می‌کند که منجر به حجم ساخت متورم، مصرف عظیم حافظه بومی و مشکلات کامپایل درایور GPU در حین گیم‌پلی می‌شود.

الف. مکانیک سربار حافظه نوع سایه‌زن

  • انفجار ترکیبی : کل انواع ممکن با هر گروه کلمه کلیدی اضافه شده به صورت تصاعدی رشد می‌کنند.
  • معماری تخصیص قطعه‌ای : گروه‌های یونیتی انواع باینری را در بلوک‌های حافظه فشرده‌شده به نام قطعه‌ای (پیش‌فرض: ۴ مگابایت) کامپایل کردند.
  • نفخ حافظه بومی : وقتی کد زمان اجرا حتی یک نوع داده را در داخل یک تکه درخواست می‌کند، کل آن تکه ۴ مگابایتی در RAM از حالت فشرده خارج می‌شود . در صورت عدم بهینه‌سازی، هزاران نوع داده استفاده نشده که در آن تکه‌ها بسته‌بندی شده‌اند، به طور دائم حافظه بومی را اشغال می‌کنند.

ب. خط لوله حذف چند مرحله‌ای داخلی یونیتی : یونیتی به طور خودکار انواع غیرضروری را در زمان ساخت بر اساس تنظیمات گرافیکی پلتفرم هدف و ویژگی‌های موتور استفاده نشده (به عنوان مثال، تنظیمات Fog، Lightmaps و XR) حذف می‌کند. علاوه بر این، انواع shader_feature اگر کلمات کلیدی آنها به طور فعال توسط هیچ ماده‌ای در پروژه استفاده نشود، به طور خودکار فیلتر می‌شوند، در حالی که انواع #multi_compile صرف نظر از میزان استفاده، به اجبار گنجانده می‌شوند.

ج. معماری حذف خودکار سفارشی ( IPreprocessShaders ) از آنجا که تحلیل استاتیک نمی‌تواند کلمات کلیدی تغییر یافته به صورت پویا را با استفاده از اسکریپت‌های C# در زمان اجرا ( Material.EnableKeyword ) تشخیص دهد، حذف استاندارد اغلب کافی نیست. برای اطمینان از اینکه فقط انواع واقعاً استفاده شده گنجانده شده‌اند، می‌توانید انواع را در طول مجموعه‌های تست QA با استفاده از Player.log (فعال کردن Log Shader Compilation در تنظیمات ویرایشگر) یا Profiler Traces (نشانگرهای Shader.CreateGPUProgram ) جمع‌آوری کنید. سپس، IPreprocessShaders.OnProcessShader را در یک اسکریپت ویرایشگر پیاده‌سازی کنید تا انواعی را که هرگز در زمان اجرا اجرا نشده‌اند فیلتر کنید و فقط موارد ضروری را در ساخت نگه دارید.

دسته C: حذف تخصیص‌های غیرضروری GC

تخصیص‌های جمع‌آوری زباله (GC) در پشته مدیریت‌شده منجر به تکه‌تکه شدن حافظه، گسترش‌های پشته‌ای که هرگز کوچک نمی‌شوند و افت فریم شدید در طول مکث‌های GC می‌شود.

۱. جلوگیری از تخصیص‌های بسته شدن لامبدا

وقتی یک عبارت لامبدا متغیرهای محلی بیرونی را دریافت می‌کند، سی‌شارپ یک کلاس نمایش ضمنی روی هیپ ایجاد می‌کند. اجرای این کلاس درون Update ، نمونه‌های closure را به هر فریم اختصاص می‌دهد.

// 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);

۲. از stackalloc و Span استفاده کنید

با استفاده از حافظه پشته (Stack memory)، از تخصیص فضای Heap برای آرایه‌های موقت با عمر کوتاه جلوگیری کنید.

// 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];

۳. بهینه‌سازی تکرار جمع‌آوری

از boxing و تخصیص‌های هیپ Enumerator که توسط افزونه‌های LINQ یا accessorهای 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;
}

۴. قوانین اضافی برای جلوگیری از تخصیص GC

  • از استفاده از Camera.allCameras خودداری کنید زیرا در هر فراخوانی یک آرایه Camera[] جدید در حافظه heap ایجاد می‌کند. در عوض، یک آرایه camera را cache کرده و آن را به Camera.GetAllCameras(_allCameras) ارسال کنید.
  • foreach در IReadOnlyList<T> اجتناب کنید : تکرار روی یک رابط باعث ایجاد محدودیت در شمارشگر struct و ایجاد تخصیص‌های GC می‌شود. به جای آن از یک حلقه for استاندارد استفاده کنید.
  • اشیاء کوروتین را کش کنید : نمونه‌های WaitForSeconds را به جای نمونه‌سازی، به طور مکرر کش کنید. yield return new WaitForSeconds(time);
  • کلیدهای ساختار سفارشی در دیکشنری‌ها : استفاده از ساختارهای سفارشی به عنوان کلیدهای دیکشنری، مقادیر پیش‌فرض Equals فراخوانی می‌کند و باعث ایجاد کادربندی اشیاء می‌شود. IEqualityComparer<T> را پیاده‌سازی کنید و آن را به سازنده‌ی دیکشنری ارسال کنید.
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());

۵. استفاده از کلمات عمومی و انعکاسی را به حداقل برسانید

اگرچه متدهای جنریک قابلیت استفاده مجدد و نگهداری کد بسیار خوبی را فراهم می‌کنند، اما استفاده بیش از حد از آنها می‌تواند تأثیر منفی بر پروژه شما در زمینه بک‌اند Unity IL2CPP (زبان میانی به ++C) داشته باشد.

  • نفخ کد IL2CPP : برای هر ترکیب نوع ژنریک منحصر به فرد، IL2CPP یک نسخه تخصصی از کد تولید می‌کند. استفاده بیش از حد از ژنریک‌های پیچیده می‌تواند منجر به "انفجار ترکیبی" کد C++ تولید شده شود و به طور قابل توجهی اندازه دودویی برنامه و فضای حافظه بومی را افزایش دهد.
  • سربار بازتاب : روش‌هایی که از بازتاب استفاده می‌کنند، مانند APIهای System.Reflection ، ذاتاً کند هستند و اغلب باعث تخصیص حافظه heap در زمان اجرا می‌شوند.
  • بهترین روش : از ژنریک‌ها با دقت استفاده کنید - آن‌ها را برای وضوح معماری اولویت‌بندی کنید تا کاربرد گسترده و بدون تبعیض. در جایی که عملکرد حیاتی است، از انواع عینی یا چندریختی مبتنی بر رابط استفاده کنید. برای بازتاب، نتایجی مانند MethodInfo یا FieldInfo را در طول مقداردهی اولیه ذخیره کنید، به جای اینکه آن‌ها را در حلقه به‌روزرسانی پرس‌وجو کنید.

۶. از پوسته‌های مدیریت‌شده‌ی نشت‌شده اجتناب کنید

هر UnityEngine.Object ، مانند MonoBehaviour ، Texture یا GameObject ، یک پوشش "Managed Shell" سی شارپ دارد که با موتور بومی سی پلاس پلاس ارتباط برقرار می‌کند.

  • مشکل : اگر یک پوسته مدیریت‌شده توسط یک ارجاع ایستا، یک اشتراک رویداد پایدار یا یک بسته شدن پاک نشده در حافظه نگه داشته شود، GC نمی‌تواند حافظه را پس بگیرد. حتی اگر شیء بومی از بین برود، پوشش مدیریت‌شده باقی می‌ماند و منجر به نشت حافظه "شبح" می‌شود که باعث بزرگ شدن پشته مدیریت‌شده می‌شود.
  • راه‌حل : همیشه الگوهای پاکسازی قوی را پیاده‌سازی کنید. هنگام از بین بردن اشیاء یا انتقال صحنه‌ها، صریحاً با استفاده از عملگر -= از رویدادها انصراف دهید و ارجاعات استاتیک به انواع UnityEngine.Object را خنثی کنید. این پاکسازی تضمین می‌کند که GC می‌تواند پس از انتشار هندل موتور بومی، با موفقیت بسته‌بندی را جمع‌آوری کند.