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(默认)、PreLateUpdate、FixedUpdate、PostLateUpdate 等,用于精确控制代码在生命周期的哪个阶段恢复执行。
线程切换: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 void:UniTask.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 CancelAfterSlim:CancelAfter 是 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 的异步流,支持 Take、Where、Select、Queue 等操作。
| 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.Action 或 UniTask.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 的创建方式有哪些?
参考答案:
创建方式:
new CancellationTokenSource():手动创建,调用Cancel()触发取消GetCancellationTokenOnDestroy():绑定 GameObject 生命周期,销毁时自动取消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?其原理是什么?
参考答案:
核心原理:
- **值类型
UniTask<T>**:UniTask 是 struct 而非 class,在栈上分配,避免了堆内存分配- 自定义 AsyncMethodBuilder:通过 C# 7.0 的 task-like 特性,自定义异步状态机的创建和执行逻辑
- 对象池复用:内部使用对象池复用状态机对象,避免重复分配
- 基于 PlayerLoop:不依赖线程和 SynchronizationContext,完全在 Unity 主线程执行
对比原生 Task:
- Task 每次创建都会在堆上分配对象
- Task 涉及线程池调度、上下文捕获等开销
- UniTask 完全基于 Unity 的 PlayerLoop 生命周期,无额外调度开销
适用场景:高频调用的异步操作,如每帧检测、大量并发的资源加载。
2. PlayerLoopTiming 是什么?如何利用它精确控制代码执行时机?
参考答案:
PlayerLoopTiming 是 UniTask 提供的枚举,用于指定异步操作在 Unity 生命周期的哪个阶段恢复执行。
常用阶段:
阶段 执行时机 典型用途 UpdateUpdate 阶段(默认) 通用逻辑 FixedUpdate物理更新阶段 物理相关操作 PreLateUpdateLateUpdate 之前 动画更新后处理 PostLateUpdateLateUpdate 之后 渲染前最后处理 使用示例:
// 在 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