Si bien la capacidad de RAM física de los dispositivos móviles modernos sigue aumentando, el consumo de memoria de los juegos supera este crecimiento debido a los recursos de alta resolución y las complejas canalizaciones de renderización.
Problemas clave causados por el uso excesivo de memoria
- Finalizaciones del optimizador de poca memoria (LMK): El SO cierra de forma forzosa las apps en segundo plano o incluso en primer plano para resolver la escasez de memoria en todo el sistema.
- Caídas de fotogramas y tartamudeo (jank): Los picos frecuentes de recolección de elementos no utilizados (GC) en el montón administrado o el intercambio de memoria a nivel del SO crean cuellos de botella de procesamiento.
- Limitación térmica y agotamiento de la batería: La asignación de memoria, la desasignación y las confirmaciones de páginas de memoria continuas generan una sobrecarga significativa de la CPU, lo que provoca un aumento del calor y un agotamiento más rápido de la batería.
Actualizaciones de la administración de memoria de Android
- Android 17: Introducción de
MemoryLimiter: Android 17 presentaMemoryLimiter, que supervisa de forma activa el consumo de memoria de la aplicación en función de los umbrales específicos del dispositivo. Las apps que superan su límite de memoria se finalizan de inmediato a nivel del sistema. Debido a que este mecanismo es más estricto que el LMK tradicional, administrar el uso máximo de memoria es más importante que nunca.
Reduce el uso de memoria en Unity
En Unity, una vez que el motor expande sus grupos de memoria internos (Native Block Allocators y el montón administrado) para adaptarse a una carga máxima alta, conserva esas páginas de memoria en lugar de devolverlas de inmediato al SO.
En consecuencia, incluso después de que se descargan los recursos pesados, la memoria residente de referencia permanece inflada, lo que hace que la aplicación sea muy vulnerable a las finalizaciones de procesos del SO Android (como LMK o MemoryLimiter).
Para evitar la aplicación a nivel del SO, la optimización de la memoria debe abordarse a través de tres pilares clave:
- Categoría A: Reduce el uso máximo de memoria
- Categoría B: Elimina las huellas innecesarias de recursos y sistemas
- Categoría C: Elimina las asignaciones innecesarias de GC
Categoría A: Reduce el uso máximo de memoria
Los asignadores de Unity conservan las páginas de memoria liberadas para su reutilización en lugar de devolverlas al SO de inmediato, por lo que la memoria de referencia tiende a reflejar el pico más alto alcanzado en lugar del uso actual. Por lo tanto, evitar los picos en primer lugar es más eficaz que depender de la limpieza después del hecho.
1. Evita los AssetBundles demasiado grandes
Debido al mecanismo de carga de paquetes de Unity, los AssetBundles gigantes provocan una gran hinchazón de la memoria y fallas de OOM. Mantén los paquetes modulares para garantizar una administración eficiente de los recursos.
Problemas clave
- Sobrecarga de memoria alta: Solicitar un solo recurso pequeño obliga a Unity a cargar todo el archivo del paquete (incluidos los encabezados, los metadatos y los búferes de transmisión) en la RAM.
- La trampa de descarga: Si algún recurso dentro de un paquete está en uso activo, no se puede descargar todo el paquete, lo que atrapa los datos no utilizados en la RAM.
Prácticas recomendadas
- Mantén los paquetes modulares: Agrupa los recursos de forma lógica por escena o ciclo de vida.
- Sugerencia para Unity 6.6 y versiones posteriores: Usa directorios de contenido para evitar dependencias no deseadas entre paquetes.
2. Optimiza las referencias de recursos en ScriptableObjects
Los campos serializados UnityEngine.Object directos en un ScriptableObject crean referencias directas y forzosas, lo que obliga a que todos los recursos a los que se hace referencia se carguen en la RAM en cuanto se carga o se crea una instancia del 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. Configura los tipos de carga de clips de audio
Cargar todos los clips de audio sin comprimir directamente en la memoria crea picos de memoria grandes y permanentes. La configuración de carga de audio debe configurarse según su perfil de uso:
| Categoría de audio | Tipo de carga | Motivo |
|---|---|---|
| BGM (música de fondo) | Transmisión | Transmite audio desde el disco en búferes pequeños para eliminar los picos de memoria. |
| SFX largos | Comprimido en la memoria | Mantiene baja la sobrecarga de la RAM y descomprime el audio sobre la marcha durante la reproducción. |
| SFX cortos y frecuentes | Descomprimir cuando finalice la carga | Descomprime el audio en la RAM cuando se carga para evitar la sobrecarga de la CPU en el tiempo de ejecución durante la reproducción. |
4. Implementa estrategias de agrupación y liberación de objetos
Las instancias no liberadas que permanecen dentro de los grupos de objetos en las transiciones de escena conservan la memoria reservada de forma indefinida, lo que mantiene inflado innecesariamente el espacio en memoria de referencia.
- Acción: Borra o recorta periódicamente los objetos agrupados no utilizados durante las transiciones de escena o los períodos de baja actividad, lo que permite que Unity devuelva o reutilice ese espacio reservado para otras asignaciones.
Categoría B: Elimina el uso innecesario de memoria
Eliminar los recursos gráficos redundantes y renderizar los búferes de destino directamente reduce el espacio en memoria de referencia.
1. Optimiza las texturas de renderización y la profundidad de la cámara
- Quita los búferes de profundidad/plantilla: Establece el formato de plantilla de profundidad en Ninguno para las texturas de renderización que solo requieren datos de color.
- Inhabilita la textura de profundidad de la cámara de la IU: Para las cámaras de la IU en las que los datos de profundidad no son
necesarios, inhabilita la generación de texturas de profundidad en la configuración de la cámara URP para
eliminar el paso
CopyDepthy la memoria de textura de la GPU asociada.
2. Optimiza las texturas y las mallas
| Categoría | Lineamiento de optimización |
|---|---|
| Compresión de texturas | Aplica siempre formatos de compresión de plataformas de destino (por ejemplo, ASTC para Android). |
| Lectura/escritura habilitada | Mantén esta opción inhabilitada, a menos que sea necesario. Si la habilitas, se duplica la memoria de textura en la RAM de la CPU y la GPU. |
| Mipmaps | Inhabilita los mipmaps para las texturas o los objetos de la IU que se fijan a una distancia constante de la cámara, lo que ahorra aproximadamente un 33% de la memoria de textura. |
| Complejidad de la malla | Reduce los recuentos de polígonos y los flujos de vértices innecesarios para disminuir el espacio en memoria de la GPU y la memoria nativa. |
3. Quita las variantes de sombreador y optimiza la memoria
Los sombreadores Uber (por ejemplo, el sombreador URP Lit) encapsulan numerosas funciones con las palabras clave #multi_compile y shader_feature. Sin optimización,
la explosión combinatoria crea decenas de miles de
variantes de sombreador únicas, lo que genera tamaños de compilación inflados, un consumo masivo de memoria nativa
y problemas de compilación del controlador de la GPU durante el juego.
A. Mecánica de la sobrecarga de memoria de la variante de sombreador
- Explosión combinatoria: Las variantes totales posibles crecen de forma exponencial con cada grupo de palabras clave agregado.
- Arquitectura de asignación de fragmentos: Unity agrupa las variantes binarias compiladas en bloques de memoria comprimidos llamados fragmentos (predeterminado: 4 MB).
- Hinchazón de la memoria nativa: Cuando el código de tiempo de ejecución solicita incluso una sola variante dentro de un fragmento, todo el fragmento de 4 MB se descomprime en la RAM. Si no se optimizan, miles de variantes no utilizadas empaquetadas en esos fragmentos ocupan permanentemente la memoria nativa.
B. Canalización de eliminación de varias etapas integrada en Unity: Unity quita automáticamente
las variantes innecesarias en el tiempo de compilación según la configuración de gráficos de la plataforma de segmentación
y las funciones del motor no utilizadas (por ejemplo, niebla, mapas de luz y configuración de realidad extendida
). Además, las variantes shader_feature se filtran automáticamente si sus palabras clave no son utilizadas de forma activa por ningún material del proyecto, mientras que las variantes #multi_compile se incluyen de forma forzosa, independientemente del uso.
C. Arquitectura de eliminación automática personalizada
(IPreprocessShaders) Debido a que el análisis estático no puede
detectar palabras clave modificadas de forma dinámica con secuencias de comandos de C# en tiempo de ejecución
(Material.EnableKeyword), la eliminación estándar suele ser insuficiente. Para
asegurarte de que solo se incluyan las variantes que se usan realmente, puedes recopilar variantes
durante los conjuntos de pruebas de QA con Player.log (habilitar
Log Shader Compilation en la configuración del editor) o Profiler Traces
(Shader.CreateGPUProgram marcadores). Luego, implementa
IPreprocessShaders.OnProcessShader en una secuencia de comandos del editor para filtrar las
variantes que nunca se ejecutaron durante el tiempo de ejecución y conservar solo las necesarias
en la compilación.
Categoría C: Elimina las asignaciones innecesarias de GC
Las asignaciones de recolección de elementos no utilizados (GC) en el montón administrado generan fragmentación de la memoria, expansiones de montón que nunca se reducen y caídas de fotogramas graves durante las pausas de GC.
1. Evita las asignaciones de cierre de lambda
Cuando una expresión lambda captura variables locales externas, C# genera una clase de visualización implícita en el montón. La ejecución de esta dentro de Update asigna instancias de cierre en cada fotograma.
// 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. Usa stackalloc y Span
Evita las asignaciones de montón para los arrays temporales de corta duración con la memoria de pila.
// 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. Optimiza la iteración de la colección
Evita el boxing y las asignaciones de montón de enumeradores causadas por las extensiones de LINQ o los descriptores de acceso 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. Reglas adicionales de prevención de asignación de GC
- Evita usar
Camera.allCamerasporque genera un arrayCamera[]nuevo en el montón en cada llamada. En su lugar, almacena en caché un array de cámaras y pásalo aCamera.GetAllCameras(_allCameras). - Evita
foreachenIReadOnlyList<T>: La iteración sobre una interfaz causa el boxing del enumerador de struct, lo que genera asignaciones de GC. Usa un bucleforestándar en su lugar. - Almacena en caché los objetos de corrutina: Almacena en caché las instancias de
WaitForSecondsen lugar de crear instancias deyield return new WaitForSeconds(time);de forma repetida. - Claves de struct personalizadas en diccionarios: El uso de structs personalizados como claves de diccionario
llama a
Equalspredeterminado, lo que activa el boxing de objetos. ImplementaIEqualityComparer<T>y pásalo al constructor de 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. Minimiza el uso genérico y de reflexión
Si bien los métodos genéricos proporcionan una excelente reutilización y capacidad de mantenimiento del código, el uso excesivo de ellos puede afectar negativamente tu proyecto en el contexto del backend de Unity IL2CPP (lenguaje intermedio a C++).
- Hinchazón del código IL2CPP: Para cada combinación de tipo genérico único, IL2CPP genera una versión especializada del código. El uso excesivo de genéricos complejos puede generar una "explosión combinatoria" de código C++ generado, lo que aumenta significativamente el tamaño binario de la aplicación y la huella de memoria nativa.
- Sobrecarga de reflexión: Los métodos que usan reflexión, como las APIs de
System.Reflection, son inherentemente lentos y, a menudo, causan asignaciones de montón durante el tiempo de ejecución. - Práctica recomendada: Usa genéricos con prudencia. Priorízalos para la
claridad arquitectónica en lugar de una aplicación amplia e indiscriminada. Cuando el rendimiento es fundamental, favorece los tipos concretos o el polimorfismo basado en interfaces. Para la reflexión, almacena en caché los resultados, como
MethodInfooFieldInfo, durante la inicialización en lugar de consultarlos en el bucle de actualización.
6. Evita los shells administrados con fugas
Cada UnityEngine.Object, como MonoBehaviour, Texture o GameObject, tiene un wrapper de "shell administrado" de C# que se comunica con el motor nativo de C++.
- El problema: Si una referencia estática, una suscripción de eventos persistente o un cierre no limpiado mantienen un shell administrado en la memoria, el GC no puede reclamar la memoria. Incluso si se destruye el objeto nativo, el wrapper administrado persiste, lo que genera fugas de memoria "fantasma" que inflan el montón administrado.
- Resolución: Implementa siempre patrones de limpieza sólidos. Cuando destruyas objetos o hagas la transición de escenas, anula la suscripción de forma explícita a los eventos con el operador
-=y anula las referencias estáticas a los tiposUnityEngine.Object. Esta limpieza garantiza que el GC pueda recopilar correctamente el wrapper una vez que el motor nativo haya liberado su controlador.