Перейти к основному содержимому

Авто-отмена и привязка жизненного цикла

Одна из самых распространённых ошибок в асинхронном коде 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 для:

  1. Подтверждения, что класс наследуется от UnityEngine.MonoBehaviour (проходит полную цепочку наследования).
  2. Перечисления всех членов. Для каждого члена проверяется, является ли он:
    • async-методом.
    • Возвращает UnaPartidaMas.Valkarn.Tasks.ValkarnTask (или ValkarnTask<T>).
    • Не имеет [NoAutoCancel].
  3. Если подходящих методов не найдено, класс молча пропускается — ничего не генерируется.
  4. Обрабатывается только первое частичное объявление. Если класс разделён на несколько файлов, генератор генерирует код один раз, привязанный к первому объявлению, чтобы избежать дублирующихся членов.

Этап 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.
  • Он связан со встроенным destroyCancellationToken Unity (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-метод как намеренно не ожидаемый. Он служит двум целям:

  1. Подавляет предупреждения VTASKS-TASK002 и VTASKS-TASK013, которые срабатывают, когда вызывающий не делает await возвращаемого значения ValkarnTask.
  2. Сигнализирует о намерении — будущие читатели кода поймут, что отброс намеренный.

Генератор исходного кода оборачивает методы [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, поэтому генерация авто-отмены для них недоступна.