29.10.2026 17 мин чтения Просмотров:1

Утечки памяти в Unity: подписки, статика, материалы и NativeArray

Шесть типичных источников утечек памяти в Unity — от забытых отписок до неотпущенных хэндлов Addressables — с примерами кода, исправлениями и монитором памяти между загрузками сцен.

Утечки памяти в Unity: подписки, статика, материалы и NativeArray

В C# есть сборщик мусора, но память в Unity-проекте всё равно утекает: после десятой загрузки уровня игра занимает на 400 МБ больше, чем после первой, а на слабом телефоне через полчаса её убивает система. В этой статье разберём шесть типичных источников утечек — подписки на события, статические поля, замыкания, материалы и меши, созданные в коде, нативные коллекции и забытые хэндлы Addressables. Для каждого покажем, как утечка выглядит в коде, почему GC её не видит и как её закрыть. В конце соберём небольшой монитор памяти, который ловит рост между загрузками сцен прямо в тестовом билде.

Почему в управляемом языке вообще бывают утечки

Сборщик мусора освобождает объект, только когда до него нельзя добраться ни по одной ссылке из «корней»: статических полей, локальных переменных на стеке, хэндлов, которые держит движок. Утечка в C# — это не «забыл вызвать free», а лишняя ссылка, которая держит объект живым дольше, чем он нужен. GC честно делает свою работу: объект достижим, значит, он нужен.

В Unity к этому добавляется вторая половина памяти — нативная. Каждый UnityEngine.Object (текстура, меш, материал, GameObject) состоит из двух частей: маленькой C#-обёртки и большого нативного объекта внутри движка. GC управляет только обёрткой. Нативная часть живёт, пока её явно не уничтожат через Object.Destroy, пока сцена, которой она принадлежит, не выгрузится, или пока Resources.UnloadUnusedAssets не найдёт её ничейной. Отсюда два класса утечек:

  • управляемые — лишняя C#-ссылка держит граф объектов, и GC его не собирает;
  • нативные — объект, созданный в коде, никто не уничтожил, и движок продолжает хранить его данные, даже если C#-ссылок на него давно нет.

Есть и гибрид, который особенно часто встречается в Unity: GameObject уничтожен, нативная часть освобождена, но C#-обёртка всё ещё достижима. Такие объекты называют «leaked managed shells» — пустые оболочки. Сами по себе они маленькие, но через свои поля держат всё, на что ссылались: списки, делегаты, другие компоненты. Именно с ними связан эффект fake null: обёртка уничтоженного объекта сравнивается с null как равная, хотя в памяти она жива.

Источник 1: подписка на событие долгоживущего объекта

Самая частая утечка. Короткоживущий объект подписывается на событие объекта, который живёт дольше, и не отписывается.

public static class GameEvents
{
    public static event Action<int> ScoreChanged;

    public static void RaiseScoreChanged(int score) => ScoreChanged?.Invoke(score);
}

public class ScoreLabel : MonoBehaviour
{
    [SerializeField] private TMP_Text label;

    private void Start()
    {
        GameEvents.ScoreChanged += OnScoreChanged;
    }

    private void OnScoreChanged(int score)
    {
        label.text = score.ToString();
    }
}

Делегат в событии хранит ссылку на целевой объект — экземпляр ScoreLabel. Статическое событие — это корень GC. Когда сцена выгружается, GameObject уничтожается, но обёртка ScoreLabel остаётся в списке вызова. Дальше два последствия:

  1. Память не освобождается: через поле label держится ещё и обёртка текста, через неё — шрифт и всё, на что ссылается компонент.
  2. При следующем вызове события Unity бросит MissingReferenceException, потому что label уже уничтожен. Это хорошо, если вы его заметите. Хуже, когда обработчик не трогает Unity-объекты: тогда он молча выполняется для «мёртвых» экземпляров, и после пяти перезагрузок сцены очки пересчитываются пять раз.

Правило простое: подписка и отписка — парные методы одного жизненного цикла. Для MonoBehaviour это OnEnable/OnDisable или Awake/OnDestroy:

private void OnEnable()
{
    GameEvents.ScoreChanged += OnScoreChanged;
}

private void OnDisable()
{
    GameEvents.ScoreChanged -= OnScoreChanged;
}

