Embora a capacidade física de RAM dos dispositivos móveis modernos continue crescendo, o consumo de memória de jogos está superando esse crescimento devido a recursos de alta resolução e pipelines de renderização complexos.
Principais problemas causados pelo uso da memória excessivo
- Encerramentos do low memory killer (LMK): o SO fecha à força apps em segundo plano ou até mesmo em primeiro plano para resolver a escassez de memória em todo o sistema.
- Quedas de frame e travamentos (jank): picos frequentes de coleta de lixo (GC) no heap gerenciado ou na troca de memória no nível do SO criam gargalos de processamento.
- Limitação térmica e consumo elevado da bateria: a alocação de memória contínua, desalocação e confirmação de páginas de memória incorrem em uma sobrecarga significativa da CPU, levando ao aumento do calor e ao esgotamento mais rápido da bateria.
Atualizações do gerenciamento de memória do Android
- Android 17: introdução do
MemoryLimiter: o Android 17 apresentaMemoryLimiterque monitora ativamente o consumo de memória do aplicativo em relação a limites específicos do dispositivo. Os apps que excedem o limite de memória são encerrados imediatamente no nível do sistema. Como esse mecanismo é mais rigoroso do que o LMK tradicional, o gerenciamento do uso máximo de memória agora é mais importante do que nunca.
Reduzir o uso da memória no Unity
No Unity, quando o mecanismo expande os pools de memória internos (alocadores de blocos nativos e o heap gerenciado) para acomodar uma carga de pico alta, ele retém essas páginas de memória em vez de retorná-las imediatamente ao SO.
Consequentemente, mesmo depois que recursos pesados são descarregados, a memória residente de referência permanece inflacionada, tornando o aplicativo altamente vulnerável a encerramentos de processos do SO Android (como LMK ou MemoryLimiter).
Para evitar a aplicação no nível do SO, a otimização da memória precisa ser abordada por três pilares principais:
- Categoria A: reduzir o uso da memória de pico
- Categoria B: eliminar pegadas desnecessárias de recursos e sistemas
- Categoria C: eliminar alocações de GC desnecessárias
Categoria A: reduzir o uso da memória de pico
Os alocadores do Unity retêm páginas de memória liberadas para reutilização em vez de retorná-las ao SO imediatamente. Assim, a memória de referência tende a refletir o pico mais alto alcançado em vez do uso atual. Portanto, evitar picos em primeiro lugar é mais eficaz do que confiar na limpeza depois do fato.
1. Evite AssetBundles muito grandes
Devido ao mecanismo de carregamento de pacotes do Unity, os AssetBundles gigantes levam a um inchaço grave da memória e a falhas de OOM. Mantenha os pacotes modulares para garantir o gerenciamento eficiente de recursos.
Principais problemas
- Alta sobrecarga de memória: solicitar um único recurso pequeno força o Unity a carregar todo o arquivo de pacote (incluindo cabeçalhos, metadados e buffers de streaming) na RAM.
- A armadilha de descarregamento: se qualquer recurso em um pacote estiver em uso ativo, o pacote inteiro não poderá ser descarregado, prendendo dados não utilizados na RAM.
Práticas recomendadas
- Mantenha os pacotes modulares: agrupe os recursos logicamente por cena ou ciclo de vida.
- Dica do Unity 6.6 e versões mais recentes: use diretórios de conteúdo para evitar dependências não intencionais entre pacotes.
2. Otimizar referências de recursos em ScriptableObjects
Os campos serializados UnityEngine.Object diretos em um ScriptableObject criam referências fixas diretas, forçando todos os recursos referenciados a serem carregados na RAM assim que o ScriptableObject for carregado ou instanciado.
// 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. Configurar tipos de carregamento de clipes de áudio
O carregamento de todos os clipes de áudio descompactados diretamente na memória cria picos de memória grandes e permanentes. As configurações de carregamento de áudio precisam ser configuradas com base no perfil de uso:
| Categoria de áudio | Tipo de carga | Motivo |
|---|---|---|
| BGM (música de fundo) | Streaming | Transmite áudio do disco em buffers pequenos para eliminar picos de memória. |
| SFX longo | Compactado na memória | Mantém a sobrecarga de RAM baixa e descompacta o áudio em tempo real durante a reprodução. |
| SFX curto e frequente | Descompactar ao carregar | Descompacta o áudio na RAM ao carregar para evitar a sobrecarga da CPU de execução durante a reprodução. |
4. Implementar estratégias de pool de objetos e lançamento
As instâncias não liberadas que permanecem em pools de objetos nas transições de cena mantêm a memória reservada indefinidamente, mantendo o consumo de memória de referência desnecessariamente inflacionado.
- Ação: limpe ou corte periodicamente objetos agrupados não utilizados durante as transições de cena ou períodos de baixa atividade, permitindo que o Unity retorne ou reutilize esse espaço reservado para outras alocações.
Categoria B: eliminar o uso da memória desnecessário
A eliminação de recursos gráficos redundantes e a renderização de buffers de destino reduzem diretamente o consumo de memória de referência.
1. Otimizar texturas de renderização e profundidade da câmera
- Remover buffers de profundidade/stencil: defina o formato de stencil de profundidade como Nenhum para texturas de renderização que exigem apenas dados de cor.
- Desativar a textura de profundidade da câmera da interface: para câmeras da interface em que os dados de profundidade são
desnecessários, desative a geração de textura de profundidade nas configurações da câmera URP para
eliminar a passagem
CopyDepthe a memória de textura da GPU associada.
2. Otimizar texturas e malhas
| Categoria | Diretriz de otimização |
|---|---|
| Compactação de textura | Sempre aplique formatos de compactação de plataforma segmentada (por exemplo, ASTC para Android). |
| Leitura/gravação ativada | Mantenha desativado, a menos que seja necessário. A ativação duplica a memória de textura na RAM da CPU e da GPU. |
| Mipmaps | Desative os mipmaps para texturas ou objetos da interface fixados a uma distância constante da câmera, economizando cerca de 33% da memória de textura. |
| Complexidade da malha | Reduza as contagens de polígonos e os fluxos de vértices desnecessários para diminuir o consumo de memória nativa e da GPU. |
3. Remover variantes de sombreador e otimizar a memória
Os uber-shaders (por exemplo, o URP Lit Shader) encapsulam vários recursos usando as palavras-chave #multi_compile e shader_feature. Sem otimização,
a explosão combinatória cria dezenas de milhares de
variantes de sombreador exclusivas, levando a tamanhos de build inflacionados, consumo de memória nativa
enorme e problemas de compilação de driver de GPU durante a jogabilidade.
A. Mecânica da sobrecarga de memória da variante de sombreador
- Explosão combinatória: o total de variantes possíveis cresce exponencialmente com cada grupo de palavras-chave adicionado.
- Arquitetura de alocação de blocos: o Unity agrupa variantes binárias compiladas em blocos de memória compactados chamados blocos (padrão: 4 MB).
- Inchaço de memória nativa: quando o código de execução solicita até mesmo uma única variante dentro de um bloco, todo o bloco de 4 MB é descompactado na RAM. Se não forem otimizadas, milhares de variantes não utilizadas empacotadas nesses blocos ocuparão permanentemente a memória nativa.
B. Pipeline de remoção de várias fases integrado ao Unity: o Unity remove automaticamente
variantes desnecessárias no momento da build com base nas configurações de gráficos da plataforma de destino
e nos recursos do mecanismo não utilizados (por exemplo, névoa, mapas de luz e configurações de XR). Além disso, as variantes shader_feature são filtradas automaticamente
se as palavras-chave não forem usadas ativamente por nenhum material no
projeto, enquanto as variantes #multi_compile são incluídas à força, independentemente
do uso.
C. Arquitetura de remoção automatizada personalizada
(IPreprocessShaders) : como a análise estática não consegue
detectar palavras-chave modificadas dinamicamente usando scripts C# de execução
(Material.EnableKeyword), a remoção padrão geralmente é insuficiente. Para
garantir que apenas as variantes realmente usadas sejam incluídas, você pode coletar variantes
durante os conjuntos de testes de QA usando Player.log (ativando
Log Shader Compilation nas configurações do editor) ou rastreamentos do Profiler
(Shader.CreateGPUProgram marcadores). Em seguida, implemente
IPreprocessShaders.OnProcessShader em um script do editor para filtrar
variantes que nunca foram executadas durante a execução, mantendo apenas as necessárias
na build.
Categoria C: eliminar alocações de GC desnecessárias
As alocações de coleta de lixo (GC) no heap gerenciado levam à fragmentação da memória, expansões de heap que nunca diminuem e quedas de frame graves durante as pausas de GC.
1. Evitar alocações de fechamento de lambda
Quando uma expressão lambda captura variáveis locais externas, o C# gera uma classe de exibição implícita no heap. A execução dela dentro de Update aloca instâncias de fechamento a cada 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. Usar stackalloc e Span
Evite alocações de heap para matrizes temporárias de curta duração usando a memória da pilha.
// 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. Otimizar a iteração da coleção
Evite alocações de heap de boxing e enumerador causadas por extensões LINQ ou acessadores 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. Outras regras de prevenção de alocação de GC
- Evite usar
Camera.allCameras, porque ele gera uma nova matrizCamera[]no heap em cada chamada. Em vez disso, armazene em cache uma matriz de câmera e transmita-a paraCamera.GetAllCameras(_allCameras). - Evite
foreachemIReadOnlyList<T>: a iteração em uma interface causa o boxing do enumerador de struct, gerando alocações de GC. Use um loopforpadrão. - Objetos de corrotina de cache: armazene em cache instâncias
WaitForSecondsem vez de instanciaryield return new WaitForSeconds(time);repetidamente. - Chaves de struct personalizadas em dicionários: o uso de structs personalizados como chaves de dicionário
chama o
Equalspadrão, acionando o boxing de objetos. ImplementeIEqualityComparer<T>e transmita-o para o construtor do dicionário.
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. Minimizar o uso genérico e de reflexão
Embora os métodos genéricos ofereçam excelente reutilização e capacidade de manutenção do código, o uso excessivo deles pode afetar negativamente seu projeto no contexto do back-end do Unity IL2CPP (Intermediate Language to C++).
- Inchaço do código IL2CPP: para cada combinação de tipo genérico exclusiva, o IL2CPP gera uma versão especializada do código. O uso excessivo de genéricos complexos pode levar a uma "explosão combinatória" de código C++ gerado, aumentando significativamente o tamanho binário e a pegada de memória nativa do aplicativo.
- Sobrecarga de reflexão: métodos que usam reflexão, como
System.ReflectionAPIs, são inerentemente lentos e geralmente causam alocações de heap durante a execução. - Prática recomendada: use genéricos com moderação. Priorize-os para
clareza arquitetônica em vez de aplicação ampla e indiscriminada. Quando a performance é essencial, favoreça tipos concretos ou polimorfismo baseado em interface. Para reflexão, armazene em cache resultados como
MethodInfoouFieldInfodurante a inicialização em vez de consultá-los no loop de atualização.
6. Evitar shells gerenciados vazados
Cada UnityEngine.Object, como MonoBehaviour, Texture ou GameObject, tem um wrapper de "shell gerenciado" em C# que se comunica com o mecanismo nativo de C++.
- O problema: se um shell gerenciado for mantido na memória por uma referência estática, uma assinatura de evento persistente ou um fechamento não limpo, o GC não poderá recuperar a memória. Mesmo que o objeto nativo seja destruído, o wrapper gerenciado persiste, levando a vazamentos de memória "fantasma" que incham o heap gerenciado.
- Resolução: sempre implemente padrões de limpeza robustos. Ao destruir objetos ou fazer a transição de cenas, cancele explicitamente a inscrição de eventos usando o operador
-=e anule as referências estáticas aos tiposUnityEngine.Object. Essa limpeza garante que o GC possa coletar o wrapper depois que o mecanismo nativo liberar o identificador.