Pular para o conteúdo principal

Vinculação de Ciclo de Vida com Auto-Cancel

Um dos bugs mais comuns no código async Unity é lançar uma task de um MonoBehaviour e depois esquecer de cancelá-la quando o objeto é destruído. A task continua executando, tenta acessar objetos Unity destruídos e lança MissingReferenceException — ou pior, corrompe silenciosamente o estado.

O ValkarnTasks elimina essa classe de bug por meio de um gerador de código-fonte Roslyn que vincula automaticamente métodos async ao tempo de vida de destruição do objeto.

O Problema

Sem nenhuma infraestrutura, todo método async em um MonoBehaviour requer que o desenvolvedor passe um CancellationToken manualmente:

// Abordagem manual — fácil de esquecer, tedioso de manter
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();
}
}
}

À medida que o número de métodos async cresce, o boilerplate também cresce. Omitir OnDestroy — ou descartar na ordem errada — causa os vazamentos descritos acima.

A Abordagem Gerada

Declare sua classe como partial e o ValkarnTasks cuida do resto:

// Depois — declare partial e o gerador faz a conexão
public partial class EnemyAI : MonoBehaviour
{
void OnEnable()
{
RunPatrolLoop().Forget();
}

async ValkarnTask RunPatrolLoop()
{
while (true)
{
await ValkarnTask.Delay(2000, __ValkarnTaskLifetimeToken);
MoveToNextWaypoint();
}
}
}

Sem CancellationTokenSource, sem OnDestroy, sem descarte. O token gerado é cancelado automaticamente quando o Unity destrói o objeto.

Como o Gerador de Código-Fonte Funciona

O gerador (LifecycleBindingGenerator) é um gerador Roslyn incremental que é executado em tempo de compilação. Seu pipeline tem três estágios.

Estágio 1 — Filtro sintático

O gerador examina toda declaração de classe em seu projeto. Uma classe é considerada candidata se:

  • É declarada com a palavra-chave partial.
  • Tem uma lista de classes base (isto é, herda de algo).

Este filtro é puramente sintático e muito rápido. Nenhuma análise semântica é executada neste estágio.

Estágio 2 — Transformação semântica

Para cada classe candidata, o gerador usa o modelo semântico Roslyn para:

  1. Confirmar que a classe deriva de UnityEngine.MonoBehaviour (percorre a cadeia de herança completa).
  2. Enumerar todos os membros. Para cada membro, verifica se ele é:
    • Um método async.
    • Retorna UnaPartidaMas.Valkarn.Tasks.ValkarnTask (ou ValkarnTask<T>).
    • Não carrega [NoAutoCancel].
  3. Se nenhum método qualificado for encontrado, a classe é silenciosamente ignorada — nada é gerado.
  4. Apenas a primeira declaração parcial é processada. Se uma classe está dividida em múltiplos arquivos, o gerador emite código uma vez, vinculado à primeira declaração, para evitar membros duplicados.

Estágio 3 — Emissão de código

Para cada classe que passa pelos estágios 1 e 2, o gerador escreve um novo arquivo .g.cs. O código gerado para uma classe chamada EnemyAI no namespace Game.Enemies se parece com:

// <auto-generated/>
#nullable disable
using System.Threading;

namespace Game.Enemies
{
partial class EnemyAI
{
[System.ComponentModel.EditorBrowsable(System.ComponentModel.EditorBrowsableState.Never)]
CancellationTokenSource __valkarnTaskLifetimeCts;

/// <summary>
/// Token de cancelamento que é disparado quando este MonoBehaviour é destruído.
/// Auto-gerado pelo gerador de código-fonte ValkarnTask.
/// </summary>
[System.ComponentModel.EditorBrowsable(System.ComponentModel.EditorBrowsableState.Never)]
protected CancellationToken __ValkarnTaskLifetimeToken
{
get
{
if (__valkarnTaskLifetimeCts == null)
__valkarnTaskLifetimeCts =
CancellationTokenSource.CreateLinkedTokenSource(destroyCancellationToken);
return __valkarnTaskLifetimeCts.Token;
}
}
}
}

Detalhes importantes:

  • O CancellationTokenSource é lazy — alocado apenas na primeira vez que __ValkarnTaskLifetimeToken é acessado.
  • É vinculado ao destroyCancellationToken integrado do Unity (MonoBehaviour.destroyCancellationToken, disponível desde o Unity 2022). Quando o Unity destrói o objeto, destroyCancellationToken dispara, o que se propaga para __valkarnTaskLifetimeCts, que cancela __ValkarnTaskLifetimeToken.
  • Tanto o campo quanto a propriedade são marcados com EditorBrowsable(Never) para que não poluam o IntelliSense para usuários da classe.
  • A propriedade é protected, para que subclasses também possam usar o mesmo token.

O Atributo [NoAutoCancel]

Aplique [NoAutoCancel] a qualquer método async ValkarnTask quando você intencionalmente quiser que ele continue executando além do tempo de vida do objeto. Cenários comuns:

  • Um método que salva dados em disco e deve ser concluído mesmo que o objeto que o disparou seja destruído.
  • Um método gerenciando um recurso compartilhado de propriedade de um sistema diferente.
  • Efeitos de transição que intencionalmente sobrevivem ao objeto que os iniciou.
