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 dalla memoria utilizzata in eccesso
- 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 di 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 overhead della CPU significativo, con conseguente aumento del calore e scaricamento più rapido della batteria.
Aggiornamenti della gestione della memoria di Android
- Android 17: introduzione di
MemoryLimiter: Android 17 introduceMemoryLimiterche monitora attivamente il consumo di memoria delle applicazioni 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 dell'utilizzo massimo della memoria utilizzata è ora più importante che mai.
Ridurre la memoria utilizzata 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 impedire 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
- Overhead 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 e versioni successive: utilizza le directory dei contenuti per impedire 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 viene fatto 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 i tipi di caricamento dei clip audio
Il caricamento di tutti i clip audio non compressi 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 buffer di piccole dimensioni per eliminare i picchi di memoria. |
| SFX lunghi | Compresso in memoria | Mantiene basso l'overhead 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 l'overhead della CPU in fase di runtime durante la riproduzione. |
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 di memoria non necessario
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.
- Disattivare la texture di profondità della videocamera UI: per le videocamere UI in cui i dati di profondità sono
non necessari, disattiva la generazione della texture di profondità nelle impostazioni della videocamera URP per
eliminare il
CopyDepthpassaggio 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 disattivata a meno che non sia necessario. L'attivazione di questa opzione duplica la memoria delle texture nella RAM della CPU e della GPU. |
| Mipmap | Disattiva i mipmap per le texture UI o gli oggetti fissati a una distanza costante dalla videocamera, risparmiando circa il 33% della memoria delle texture. |
| Complessità della mesh | Riduci il numero di poligoni e gli stream 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 dell'overhead 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 multifase integrata di Unity: Unity rimuove automaticamente
le varianti non necessarie in tempo di compilazione 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.
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 (attivando
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.
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 GC.
1. Impedire 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 le allocazioni dell'heap di boxing e 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.allCamerasperché genera un nuovo arrayCamera[]nell'heap a ogni chiamata. Memorizza nella cache un array di videocamere e passalo aCamera.GetAllCameras(_allCameras). - Evita
foreachsuIReadOnlyList<T>: l'iterazione su un'interfaccia causa il boxing dell'enumeratore di struct, generando allocazioni GC. Utilizza invece un cicloforstandard. - Memorizza nella cache gli oggetti coroutine: memorizza nella cache le istanze
WaitForSecondsanziché istanziareyield return new WaitForSeconds(time);ripetutamente. - Chiavi di struct personalizzate nei dizionari: l'utilizzo di struct personalizzate come chiavi del dizionario
chiama
Equalspredefinito, attivando il boxing degli oggetti. ImplementaIEqualityComparer<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 significativamente le dimensioni binarie e il footprint della memoria nativa dell'applicazione.
- Overhead 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
MethodInfooFieldInfodurante l'inizializzazione anziché eseguirne la query nel ciclo di aggiornamento.
6. Evitare le shell gestite con perdite di memoria
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 tipiUnityEngine.Object. Questa pulizia garantisce che la GC possa raccogliere correttamente il wrapper una volta che il motore nativo ha rilasciato l'handle.