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

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

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

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

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

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

کاهش استفاده از حافظه در Unity

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

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

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

دسته الف: کاهش اوج استفاده از حافظه

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

۱. از AssetBundleهای بیش‌ازحد بزرگ اجتناب کنید

به‌دلیل سازوکار بارگذاری بسته Unity، «بسته‌های دارایی» بزرگ منجر به تورم شدید حافظه و خرابی‌های OOM می‌شود. دسته‌ها را به‌صورت واحدی نگه دارید تا از مدیریت کارآمد منابع اطمینان حاصل شود.

مسائل مربوط به کلید

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

روال‌های مطلوب

  • بسته‌ها را به‌صورت واحدی نگه دارید: دارایی‌ها را به‌صورت منطقی براساس صحنه یا چرخه عمر گروه‌بندی کنید.
  • نکته Unity 6.6+‎: از Content Directories برای جلوگیری از وابستگی‌های ناخواسته بین دسته‌ها استفاده کنید.

۲. بهینه‌سازی ارجاع‌های دارایی در 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) جاری‌سازی صدا را از دیسک در میان‌گیرهای کوچک جاری‌سازی می‌کند تا جهش‌های حافظه را حذف کند.
جلوه‌های صوتی طولانی فشرده‌سازی در حافظه سربار RAM را پایین نگه می‌دارد و صدا را درحین بازپخش به‌صورت آنی فشرده‌سازی می‌کند.
جلوه‌های صوتی کوتاه و مکرر خارج کردن از حالت فشرده هنگام بار کردن هنگام بار کردن، صدا را در RAM از حالت فشرده خارج می‌کند تا از سربار CPU زمان اجرا درطول بازپخش جلوگیری شود.

علاوه‌بر انواع بار، پیکربندی‌های صوتی زیر را برای کاهش بیشتر سربار حافظه اعمال کنید:

  • فعال کردن «اجبار به مونو» برای صدای غیرجهتی: بسیاری از دارایی‌های صوتی—مانند جلوه‌های صوتی رابط کاربری، صداگذاری‌ها، و جلوه‌های صوتی فضایی سه‌بعدی—به جداسازی کانال استریو نیاز ندارند. اجبار به تک‌صدایی را در AudioClipتنظیمات وارد کردن این دارایی‌ها فعال کنید تا ردپای حافظه و اندازه بافر آن‌ها به نصف کاهش یابد (۵۰٪ کاهش).
  • محدود کردن کانال‌های صوتی فعال براساس ردیف دستگاه: هر صدای صوتی فعال، بافرهای وافشرده‌سازی و ترکیب‌کننده DSP بومی را اختصاص می‌دهد. در دستگاه‌های با حافظه کم، حداکثر تعداد کانال‌های فعال هم‌زمان را کاهش دهید—برای مثال، با کاهش Max Real Voices (مثلاً از ۳۲ به ۱۶) بااستفاده از AudioSettings.Reset در Unity، یا با محدود کردن کانال‌های بازپخش نرم‌افزار در موتورهای صوتی شخص ثالث.

۴. پیاده‌سازی استراتژی‌های انتشار و استخر شیء

نمونه‌های منتشرنشده‌ای که درطول انتقال صحنه در «مجموعه اشیا» باقی می‌مانند، «حافظه رزروشده» را به‌طور نامحدود نگه می‌دارند و ردپای حافظه پایه را به‌طور غیرضروری افزایش می‌دهند.

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

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

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

۱. بهینه‌سازی بافت‌های پردازشی و عمق دوربین

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

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

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

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

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

پاسخ. مکانیک سربار حافظه متغیر سایه‌زن

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

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

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

