16.Unity进阶UniTask总结

16.总结


16.1 核心要点速览

安装UniTask

  • 指定版本:在链接后附加版本标签,如https://github.com/Cysharp/UniTask.git?path=src/UniTask/Assets/Plugins/UniTask#2.1.0
  • 手动编辑:在Packages/manifest.json添加依赖 "com.cysharp.unitask": "https://github.com/Cysharp/UniTask.git?path=src/UniTask/Assets/Plugins/UniTask"

协程、Task与UniTask对比

类型 代码结构 异常处理 性能开销 取消支持 依赖关系 返回值处理 适用场景
协程 手动管理生命周期,需继承MonoBehaviour 需在协程内部捕获异常,无法向上传播 每次yield return产生GC分配(约24字节) 需自定义取消逻辑,无原生支持 依赖MonoBehaviour 通过回调或共享变量获取 简单异步逻辑,如动画过渡、场景加载
Task 异步代码较简洁,支持async/await 支持全局异常处理器TaskScheduler.UnobservedTaskException 涉及线程切换和上下文捕获,开销较高 原生支持CancellationToken 依赖.NET线程池 通过Task<T>返回值 跨平台异步操作,如文件IO、计算密集型任务
UniTask 异步代码扁平化,更直观 支持全局异常处理器UniTaskScheduler.UnobservedTaskException 零GC分配(基于值类型和对象池) 原生支持CancellationToken,可结合GetCancellationTokenOnDestroy 基于Unity的PlayerLoop,不依赖线程 通过UniTask<T>返回值 Unity内异步操作,如UI事件、资源加载、网络请求

常见API

分类 详情
优势 解决协程依赖MonoBehaviour、无法异常处理、难获取返回值问题

延时操作

API 作用 关键参数 注意
Delay 按时间延时 ignoreTimeScale: true 忽略时间缩放 默认受 Time.timeScale 影响
DelayFrame 按帧数延时 传入帧数 适用于动画/物理帧同步
Yield 让出执行权,等待一帧 PlayerLoopTiming 指定恢复阶段 可切换到线程池后用 Yield() 回主线程
NextFrame 等待下一帧 无参数 Yield() 语义更明确
WaitForEndOfFrame 等待帧结束 Unity 2023.1 前需传 MonoBehaviour 新版本可无参调用

PlayerLoopTiming 常用阶段Update(默认)、PreLateUpdateFixedUpdatePostLateUpdate 等,用于精确控制代码在生命周期的哪个阶段恢复执行。

线程切换SwitchToThreadPool() 切到线程池执行耗时计算,Yield() 返回主线程操作 Unity API。

等待操作

API 作用 典型场景
WaitUntil 等待条件为 true 等待物体位置超过阈值、等待状态标志变化
WaitUntilValueChanged 等待值发生变化 监控 Transform.position、自定义属性变化

两个 API 都支持 CancellationToken 取消,避免无限等待。

条件操作

API 作用 返回值 典型场景
WhenAll 等待所有任务完成 所有结果数组 多个资源加载完毕、多个动画结束
WhenAny 等待任一任务完成 (int winIndex, T result) 用户点击任意按钮、首个网络响应

异步委托

API 作用 执行时机 典型场景
UniTask.Void 即发即弃,无返回值 立即执行 事件回调中启动异步逻辑
UniTask.Defer 延迟创建 UniTask await 时才执行委托 条件性异步操作
UniTask.Lazy 单次执行,多次 await 首次 await 时执行 初始化逻辑、资源预加载
.Preserve() 包装已有 UniTask 支持多次 await 原任务执行后缓存结果 需要重复获取同一异步结果

多次 await 问题:直接对同一 UniTask 实例多次 await 会抛出 InvalidOperationException。解决方案:使用 .Preserve()UniTask.Lazy 封装,两者都保证内部逻辑仅执行一次,后续 await 直接返回缓存结果。

UniTask.Void vs async voidUniTask.Void 可被 UniTaskTracker 追踪,异常能被全局处理器捕获;async void 无法追踪,异常会导致应用崩溃。优先使用 UniTask.Void

