Авто-отмена и привязка жизненного цикла
Одна из самых распространённых ошибок в асинхронном коде Unity — запустить задачу из MonoBehaviour и забыть отменить её при уничтожении объекта. Задача продолжает выполняться, пытается обращаться к уничтоженным объектам Unity и бросает MissingReferenceException — или, что хуже, молча портит состояние.
ValkarnTasks устраняет этот класс ошибок с помощью генератора исходного кода Roslyn, который автоматически привязывает async-методы к времени жизни объекта при уничтожении.
Проблема
Без какой-либо инфраструктуры каждый async-метод в MonoBehaviour требует от разработчика вручную передавать CancellationToken:
// Ручной подход — легко забыть, сложно поддерживать
public class EnemyAI : MonoBehaviour
{
CancellationTokenSource _cts;
void OnEnable()
{
_cts = new CancellationTokenSource();
RunPatrolLoop(_cts.Token).Forget();
}
void OnDestroy()
{
_cts?.Cancel();
_cts?.Dispose();
}
async ValkarnTask RunPatrolLoop(CancellationToken ct)
{
while (true)
{
await ValkarnTask.Delay(2000, ct);
MoveToNextWaypoint();
}
}
}
По мере увеличения количества async-методов растёт и шаблонный код. Пропуск OnDestroy — или освобождение в неправильном порядке — вызывает описанные выше утечки.
Подход с генерацией кода
Объявите класс partial — ValkarnTasks сделает всё остальное:
// После — объявите partial и генератор выполнит привязку
public partial class EnemyAI : MonoBehaviour
{
void OnEnable()
{
RunPatrolLoop().Forget();
}
async ValkarnTask RunPatrolLoop()
{
while (true)
{
await ValkarnTask.Delay(2000, __ValkarnTaskLifetimeToken);
MoveToNextWaypoint();
}
}
}
Никакого CancellationTokenSource, никакого OnDestroy, никакого освобождения. Сгенерированный токен автоматически отменяется, когда Unity уничтожает объект.
Как работает генератор исходного кода
Генератор (LifecycleBindingGenerator) — это инкрементальный генератор Roslyn, работающий во время компиляции. Его конвейер состоит из трёх этапов.
Этап 1 — Синтаксический фильтр
Генератор проверяет каждое объявление класса в вашем проекте. Класс считается кандидатом, если:
- Он объявлен с ключевым словом
partial. - Он имеет список базовых классов (т.е. наследует от чего-то).
Этот фильтр чисто синтаксический и очень быстрый. На этом этапе семантический анализ не выполняется.
Этап 2 — Семантическое преобразование
Для каждого класса-кандидата генератор использует семантическую модель Roslyn для:
- Подтверждения, что класс наследуется от
UnityEngine.MonoBehaviour(проходит полную цепочку наследования). - Перечисления всех членов. Для каждого члена проверяется, является ли он:
async-методом.- Возвращает
UnaPartidaMas.Valkarn.Tasks.ValkarnTask(илиValkarnTask<T>). - Не имеет
[NoAutoCancel].
- Если подходящих методов не найдено, класс молча пропускается — ничего не генерируется.
- Обрабатывается только первое частичное объявление. Если класс разделён на несколько файлов, генератор генерирует код один раз, привязанный к первому объявлению, чтобы избежать дублирующихся членов.
Этап 3 — Генерация кода
Для каждого класса, прошедшего этапы 1 и 2, генератор создаёт новый файл .g.cs. Сгенерированный код для класса EnemyAI в пространстве имён Game.Enemies выглядит так:
// <auto-generated/>
#nullable disable
using System.Threading;
namespace Game.Enemies
{
partial class EnemyAI
{
[System.ComponentModel.EditorBrowsable(System.ComponentModel.EditorBrowsableState.Never)]
CancellationTokenSource __valkarnTaskLifetimeCts;
/// <summary>
/// Токен отмены, срабатывающий при уничтожении этого MonoBehaviour.
/// Автогенерируется генератором исходного кода ValkarnTask.
/// </summary>
[System.ComponentModel.EditorBrowsable(System.ComponentModel.EditorBrowsableState.Never)]
protected CancellationToken __ValkarnTaskLifetimeToken
{
get
{
if (__valkarnTaskLifetimeCts == null)
__valkarnTaskLifetimeCts =
CancellationTokenSource.CreateLinkedTokenSource(destroyCancellationToken);
return __valkarnTaskLifetimeCts.Token;
}
}
}
}
Ключевые детали:
CancellationTokenSourceявляется ленивым — выделяется только при первом обращении к__ValkarnTaskLifetimeToken.- Он связан со встроенным
destroyCancellationTokenUnity (MonoBehaviour.destroyCancellationToken, доступен с Unity 2022). При уничтожении объекта Unity срабатываетdestroyCancellationToken, что каскадируется на__valkarnTaskLifetimeCtsи отменяет__ValkarnTaskLifetimeToken. - Поле и свойство помечены
EditorBrowsable(Never), чтобы не засорять IntelliSense для пользователей класса. - Свойство
protected, поэтому подклассы также могут использовать тот же токен.
Атрибут [NoAutoCancel]
Примените [NoAutoCancel] к любому async ValkarnTask-методу, когда вы намеренно хотите, чтобы он продолжал выполняться после уничтожения объекта. Распространённые сценарии:
- Метод, сохраняющий данные на диск, который должен завершиться даже если инициирующий объект уничтожен.
- Метод, управляющий общим ресурсом, принадлежащим другой системе.
- Эффекты перехода, намеренно переживающие объект, который их запустил.
public partial class SaveManager : MonoBehaviour
{
// Этот метод БУДЕТ авто-отменён при уничтожении
async ValkarnTask AutoSaveLoop()
{
while (true)
{
await ValkarnTask.Delay(30_000, __ValkarnTaskLifetimeToken);
await WriteSaveToDisk();
}
}
// Этот метод НЕ будет авто-отменён — он должен завершить запись
[NoAutoCancel]
async ValkarnTask WriteSaveToDisk(CancellationToken ct = default)
{
await FileSystem.WriteAsync(_saveData, ct);
}
}
[NoAutoCancel] — это атрибут уровня метода. Генератор просто исключает этот метод из своего подсчёта подходящих методов. Если все методы в классе имеют [NoAutoCancel], генератор ничего не генерирует для этого класса.
Анализатор: TT014 — NoAutoCancel без параметра CancellationToken
Сопутствующий анализатор (NoAutoCancelAnalyzer) сообщает диагностику TT014, когда вы применяете [NoAutoCancel] к методу без параметра CancellationToken. Если параметра токена нет, метод не имеет способа наблюдать отмену — это означает, что [NoAutoCancel] присутствует, но не имеет практического эффекта. Обычно это означает, что вы забыли добавить параметр токена:
// TT014: [NoAutoCancel] применён, но метод не имеет параметра CancellationToken
[NoAutoCancel]
async ValkarnTask WriteSaveToDisk() // <-- отсутствует параметр ct
{
await FileSystem.WriteAsync(_saveData);
}
Исправление: добавьте параметр CancellationToken:
[NoAutoCancel]
async ValkarnTask WriteSaveToDisk(CancellationToken ct = default)
{
await FileSystem.WriteAsync(_saveData, ct);
}
Атрибут [FireAndForget]
[FireAndForget] — это отдельный, дополняющий атрибут, который помечает async-метод как намеренно не ожидаемый. Он служит двум целям:
- Подавляет предупреждения VTASKS-TASK002 и VTASKS-TASK013, которые срабатывают, когда вызывающий не делает
awaitвозвращаемого значенияValkarnTask. - Сигнализирует о намерении — будущие читатели кода поймут, что отброс намеренный.
Генератор исходного кода оборачивает методы [FireAndForget], чтобы все необнаруженные исключения публиковались через обработчик необнаруженных исключений ValkarnTasks, а не молча терялись.
public partial class SpawnManager : MonoBehaviour
{
void OnPlayerDied()
{
// Без предупреждения, намерение ясно
ShowDeathScreenAsync();
}
[FireAndForget]
async ValkarnTask ShowDeathScreenAsync()
{
await ValkarnTask.Delay(500, __ValkarnTaskLifetimeToken);
_deathScreen.SetActive(true);
}
}
[FireAndForget] и [NoAutoCancel] независимы и могут комбинироваться:
[FireAndForget]
[NoAutoCancel]
async ValkarnTask PlayGlobalMusicAsync(CancellationToken ct = default) { ... }
Анализатор: TT010 — Авто-отмена активна
AutoCancelInfoAnalyzer сообщает информационную диагностику TT010 для каждого async ValkarnTask-метода в MonoBehaviour, который будет авто-отменён (т.е. не имеет [NoAutoCancel]). Это не ошибка и не предупреждение — это намеренная прозрачность, чтобы разработчики могли с первого взгляда видеть, какие методы привязаны к жизненному циклу.
Вы можете подавить TT010 для отдельного метода с помощью [NoAutoCancel], или отключить его во всём проекте через .editorconfig, если предпочитаете его не видеть.
Ограничения
Класс должен быть объявлен partial. Генератор исходного кода не может добавлять члены в не-partial класс. Если ваш MonoBehaviour не является partial, генератор молча пропускает его и привязка не создаётся. Атрибуты [NoAutoCancel] и [FireAndForget] по-прежнему работают как документация и для анализаторов, но __ValkarnTaskLifetimeToken не будет доступен.
Вложенные классы. Если MonoBehaviour объявлен как вложенный класс внутри другого класса, оба — внешнее и внутреннее — объявления класса должны быть partial. Roslyn требует, чтобы все охватывающие типы были partial для корректной компиляции сгенерированных членов.
Базовые классы. Сгенерированное свойство __ValkarnTaskLifetimeToken является protected. Подклассы автоматически наследуют доступ к нему. Генератор независимо выполняется для каждого класса в иерархии; если и базовый, и производный классы являются partial MonoBehaviour'ами с async-методами, каждый получает свой generated partial, но они разделяют тот же базовый токен, потому что destroyCancellationToken наследуется от базы MonoBehaviour.
Множественное наследование. C# не поддерживает множественное наследование классов. MonoBehaviour может иметь только одну базу класса, поэтому нет неоднозначности в том, к какому destroyCancellationToken привязываться.
ScriptableObjects. Генератор в настоящее время нацелен только на MonoBehaviour. ScriptableObject не имеет эквивалента destroyCancellationToken в API Unity, поэтому генерация авто-отмены для них недоступна.