Пара OnEnable/OnDisable удобнее: выключенный объект не получает события, а OnDisable гарантированно вызывается и перед уничтожением. Start + OnDestroy — плохая пара: если объект создан выключенным и так и не включился, Start не вызовется, а OnDestroy вызовется только для объектов, у которых был вызван Awake. Асимметрия рано или поздно выстрелит.

Ловушка с лямбдами

Отписаться от лямбды нельзя, если не сохранить её в поле:

// Утечка: каждая лямбда — новый делегат, -= ничего не найдёт
GameEvents.ScoreChanged += s => label.text = s.ToString();
GameEvents.ScoreChanged -= s => label.text = s.ToString();

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

private Action<int> scoreHandler;

private void OnEnable()
{
    scoreHandler ??= s => label.text = s.ToString();
    GameEvents.ScoreChanged += scoreHandler;
}

private void OnDisable()
{
    GameEvents.ScoreChanged -= scoreHandler;
}

Подписки как ресурс

Когда подписок в классе становится много, удобно превратить их в IDisposable и складывать в один контейнер. Отписка тогда пишется рядом с подпиской, а не в другом методе, где её легко забыть:

public sealed class Subscription : IDisposable
{
    private Action unsubscribe;

    public Subscription(Action unsubscribe) => this.unsubscribe = unsubscribe;

    public void Dispose()
    {
        unsubscribe?.Invoke();
        unsubscribe = null;
    }
}

public static class EventExtensions
{
    public static IDisposable Subscribe<T>(Action<Action<T>> add, Action<Action<T>> remove, Action<T> handler)
    {
        add(handler);
        return new Subscription(() => remove(handler));
    }
}

public class ScoreLabel : MonoBehaviour
{
    [SerializeField] private TMP_Text label;
    private readonly List<IDisposable> subscriptions = new();

    private void OnEnable()
    {
        subscriptions.Add(EventExtensions.Subscribe<int>(
            h => GameEvents.ScoreChanged += h,
            h => GameEvents.ScoreChanged -= h,
            OnScoreChanged));
    }

    private void OnDisable()
    {
        foreach (var s in subscriptions) s.Dispose();
        subscriptions.Clear();
    }

    private void OnScoreChanged(int score) => label.text = score.ToString();
}

Тот же подход лежит в основе реактивных библиотек вроде R3: каждая подписка возвращает IDisposable, а AddTo(this) привязывает её к жизни компонента.

Источник 2: статические коллекции и кэши

Статическое поле живёт до выгрузки домена — в билде это до закрытия игры. Всё, что туда попало, не будет собрано никогда, если его не удалить вручную. Классические примеры — реестры «всех врагов на сцене» и кэши:

public class Enemy : MonoBehaviour
{
    public static readonly List<Enemy> All = new();

    private void Awake() => All.Add(this);
    // OnDestroy забыли — список растёт с каждой волной
}

Исправление очевидное — симметричное удаление в OnDisable или OnDestroy. Менее очевидны кэши, которые заполняются «по требованию» и никогда не чистятся:

public static class IconCache
{
    private static readonly Dictionary<string, Sprite> cache = new();

    public static Sprite Get(string id)
    {
        if (!cache.TryGetValue(id, out var sprite))
        {
            sprite = Resources.Load<Sprite>($"Icons/{id}");
            cache[id] = sprite;
        }
        return sprite;
    }
}

Пока спрайт лежит в словаре, текстура атласа не выгрузится ни при смене сцены, ни при вызове Resources.UnloadUnusedAssets: на неё есть ссылка из статического корня. У каждого кэша должна быть политика очистки. Самые практичные варианты:

  • Очистка по событию жизненного цикла — например, при выходе из уровня в меню. Подписка на SceneManager.sceneUnloaded или явный вызов IconCache.Clear() из кода загрузки уровня.
  • Ограниченный размер — LRU-кэш на N элементов, который вытесняет самые давно не используемые записи.
  • Владелец вместо статики — кэш живёт в объекте уровня или сервисе с понятным временем жизни, а не в статическом классе.

Статика и Enter Play Mode Options

Если в проекте отключена перезагрузка домена при входе в Play Mode (опция Enter Play Mode без Domain Reload заметно ускоряет итерации), статические поля переживают выход из Play Mode. Список Enemy.All из прошлого запуска будет содержать обёртки уничтоженных объектов уже в следующем. Для таких полей нужен сброс:

