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 de bateria: a alocação contínua de memória, a desalocação e a 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. Otimize as referências de recursos em ScriptableObjects
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. Configure clipes e canais de áudio
Carregar 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 em tempo de execução durante a reprodução. |
Além dos tipos de carga, aplique as seguintes configurações de áudio para reduzir ainda mais a sobrecarga de memória:
- Ativar a opção "Forçar para mono" para áudio não direcional: muitos recursos de áudio, como
efeitos sonoros da interface, dublagens e efeitos sonoros espacializados em 3D, não
exigem separação de canais estéreo. Ative a opção Forçar para mono nas configurações de importação
AudioClippara esses recursos para reduzir o consumo de memória e o tamanho do buffer pela metade (redução de 50%). - Limitar canais de áudio ativos por nível de dispositivo: cada voz de áudio ativa
aloca o mixer DSP nativo e buffers de descompressão. Em dispositivos com pouca memória,
reduza o número máximo de canais ativos simultâneos, por exemplo, diminuindo
Max Real Voices (como de 32 para 16) usando
AudioSettings.Resetno Unity ou restringindo canais de reprodução de software em mecanismos de áudio de terceiros.
4. Implementar estratégias de pool de objetos e lançamento
Instâncias não liberadas que permanecem em pools de objetos em transições de cena retê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 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 a pegada 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 da plataforma de destino (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 o jogo.
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 tempo 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 criação com base nas configurações gráficas da plataforma de destino e nos recursos do mecanismo não utilizados (por exemplo, configurações de névoa, mapas de luz e XR
configurações). 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, independente do uso. No entanto, se os recursos de renderização ou pós-processamento (como
Luzes adicionais, sombras em tempo real ou efeitos UberPost não utilizados) permanecerem
ativados nas configurações do URP ou do pipeline, o Unity não poderá determinar se o
conteúdo realmente os usa e gera todas as variantes #multi_compile
correspondentes. Audite e desative as configurações de pipeline não utilizadas para que elas correspondam aos seus
requisitos de renderização reais e evitem a geração de variantes desnecessárias.
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 tempo 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 rastros do Profiler
(Shader.CreateGPUProgram marcadores). Em seguida, implemente
IPreprocessShaders.OnProcessShader em um script do editor para filtrar
variantes que nunca foram executadas durante o tempo de execução, mantendo apenas as necessárias
no build.
D. Consolidar sombreadores redundantes: Uber-shaders como Lit e Simple
Lit contêm muitas #multi_compile diretivas e consomem memória substancial
memória ao gerar várias variantes:
- Perfil de uso simultâneo: crie um perfil de uso da memória para verificar se os dois shaders consomem memória significativa ao mesmo tempo e verifique se o projeto realmente precisa dos dois.
- Padronizar em um único sombreador: quando a renderização baseada em física (PBR) não for necessária, configure seus recursos para eliminar o uso redundante do sombreador Lit e padronize o Simple Lit (por exemplo, verifique se os modelos FBX exportados têm materiais Lit padrão aplicados sem querer durante a importação).
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 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 de 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. Regras adicionais 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 ao 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 o tempo de 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 o desempenho é fundamental, 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.
7. Usar catálogos binários para Addressables
No Unity 2022 e versões anteriores, o Addressables usa catálogos JSON em vez de catálogos binários (que são o padrão no Unity 2023 e versões mais recentes). Se o projeto usa catálogos JSON, o carregamento de um catálogo grande na inicialização lê todo o arquivo em uma única string C# enorme e aloca milhares de strings gerenciadas e matrizes byte[] temporárias durante a análise JSON e a decodificação Base64. Isso causa um grande pico de alocação de GC que expande permanentemente o heap gerenciado. Portanto, mude para um catálogo binário para transmitir dados compactos diretamente.