Pengoptimalan memori game Unity di Android

Meskipun kapasitas RAM fisik perangkat seluler modern terus bertambah, konsumsi memori game melampaui pertumbuhan ini karena aset beresolusi tinggi dan pipeline rendering yang kompleks.

Masalah utama yang disebabkan oleh penggunaan memori yang berlebihan

  • Penghentian low memory killer (LMK): OS menutup paksa aplikasi latar belakang atau bahkan latar depan untuk mengatasi kekurangan memori di seluruh sistem.
  • Frame drop dan ketersendatan (jank): Peningkatan pembersihan sampah memori (GC) yang sering di Managed Heap atau pertukaran memori tingkat OS menciptakan bottleneck pemrosesan.
  • Pembatasan termal dan baterai cepat habis: Alokasi memori, dealokasi, dan penerapan halaman memori yang berkelanjutan menimbulkan overhead CPU yang signifikan, sehingga menyebabkan peningkatan panas dan baterai cepat habis.

Update pengelolaan memori Android

  • Android 17: Pengenalan MemoryLimiter: Android 17 memperkenalkan MemoryLimiter yang secara aktif memantau penggunaan memori aplikasi terhadap nilai minimum khusus perangkat. Aplikasi yang melebihi batas memori akan segera dihentikan di tingkat sistem. Karena mekanisme ini lebih ketat daripada LMK tradisional, pengelolaan penggunaan memori puncak kini lebih penting daripada sebelumnya.

Mengurangi penggunaan memori di Unity

Di Unity, setelah mesin memperluas kumpulan memori internalnya (Native Block Allocators dan Managed Heap) untuk mengakomodasi beban puncak yang tinggi, mesin akan mempertahankan halaman memori tersebut, bukan langsung mengembalikannya ke OS. Akibatnya, meskipun setelah aset berat dibongkar, Memori Residen dasar tetap meningkat, sehingga membuat aplikasi sangat rentan terhadap penghentian proses Android OS (seperti LMK atau MemoryLimiter).

Untuk mencegah penerapan tingkat OS, pengoptimalan memori harus dilakukan melalui tiga pilar utama:

  • Kategori A: Mengurangi penggunaan memori puncak
  • Kategori B: Menghilangkan jejak aset dan sistem yang tidak perlu
  • Kategori C: Menghilangkan alokasi GC yang tidak perlu

Kategori A: Mengurangi penggunaan memori puncak

Pengalokasi Unity mempertahankan halaman memori yang dibebaskan untuk digunakan kembali, bukan langsung mengembalikannya ke OS, sehingga memori dasar cenderung mencerminkan puncak tertinggi yang tercapai, bukan penggunaan saat ini. Oleh karena itu, mencegah lonjakan sejak awal lebih efektif daripada mengandalkan pembersihan setelahnya.

1. Menghindari AssetBundle yang terlalu besar

Karena mekanisme Pemuatan Bundle Unity, AssetBundle berukuran besar menyebabkan kebocoran memori yang parah dan error OOM. Pertahankan agar paket tetap modular untuk memastikan pengelolaan resource yang efisien.

Masalah utama

  • Overhead memori tinggi: Meminta satu aset kecil memaksa Unity untuk memuat seluruh file paket (termasuk header, metadata, dan buffer streaming) ke dalam RAM.
  • Perangkap pelepasan: Jika aset apa pun dalam bundle sedang digunakan secara aktif, seluruh bundle tidak dapat dilepaskan, sehingga data yang tidak digunakan terperangkap dalam RAM.

Praktik terbaik

  • Pertahankan modularitas paket: Kelompokkan aset secara logis berdasarkan adegan atau siklus proses.
  • Tips Unity 6.6+: Gunakan Direktori Konten untuk mencegah dependensi lintas paket yang tidak diinginkan.

2. Mengoptimalkan referensi aset di ScriptableObjects

Kolom berserial UnityEngine.Object langsung dalam ScriptableObject membuat referensi tetap langsung, sehingga memaksa semua aset yang direferensikan dimuat ke dalam RAM segera setelah ScriptableObject itu sendiri dimuat atau di-instansiasi.

// 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. Mengonfigurasi jenis pemuatan klip audio

Memuat semua klip audio tanpa dikompresi langsung ke dalam memori akan menciptakan puncak memori yang besar dan permanen. Setelan beban audio harus dikonfigurasi berdasarkan profil penggunaannya:

