Ottimizzazione della memoria dei giochi Unity su Android

Sebbene la capacità di RAM fisica dei moderni dispositivi mobili continui a crescere, il consumo di memoria dei giochi sta superando questa crescita a causa di asset ad alta risoluzione e pipeline di rendering complesse.

Problemi principali causati da un utilizzo eccessivo della memoria

  • Terminazioni per memoria insufficiente (LMK): il sistema operativo chiude forzatamente le app in background o persino in primo piano per risolvere le carenze di memoria a livello di sistema.
  • Frame drop e stuttering (jank): i picchi frequenti di garbage collection (GC) nell'heap gestito o lo swapping della memoria a livello di sistema operativo creano colli di bottiglia di elaborazione.
  • Limitazione termica e consumo eccessivo della batteria: l'allocazione della memoria, la deallocazione e i commit delle pagine di memoria continui comportano un sovraccarico significativo della CPU, con conseguente aumento del calore e consumo più rapido della batteria.

Aggiornamenti della gestione della memoria di Android

  • Android 17: introduzione di MemoryLimiter: Android 17 introduce MemoryLimiter che monitora attivamente il consumo di memoria dell'applicazione rispetto alle soglie specifiche del dispositivo. Le app che superano il limite di memoria vengono terminate immediatamente a livello di sistema. Poiché questo meccanismo è più rigoroso del tradizionale LMK, la gestione del picco di memoria utilizzata è ora più importante che mai.

Ridurre l'utilizzo della memoria in Unity

In Unity, una volta che il motore espande i pool di memoria interni (Allocatori di blocchi nativi e Heap gestito) per gestire un carico di picco elevato, conserva queste pagine di memoria anziché restituirle immediatamente al sistema operativo. Di conseguenza, anche dopo che gli asset pesanti vengono scaricati, la memoria residente di base rimane aumentata, rendendo l'applicazione altamente vulnerabile alle terminazioni dei processi del sistema operativo Android (come LMK o MemoryLimiter).

Per evitare l'applicazione a livello di sistema operativo, l'ottimizzazione della memoria deve essere affrontata attraverso tre pilastri chiave:

  • Categoria A: ridurre la memoria utilizzata al picco
  • Categoria B: eliminare i footprint di asset e sistema non necessari
  • Categoria C: eliminare le allocazioni GC non necessarie

Categoria A: ridurre la memoria utilizzata al picco

Gli allocatori di Unity conservano le pagine di memoria liberate per il riutilizzo anziché restituirle immediatamente al sistema operativo, quindi la memoria di base tende a riflettere il picco più alto raggiunto anziché l'utilizzo corrente. Prevenire i picchi in primo luogo è quindi più efficace che affidarsi alla pulizia dopo il fatto.

1. Evitare AssetBundle eccessivamente grandi

A causa del meccanismo di caricamento dei bundle di Unity, gli AssetBundle di grandi dimensioni causano un grave aumento della memoria e arresti anomali per esaurimento della memoria. Mantieni i bundle modulari per garantire una gestione efficiente delle risorse.

Problemi principali

  • Sovraccarico di memoria elevato: la richiesta di un singolo asset di piccole dimensioni costringe Unity a caricare l'intero file del bundle (inclusi intestazioni, metadati e buffer di streaming) nella RAM.
  • La trappola di scaricamento: se qualsiasi asset all'interno di un bundle è in uso attivo, l' intero bundle non può essere scaricato, intrappolando i dati inutilizzati nella RAM.

Best practice

  • Mantieni i bundle modulari: raggruppa gli asset in modo logico per scena o ciclo di vita.
  • Suggerimento per Unity 6.6+: utilizza le directory dei contenuti per evitare dipendenze non intenzionali tra i bundle.

2. Ottimizzare i riferimenti agli asset in ScriptableObjects

I campi serializzati UnityEngine.Object diretti in un ScriptableObject creano riferimenti hard diretti, forzando il caricamento di tutti gli asset a cui si fa riferimento nella RAM non appena viene caricato o istanziato lo stesso 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. Configurare clip e canali audio