[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]
private static void ResetStatics()
{
    All.Clear();
}

Атрибут с SubsystemRegistration вызывается раньше загрузки первой сцены, поэтому к моменту Awake список уже пуст. Тот же приём пригодится для статических событий: присвойте им null, чтобы обработчики прошлой сессии не тянулись дальше.

Источник 3: замыкания и асинхронный код

Лямбда, которая использует this или поля объекта, захватывает ссылку на объект. Пока жива лямбда, жив и объект. Опасно, когда лямбда уходит в долгоживущее место: в планировщик, статический таймер, очередь сетевых сообщений.

public class Turret : MonoBehaviour
{
    private void Start()
    {
        // Таймер-сервис живёт всю игру и хранит лямбду, а лямбда держит Turret
        TimerService.Repeat(1f, () => Fire());
    }
}

Если сервис не умеет отменять задачи, турель не будет собрана никогда. Хороший API таймеров и планировщиков возвращает хэндл для отмены (тот же IDisposable) или принимает владельца и сам проверяет, жив ли он.

С async-методами то же самое, только неявно. Метод, ожидающий что-то долгое, держит в своей машине состояний ссылку на this:

private async Awaitable PollServerAsync()
{
    while (true)
    {
        await Awaitable.WaitForSecondsAsync(5f);
        var data = await api.FetchAsync();
        Apply(data);
    }
}

Такой цикл не остановится при уничтожении объекта, если ему этого не сказать. Он продолжит держать компонент и делать запросы. Передавайте токен отмены, привязанный к жизни компонента. В Unity 2022.2 и новее у MonoBehaviour есть свойство destroyCancellationToken:

private async Awaitable PollServerAsync()
{
    var token = destroyCancellationToken;
    while (!token.IsCancellationRequested)
    {
        await Awaitable.WaitForSecondsAsync(5f, token);
        var data = await api.FetchAsync(token);
        Apply(data);
    }
}

Awaitable.WaitForSecondsAsync при отмене бросает OperationCanceledException, и метод завершается. Чтобы исключение не попадало в лог как ошибка, оберните тело в try/catch (OperationCanceledException) в точке запуска.

Источник 4: материалы, меши и текстуры, созданные в коде

Это уже нативные утечки, и они обычно самые тяжёлые по объёму. Любой UnityEngine.Object, созданный через new или Instantiate и не привязанный к сцене как компонент, живёт, пока его не уничтожат. GC здесь не поможет: потеря C#-ссылки не освобождает нативную часть.

Неявные копии: renderer.material и meshFilter.mesh

Обращение к Renderer.material на чтение или запись создаёт копию общего материала, уникальную для этого рендерера. Документация Unity прямо пишет, что уничтожить такую копию — ответственность разработчика. Аналогично работает MeshFilter.mesh: первое обращение клонирует sharedMesh.

public class HitFlash : MonoBehaviour
{
    [SerializeField] private Renderer target;

    public void Flash()
    {
        // Каждый объект с этим скриптом получает свою копию материала
        target.material.color = Color.red;
    }
}

Если таких объектов спавнится много и они уничтожаются, копии материалов остаются до выгрузки сцены или ближайшего Resources.UnloadUnusedAssets. В игре с долгой сессией на одной сцене (раннер, сессионный шутер, open world) это тысячи висящих материалов. Есть три способа это исправить, от лучшего к допустимому:

  1. Не создавать копию вовсе. Для изменения цвета или параметра у одного объекта используйте MaterialPropertyBlock: он не плодит материалы. В SRP учтите, что property block ломает совместимость с SRP Batcher для этого рендерера, поэтому для массовых эффектов лучше передавать данные через per-instance свойства или вершинные цвета.
  2. Создать копию явно и уничтожить её самостоятельно.
  3. Закешировать неявную копию и уничтожить её в OnDestroy — как в примере из документации.
public class HitFlash : MonoBehaviour
{
    private static readonly int ColorId = Shader.PropertyToID("_BaseColor");

    [SerializeField] private Renderer target;
    private MaterialPropertyBlock block;

    private void Awake() => block = new MaterialPropertyBlock();

    public void Flash()
    {
        target.GetPropertyBlock(block);
        block.SetColor(ColorId, Color.red);
        target.SetPropertyBlock(block);
    }
}

