Как оптимизировать память в играх Unity на устройствах Android

Хотя объем физической ОЗУ современных мобильных устройств постоянно увеличивается, потребление памяти в играх растет ещё быстрее из-за использования ресурсов высокого разрешения и сложных конвейеров рендеринга.

Основные проблемы, вызванные чрезмерным использованием памяти

  • Завершение работы приложения при нехватке памяти (LMK). ОС принудительно закрывает фоновые или даже активные приложения, чтобы устранить нехватку памяти в системе.
  • Пропуски кадров и заикания. Частые пики сборки мусора в управляемой куче или подкачка памяти на уровне ОС создают узкие места в обработке.
  • Перегрев и разряд батареи. Постоянное выделение, освобождение и фиксация страниц памяти приводят к значительной нагрузке на ЦП, что вызывает перегрев и более быстрый разряд батареи.

Обновления управления памятью Android

  • Android 17: появление MemoryLimiter. В Android 17 появился класс MemoryLimiter, который активно отслеживает потребление памяти приложением и сравнивает его с пороговыми значениями для конкретного устройства. Приложения, превышающие лимит памяти, немедленно закрываются на системном уровне. Поскольку этот механизм строже, чем традиционный LMK, управление пиковым использованием памяти сейчас важно как никогда.

Как уменьшить использование памяти в Unity

В Unity, когда движок расширяет свои внутренние пулы памяти (Native Block Allocators и Managed Heap) для обработки высокой пиковой нагрузки, он сохраняет эти страницы памяти, а не возвращает их сразу в ОС. В результате даже после выгрузки тяжелых объектов базовый объем резидентной памяти остается завышенным, что делает приложение уязвимым к завершению процессов ОС Android (например, LMK или MemoryLimiter).

Чтобы избежать принудительного закрытия приложений на уровне ОС, оптимизируйте память, следуя трем основным принципам:

  • Категория А. Снижение пикового использования памяти
  • Категория Б. Удаление ненужных объектов и системных следов
  • Категория В. Устранение ненужного выделения памяти для сборщика мусора

Категория А. Уменьшение пикового использования памяти

Выделенные в Unity области памяти не возвращаются ОС сразу после освобождения, а сохраняются для повторного использования. Поэтому базовый объем памяти обычно отражает максимальное значение, а не текущее использование. Поэтому предотвращать скачки трафика эффективнее, чем устранять их последствия.

1. Не создавайте слишком большие AssetBundle

Из-за механизма загрузки пакетов Unity большие пакеты ресурсов приводят к значительному увеличению объема памяти и сбоям из-за нехватки памяти. Чтобы эффективно управлять ресурсами, используйте модульные наборы.

Основные проблемы

  • Большие затраты памяти. При запросе одного небольшого объекта Unity загружает в ОЗУ весь файл пакета, включая заголовки, метаданные и буферы потоковой передачи.
  • Ловушка выгрузки. Если любой объект в наборе активно используется, весь набор нельзя выгрузить, и неиспользуемые данные остаются в ОЗУ.

Рекомендации

  • Используйте модульные наборы. Группируйте объекты по сценам или жизненному циклу.
  • Совет для Unity 6.6 и более поздних версий. Используйте каталоги контента, чтобы предотвратить непреднамеренные перекрестные зависимости между наборами.

2. Оптимизируйте ссылки на объекты в ScriptableObjects

Прямые сериализованные поля UnityEngine.Object в ScriptableObject создают прямые жесткие ссылки, заставляя все объекты, на которые есть ссылки, загружаться в ОЗУ, как только загружается или создается экземпляр самого 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. Настройка аудиозаписей и каналов

Загрузка всех аудиозаписей без сжатия непосредственно в память приводит к большим постоянным пиковым нагрузкам на память. Настройки загрузки аудио должны быть заданы в соответствии с профилем использования:

Категория аудио Тип загрузки Причина
Фоновая музыка Потоковая передача Потоковая передача аудио с диска небольшими буферами для устранения пиковых нагрузок на память.
Длинные звуковые эффекты Сжато в памяти Не перегружает ОЗУ и распаковывает аудио во время воспроизведения.
Короткие и частые спецэффекты Распаковывать при загрузке При загрузке аудиофайлы распаковываются в ОЗУ, чтобы избежать перегрузки ЦП во время воспроизведения.

Помимо типов загрузки, примените следующие конфигурации аудио, чтобы ещё больше снизить нагрузку на память:

  • Включите принудительное моно для ненаправленного аудио. Для многих аудиообъектов, таких как звуковые эффекты пользовательского интерфейса, закадровая речь и пространственные 3D-эффекты, не требуется разделение стереоканалов. Включите параметр Force To Mono (Принудительно в моно) в настройках импорта AudioClip для этих объектов, чтобы вдвое сократить объем потребляемой памяти и размер буфера.
  • Ограничение активных аудиоканалов по уровню устройства. Каждый активный аудиоканал выделяет нативные буферы микшера и декомпрессии DSP. На устройствах с небольшим объемом памяти уменьшите максимальное количество одновременно активных каналов. Например, снизьте значение Max Real Voices (с 32 до 16) с помощью AudioSettings.Reset в Unity или ограничьте количество каналов воспроизведения в сторонних аудиодвижках.