Il caricamento di tutte le clip audio non compresse direttamente in memoria crea picchi di memoria permanenti di grandi dimensioni. Le impostazioni di caricamento audio devono essere configurate in base al profilo di utilizzo:

Categoria audio Tipo di carico Motivo
BGM (musica di sottofondo) Streaming Trasmette l'audio dal disco in piccoli buffer per eliminare i picchi di memoria.
SFX lunghi Compresso in memoria Mantiene basso il sovraccarico della RAM e decomprime l'audio al volo durante la riproduzione.
SFX brevi e frequenti Decomprimi al caricamento Decomprime l'audio nella RAM al caricamento per evitare il sovraccarico della CPU in fase di esecuzione durante la riproduzione.

Oltre ai tipi di carico, applica le seguenti configurazioni audio per ridurre ulteriormente il sovraccarico della memoria:

  • Abilita Forza a mono per l'audio non direzionale: molti asset audio, come gli effetti sonori dell'interfaccia utente, le voci fuori campo e gli effetti sonori spazializzati 3D, non richiedono la separazione dei canali stereo. Abilita Forza a mono nelle AudioClip impostazioni di importazione per questi asset per ridurre a metà il footprint della memoria e le dimensioni del buffer (riduzione del 50%).
  • Limita i canali audio attivi in base al livello del dispositivo: ogni voce audio attiva alloca i buffer di decompressione e mixer DSP nativi. Sui dispositivi con poca memoria, riduci il numero massimo di canali attivi simultanei, ad esempio riducendo Max Real Voices (ad esempio da 32 a 16) utilizzando AudioSettings.Reset in Unity o limitando i canali di riproduzione software nei motori audio di terze parti.

4. Implementare strategie di pooling e rilascio degli oggetti

Le istanze non rilasciate che rimangono all'interno dei pool di oggetti durante le transizioni di scena mantengono la memoria riservata a tempo indeterminato, mantenendo inutilmente aumentato il footprint della memoria di base.

  • Azione: cancella o taglia periodicamente gli oggetti in pool inutilizzati durante le transizioni di scena o i periodi di bassa attività, consentendo a Unity di restituire o riutilizzare lo spazio riservato per altre allocazioni.

Categoria B: eliminare l'utilizzo non necessario della memoria

L'eliminazione degli asset grafici ridondanti e il rendering diretto dei buffer di destinazione riducono il footprint della memoria di base.

1. Ottimizzare le texture di rendering e la profondità della videocamera

  • Rimuovere i buffer di profondità/stencil: imposta il formato di profondità stencil su Nessuno per le texture di rendering che richiedono solo dati di colore.
  • Disabilitare la texture di profondità della videocamera dell'interfaccia utente: per le videocamere dell'interfaccia utente in cui i dati di profondità sono non necessari, disabilita la generazione della texture di profondità nelle impostazioni della videocamera URP per eliminare il passaggio CopyDepth e la memoria della texture GPU associata.

2. Ottimizzare texture e mesh

Categoria Linee guida per l'ottimizzazione
Compressione delle texture Applica sempre i formati di compressione della piattaforma di destinazione (ad esempio, ASTC per Android).
Lettura/scrittura abilitata Mantieni disabilitata a meno che non sia necessario. L'abilitazione di questa opzione duplica la memoria delle texture nella RAM della CPU e della GPU.
Mipmap Disabilita i mipmap per le texture o gli oggetti dell'interfaccia utente fissati a una distanza costante dalla videocamera, risparmiando circa il 33% della memoria delle texture.
Complessità della mesh Riduci il numero di poligoni e i flussi di vertici non necessari per ridurre il footprint della memoria nativa e della GPU.

3. Rimuovere le varianti dello shader e ottimizzare la memoria

Gli shader Uber (ad esempio, URP Lit Shader) incapsulano numerose funzionalità utilizzando le parole chiave #multi_compile e shader_feature. Senza ottimizzazione, l'esplosione combinatoria crea decine di migliaia di varianti di shader uniche, con conseguente aumento delle dimensioni della build, consumo massiccio di memoria nativa e problemi di compilazione dei driver GPU durante il gameplay.