ت. ادغام سایه‌زن‌های زائد: ابرسایه‌زن‌هایی مانند Lit و Simple Lit حاوی دستورالعمل‌های #multi_compile زیادی هستند و هنگام تولید انواع متعدد، حافظه قابل‌توجهی مصرف می‌کنند:

  • استفاده هم‌زمان از نمایه: مصرف حافظه خود را نمایه‌بندی کنید تا بررسی کنید آیا هر دو سایه‌زن به‌طور هم‌زمان حافظه قابل‌توجهی مصرف می‌کنند یا نه، و تأیید کنید که آیا پروژه شما واقعاً به هر دو نیاز دارد یا نه.
  • استانداردسازی روی یک سایه‌زن واحد: وقتی رندرینگ مبتنی بر فیزیک (PBR) الزامی نیست، دارایی‌هایتان را به‌گونه‌ای پیکربندی کنید که استفاده از سایه‌زن Lit اضافی را حذف کنید و از Simple Lit استانداردسازی کنید (برای مثال، بررسی کنید که آیا مدل‌های FBX صادرشده به‌طور ناخواسته درطول وارد کردن، مواد Lit پیش‌فرض را اعمال کرده‌اند یا نه).

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

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

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

وقتی عبارت لامبدا متغیرهای محلی بیرونی را ضبط می‌کند، 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);

۲. استفاده از 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];

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

از تخصیص‌های پشته 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;
}

۴. قوانین تکمیلی جلوگیری از تخصیص فضای ذخیره‌سازی

  • از Camera.allCameras استفاده نکنید زیرا در هر فراخوانی، آرایه Camera[] جدیدی در پشته تولید می‌کند. به‌جای آن، آرایه دوربین را ذخیره کنید و آن را به Camera.GetAllCameras(_allCameras) منتقل کنید.
  • اجتناب از foreach در IReadOnlyList<T>: تکرار کردن در سراسر واسط باعث جعبه‌گذاری شمارشگر ساختار می‌شود و تخصیص‌های GC را تولید می‌کند. به‌جای آن از حلقه for استاندارد استفاده کنید.
  • ذخیره کردن اشیای هم‌روال در حافظه نهان: ذخیره کردن نمونه‌های WaitForSeconds در حافظه نهان به‌جای نمونه‌سازی مکرر yield return new WaitForSeconds(time);.
  • کلیدهای ساختار سفارشی در فرهنگ لغت‌ها: استفاده از ساختارهای سفارشی به‌عنوان کلیدهای Dictionary ، 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++‎ تولیدشده شود و اندازه دودویی برنامه و ردپای حافظه بومی را به‌طور قابل‌توجهی افزایش دهد.
  • سربار انعکاس: روش‌های استفاده از انعکاس، مانند System.Reflection میاناهای برنامه‌سازی کاربردی، ذاتاً کند هستند و اغلب باعث تخصیص پشته در زمان اجرا می‌شوند.
  • روال مطلوب: از عناصر عمومی بااحتیاط استفاده کنید—آن‌ها را برای شفافیت معماری در اولویت قرار دهید نه برای کاربرد گسترده و بی‌رویه. در مواردی که عملکرد حیاتی است، انواع عینی یا چندریختی مبتنی بر میانجی را ترجیح دهید. برای بازتاب، نتایج حافظه نهان مثل MethodInfo یا FieldInfo را درطول مقداردهی اولیه به‌جای پُرسمان کردن آن‌ها در حلقه به‌روزرسانی ذخیره کنید.

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

هر UnityEngine.Object، مثل MonoBehaviour،‏ Texture، یا GameObject، دارای بسته‌بندی «پوسته مدیریت‌شده» C# است که با موتور C++ بومی ارتباط برقرار می‌کند.

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

‫۷. استفاده از کاتالوگ‌های باینری برای «نشانی‌پذیرها»

در Unity 2022 و نسخه‌های قبلی، Addressables به‌جای کاتالوگ‌های دودویی (که در Unity 2023 و نسخه‌های جدیدتر پیش‌فرض است) به‌طور پیش‌فرض از کاتالوگ‌های JSON استفاده می‌کند. اگر پروژه شما از کاتالوگ‌های JSON استفاده می‌کند، بار کردن کاتالوگ بزرگ در زمان راه‌اندازی کل فایل را در یک رشته C# عظیم می‌خواند و هزاران رشته مدیریت‌شده و آرایه موقت byte[] را درطول تجزیه JSON و کدگشایی Base64 تخصیص می‌دهد. این کار باعث افزایش شدید تخصیص GC می‌شود که «توده مدیریت‌شده» را برای همیشه گسترش می‌دهد، بنابراین برای جاری‌سازی مستقیم داده‌های فشرده، به کاتالوگ دودویی بروید.