Имя свойства зависит от шейдера: _BaseColor у URP Lit, _Color у Standard из Built-in. Если нужна именно копия материала, держите её в поле и чистите:

private Material instance;

private void Awake()
{
    instance = new Material(target.sharedMaterial);
    target.sharedMaterial = instance;
}

private void OnDestroy()
{
    if (instance != null) Destroy(instance);
}

Текстуры и RenderTexture

Код, который генерирует текстуру (миникарта, превью, скриншот для сохранения, процедурный шум), обязан её уничтожить. Особенно коварен паттерн «пересоздать при изменении»:

public void Redraw()
{
    // Старая текстура остаётся в памяти навсегда
    preview = new Texture2D(256, 256, TextureFormat.RGBA32, false);
    Fill(preview);
    image.texture = preview;
}

Текстура 256×256 RGBA32 — 256 КБ. Перерисовка раз в секунду даёт 15 МБ в минуту. Правильно — переиспользовать текстуру или уничтожать старую:

public void Redraw()
{
    if (preview == null)
        preview = new Texture2D(256, 256, TextureFormat.RGBA32, false);

    Fill(preview);
    preview.Apply();
    image.texture = preview;
}

private void OnDestroy()
{
    if (preview != null) Destroy(preview);
}

Для RenderTexture есть два уровня: Release() освобождает GPU-память, но оставляет объект, а Destroy уничтожает его целиком. Временные RT лучше брать через RenderTexture.GetTemporary и обязательно возвращать через RenderTexture.ReleaseTemporary — каждый невозвращённый временный RT тоже утечка.

То же касается мешей, созданных через new Mesh(): процедурная генерация чанков без Destroy(mesh) при выгрузке чанка — частая причина того, что open world «распухает» по мере исследования карты.

Источник 5: нативные коллекции

NativeArray, NativeList, NativeHashMap и прочие коллекции из Unity.Collections выделяют неуправляемую память. GC о ней не знает, освобождает её только Dispose(). Правила зависят от аллокатора:

  • Allocator.Temp — живёт в пределах кадра, освобождается автоматически, явный Dispose допустим и бесплатен;
  • Allocator.TempJob — для передачи в джобы, должен быть освобождён в течение четырёх кадров, иначе движок выдаст предупреждение;
  • Allocator.Persistent — живёт сколько угодно и освобождается только вручную.

Самый надёжный шаблон для persistent-коллекции в компоненте — выделение в OnEnable и освобождение в OnDisable, с проверкой IsCreated:

public class BoidSystem : MonoBehaviour
{
    private NativeArray<float3> positions;
    private JobHandle handle;

    private void OnEnable()
    {
        positions = new NativeArray<float3>(1024, Allocator.Persistent);
    }

    private void OnDisable()
    {
        handle.Complete();
        if (positions.IsCreated) positions.Dispose();
    }
}

Обратите внимание на handle.Complete() перед Dispose: освободить массив, с которым ещё работает джоба, нельзя — система безопасности бросит исключение. Для временных коллекций, которые передаются в джобу, можно повесить освобождение на завершение самой джобы: array.Dispose(jobHandle) вернёт новый хэндл и освободит память, когда джоба закончит работу.

Утечки нативных коллекций Unity умеет ловить сам. Настройка находится в Edit → Preferences → Jobs → Leak Detection Level и имеет три режима: Disabled, Enabled и Enabled With Stack Trace. В режиме Enabled движок только сообщает, что коллекция не освобождена, а с трассировкой стека — показывает место выделения. Трассировка замедляет каждое выделение, поэтому включайте её на время поиска, а не насовсем. Из кода режим переключается через NativeLeakDetection.Mode:

#if UNITY_EDITOR || DEVELOPMENT_BUILD
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]
private static void EnableLeakTraces()
{
    NativeLeakDetection.Mode = NativeLeakDetectionMode.EnabledWithStackTrace;
}
#endif

Проверка работает только в редакторе и development-билдах. В релизе утечка просто тихо съест память.

Источник 6: Addressables и ассет-бандлы

Addressables считает ссылки: каждый LoadAssetAsync и InstantiateAsync увеличивает счётчик, каждый Release уменьшает. Бандл выгружается, когда счётчик всех ассетов в нём падает до нуля. Если хотя бы один хэндл не отпущен, в памяти остаётся весь бандл, а не только один ассет.