A. Meccanica del sovraccarico di memoria delle varianti dello shader

  • Esplosione combinatoria: il numero totale di varianti possibili cresce in modo esponenziale con ogni gruppo di parole chiave aggiunto.
  • Architettura di allocazione dei blocchi: Unity raggruppa le varianti binarie compilate in blocchi di memoria compressi chiamati blocchi (valore predefinito: 4 MB).
  • Aumento della memoria nativa: quando il codice di runtime richiede anche una singola variante all'interno di un blocco, l'intero blocco di 4 MB viene decompresso nella RAM. Se non ottimizzate, migliaia di varianti inutilizzate incluse in questi blocchi occupano in modo permanente la memoria nativa.

B. Pipeline di rimozione a più fasi integrata di Unity: Unity rimuove automaticamente varianti non necessarie in fase di build in base alle impostazioni grafiche della piattaforma di destinazione e alle funzionalità del motore inutilizzate (ad esempio, Nebbia, Lightmap e impostazioni XR ). Inoltre, le varianti shader_feature vengono filtrate automaticamente se le relative parole chiave non vengono utilizzate attivamente da alcun materiale nel progetto, mentre le varianti #multi_compile vengono incluse forzatamente indipendentemente dall'utilizzo. Tuttavia, se le funzionalità di rendering o post-elaborazione (come luci aggiuntive, ombre in tempo reale o effetti UberPost inutilizzati) rimangono abilitate nelle impostazioni URP o della pipeline, Unity non può determinare se i contenuti le utilizzano effettivamente e genera tutte le varianti #multi_compile corrispondenti. Controlla e disabilita le impostazioni della pipeline inutilizzate in modo che corrispondano ai tuoi requisiti di rendering effettivi ed evita la generazione di varianti non necessarie.

C. Architettura di rimozione automatica personalizzata (IPreprocessShaders) Poiché l'analisi statica non è in grado di rilevare le parole chiave modificate dinamicamente utilizzando gli script C# di runtime (Material.EnableKeyword), la rimozione standard è spesso insufficiente. Per assicurarti che vengano incluse solo le varianti effettivamente utilizzate, puoi raccogliere le varianti durante le suite di test QA utilizzando Player.log (abilitando Log Shader Compilation nelle impostazioni dell'Editor) o Profiler Traces (Shader.CreateGPUProgram indicatori). Quindi, implementa IPreprocessShaders.OnProcessShader in uno script dell'Editor per filtrare le varianti che non sono mai state eseguite durante il runtime, mantenendo solo quelle necessarie nella build.

D. Consolidare gli shader ridondanti: gli shader Uber come Lit e Simple Lit contengono molte #multi_compile direttive e consumano una quantità significativa di memoria quando generano numerose varianti:

  • Profilare l'utilizzo simultaneo: profila la memoria utilizzata per verificare se entrambi gli shader consumano una quantità significativa di memoria contemporaneamente e verifica se il progetto ha effettivamente bisogno di entrambi.
  • Standardizzare su un singolo shader: quando il rendering basato sulla fisica (PBR) non è obbligatorio, configura gli asset per eliminare l'utilizzo ridondante dello shader Lit e standardizzare su Simple Lit (ad esempio, verifica se i modelli FBX esportati hanno materiali Lit predefiniti applicati involontariamente durante l'importazione).

Categoria C: eliminare le allocazioni GC non necessarie

Le allocazioni di garbage collection (GC) nell'heap gestito comportano la frammentazione della memoria, l'espansione dell'heap che non si riduce mai e gravi frame drop durante le pause di GC.

1. Evitare le allocazioni di chiusura lambda

Quando un'espressione lambda acquisisce variabili locali esterne, C# genera una classe di visualizzazione implicita nell'heap. L'esecuzione di questa operazione all'interno di Update alloca le istanze di chiusura ogni 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. Utilizzare stackalloc e Span

Evita le allocazioni dell'heap per gli array temporanei di breve durata utilizzando la memoria dello 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. Ottimizzare l'iterazione della raccolta