UniTask 与协程转换

转换方向 方法 说明
直接 await 协程 await CoroutineTest() 无需 StartCoroutine,直接等待
协程 → UniTask coroutine.ToUniTask(monoBehaviour) 需传入 MonoBehaviour 作为运行载体
UniTask → 协程 uniTask.ToCoroutine() 在 StartCoroutine 中使用

转换后仍保持原有性能特性:协程转 UniTask 后可享受零 GC,UniTask 转协程则回退到协程开销。

异常处理

方式 作用 典型场景
try-catch 捕获并处理异常 需要特定错误处理的逻辑
SuppressCancellationThrow 返回 (bool IsCanceled, T Result) 而非抛异常 取消是正常流程的场景

全局异常处理:通过 UniTaskScheduler.UnobservedTaskException 注册全局处理器,捕获未观察的异常。

资源异步加载

方式 代码复杂度 GC 开销 取消支持
原生协程 轮询 isDone、嵌套回调 每次 yield 有分配 需自行实现
UniTask 直接 await,一行代码 零 GC 原生 CancellationToken

示例await Addressables.LoadAssetAsync<GameObject>("prefab"); 直接返回结果,无需回调。

超时处理

方式 特点 适用场景
CancelAfterSlim 基于 PlayerLoop,无线程开销 单次超时控制
TimeoutController 可复用令牌,减少分配 频繁超时检查

CancelAfter vs CancelAfterSlimCancelAfter 是 C# 原生 API,依赖线程计时器;CancelAfterSlim 是 UniTask 扩展,基于 PlayerLoop 驱动,Unity 中应优先使用后者。

超时本质是取消操作,需配合 SuppressCancellationThrow 判断是超时还是正常完成。

取消操作

CancellationToken 创建方式

方式 说明
new CancellationTokenSource() 手动创建,需调用 Cancel() 触发
GetCancellationTokenOnDestroy() 绑定 GameObject 生命周期,销毁时自动取消

多条件取消:使用 CancellationTokenSource.CreateLinkedTokenSource 组合多个取消源。

var linkedToken = CancellationTokenSource.CreateLinkedTokenSource(
    buttonCancelToken, 
    timeoutToken
);
await SomeAsync(linkedToken.Token);

取消检测:任务内部通过 token.ThrowIfCancellationRequested() 主动检查,或传给支持 CancellationToken 的 UniTask API 自动检测。

UI 监听

异步 LINQ:将事件转为可 await 的异步流,支持 TakeWhereSelectQueue 等操作。

API 作用 典型场景
OnClickAsync() 等待一次点击 单次响应
OnClickAsAsyncEnumerable() 获取点击事件流 持续监听、计数、过滤
OnValueChangedAsAsyncEnumerable() 监听值变化 Toggle、Slider 实时响应
OnEndEditAsAsyncEnumerable() 监听编辑结束 InputField 提交处理

常用异步 LINQ 操作

// 只取前 N 次点击
await btn.OnClickAsAsyncEnumerable().Take(3).ForEachAsync(_ => Debug.Log("点击"));

// 事件排队处理(确保顺序执行)
await btn.OnClickAsAsyncEnumerable().Queue().ForEachAwaitAsync(async _ =>
{
    await ProcessClick();
});

// 单击/双击判断
int index = await UniTask.WhenAny(
    btn.OnClickAsync(),
    UniTask.Delay(TimeSpan.FromSeconds(1))
);
bool isDoubleClick = (index == 0);

事件注册注意事项:避免使用 async void 注册事件,应使用 UniTask.ActionUniTask.UnityAction

碰撞与点击事件

API 作用 返回值
GetAsyncCollisionEnterTrigger() 获取碰撞触发器 可 await 碰撞事件
GetAsyncMouseDownTrigger() 获取鼠标点击触发器 可 await 点击事件
GetAsyncTriggerEnterTrigger() 获取 Trigger 碰撞触发器 可 await Trigger 事件

示例