4. Реализуйте стратегии пула объектов и выпуска

Неосвобожденные экземпляры, оставшиеся в пулах объектов после перехода между сценами, бесконечно удерживают зарезервированную память, из-за чего базовый объем памяти неоправданно увеличивается.

  • Решение. Периодически очищайте или сокращайте неиспользуемые объединенные объекты во время переходов между сценами или периодов низкой активности. Это позволит Unity возвращать или повторно использовать зарезервированное пространство для других целей.

Категория Б. Устранение ненужного использования памяти

Удаление лишних графических объектов и рендеринг целевых буферов напрямую снижает базовый объем потребляемой памяти.

1. Оптимизация текстур рендеринга и глубины камеры

  • Удалите буферы глубины/трафарета. Установите для параметра "Формат буфера глубины/трафарета" значение Нет для текстур отрисовки, которым требуются только данные о цвете.
  • Отключите текстуру глубины камеры пользовательского интерфейса. Для камер пользовательского интерфейса, где данные о глубине не нужны, отключите создание текстуры глубины в настройках камеры URP, чтобы исключить проход CopyDepth и связанную с ним память текстуры графического процессора.

2. Оптимизируйте текстуры и сетки

Категория Рекомендации по оптимизации
Сжатие текстур Всегда применяйте форматы сжатия целевой платформы (например, ASTC для Android).
Включен API для чтения и записи Не включайте без необходимости. Если включить этот параметр, текстуры будут дублироваться в ОЗУ и видеопамяти.
Mip-текстурирование Отключите мип-карты для текстур интерфейса или объектов, расположенных на постоянном расстоянии от камеры, чтобы сэкономить около 33% памяти текстур.
Сложность сетки Уменьшите количество ненужных полигонов и потоков вершин, чтобы снизить нагрузку на графический процессор и объем используемой памяти.

3. Удалите варианты шейдеров и оптимизируйте память

Убер-шейдеры (например, URP Lit Shader) инкапсулируют многочисленные функции с помощью ключевых слов #multi_compile и shader_feature. Без оптимизации комбинаторный взрыв создает десятки тысяч уникальных вариантов шейдеров, что приводит к увеличению размеров сборки, огромному потреблению нативной памяти и сбоям компиляции драйвера графического процессора во время игры.

А. Механика перегрузки памяти вариантами шейдеров

  • Комбинаторный взрыв. Общее количество возможных вариантов растет экспоненциально с каждой добавленной группой ключевых слов.
  • Архитектура распределения фрагментов. Unity группирует скомпилированные двоичные варианты в сжатые блоки памяти, называемые фрагментами (по умолчанию 4 МБ).
  • Раздувание памяти. Когда код во время выполнения запрашивает даже один вариант внутри фрагмента, весь фрагмент размером 4 МБ распаковывается в ОЗУ. Если не оптимизировать этот процесс, тысячи неиспользуемых вариантов, упакованных в эти фрагменты, будут постоянно занимать нативную память.

Б. Встроенный многоэтапный конвейер удаления Unity. Unity автоматически удаляет ненужные варианты во время сборки на основе настроек графики целевой платформы и неиспользуемых функций движка (например, настроек тумана, карт освещения и XR). Кроме того, варианты shader_feature автоматически отфильтровываются, если их ключевые слова не используются ни в одном материале проекта, а варианты #multi_compile принудительно включаются независимо от использования. Однако если в настройках URP или конвейера включены функции рендеринга или постобработки (например, дополнительные источники света, тени в реальном времени или неиспользуемые эффекты UberPost), Unity не может определить, использует ли их ваш контент, и генерирует все соответствующие варианты #multi_compile. Проверьте и отключите неиспользуемые настройки конвейера, чтобы они соответствовали вашим фактическим требованиям к рендерингу и предотвращали создание ненужных вариантов.

В. Специальная автоматизированная архитектура удаления (IPreprocessShaders). Статический анализ не позволяет обнаружить ключевые слова, динамически измененные с помощью скриптов C# (Material.EnableKeyword), поэтому стандартного удаления часто недостаточно. Чтобы в сборку попали только те варианты, которые действительно используются, вы можете собрать их во время тестирования контроля качества с помощью Player.log (включив Log Shader Compilation в настройках редактора) или Profiler Traces (маркеры Shader.CreateGPUProgram). Затем реализуйте IPreprocessShaders.OnProcessShader в скрипте Редактора, чтобы отфильтровать варианты, которые никогда не выполнялись во время работы, и оставить в сборке только необходимые.