public partial class SaveManager : MonoBehaviour
{
// Este método SERÁ cancelado automaticamente ao destruir
async ValkarnTask AutoSaveLoop()
{
while (true)
{
await ValkarnTask.Delay(30_000, __ValkarnTaskLifetimeToken);
await WriteSaveToDisk();
}
}

// Este método NÃO será cancelado automaticamente — deve terminar de escrever
[NoAutoCancel]
async ValkarnTask WriteSaveToDisk(CancellationToken ct = default)
{
await FileSystem.WriteAsync(_saveData, ct);
}
}

[NoAutoCancel] é um atributo de nível de método. O gerador simplesmente exclui esse método da contagem de métodos qualificados. Se todos os métodos em uma classe carregam [NoAutoCancel], o gerador não emite nada para essa classe.

Analisador: TT014 — NoAutoCancel sem parâmetro CancellationToken

Um analisador complementar (NoAutoCancelAnalyzer) relata o diagnóstico TT014 quando você aplica [NoAutoCancel] a um método que não tem parâmetro CancellationToken. Se não há parâmetro de token, o método não tem como observar o cancelamento — o que significa que [NoAutoCancel] está presente mas não tem efeito prático. Isso geralmente significa que você esqueceu de adicionar o token:

// TT014: [NoAutoCancel] aplicado mas o método não tem parâmetro CancellationToken
[NoAutoCancel]
async ValkarnTask WriteSaveToDisk() // <-- parâmetro ct ausente
{
await FileSystem.WriteAsync(_saveData);
}

Corrija adicionando um parâmetro CancellationToken:

[NoAutoCancel]
async ValkarnTask WriteSaveToDisk(CancellationToken ct = default)
{
await FileSystem.WriteAsync(_saveData, ct);
}

O Atributo [FireAndForget]

[FireAndForget] é um atributo separado e complementar que marca um método async como intencionalmente não aguardado. Serve a dois propósitos:

  1. Suprime avisos VTASKS-TASK002 e VTASKS-TASK013, que disparam quando chamadores não fazem await em um valor de retorno ValkarnTask.
  2. Sinaliza intenção — leitores futuros do código sabem que o descarte é deliberado.

O gerador de código-fonte envolve métodos [FireAndForget] para garantir que quaisquer exceções não observadas sejam publicadas por meio do handler de exceção não observada do ValkarnTasks em vez de serem silenciosamente perdidas.

public partial class SpawnManager : MonoBehaviour
{
void OnPlayerDied()
{
// Sem aviso, a intenção é clara
ShowDeathScreenAsync();
}

[FireAndForget]
async ValkarnTask ShowDeathScreenAsync()
{
await ValkarnTask.Delay(500, __ValkarnTaskLifetimeToken);
_deathScreen.SetActive(true);
}
}

[FireAndForget] e [NoAutoCancel] são independentes e podem ser combinados:

[FireAndForget]
[NoAutoCancel]
async ValkarnTask PlayGlobalMusicAsync(CancellationToken ct = default) { ... }

Analisador: TT010 — Auto-Cancel Ativo

O AutoCancelInfoAnalyzer relata um diagnóstico informacional TT010 em todo método async ValkarnTask em um MonoBehaviour que será cancelado automaticamente (isto é, não tem [NoAutoCancel]). Isso não é um erro ou aviso — é transparência intencional para que desenvolvedores possam ver de relance quais métodos estão vinculados ao ciclo de vida.

Você pode suprimir TT010 por método com [NoAutoCancel], ou desabilitá-lo em todo o projeto via .editorconfig se preferir não vê-lo.

Limitações

A classe deve ser declarada partial. O gerador de código-fonte não pode adicionar membros a uma classe não-partial. Se seu MonoBehaviour não é partial, o gerador o ignora silenciosamente e nenhum vínculo é criado. Os atributos [NoAutoCancel] e [FireAndForget] ainda funcionam como documentação e para os analisadores, mas __ValkarnTaskLifetimeToken não estará disponível.

Classes aninhadas. Se um MonoBehaviour é declarado como uma classe aninhada dentro de outra classe, tanto as declarações de classe externa quanto interna devem ser partial. O Roslyn exige que todos os tipos enclosing sejam partial para que membros gerados compilem corretamente.

Classes base. A propriedade gerada __ValkarnTaskLifetimeToken é protected. Subclasses herdam automaticamente acesso a ela. O gerador é executado para cada classe na hierarquia independentemente; se tanto uma classe base quanto uma classe derivada são MonoBehaviours partial com métodos async, cada uma recebe seu próprio partial gerado, mas compartilham o mesmo token subjacente porque destroyCancellationToken é herdado da base MonoBehaviour.

Herança múltipla. C# não suporta herança múltipla de classes. Um MonoBehaviour só pode ter uma base de classe, portanto não há ambiguidade sobre qual destroyCancellationToken vincular.

ScriptableObjects. O gerador atualmente tem como alvo apenas MonoBehaviour. ScriptableObject não tem um equivalente de destroyCancellationToken na API do Unity, portanto, a geração de auto-cancel não está disponível para eles.