public class ShopItemView : MonoBehaviour
{
    [SerializeField] private AssetReferenceSprite iconRef;
    [SerializeField] private Image icon;
    private AsyncOperationHandle<Sprite> handle;

    private async void OnEnable()
    {
        handle = iconRef.LoadAssetAsync();
        var sprite = await handle.Task;
        if (this != null) icon.sprite = sprite;
    }

    private void OnDisable()
    {
        if (handle.IsValid()) Addressables.Release(handle);
    }
}

Типичные ошибки:

  • Загрузить в OnEnable, а отпустить в OnDestroy — каждое выключение и включение объекта даёт лишнюю загрузку, счётчик растёт.
  • Создать объект через Addressables.InstantiateAsync, а удалить через Destroy. Для таких объектов используйте Addressables.ReleaseInstance, иначе счётчик ссылок на префаб не уменьшится.
  • Перезаписать поле хэндла новой загрузкой, не отпустив старую: предыдущий хэндл теряется, и отпустить его уже нечем.

Проверка this != null после await в примере не случайна: за время загрузки объект могли уничтожить, и обращаться к его полям-компонентам уже нельзя.

Для обычных Resources.Load и ассетов сцены работает другой механизм: Resources.UnloadUnusedAssets выгружает ассеты, на которые нет ссылок ни из сцен, ни из C#-кода. Сам вызов дорогой — он обходит граф объектов, — поэтому делать его стоит в моменты загрузки, а не каждый кадр. Метод асинхронный и возвращает AsyncOperation, его можно дождаться на экране загрузки. При загрузке сцены в режиме LoadSceneMode.Single Unity вызывает его сам. При аддитивной загрузке и выгрузке — нет, и это ещё одна частая причина роста памяти в проектах с аддитивными сценами.

Как найти утечку: от симптома к причине

Утечку почти всегда видно по одному признаку: память после возврата в исходное состояние больше, чем была. Отсюда рабочая методика.

  1. Выберите повторяемый цикл. Например: главное меню → уровень → главное меню. В исходной точке (меню) игра должна возвращаться к одному и тому же объёму памяти.
  2. Прогоните цикл несколько раз и измерьте. Один прогон ничего не доказывает: первый раз прогреваются кэши, шейдеры, пулы. Рост, который повторяется на каждой итерации, — утечка.
  3. Определите, какая память растёт. Управляемая куча — ищите лишние C#-ссылки (подписки, статика, замыкания). Нативная память — ищите не уничтоженные объекты движка и неотпущенные хэндлы.
  4. Сравните снимки. Пакет Memory Profiler (com.unity.memoryprofiler) умеет делать снимки памяти билда и сравнивать два снимка. Сделайте снимок в меню после первой итерации и после пятой и посмотрите, объектов каких типов стало больше. Отдельно там выводятся «leaked managed shells» — C#-обёртки уничтоженных объектов, и путь ссылок до корня, который держит объект.

Профилировать стоит development-билд на целевом устройстве, а не редактор: редактор сам держит много объектов, а инспектор, открытый на объекте, — ещё одна ссылка, которая мешает выгрузке.

Монитор памяти между загрузками сцен

Шаг 2 удобно автоматизировать. Компонент ниже живёт всю игру, после каждой загрузки сцены запускает очистку неиспользуемых ассетов и сборку мусора, а затем пишет в лог объёмы памяти. Если между двумя посещениями одной и той же сцены память выросла больше порога — выводится предупреждение.

using System;
using System.Collections.Generic;
using Unity.Profiling;
using UnityEngine;
using UnityEngine.SceneManagement;

public sealed class MemoryWatchdog : MonoBehaviour
{
    [SerializeField] private long warnThresholdMb = 20;

    private ProfilerRecorder totalUsed;
    private ProfilerRecorder gcUsed;
    private readonly Dictionary<string, long> baseline = new();

#if UNITY_EDITOR || DEVELOPMENT_BUILD
    [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterSceneLoad)]
    private static void Create()
    {
        var go = new GameObject(nameof(MemoryWatchdog));
        DontDestroyOnLoad(go);
        go.AddComponent<MemoryWatchdog>();
    }