// 等待碰撞
var collision = await obj.GetAsyncCollisionEnterTrigger().OnCollisionEnterAsync();
Debug.Log($"碰撞对象: {collision.gameObject.name}");

// 持续监听碰撞(异步流)
obj.GetAsyncCollisionEnterTrigger()
    .ForEachAsync(c => Debug.Log($"碰撞: {c.gameObject.name}"), token);

生命周期绑定:使用 obj.GetCancellationTokenOnDestroy() 确保对象销毁时自动取消异步操作。

UniTaskCompletionSource

作用:手动控制异步任务的完成、失败或取消,类似 .NET 的 TaskCompletionSource<T>,但针对 Unity 优化。

方法 作用
TrySetResult(T value) 设置任务成功结果
TrySetCanceled() 设置任务取消
TrySetException(Exception ex) 设置任务异常

典型场景:将回调式 API 包装为可 await 的 UniTask。

public UniTask<string> WrapCallbackToUniTask()
{
    var utcs = new UniTaskCompletionSource<string>();
    
    SomeCallbackAPI.OnComplete += result => utcs.TrySetResult(result);
    SomeCallbackAPI.OnError += error => utcs.TrySetException(error);
    SomeCallbackAPI.OnCancel += () => utcs.TrySetCanceled();
    
    return utcs.Task;
}

特点:同一 UniTaskCompletionSource 可被多处 await;TrySetXxx 方法仅首次调用生效,后续调用被忽略。


16.2 面试题精选

基础题

1. UniTask 相比协程有哪些优势?

参考答案

对比项 协程 UniTask
异常处理 只能在协程内部捕获,无法向上传播 支持全局异常处理器,异常可正常传播
返回值 通过回调或共享变量获取 直接通过 UniTask<T> 返回
性能开销 每次 yield 产生约 24 字节 GC 零 GC,基于值类型和对象池
取消支持 需自定义逻辑 原生支持 CancellationToken
依赖关系 必须依赖 MonoBehaviour 不依赖 MonoBehaviour,基于 PlayerLoop

进阶题

1. UniTask 如何实现取消操作?CancellationToken 的创建方式有哪些?

参考答案

创建方式

  1. new CancellationTokenSource():手动创建,调用 Cancel() 触发取消
  2. GetCancellationTokenOnDestroy():绑定 GameObject 生命周期,销毁时自动取消
  3. CancellationTokenSource.CreateLinkedTokenSource():组合多个取消源

使用示例

// 方式一:手动取消
var cts = new CancellationTokenSource();
cancelButton.onClick.AddListener(() => cts.Cancel());
await SomeAsync(cts.Token);

// 方式二:生命周期绑定
await UniTask.Delay(TimeSpan.FromSeconds(5), 
    cancellationToken: this.GetCancellationTokenOnDestroy());

取消后的行为:任务抛出 OperationCanceledException,可用 SuppressCancellationThrow 获取 (bool IsCanceled, T Result) 元组避免异常。

2. 如何处理 UniTask 的超时?CancelAfter 和 CancelAfterSlim 有什么区别?

参考答案

超时处理方式

var cts = new CancellationTokenSource();
cts.CancelAfterSlim(TimeSpan.FromSeconds(5)); // 设置 5 秒超时

try
{
    await SomeAsync(cts.Token);
}
catch (OperationCanceledException)
{
    if (cts.IsCancellationRequested)
        Debug.Log("超时");
}

CancelAfter vs CancelAfterSlim

  • CancelAfter:C# 原生 API,依赖线程计时器,有线程开销
  • CancelAfterSlim:UniTask 扩展,基于 PlayerLoop 驱动,无线程开销,Unity 中应优先使用

频繁超时场景:使用 TimeoutController 复用令牌,减少分配。

3. UniTask 多次 await 会怎样?如何解决?

参考答案

问题:直接对同一 UniTask 实例多次 await 会抛出 InvalidOperationException。这是 UniTask 的设计约束,与 .NET 的 ValueTask 一致。

解决方案