Kategori audio Jenis pemuatan Alasan
BGM (Musik Latar) Streaming Mengalirkan audio dari disk dalam buffer kecil untuk menghilangkan lonjakan memori.
SFX Panjang Dikompresi Dalam Memori Mempertahankan overhead RAM yang rendah dan mendekompresi audio secara langsung selama pemutaran.
SFX Pendek & Sering Decompress On Load Mendekompresi audio ke dalam RAM saat memuat untuk menghindari overhead CPU runtime selama pemutaran.

4. Menerapkan strategi penggabungan dan pelepasan objek

Instance yang belum dirilis yang tetap berada di dalam Object Pool selama transisi adegan akan mempertahankan Memori yang Dicadangkan tanpa batas waktu, sehingga membuat jejak memori dasar meningkat tanpa perlu.

  • Tindakan: Hapus atau pangkas objek gabungan yang tidak digunakan secara berkala selama transisi adegan atau periode aktivitas rendah, sehingga Unity dapat mengembalikan atau menggunakan kembali ruang yang dicadangkan tersebut untuk alokasi lain.

Kategori B: Menghilangkan penggunaan memori yang tidak perlu

Menghilangkan aset visual yang berlebihan dan merender buffer target secara langsung akan menurunkan jejak memori dasar.

1. Mengoptimalkan tekstur render dan kedalaman kamera

  • Menghapus buffer depth/stencil: Tetapkan Format Stensil Kedalaman ke Tidak Ada untuk Tekstur Render yang hanya memerlukan data warna.
  • Menonaktifkan tekstur kedalaman kamera UI: Untuk kamera UI yang tidak memerlukan data kedalaman, nonaktifkan pembuatan tekstur kedalaman di setelan kamera URP untuk menghilangkan CopyDepth Pass dan memori tekstur GPU terkait.

2. Mengoptimalkan tekstur dan mesh

Kategori Pedoman pengoptimalan
Kompresi tekstur Selalu terapkan format kompresi platform target (misalnya, ASTC untuk Android).
Baca/Tulis diaktifkan Tetap nonaktifkan kecuali diperlukan. Mengaktifkan ini akan menduplikasi memori tekstur di seluruh RAM CPU dan GPU.
Mipmap Nonaktifkan Mipmap untuk tekstur atau objek UI yang tetap pada jarak kamera konstan, sehingga menghemat memori tekstur sekitar 33%.
Kompleksitas mesh Kurangi jumlah poligon dan aliran verteks yang tidak perlu untuk menurunkan jejak memori GPU dan native.

3. Menghapus varian shader dan mengoptimalkan memori

Uber-shader (misalnya, URP Lit Shader) merangkum banyak fitur menggunakan kata kunci #multi_compile dan shader_feature. Tanpa pengoptimalan, ledakan kombinatorial akan membuat puluhan ribu varian shader unik, sehingga menyebabkan ukuran build yang besar, konsumsi memori native yang besar, dan gangguan kompilasi driver GPU selama gameplay.

A. Mekanisme overhead memori varian shader

  • Ledakan kombinatorial: Total kemungkinan varian bertambah secara eksponensial dengan setiap grup kata kunci yang ditambahkan.
  • Arsitektur alokasi chunk: Unity mengelompokkan varian biner yang dikompilasi ke dalam blok memori terkompresi yang disebut Chunk (default: 4 MB).
  • Pembengkakan memori native: Saat kode runtime meminta satu varian di dalam chunk, seluruh chunk 4 MB akan didekompresi ke dalam RAM. Jika tidak dioptimalkan, ribuan varian yang tidak digunakan yang dikemas dalam potongan tersebut akan menempati memori native secara permanen.

B. Pipeline penghapusan multi-tahap bawaan Unity: Unity secara otomatis menghapus varian yang tidak diperlukan pada waktu build berdasarkan setelan grafis platform target dan fitur mesin yang tidak digunakan (misalnya, setelan Kabut, Peta Cahaya, dan XR). Selain itu, varian shader_feature akan otomatis dikecualikan jika kata kuncinya tidak digunakan secara aktif oleh materi apa pun dalam project, sedangkan varian #multi_compile akan disertakan secara paksa terlepas dari penggunaannya.