Г. Объединяйте избыточные шейдеры. Универсальные шейдеры, такие как Lit и Simple Lit, содержат много директив #multi_compile и потребляют значительный объем памяти при создании многочисленных вариантов.

  • Профилирование одновременного использования. Профилируйте использование памяти, чтобы проверить, потребляют ли оба шейдера значительный объем памяти одновременно, и убедиться, что они оба нужны вашему проекту.
  • Используйте один шейдер. Если физически корректный рендеринг (PBR) не требуется, настройте объекты так, чтобы устранить избыточное использование шейдера Lit и стандартизировать Simple Lit (например, проверьте, не были ли случайно применены материалы Lit по умолчанию к экспортированным моделям FBX во время импорта).

Категория C. Устраните ненужные выделения памяти для сборщика мусора

Выделение памяти в управляемой куче для сборки мусора приводит к фрагментации памяти, расширению кучи, которое никогда не уменьшается, и серьезным падениям кадров во время пауз сборки мусора.

1. Как предотвратить выделение памяти для замыканий лямбда-функций

Когда лямбда-выражение захватывает внешние локальные переменные, C# генерирует неявный класс отображения в куче. Выполнение этого кода внутри Update выделяет экземпляры замыкания в каждом кадре.

// 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. Используйте stackalloc и Span

Избегайте выделения памяти в куче для временных массивов, используя память стека.

// 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. Оптимизация итераций сбора

Избегайте упаковки и выделения памяти в куче для перечислителей, вызванных расширениями LINQ или методами доступа 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. Дополнительные правила предотвращения выделения памяти в GC

  • Не рекомендуется использовать Camera.allCameras, поскольку при каждом вызове создается новый массив Camera[] в куче. Вместо этого кешируйте массив камер и передавайте его в Camera.GetAllCameras(_allCameras).
  • Избегайте foreach на IReadOnlyList<T>: итерация по интерфейсу вызывает упаковку перечислителя структуры, генерируя выделения GC. Используйте стандартный цикл for.
  • Кешируйте объекты сопрограмм. Кешируйте экземпляры WaitForSeconds вместо того, чтобы повторно создавать экземпляры yield return new WaitForSeconds(time);.
  • Пользовательские ключи структур в словарях. При использовании пользовательских структур в качестве ключей словаря вызывается метод Equals по умолчанию, что приводит к упаковке объектов. Реализуйте интерфейс IEqualityComparer<T> и передайте его конструктору 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. Минимизируйте использование обобщений и рефлексии

Хотя обобщенные методы обеспечивают отличное повторное использование кода и удобство обслуживания, их чрезмерное использование может негативно повлиять на ваш проект в контексте бэкенда Unity IL2CPP (Intermediate Language to C++).

  • Раздувание кода IL2CPP. Для каждой уникальной комбинации универсальных типов IL2CPP генерирует специализированную версию кода. Чрезмерное использование сложных обобщенных типов может привести к "комбинаторному взрыву" сгенерированного кода C++, что значительно увеличит размер двоичного файла приложения и занимаемый объем памяти.
  • Накладные расходы на рефлексию. Методы, использующие рефлексию, например API System.Reflection, по своей природе медленные и часто вызывают выделение памяти в куче во время выполнения.
  • Рекомендация. Используйте обобщенные типы с умом. Применяйте их в первую очередь для ясности архитектуры, а не повсеместно. Если производительность критична, отдавайте предпочтение конкретным типам или полиморфизму на основе интерфейсов. Для рефлексии кешируйте результаты, такие как MethodInfo или FieldInfo, во время инициализации, а не запрашивайте их в цикле обновления.

6. Как избежать утечки управляемых оболочек

Каждый UnityEngine.Object, например MonoBehaviour, Texture или GameObject, имеет оболочку Managed Shell на C#, которая взаимодействует с нативным движком C++.

  • Проблема. Если управляемая оболочка хранится в памяти в виде статической ссылки, постоянной подписки на события или неочищенного замыкания, сборщик мусора не может освободить память. Даже если нативный объект уничтожен, управляемая оболочка сохраняется, что приводит к утечкам памяти, которые увеличивают размер управляемой кучи.
  • Решение. Всегда используйте надежные шаблоны очистки. При уничтожении объектов или переходе между сценами явно отменяйте подписку на события с помощью оператора -= и обнуляйте статические ссылки на типы UnityEngine.Object. Это гарантирует, что сборщик мусора сможет успешно собрать оболочку, как только нативный движок освободит свой дескриптор.

7. Как использовать двоичные каталоги для Addressables

В Unity 2022 и более ранних версиях по умолчанию используются каталоги JSON, а не двоичные каталоги (которые используются по умолчанию в Unity 2023 и более поздних версиях). Если в вашем проекте используются каталоги JSON, при загрузке большого каталога во время запуска весь файл считывается в одну огромную строку C#, а во время синтаксического анализа JSON и декодирования Base64 выделяются тысячи управляемых строк и временных массивов byte[]. Это приводит к резкому увеличению объема памяти, выделяемой сборщиком мусора, и постоянному расширению управляемой кучи. Поэтому перейдите на двоичный каталог, чтобы передавать компактные данные напрямую.