// 方案一:使用 .Preserve() 包装
var task = SomeAsync().Preserve();
await task; // 第一次
await task; // 第二次,返回缓存结果

// 方案二:使用 UniTask.Lazy
var lazyTask = UniTask.Lazy(async () => await SomeAsync());
await lazyTask;
await lazyTask;

两者都保证内部逻辑仅执行一次,后续 await 直接返回缓存结果。

4. 如何将回调式 API 包装成可 await 的 UniTask?

参考答案

使用 UniTaskCompletionSource<T> 手动控制任务完成:

public UniTask<string> WrapCallbackAPI()
{
    var utcs = new UniTaskCompletionSource<string>();
    
    SomeAPI.OnSuccess += result => utcs.TrySetResult(result);
    SomeAPI.OnError += error => utcs.TrySetException(error);
    SomeAPI.OnCancel += () => utcs.TrySetCanceled();
    
    return utcs.Task;
}

// 使用
string result = await WrapCallbackAPI();

注意TrySetXxx 方法仅首次调用生效,任务状态一旦设置就不可更改。


深度题

1. UniTask 为什么能做到零 GC?其原理是什么?

参考答案

核心原理

  1. **值类型 UniTask<T>**:UniTask 是 struct 而非 class,在栈上分配,避免了堆内存分配
  2. 自定义 AsyncMethodBuilder:通过 C# 7.0 的 task-like 特性,自定义异步状态机的创建和执行逻辑
  3. 对象池复用:内部使用对象池复用状态机对象,避免重复分配
  4. 基于 PlayerLoop:不依赖线程和 SynchronizationContext,完全在 Unity 主线程执行

对比原生 Task

  • Task 每次创建都会在堆上分配对象
  • Task 涉及线程池调度、上下文捕获等开销
  • UniTask 完全基于 Unity 的 PlayerLoop 生命周期,无额外调度开销

适用场景:高频调用的异步操作,如每帧检测、大量并发的资源加载。

2. PlayerLoopTiming 是什么?如何利用它精确控制代码执行时机?

参考答案

PlayerLoopTiming 是 UniTask 提供的枚举,用于指定异步操作在 Unity 生命周期的哪个阶段恢复执行。

常用阶段

阶段 执行时机 典型用途
Update Update 阶段(默认) 通用逻辑
FixedUpdate 物理更新阶段 物理相关操作
PreLateUpdate LateUpdate 之前 动画更新后处理
PostLateUpdate LateUpdate 之后 渲染前最后处理

使用示例

// 在 FixedUpdate 阶段恢复
await UniTask.Yield(PlayerLoopTiming.FixedUpdate);

// 等待帧结束再执行
await UniTask.WaitForEndOfFrame();

实际应用:比如需要在所有动画更新完成后再处理相机跟随,可以使用 PreLateUpdate 确保执行顺序。

3. 异步 LINQ 的 Queue() 方法有什么作用?在什么场景下使用?

参考答案

作用Queue() 确保事件按顺序依次处理,即使前一个事件的处理还未完成,新事件也会排队等待。

对比默认行为

// 默认:并发处理,可能同时有多个处理中
await btn.OnClickAsAsyncEnumerable().ForEachAwaitAsync(async _ =>
{
    await UniTask.Delay(TimeSpan.FromSeconds(3));
    // 快速点击 3 次 → 3 个处理同时进行
});

// 使用 Queue:串行处理,确保顺序
await btn.OnClickAsAsyncEnumerable().Queue().ForEachAwaitAsync(async _ =>
{
    await UniTask.Delay(TimeSpan.FromSeconds(3));
    // 快速点击 3 次 → 依次处理,总耗时 9 秒
});

适用场景

  • 网络请求队列:确保请求按顺序发送
  • 存档操作:避免并发写入冲突
  • UI 动画队列:确保动画按顺序播放


转载请注明来源,欢迎对文章中的引用来源进行考证,欢迎指出任何有错误或不够清晰的表达。可以在下面评论区评论,也可以邮件至 785293209@qq.com

×

喜欢就点赞,疼爱就打赏