C. Arsitektur penghapusan otomatis kustom (IPreprocessShaders) Karena analisis statis tidak dapat mendeteksi kata kunci yang diubah secara dinamis menggunakan skrip C# runtime (Material.EnableKeyword), penghapusan standar sering kali tidak memadai. Untuk memastikan hanya varian yang benar-benar digunakan yang disertakan, Anda dapat mengumpulkan varian selama rangkaian pengujian QA menggunakan Player.log (dengan mengaktifkan Log Shader Compilation di setelan Editor) atau Profiler Traces (penanda Shader.CreateGPUProgram). Kemudian, terapkan IPreprocessShaders.OnProcessShader dalam skrip Editor untuk memfilter varian yang tidak pernah dieksekusi selama runtime, dan hanya menyimpan varian yang diperlukan dalam build.

Kategori C: Menghilangkan alokasi GC yang tidak perlu

Alokasi Pengumpulan Sampah (GC) di Managed Heap menyebabkan fragmentasi memori, perluasan heap yang tidak pernah menyusut, dan penurunan frame yang parah selama jeda GC.

1. Mencegah alokasi penutupan lambda

Saat ekspresi lambda mengambil variabel lokal luar, C# akan membuat class tampilan implisit di Heap. Mengeksekusi ini di dalam Update akan mengalokasikan instance penutupan setiap frame.

// 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. Menggunakan stackalloc dan Span

Hindari alokasi Heap untuk array sementara berumur pendek dengan menggunakan memori 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. Mengoptimalkan iterasi pengumpulan

Hindari alokasi heap boxing dan Enumerator yang disebabkan oleh ekstensi LINQ atau pengakses 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. Aturan pencegahan alokasi GC tambahan

  • Hindari penggunaan Camera.allCameras karena akan menghasilkan array Camera[] baru di heap pada setiap panggilan. Sebagai gantinya, simpan array kamera dalam cache dan teruskan ke Camera.GetAllCameras(_allCameras).
  • Hindari foreach di IReadOnlyList<T>: Melakukan iterasi pada antarmuka menyebabkan boxing enumerator struct, yang menghasilkan alokasi GC. Gunakan loop for standar sebagai gantinya.
  • Meng-cache objek coroutine: Meng-cache instance WaitForSeconds, bukan meng-instance yield return new WaitForSeconds(time); berulang kali.
  • Kunci struct kustom dalam kamus: Menggunakan struct kustom sebagai kunci Dictionary memanggil Equals default, yang memicu boxing objek. Terapkan IEqualityComparer<T> dan teruskan ke konstruktor 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. Meminimalkan penggunaan generik dan refleksi

Meskipun metode generik memberikan penggunaan ulang dan pemeliharaan kode yang sangat baik, penggunaan berlebihan dapat berdampak negatif pada project Anda dalam konteks backend Unity IL2CPP (Intermediate Language to C++).

  • Pembengkakan kode IL2CPP: Untuk setiap kombinasi jenis generik yang unik, IL2CPP membuat versi kode khusus. Penggunaan generik yang kompleks secara berlebihan dapat menyebabkan "ledakan kombinatorial" kode C++ yang dihasilkan, sehingga meningkatkan ukuran biner aplikasi dan jejak memori native secara signifikan.
  • Overhead refleksi: Metode yang menggunakan refleksi, seperti API System.Reflection, pada dasarnya lambat dan sering menyebabkan alokasi heap selama runtime.
  • Praktik terbaik: Gunakan generik dengan bijak—prioritaskan untuk kejelasan arsitektur, bukan untuk penerapan yang luas dan tidak pandang bulu. Jika performa sangat penting, gunakan jenis konkret atau polimorfisme berbasis antarmuka. Untuk refleksi, simpan hasil cache seperti MethodInfo atau FieldInfo selama inisialisasi, bukan mengkuerinya dalam loop pembaruan.

6. Menghindari kebocoran shell terkelola

Setiap UnityEngine.Object, seperti MonoBehaviour, Texture, atau GameObject, memiliki wrapper "Managed Shell" C# yang berkomunikasi dengan mesin C++ native.

  • Masalahnya: Jika Managed Shell disimpan dalam memori oleh referensi statis, langganan peristiwa persisten, atau penutupan yang tidak dibersihkan, GC tidak dapat mengklaim kembali memori. Meskipun objek native dihancurkan, wrapper terkelola tetap ada, sehingga menyebabkan kebocoran memori "hantu" yang memperbesar Heap Terkelola.
  • Resolusi: Selalu terapkan pola pembersihan yang kuat. Saat menghancurkan objek atau melakukan transisi adegan, batalkan langganan peristiwa secara eksplisit menggunakan operator -= dan batalkan referensi statis ke jenis UnityEngine.Object. Pembersihan ini memastikan GC dapat berhasil mengumpulkan wrapper setelah mesin native melepaskan handle-nya.