Evita il boxing e le allocazioni dell'heap dell'enumeratore causate dalle estensioni LINQ o dagli 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;
}

4. Regole aggiuntive per la prevenzione dell'allocazione GC

  • Evita di utilizzare Camera.allCameras perché genera un nuovo array Camera[] nell'heap a ogni chiamata. Memorizza invece nella cache un array di videocamere e passalo a Camera.GetAllCameras(_allCameras).
  • Evita foreach su IReadOnlyList<T>: l'iterazione su un'interfaccia causa il boxing dell'enumeratore struct, generando allocazioni GC. Utilizza invece un ciclo for standard.
  • Memorizza nella cache gli oggetti coroutine: memorizza nella cache le istanze WaitForSeconds anziché istanziare yield return new WaitForSeconds(time); ripetutamente.
  • Chiavi struct personalizzate nei dizionari: l'utilizzo di struct personalizzate come chiavi del dizionario chiama Equals predefinito, attivando il boxing degli oggetti. Implementa IEqualityComparer<T> e passalo al costruttore del dizionario.
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. Ridurre al minimo l'utilizzo di generici e reflection

Sebbene i metodi generici forniscano un'eccellente riutilizzabilità e gestibilità del codice, il loro utilizzo eccessivo può influire negativamente sul progetto nel contesto del backend Unity IL2CPP (Intermediate Language to C++).

  • Aumento del codice IL2CPP: per ogni combinazione di tipi generici univoca, IL2CPP genera una versione specializzata del codice. L'utilizzo eccessivo di generici complessi può portare a un'esplosione combinatoria del codice C++ generato, aumentando in modo significativo le dimensioni binarie dell'applicazione e il footprint della memoria nativa.
  • Sovraccarico di reflection: i metodi che utilizzano la reflection, come le API System.Reflection, sono intrinsecamente lenti e spesso causano allocazioni dell'heap durante il runtime.
  • Best practice: utilizza i generici con giudizio, dando la priorità alla chiarezza dell'architettura anziché a un'applicazione ampia e indiscriminata. Quando le prestazioni sono fondamentali, preferisci i tipi concreti o il polimorfismo basato su interfacce. Per la reflection, memorizza nella cache i risultati come MethodInfo o FieldInfo durante l'inizializzazione anziché eseguirne la query nel ciclo di aggiornamento.

6. Evitare le shell gestite con perdite

Ogni UnityEngine.Object, come MonoBehaviour, Texture o GameObject, ha un wrapper "shell gestita" C# che comunica con il motore C++ nativo.

  • Il problema: se una shell gestita viene mantenuta in memoria da un riferimento statico, una sottoscrizione di eventi persistente o una chiusura non pulita, la GC non può recuperare la memoria. Anche se l'oggetto nativo viene eliminato, il wrapper gestito persiste, causando perdite di memoria "fantasma" che aumentano l'heap gestito.
  • Risoluzione: implementa sempre pattern di pulizia robusti. Quando elimini oggetti o esegui la transizione tra le scene, annulla esplicitamente la sottoscrizione agli eventi utilizzando l'operatore -= e imposta su null i riferimenti statici ai tipi UnityEngine.Object. Questa pulizia garantisce che la GC possa raccogliere correttamente il wrapper una volta che il motore nativo ha rilasciato l'handle.

7. Utilizzare cataloghi binari per Addressables

In Unity 2022 e versioni precedenti, Addressables utilizza per impostazione predefinita i cataloghi JSON anziché i cataloghi binari (che sono quelli predefiniti in Unity 2023 e versioni successive). Se il tuo progetto utilizza cataloghi JSON, il caricamento di un catalogo di grandi dimensioni all'avvio legge l'intero file in una singola stringa C# di grandi dimensioni e alloca migliaia di stringhe gestite e array byte[] temporanei durante l'analisi JSON e la decodifica Base64. Questo causa un picco di allocazione GC di grandi dimensioni che espande in modo permanente l'heap gestito, quindi passa a un catalogo binario per trasmettere in streaming i dati compatti direttamente.