Если нажатие Play в вашем проекте превращается в паузу на пять–пятнадцать секунд, почти всё это время уходит на перезагрузку домена скриптов. Её можно отключить, и вход в Play Mode станет почти мгновенным. Платить за это придётся статикой: статические поля, события и синглтоны перестанут сбрасываться сами. В статье разберём, что именно делает Unity при входе в Play Mode, какой код ломается без перезагрузки домена, как его чинить и как найти проблемные места в уже большом проекте, не перебирая файлы вручную.
Что происходит, когда вы нажимаете Play
По умолчанию в старых версиях Unity вход в Play Mode состоит из двух тяжёлых шагов.
- Перезагрузка домена (Domain Reload). Unity сериализует состояние всех загруженных MonoBehaviour и ScriptableObject, выгружает скриптовый домен со всеми сборками (вашими, пакетов и самого редактора), загружает их заново и десериализует состояние обратно. Заодно заново выполняются статические конструкторы и методы с
[InitializeOnLoad]. - Перезагрузка сцены (Scene Reload). Открытые сцены уничтожаются и загружаются с диска (точнее, из резервной копии, если сцена была изменена), будто вы запустили билд.
Первый шаг и есть та самая пауза. Чем больше сборок в проекте, пакетов и плагинов с [InitializeOnLoad], тем она длиннее. При этом пользы от неё для большинства итераций никакой: вы поменяли значение в инспекторе, а Unity заново поднимает весь C#-рантайм.
Главное, что даёт перезагрузка домена: чистое статическое состояние. Все static-поля возвращаются к значениям по умолчанию, все подписки на статические события пропадают, статические конструкторы выполняются снова. Именно поэтому код, который годами работал «нормально», после её отключения начинает вести себя странно.
Где это настраивается
Настройка лежит в Edit > Project Settings > Editor, раздел Enter Play Mode Settings. В Unity 6 там выпадающий список When entering Play Mode с четырьмя вариантами:
- Reload Domain and Scene — классическое поведение, обе перезагрузки;
- Reload Scene only — домен не перезагружается, сцена перезагружается;
- Reload Domain only — наоборот;
- Do not reload Domain or Scene — максимально быстрый вход, ничего не перезагружается.
В более старых версиях, начиная с 2019.3, то же самое выглядело как галочка Enter Play Mode Options и две вложенные галочки Reload Domain и Reload Scene. Настройка хранится в ProjectSettings/EditorSettings.asset, то есть попадает в git и действует на всю команду.
Обратите внимание на тренд: в документации Unity 6.6 вариантом по умолчанию указан уже Reload Scene only, то есть без перезагрузки домена. Unity сама подталкивает проекты к быстрому входу, и код, который полагается на сброс статики, со временем будет ломаться всё чаще. Проверьте, что выбрано у вас: если проект создан в новой версии, перезагрузка домена может быть уже выключена, а вы об этом не знаете.
Практическая рекомендация: начинайте с Reload Scene only. Перезагрузка сцены стоит недорого и избавляет от целого класса проблем с объектами на сцене, а основной выигрыш по времени даёт как раз отключение домена.
Важно: перезагрузка домена после изменения кода никуда не девается. Когда вы правите скрипт и Unity его перекомпилирует, домен перезагружается как обычно. Настройка касается только момента нажатия Play.
Что ломается без перезагрузки домена
Посмотрим на типичный код, который тихо полагается на сброс статики. Все примеры ниже корректно работают при первом запуске и ломаются при втором, третьем и так далее. Поэтому после включения настройки всегда проверяйте игру, заходя в Play Mode несколько раз подряд.
Статические поля сохраняют значения
public class ScoreCounter : MonoBehaviour
{
public static int TotalKills;
public void RegisterKill()
{
TotalKills++;
}
}
Первый запуск: убили пять врагов, счётчик равен 5. Остановили игру, снова Play — счётчик начинается с 5, а не с 0. Объект ScoreCounter на сцене новый, а статическое поле живёт в домене, который никто не перезагружал.
Синглтон ссылается на уничтоженный объект
public class AudioManager : MonoBehaviour
{
public static AudioManager Instance { get; private set; }
private void Awake()
{
if (Instance != null)
{
Destroy(gameObject);
return;
}
Instance = this;
DontDestroyOnLoad(gameObject);
}
}
Во втором запуске Instance не равен null в смысле C#: там лежит ссылка на объект из прошлой сессии. Unity его уже уничтожила, поэтому перегруженный оператор != вернёт «равно null» (это fake null), и в этом конкретном коде всё сработает. Но стоит заменить проверку на Instance is not null или Instance?., и новый менеджер уничтожит сам себя, а вызовы пойдут в мёртвый объект с MissingReferenceException. Синглтоны на чистом C# без MonoBehaviour ломаются ещё прямее: экземпляр из прошлой сессии со всеми накопленными данными просто продолжает жить.
Статические события держат старых подписчиков
public static class GameEvents
{
public static event Action<int> PlayerDamaged;
public static void RaisePlayerDamaged(int amount)
{
PlayerDamaged?.Invoke(amount);
}
}
public class HealthBar : MonoBehaviour
{
private void Start()
{
GameEvents.PlayerDamaged += OnDamaged;
}
private void OnDamaged(int amount)
{
// обновляем полоску здоровья
}
}
Подписка без отписки раньше «прощалась»: домен перезагружался и делегат исчезал. Теперь после каждого запуска к событию добавляется ещё один подписчик, а старые указывают на уничтоженные HealthBar. При вызове события старый обработчик полезет к своему transform или Image и получит MissingReferenceException. Хуже, если обработчик не трогает Unity-объекты: тогда он молча выполнится несколько раз, и вы будете долго искать, почему урон считается трижды.
Кстати, это ровно та же утечка, которая в билде проявится при перезагрузке сцены. Отключённая перезагрузка домена просто делает её видимой сразу.
Статические коллекции и кэши
public static class EnemyRegistry
{
private static readonly List<Enemy> Enemies = new List<Enemy>();
public static void Add(Enemy enemy) => Enemies.Add(enemy);
public static void Remove(Enemy enemy) => Enemies.Remove(enemy);
public static IReadOnlyList<Enemy> All => Enemies;
}
Модификатор readonly здесь не спасает: он запрещает заменить список, но не очистить его. Если враг где-то не вызвал Remove (например, объект уничтожили через выгрузку сцены, а отписка висела в OnDestroy другого компонента), в реестре остаются мёртвые записи. В следующей сессии цикл по EnemyRegistry.All встретит их первыми.
Статический конструктор выполняется один раз
public static class Config
{
public static readonly float Difficulty;
static Config()
{
Difficulty = PlayerPrefs.GetFloat("difficulty", 1f);
}
}
Статический конструктор отрабатывает при первом обращении к типу в рамках домена. Без перезагрузки домена он не вызовется повторно, пока вы не измените код. Поменяли сложность в меню, вышли из игры, снова зашли — Difficulty осталась прежней. Поля, инициализируемые прямо в объявлении (static int x = 10;), устроены так же: это тот же статический конструктор.
Нестатические поля ScriptableObject-ассетов
Это не статика, но ломается похоже. Поля ScriptableObject, которые Unity не сериализует (помеченные [NonSerialized], приватные без [SerializeField], свойства с автоматическим полем), раньше сбрасывались при перезагрузке домена. Без неё ассет живёт в памяти редактора с последними значениями. Если вы храните в SO рантайм-состояние (например, текущее здоровье в «переменной»-ассете или список подписчиков в канале событий), оно переедет из прошлой сессии в новую.
Главный инструмент: RuntimeInitializeOnLoadMethod
Вместо того чтобы надеяться на перезагрузку домена, сбрасывайте статику явно при каждом входе в Play Mode. Документация Unity рекомендует для этого атрибут [RuntimeInitializeOnLoadMethod] с типом загрузки RuntimeInitializeLoadType.SubsystemRegistration:
public class ScoreCounter : MonoBehaviour
{
public static int TotalKills;
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]
private static void ResetStatics()
{
TotalKills = 0;
}
public void RegisterKill()
{
TotalKills++;
}
}
Метод должен быть статическим и без параметров; модификатор доступа может быть любым, приватный тоже подойдёт. Атрибут живёт в UnityEngine, поэтому работает и в билде: там метод вызовется один раз при старте игры, что безвредно.
Почему именно SubsystemRegistration? Это самая ранняя точка из доступных. Порядок такой:
SubsystemRegistration— регистрация подсистем, до загрузки сцены и до любогоAwake;AfterAssembliesLoaded— сборки загружены;BeforeSplashScreen— перед заставкой;BeforeSceneLoad— перед загрузкой первой сцены, объекты сцены ещё не созданы;AfterSceneLoad— значение по умолчанию: после загрузки сцены и вызоваAwake.
Сброс должен случиться раньше, чем кто-то начнёт статику использовать. Если поставить сброс на AfterSceneLoad (то есть просто написать атрибут без параметра), он выполнится уже после Awake объектов сцены и сотрёт то, что они успели зарегистрировать. Это самая частая ошибка при починке статики, и её трудно заметить: всё работает, пока какой-нибудь объект не начнёт регистрироваться в Awake.
Порядок вызова нескольких методов с одинаковым типом загрузки не гарантирован. Поэтому в методах сброса только обнуляйте состояние и не обращайтесь к другим системам.
Синглтон, который переживает быстрый вход
public class AudioManager : MonoBehaviour
{
public static AudioManager Instance { get; private set; }
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]
private static void ResetStatics()
{
Instance = null;
}
private void Awake()
{
if (Instance != null && Instance != this)
{
Destroy(gameObject);
return;
}
Instance = this;
DontDestroyOnLoad(gameObject);
}
private void OnDestroy()
{
if (Instance == this)
Instance = null;
}
}
Двойная страховка: сброс при входе в Play Mode и обнуление в OnDestroy. Второе полезно и в билде, если менеджер когда-нибудь уничтожат вручную.
Статические события
Для событий у вас два пути, и лучше использовать оба. Первый — честно отписываться там же, где подписались, в парных методах:
public class HealthBar : MonoBehaviour
{
private void OnEnable()
{
GameEvents.PlayerDamaged += OnDamaged;
}
private void OnDisable()
{
GameEvents.PlayerDamaged -= OnDamaged;
}
private void OnDamaged(int amount) { }
}
Второй — обнулять событие при входе в Play Mode, чтобы забытая где-то отписка не тянулась из сессии в сессию:
public static class GameEvents
{
public static event Action<int> PlayerDamaged;
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]
private static void ResetStatics()
{
PlayerDamaged = null;
}
public static void RaisePlayerDamaged(int amount)
{
PlayerDamaged?.Invoke(amount);
}
}
Обнулить событие можно только изнутри класса, где оно объявлено: снаружи C# разрешает только += и -=. Это удобно, сброс естественно живёт рядом с объявлением.
Если вы подписываетесь на события самого Unity (Application.quitting, Application.logMessageReceived, SceneManager.sceneLoaded) из статического кода, их обнулить нельзя. Тут работает только связка «отписаться, затем подписаться»:
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]
private static void Init()
{
SceneManager.sceneLoaded -= OnSceneLoaded;
SceneManager.sceneLoaded += OnSceneLoaded;
}
Отписка от метода, на который вы не подписаны, ничего не делает, так что повторный вызов безопасен. А вот без неё каждый вход в Play Mode добавит ещё одну подписку.
Код, который нужен только в редакторе
Иногда сбросить что-то нужно не при входе, а при выходе из Play Mode: например, вернуть настройки, которые игра поменяла в редакторских объектах. Для этого есть EditorApplication.playModeStateChanged из пространства UnityEditor. В рантайм-скриптах оборачивайте его в директиву, иначе билд не соберётся:
#if UNITY_EDITOR
using UnityEditor;
#endif
public static class DebugOverlay
{
public static bool Visible;
#if UNITY_EDITOR
[InitializeOnLoadMethod]
private static void RegisterEditorHooks()
{
EditorApplication.playModeStateChanged -= OnPlayModeChanged;
EditorApplication.playModeStateChanged += OnPlayModeChanged;
}
private static void OnPlayModeChanged(PlayModeStateChange change)
{
if (change == PlayModeStateChange.ExitingPlayMode)
Visible = false;
}
#endif
}
Документация советует отписывать статические обработчики именно при выходе, а не ждать следующего входа: иначе ссылки могут успеть «поехать», и вы отпишете не тот обработчик. Но если этот же код работает и в Edit Mode (например, через [ExecuteAlways]), сброс при выходе не сработает как надо, и надёжнее сбрасывать при входе. Для чисто редакторских скриптов есть ещё атрибут [InitializeOnEnterPlayMode]: помеченный им статический метод вызывается при каждом входе в Play Mode, а в его перегрузке с параметром EnterPlayModeOptions можно узнать, перезагружался ли домен.
Статический конструктор превращаем в метод
Код из статического конструктора, который зависит от состояния игры, выносите в обычный метод и вызывайте его из точки сброса:
public static class Config
{
public static float Difficulty { get; private set; }
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]
private static void Load()
{
Difficulty = PlayerPrefs.GetFloat("difficulty", 1f);
}
}
Константы и неизменяемые таблицы (static readonly string[] Names = { ... }, кэш Shader.PropertyToID, Animator.StringToHash) трогать не нужно. Им всё равно, сколько раз вы зашли в Play Mode, и именно такая статика безопасна.
Обобщённые классы: сброс, который нельзя написать
Отдельная боль — статика в обобщённых типах:
public static class Pool<T> where T : class, new()
{
private static readonly Stack<T> Items = new Stack<T>();
public static T Get() => Items.Count > 0 ? Items.Pop() : new T();
public static void Release(T item) => Items.Push(item);
}
У Pool<Bullet> и Pool<Particle> свои независимые стеки, а [RuntimeInitializeOnLoadMethod] на методе обобщённого класса Unity не вызовет: она не знает, с какими T вы класс использовали. Перечислять все варианты вручную ненадёжно.
Рабочий приём — счётчик игровых сессий. Один необобщённый класс увеличивает его при каждом входе в Play Mode, а обобщённые классы при обращении сравнивают свою сохранённую сессию с текущей и, если она устарела, сбрасываются сами:
public static class PlaySession
{
public static int Id { get; private set; }
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]
private static void Advance()
{
Id++;
}
}
public static class Pool<T> where T : class, new()
{
private static readonly Stack<T> Items = new Stack<T>();
private static int _session = -1;
private static Stack<T> Storage
{
get
{
if (_session != PlaySession.Id)
{
Items.Clear();
_session = PlaySession.Id;
}
return Items;
}
}
public static T Get() => Storage.Count > 0 ? Storage.Pop() : new T();
public static void Release(T item) => Storage.Push(item);
}
Проверка — одно сравнение целых чисел, её цена в горячем коде ничтожна. Тот же приём подходит для любой ленивой статики, которую неудобно сбрасывать заранее: кэшей рефлексии, таблиц поиска, построенных в рантайме, и так далее.
Как найти проблемную статику в большом проекте
В проекте на сотни скриптов перечитывать каждый файл бессмысленно. Пусть редактор сам перечислит изменяемые статические поля в ваших сборках и отметит классы, в которых нет метода сброса. Скрипт ниже кладите в папку Editor:
using System;
using System.Linq;
using System.Reflection;
using System.Runtime.CompilerServices;
using System.Text;
using UnityEditor;
using UnityEngine;
public static class StaticStateAudit
{
private const BindingFlags StaticFields =
BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.DeclaredOnly;
[MenuItem("Tools/Audit Static State")]
private static void Run()
{
var report = new StringBuilder();
int suspicious = 0;
var assemblies = AppDomain.CurrentDomain.GetAssemblies()
.Where(IsProjectAssembly);
foreach (var assembly in assemblies)
foreach (var type in SafeGetTypes(assembly))
{
if (type.IsDefined(typeof(CompilerGeneratedAttribute)))
continue;
var fields = type.GetFields(StaticFields)
.Where(f => !f.IsLiteral)
.Where(f => !(f.IsInitOnly && IsImmutable(f.FieldType)))
.ToList();
if (fields.Count == 0)
continue;
bool hasReset = type.GetMethods(StaticFields)
.Any(m => m.IsDefined(typeof(RuntimeInitializeOnLoadMethodAttribute)));
if (hasReset)
continue;
suspicious++;
report.AppendLine(type.FullName);
foreach (var field in fields)
report.AppendLine($" {field.FieldType.Name} {field.Name}");
}
Debug.Log($"Static state audit: {suspicious} types without reset\n{report}");
}
private static bool IsProjectAssembly(Assembly assembly)
{
string name = assembly.GetName().Name;
if (name.StartsWith("Assembly-CSharp"))
return !name.Contains("Editor");
// Добавьте сюда префиксы своих asmdef
return name.StartsWith("MyGame.");
}
private static bool IsImmutable(Type type) =>
type.IsPrimitive || type.IsEnum || type == typeof(string) || type == typeof(decimal);
private static Type[] SafeGetTypes(Assembly assembly)
{
try { return assembly.GetTypes(); }
catch (ReflectionTypeLoadException e) { return e.Types.Where(t => t != null).ToArray(); }
}
}
Что делает аудит:
- берёт только ваши сборки:
Assembly-CSharpи сборки с префиксом ваших asmdef (поправьтеMyGame.под свой проект); - пропускает константы и
static readonly-поля неизменяемых типов: строки, числа, перечисления; - оставляет
static readonly-поля ссылочных типов, ведь список или словарь можно менять и через неизменяемую ссылку; - оставляет автоматические статические свойства (их поля компилятор называет
<Name>k__BackingField) и события; - пропускает служебные типы, которые компилятор генерирует для лямбд и итераторов;
- отбрасывает типы, в которых уже есть метод с
[RuntimeInitializeOnLoadMethod].
Результат — список «подозреваемых», а не готовый приговор. Кэш хэшей шейдерных свойств в static readonly Dictionary попадёт в отчёт, хотя сбрасывать его не нужно. Пройдитесь по списку и либо добавьте сброс, либо убедитесь, что состояние действительно не зависит от сессии. Удобно запускать аудит перед ревью крупных веток: новая статика без сброса сразу бросается в глаза.
Обобщённые типы с приёмом «счётчика сессий» аудит тоже покажет, потому что метода с атрибутом в них нет. Если таких классов много, добавьте свой маркерный атрибут (например, [SessionScoped]) и пропускайте помеченные им типы.
Сторонние ассеты и пакеты
Ваш код — только половина проекта. Старые ассеты из Asset Store и плагины, написанные до 2019 года, часто держат состояние в статике и об отключённой перезагрузке домена не знают. Признаки знакомые: плагин работает при первом запуске и сыплет ошибками или ведёт себя странно при втором.
Что можно сделать:
- обновить ассет: многие популярные пакеты давно поддерживают быстрый вход в Play Mode, и в changelog это обычно отмечено;
- написать сброс снаружи: если поле публичное или достижимо через рефлексию, его можно обнулить из своего метода с
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]; - если плагин лежит в проекте исходниками, добавить сброс прямо в его код и записать это в заметки к обновлению, чтобы правка не потерялась при следующем импорте;
- в крайнем случае остаться на Reload Domain and Scene, пока плагин не починят. Это лучше, чем тратить день на поиск бага, которого в билде нет.
А что с отключением перезагрузки сцены
Перезагрузка сцены обходится дешевле, а ломает меньше, но и у её отключения есть особенности. Unity не загружает сцену заново, а сбрасывает уже загруженные объекты. Для обычных MonoBehaviour вызовы Awake, OnEnable и Start при входе в Play Mode приходят как обычно. А вот скрипты с [ExecuteAlways] или [ExecuteInEditMode], которые уже работали в редакторе, лишних OnDisable и OnEnable не получат, а при отключённой перезагрузке сцены не получат и OnDestroy. Если такой скрипт выделяет ресурсы в OnEnable для режима редактора и ждёт пересоздания при входе в игру, проверьте его отдельно.
Отсюда и совет начинать с Reload Scene only: почти вся экономия времени достаётся от отключённого домена, а сцена остаётся честной. К полному отключению переходите, когда проект уже чисто работает без перезагрузки домена и хочется выжать ещё немного.
Чек-лист перехода
- Выберите в Project Settings > Editor > Enter Play Mode Settings вариант Reload Scene only.
- Запустите аудит статики и пройдитесь по списку.
- Для каждого изменяемого статического поля, события и синглтона добавьте сброс с
RuntimeInitializeLoadType.SubsystemRegistration, а не с типом по умолчанию. - Подписки на события делайте парами
OnEnable/OnDisable, подписки на события Unity из статики — по схеме «отписаться, затем подписаться». - Логику из статических конструкторов, зависящую от сессии, перенесите в метод сброса.
- Обобщённую статику переведите на счётчик сессий.
- Проверьте рантайм-состояние в ScriptableObject-ассетах: при входе в игру его тоже надо сбрасывать.
- Зайдите в Play Mode три-четыре раза подряд и пройдите ключевые сценарии: старт уровня, смерть, перезапуск, выход в меню.
- Если сломался сторонний плагин — обновите его или допишите сброс снаружи.
Самый приятный побочный эффект всей этой работы проявляется вовсе не в редакторе. Статика, которая корректно сбрасывается при входе в Play Mode, обычно так же корректно ведёт себя при перезапуске уровня, возврате в меню и повторной загрузке сцен в билде. Многие баги вида «после второй катки всё ломается» исчезают вместе с секундами ожидания перед Play.