跳到主要内容

自动取消生命周期绑定

Unity 异步代码中最常见的 bug 之一,是从 MonoBehaviour 启动一个任务,然后在对象被销毁时忘记取消它。任务继续运行,尝试访问已销毁的 Unity 对象,并抛出 MissingReferenceException——或者更糟,静默地破坏状态。

ValkarnTasks 通过 Roslyn 源码生成器消除了这类 bug,该生成器会自动将异步方法与对象的销毁生命周期绑定。

问题所在

在没有任何基础设施的情况下,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();
}
}
}

随着异步方法数量增加,样板代码也随之增多。跳过 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,无需释放资源。生成的 token 会在 Unity 销毁对象时自动取消。

源码生成器的工作原理

生成器(LifecycleBindingGenerator)是一个在编译时运行的增量式 Roslyn 生成器。其流程包含三个阶段。

阶段 1——语法过滤器

生成器检查你项目中的每个类声明。满足以下条件的类被视为候选:

  • 使用 partial 关键字声明。
  • 有基类列表(即继承自某个类)。

此过滤器纯粹基于语法,速度很快。此阶段不运行语义分析。

阶段 2——语义变换

对于每个候选类,生成器使用 Roslyn 语义模型来:

  1. 确认该类派生自 UnityEngine.MonoBehaviour(遍历完整的继承链)。
  2. 枚举所有成员。对于每个成员,检查它是否:
    • async 方法。
    • 返回 UnaPartidaMas.Valkarn.Tasks.ValkarnTask(或 ValkarnTask<T>)。
    • 带有 [NoAutoCancel]
  3. 如果未找到符合条件的方法,则静默跳过该类——不生成任何代码。
  4. 仅处理第一个 partial 声明。如果一个类分散在多个文件中,生成器只为第一个声明生成代码,以避免重复成员。

阶段 3——代码生成

对于通过阶段 1 和 2 的每个类,生成器写入一个新的 .g.cs 文件。对于命名空间为 Game.Enemies 中名为 EnemyAI 的类,生成的代码如下:

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

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

/// <summary>
/// 当此 MonoBehaviour 被销毁时触发的取消 token。
/// 由 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 时分配。
  • 链接到 Unity 内置的 destroyCancellationTokenMonoBehaviour.destroyCancellationToken,自 Unity 2022 起可用)。当 Unity 销毁对象时,destroyCancellationToken 触发,级联到 __valkarnTaskLifetimeCts,进而取消 __ValkarnTaskLifetimeToken
  • 字段和属性都被标记为 EditorBrowsable(Never),使它们不出现在类的使用者的 IntelliSense 中。
  • 属性为 protected,因此子类也可以使用同一个 token。

[NoAutoCancel] 特性

当你有意希望某个异步 ValkarnTask 方法在对象生命周期结束后继续运行时,对其应用 [NoAutoCancel]。常见场景:

  • 将数据保存到磁盘且必须完成的方法,即使触发它的对象已被销毁。
  • 管理属于不同系统的共享资源的方法。
  • 有意在启动它的对象生命周期之外继续的过渡效果。
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——没有 CancellationToken 参数的 [NoAutoCancel]

配套分析器(NoAutoCancelAnalyzer)在你将 [NoAutoCancel] 应用于没有 CancellationToken 参数的方法时报告诊断 TT014。如果没有 token 参数,该方法没有任何方式来观察取消——这意味着 [NoAutoCancel] 存在但没有实际效果。这通常意味着你忘记了添加 token:

// 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] 是一个独立的、互补的特性,用于将异步方法标记为有意不被等待。它有两个用途:

  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——自动取消已激活

AutoCancelInfoAnalyzerMonoBehaviour 中每个将被自动取消的异步 ValkarnTask 方法(即没有 [NoAutoCancel] 的方法)报告信息性诊断 TT010。这不是错误或警告——它是有意提供的透明度,让开发者能一眼看出哪些方法绑定了生命周期。

你可以用 [NoAutoCancel] 在每个方法上抑制 TT010,或者如果你不想看到它,可以通过 .editorconfig 在项目范围内禁用它。

限制

类必须声明为 partial 源码生成器无法向非 partial 类添加成员。如果你的 MonoBehaviour 不是 partial 的,生成器会静默跳过它,不创建任何绑定。[NoAutoCancel][FireAndForget] 特性仍然作为文档和分析器标注有效,但 __ValkarnTaskLifetimeToken 将不可用。

嵌套类。 如果一个 MonoBehaviour 被声明为另一个类中的嵌套类,外部类和内部类的声明都必须是 partial 的。Roslyn 要求所有封闭类型都是 partial 的,生成的成员才能正确编译。

基类。 生成的 __ValkarnTaskLifetimeToken 属性是 protected 的。子类自动继承对它的访问权限。生成器对继承层次中的每个类独立运行;如果基类和派生类都是带有异步方法的 partial MonoBehaviour,每个都会获得自己的生成 partial,但它们共享同一个底层 token,因为 destroyCancellationToken 是从 MonoBehaviour 基类继承的。

多重继承。 C# 不支持类的多重继承。MonoBehaviour 只能有一个类基,因此不存在关于链接哪个 destroyCancellationToken 的歧义。

ScriptableObjects。 生成器当前仅针对 MonoBehaviourScriptableObject 在 Unity API 中没有等效的 destroyCancellationToken,因此自动取消生成不适用于它们。