#endif

    private void OnEnable()
    {
        totalUsed = ProfilerRecorder.StartNew(ProfilerCategory.Memory, "Total Used Memory");
        gcUsed = ProfilerRecorder.StartNew(ProfilerCategory.Memory, "GC Used Memory");
        SceneManager.sceneLoaded += OnSceneLoaded;
    }

    private void OnDisable()
    {
        SceneManager.sceneLoaded -= OnSceneLoaded;
        totalUsed.Dispose();
        gcUsed.Dispose();
    }

    private async void OnSceneLoaded(Scene scene, LoadSceneMode mode)
    {
        try
        {
            // Даём сцене кадр на Awake/Start, затем чистим всё, что можно
            await Awaitable.NextFrameAsync(destroyCancellationToken);
            await Resources.UnloadUnusedAssets();
            GC.Collect();
            await Awaitable.NextFrameAsync(destroyCancellationToken);

            Report(scene.name);
        }
        catch (OperationCanceledException)
        {
        }
    }

    private void Report(string sceneName)
    {
        long total = totalUsed.LastValue;
        long gc = gcUsed.LastValue;

        Debug.Log($"[Memory] {sceneName}: total {ToMb(total)} MB, managed {ToMb(gc)} MB");

        if (baseline.TryGetValue(sceneName, out long previous))
        {
            long growth = ToMb(total - previous);
            if (growth > warnThresholdMb)
                Debug.LogWarning($"[Memory] {sceneName}: +{growth} MB since last visit, possible leak");
        }

        baseline[sceneName] = total;
    }

    private static long ToMb(long bytes) => bytes / (1024 * 1024);
}

Несколько замечаний по коду.

  • await Resources.UnloadUnusedAssets() работает, потому что в Unity 2023.1 и новее AsyncOperation можно ожидать через await. В более старых версиях замените на корутину с yield return.
  • Счётчики "Total Used Memory" и "GC Used Memory" относятся к категории ProfilerCategory.Memory и доступны в релизных билдах тоже, но сам монитор мы включаем только для редактора и development-билдов.
  • Порог в 20 МБ условный. Подберите его под проект: после пары прогонов станет видно, какой разброс между итерациями нормален.
  • GC.Collect() в обычном игровом коде вызывать не надо. Здесь он нужен, чтобы отделить мусор, который ещё не собран, от действительно удерживаемой памяти.

Такой монитор хорошо сочетается с автоматическим прогоном: Play Mode-тест, который пять раз загружает уровень и меню, и проверяет, что рост памяти в меню меньше порога. Утечка, пойманная тестом в CI, обходится гораздо дешевле утечки, найденной по отзывам игроков.

Чек-лист для код-ревью

Большинство утечек видно ещё на этапе чтения кода. При ревью проверяйте:

  • у каждого += на событие есть -= в парном методе жизненного цикла, а лямбды, от которых нужно отписываться, сохранены в поле;
  • у каждой статической коллекции и кэша есть понятный момент очистки, а при отключённой перезагрузке домена — сброс через RuntimeInitializeOnLoadMethod;
  • долгие async-циклы и корутины получают токен отмены или останавливаются вместе с объектом;
  • подписки и задачи в сервисах-долгожителях возвращают хэндл отмены;
  • нет обращений к renderer.material и meshFilter.mesh в коде, который выполняется для множества объектов; если есть — копия уничтожается в OnDestroy;
  • у каждого new Texture2D, new Mesh, new Material, new RenderTexture есть Destroy, у каждого GetTemporary — ReleaseTemporary;
  • у каждой коллекции с Allocator.Persistent есть Dispose после завершения джоб;
  • у каждого LoadAssetAsync есть Release в симметричном методе, а объекты из InstantiateAsync удаляются через ReleaseInstance;
  • при аддитивной работе со сценами в точках перехода вызывается Resources.UnloadUnusedAssets.

Итог

Утечка памяти в Unity — это почти всегда нарушенная симметрия: подписались и не отписались, создали и не уничтожили, загрузили и не отпустили, выделили и не освободили. GC закрывает только одну из этих пар, да и то лишь когда на объект не осталось ссылок. Остальное — ответственность кода. Привычка писать освобождение сразу рядом с захватом ресурса, IDisposable для подписок и простой монитор, который замеряет память при каждом возвращении в меню, закрывают подавляющую часть проблем ещё до того, как их заметят игроки на слабых устройствах.

Комментарии

Чтобы оставить комментарий, войдите в аккаунт.

Пока нет комментариев. Будьте первым!

Смотрите также