UniTask 学习文档
基于UniTask 源码整理,版本适配 Unity 2019.3+
目录
- 什么是 UniTask
- 核心类型与基本用法
- 时间与帧等待
- PlayerLoopTiming — Unity 生命周期时序
- 取消机制 (CancellationToken)
- Unity 对象操作
- Unity 生命周期异步触发器 (AsyncTrigger)
- 条件等待
- 并发控制 WhenAll / WhenAny
- 线程切换
- UniTaskCompletionSource 手动控制任务
- 错误处理与 Forget
- UniTaskVoid — 即发即弃
- TimeoutController 超时控制
- AsyncLazy 懒加载异步
- UniTask Tracker 调试工具
- 最佳实践与常见陷阱
1. 什么是 UniTask
UniTask 是由 Cysharp 开源的、专为 Unity 设计的高性能异步库,命名空间为 Cysharp.Threading.Tasks。
要理解 UniTask 为什么有存在的必要,得先看清楚为什么 .NET 原生的 Task 在 Unity 里不够好——它不是不能用,而是从设计目标到运行机制都和 Unity 错位。
1.1 为什么不能直接用 System.Threading.Tasks.Task
Task 是为通用 .NET 服务端、桌面端设计的,它的设计假设是多线程 ThreadPool 调度 + GC 不敏感。这两个假设在 Unity 里都站不住脚。
(1) Task 是引用类型,每次都堆分配
每写一个 async Task 方法、每 new Task(...) 一次、每 await 一个尚未完成的 Task,背后都至少分配这几个对象:
Task / Task<T> 对象本身(引用类型)
- 异步状态机被装箱成的
IAsyncStateMachine 引用类型实例
MoveNextRunner 委托
- 续体回调(continuation)
- 若使用
CancellationToken,还有 CancellationTokenRegistration
在 Unity 这种主循环每帧 16ms(60帧) 必须搞定一切的环境里,高频路径上跑 Task(比如每个子弹、每个 AI 决策都 await 一下)会迅速把 GC 曲线拉成锯齿,触发不定时的卡顿。这是 Task 在 Unity 里最致命的问题。
UniTask 用 struct 实现 UniTask 类型,配合自定义的 AsyncUniTaskMethodBuilder 和对象池,同步完成路径零分配,异步路径上也远低于 Task。
(2) Task 的调度器和 Unity 主循环没关系
Task 默认通过 ThreadPool 调度续体,或者通过捕获的 SynchronizationContext 切回原线程。Unity 虽然提供了 UnitySynchronizationContext 让续体能回到主线程,但这个机制有几个问题:
- 时序不可控:续体只能粗粒度地”回到主线程下一个能跑的时机”,你说不清是 Update、LateUpdate 还是某个具体的 PlayerLoop 节点。
- 会走一遍 Post 队列:哪怕续体逻辑上是同步可执行的,也会被
Post 进队列绕一圈,引入额外开销和帧延迟。
- WebGL 上行为微妙:WebGL 是单线程的,
ConfigureAwait(false) 之类的玩法跟桌面端语义不一致,容易踩坑。
UniTask 把异步续体直接挂在 Unity PlayerLoop 的指定节点上跑,时序精确到 PreUpdate、Update、PreLateUpdate、LateUpdate、PostLateUpdate 等等,整个过程不切线程、不走 SyncContext。
(3) Task 不认识 Unity 的异步对象
Unity 自己的异步 API(AsyncOperation、UnityWebRequest、Resources.LoadAsync、SceneManager.LoadSceneAsync、Addressables 等)返回的都不是 Task。想 await 它们,要么自己写 TaskCompletionSource 包一层(又是堆分配),要么用社区的扩展方法。
UniTask 对所有这些 Unity 异步 API 提供了原生扩展,可以直接 await someAsyncOperation,无需任何包装。
(4) Task 的生命周期跟 Unity 对象毫无关联
经典事故:
1 2 3 4 5
| async Task LoadAndShowAsync() { await Task.Delay(2000); text.text = "done"; }
|
Task 不知道 Unity 里”这个 GameObject 已经被销毁”是什么意思。你得自己手写 CancellationToken 并且在 OnDestroy 里 cancel。
UniTask 提供了 this.GetCancellationTokenOnDestroy() / GetCancellationTokenOnDisable() 等扩展,一行代码就把异步任务的生命周期绑到 MonoBehaviour 上,对象销毁时所有相关 await 自动取消。
(5) Task 的取消是”协作式”但不友好
Task.Delay(1000, token) 取消时会抛 TaskCanceledException,你必须 try/catch 它——否则在 async void(这本身又是另一个坑)里直接炸主线程。
UniTask 提供更友好的取消语义:可以选择抛 OperationCanceledException,也可以选择返回一个标记取消的 UniTask(通过 SuppressCancellationThrow()),让上层代码不必到处包 try/catch。
(6) 调试和观测能力弱
Task 在 Unity 里基本是”黑盒”:当前有多少个未完成的 Task、它们卡在哪一行、由哪个对象持有——Profiler 里看不出来,调试只能靠日志。
UniTask 自带 Tracker 窗口(Window → UniTask Tracker),可以实时看到所有活跃的 UniTask、栈位置、发起者,泄漏几乎无所遁形。
1.2 Task vs UniTask 对比总览
| 维度 |
System.Threading.Tasks.Task |
UniTask |
| 类型 |
class(引用类型,堆分配) |
struct(值类型,栈分配) |
| 内存分配 |
每次 await 都产生多个堆对象 |
同步完成零分配;异步路径用对象池 |
| 调度器 |
ThreadPool / SynchronizationContext |
直接挂载 Unity PlayerLoop,可精确到具体时序节点 |
| 线程模型 |
默认可能跑在线程池 |
默认全程主线程;显式 SwitchToThreadPool 才换线程 |
| Unity 异步 API |
无法直接 await,需要手写包装 |
原生支持 AsyncOperation、UnityWebRequest、Addressables 等 |
| 生命周期联动 |
完全独立,对象销毁也不会自动取消 |
GetCancellationTokenOnDestroy() 一行搞定 |
| 取消语义 |
强制抛 TaskCanceledException |
可抛异常也可静默返回 |
| Fire-and-forget |
只能 async void(吞异常) |
UniTaskVoid + .Forget(),显式且安全 |
| 调试工具 |
基本没有 |
Tracker 窗口、栈追踪 |
| WebGL 兼容性 |
SyncContext 行为微妙 |
专门适配,行为一致 |
| 设计目标 |
通用 .NET 异步(服务端为主) |
专为 Unity 主循环设计 |
1.3 那 Task 是不是就完全不用了?
不是。在以下场景仍然推荐用 Task:
- 调用第三方库的 API,对方返回
Task(这种情况可以 await task.AsUniTask() 转成 UniTask 再继续)。
- 真正需要跨线程并行的 CPU 密集计算(虽然 UniTask 也支持,但
Task + Parallel 在这块生态更完整)。
- 编写跨平台 .NET 库,库本身不依赖 Unity。
经验法则:写 Unity 游戏逻辑层(GamePlay、UI、剧情、加载流程)→ UniTask;写跨平台底层 / 调用第三方 .NET 库 → Task,必要时用 AsUniTask() 桥接。
2. UniTask的核心类型与基本用法
2.1 UniTask 与 UniTask<T>
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
| public async UniTask DoSomethingAsync() { await UniTask.Delay(1000); Debug.Log("完成"); }
public async UniTask<int> LoadDataAsync() { await UniTask.Delay(500); return 42; }
private async UniTask StartAsync() { await DoSomethingAsync(); int result = await LoadDataAsync(); Debug.Log(result); }
|
2.2 快速创建已完成的任务
1 2 3 4 5 6 7 8 9
| UniTask completed = UniTask.CompletedTask; UniTask<int> withValue = UniTask.FromResult(100);
UniTask canceled = UniTask.FromCanceled(cancellationToken);
UniTask faulted = UniTask.FromException(new Exception("出错了"));
|
2.3 Preserve — 允许多次 await
UniTask 默认只能被 await 一次(类似消费一次的流)。若需多次 await 同一个任务,先调用 Preserve():
1 2 3 4 5
| var task = SomeHeavyTask().Preserve();
await task; await task;
|
3. 时间与帧等待
3.1 DelayType — 时间类型选择
1 2 3 4 5 6
| public enum DelayType { DeltaTime, UnscaledDeltaTime, Realtime }
|
3.2 Delay — 按时间等待
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| await UniTask.Delay(2000); await UniTask.Delay(TimeSpan.FromSeconds(2));
await UniTask.Delay(2000, ignoreTimeScale: true);
await UniTask.Delay( TimeSpan.FromSeconds(2), delayType: DelayType.Realtime, delayTiming: PlayerLoopTiming.Update, cancellationToken: this.GetCancellationTokenOnDestroy() );
await UniTask.WaitForSeconds(2.5f); await UniTask.WaitForSeconds(2.5f, ignoreTimeScale: true);
|
3.3 DelayFrame — 按帧等待
1 2 3 4 5
| await UniTask.DelayFrame(10);
await UniTask.DelayFrame(10, PlayerLoopTiming.FixedUpdate);
|
3.4 Yield — 让出当前帧
1 2 3 4 5 6 7 8
| await UniTask.Yield(); await UniTask.Yield(PlayerLoopTiming.PreLateUpdate);
await UniTask.NextFrame(); await UniTask.NextFrame(PlayerLoopTiming.Update);
|
3.5 WaitForFixedUpdate / WaitForEndOfFrame
1 2 3 4 5 6 7 8
| await UniTask.WaitForFixedUpdate();
await UniTask.WaitForEndOfFrame(this);
|
内部实现说明:WaitForFixedUpdate 等价于 UniTask.Yield(PlayerLoopTiming.LastFixedUpdate)。WaitForEndOfFrame 内部通过 StartCoroutine + WaitForEndOfFrame 协程实现,因此需要传入 MonoBehaviour。
4. PlayerLoopTiming — Unity 生命周期时序
UniTask 将续体(continuation)注入 Unity 的 PlayerLoop,精确控制代码在哪个阶段恢复执行。
1 2 3 4 5 6 7 8
| Initialization → LastInitialization EarlyUpdate → LastEarlyUpdate FixedUpdate → LastFixedUpdate PreUpdate → LastPreUpdate Update ★默认 → LastUpdate PreLateUpdate → LastPreLateUpdate PostLateUpdate → LastPostLateUpdate TimeUpdate (2020.2+) → LastTimeUpdate
|
对应 Unity 生命周期:
1 2 3 4 5 6 7
| Initialization → 场景初始化阶段 EarlyUpdate → Input 处理前 FixedUpdate → 物理更新(FixedUpdate) PreUpdate → Update 前 Update → MonoBehaviour.Update() PreLateUpdate → LateUpdate 前 PostLateUpdate → LateUpdate 后(相机、渲染)
|
实际使用建议:
1 2 3 4 5 6 7 8
| await UniTask.Yield();
await UniTask.Yield(PlayerLoopTiming.FixedUpdate);
await UniTask.Yield(PlayerLoopTiming.PostLateUpdate);
|
5. 取消机制 (CancellationToken)
5.1 GetCancellationTokenOnDestroy — 对象销毁自动取消
这是 UniTask 与 Unity 对象生命周期结合最关键的 API:
1 2 3 4 5 6 7 8 9 10 11 12
| public class MyBehaviour : MonoBehaviour { async UniTask Start() { var token = this.GetCancellationTokenOnDestroy();
await UniTask.Delay(5000, cancellationToken: token); Debug.Log("5秒后执行"); } }
|
内部原理:GetCancellationTokenOnDestroy() 会自动向 GameObject 挂载一个 AsyncDestroyTrigger 组件。该组件在 OnDestroy() 时调用 CancellationTokenSource.Cancel(),从而取消所有持有该 token 的任务。
5.2 手动创建 CancellationTokenSource
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32
| public class MyBehaviour : MonoBehaviour { CancellationTokenSource _cts;
void Start() { _cts = new CancellationTokenSource(); DoLoopAsync(_cts.Token).Forget(); }
public void StopTask() { _cts.Cancel(); _cts.Dispose(); }
async UniTaskVoid DoLoopAsync(CancellationToken token) { while (!token.IsCancellationRequested) { Debug.Log("循环中..."); await UniTask.Delay(1000, cancellationToken: token); } }
void OnDestroy() { _cts?.Cancel(); _cts?.Dispose(); } }
|
5.3 链接多个 Token
1 2 3 4 5 6
| var cts = CancellationTokenSource.CreateLinkedTokenSource( this.GetCancellationTokenOnDestroy(), manualCts.Token ); var linkedToken = cts.Token;
|
5.4 SuppressCancellationThrow — 不抛出异常
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| try { await UniTask.Delay(1000, cancellationToken: token); } catch (OperationCanceledException) { Debug.Log("已取消"); }
bool isCanceled = await UniTask.Delay(1000, cancellationToken: token) .SuppressCancellationThrow(); if (isCanceled) { Debug.Log("已取消,不抛异常"); return; }
|
5.5 WaitUntilCanceled — 等待直到 Token 被取消
1 2 3 4 5 6
| await token.WaitUntilCanceled(); Debug.Log("Token 已被取消,继续执行");
await UniTask.WaitUntilCanceled(token);
|
默认情况下,取消检测发生在 PlayerLoop 的下一次循环(即下一帧或下一个时机)。若需要立即响应取消,使用 cancelImmediately: true:
1 2
| await UniTask.Delay(5000, cancellationToken: token, cancelImmediately: true);
|
注意:cancelImmediately: true 会向 CancellationToken 注册回调,有轻微额外开销,仅在需要即时响应时使用。
6. Unity 对象操作
6.1 AsyncOperation 异步加载
1 2 3 4 5 6 7 8 9 10 11 12
| var op = UnityEngine.SceneManagement.SceneManager.LoadSceneAsync("GameScene"); await op;
await op.ToUniTask( progress: Progress.Create<float>(p => Debug.Log($"加载进度: {p:P0}")), cancellationToken: this.GetCancellationTokenOnDestroy() );
await op.WithCancellation(this.GetCancellationTokenOnDestroy());
|
6.2 资源加载 (Resources.LoadAsync)
1 2 3 4 5 6 7 8 9 10 11
| var resourceRequest = Resources.LoadAsync<Texture2D>("MyTexture"); await resourceRequest; var texture = resourceRequest.asset as Texture2D;
var req = Resources.LoadAsync<AudioClip>("BGM"); var clip = await req.ToUniTask( progress: Progress.Create<float>(p => progressBar.value = p), cancellationToken: token ) as AudioClip;
|
6.3 UnityWebRequest 网络请求
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
| using UnityEngine.Networking;
var request = UnityWebRequest.Get("https://api.example.com/data"); await request.SendWebRequest().WithCancellation(token);
if (request.result == UnityWebRequest.Result.Success) { Debug.Log(request.downloadHandler.text); }
try { var req = UnityWebRequest.Get("https://api.example.com/data"); await req.SendWebRequest().WithCancellation(token); Debug.Log(req.downloadHandler.text); } catch (UnityWebRequestException e) { Debug.LogError($"请求失败: {e.Error}"); }
|
6.4 AssetBundle 加载
1 2 3 4 5 6 7 8 9 10 11
| var bundleReq = AssetBundle.LoadFromFileAsync("path/to/bundle"); var bundle = await bundleReq;
var assetReq = bundle.LoadAssetAsync<GameObject>("Prefab"); var prefab = await assetReq as GameObject;
var bundle2 = await AssetBundle.LoadFromFileAsync("path/to/bundle") .ToUniTask(Progress.Create<float>(p => Debug.Log($"{p:P}")));
|
6.5 AsyncInstantiate(Unity 2022.2+)
1 2
| var instance = await Object.InstantiateAsync(prefab, parent);
|
6.6 MonoBehaviour.StartAsyncCoroutine
1 2 3 4 5 6
| await monoBehaviour.StartAsyncCoroutine(async (token) => { await UniTask.Delay(1000, cancellationToken: token); });
|
7. Unity 生命周期异步触发器 (AsyncTrigger)
UniTask 提供了一套 AsyncTrigger 系统,将 MonoBehaviour 的各类消息事件(FixedUpdate、OnCollisionEnter 等)转换为可 await 的异步流。
7.1 获取 Trigger
所有 Trigger 都通过扩展方法从 GameObject 或 Component 获取:
1 2 3 4
| var fixedUpdateTrigger = this.GetAsyncFixedUpdateTrigger(); var updateTrigger = this.GetAsyncUpdateTrigger(); var lateUpdateTrigger = this.GetAsyncLateUpdateTrigger();
|
7.2 一次性等待(await 单次触发)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| async UniTask Start() { var token = this.GetCancellationTokenOnDestroy();
await this.GetAsyncFixedUpdateTrigger().FixedUpdateAsync(token); Debug.Log("FixedUpdate 触发了");
var collider = await this.GetAsyncOnTriggerEnterTrigger().OnTriggerEnterAsync(token); Debug.Log($"碰到了: {collider.name}");
var collision = await this.GetAsyncOnCollisionEnterTrigger().OnCollisionEnterAsync(token); Debug.Log($"碰撞: {collision.gameObject.name}"); }
|
7.3 持续监听(async foreach 异步流)
1 2 3 4 5 6 7 8 9 10 11 12
| async UniTask MonitorUpdates() { var token = this.GetCancellationTokenOnDestroy();
await foreach (var _ in this.GetAsyncLateUpdateTrigger() .WithCancellation(token)) { UpdateCamera(); } }
|
7.4 常用 Trigger 列表
| Trigger 获取方法 |
对应 Unity 消息 |
GetAsyncUpdateTrigger() |
Update |
GetAsyncFixedUpdateTrigger() |
FixedUpdate |
GetAsyncLateUpdateTrigger() |
LateUpdate |
GetAsyncOnTriggerEnterTrigger() |
OnTriggerEnter |
GetAsyncOnTriggerExitTrigger() |
OnTriggerExit |
GetAsyncOnCollisionEnterTrigger() |
OnCollisionEnter |
GetAsyncOnCollisionExitTrigger() |
OnCollisionExit |
GetAsyncOnMouseDownTrigger() |
OnMouseDown |
GetAsyncOnEnableTrigger() |
OnEnable |
GetAsyncOnDisableTrigger() |
OnDisable |
GetAsyncDestroyTrigger() |
OnDestroy |
GetAsyncAnimatorIKTrigger() |
OnAnimatorIK |
GetAsyncOnBecameVisibleTrigger() |
OnBecameVisible |
GetAsyncOnBecameInvisibleTrigger() |
OnBecameInvisible |
7.5 OnDestroyAsync — 等待对象销毁
1 2 3 4 5 6 7 8
| await enemy.GetAsyncDestroyTrigger().OnDestroyAsync(); Debug.Log("敌人已被销毁");
var token = this.GetAsyncDestroyTrigger().CancellationToken;
var token2 = this.GetCancellationTokenOnDestroy();
|
8. 条件等待
8.1 WaitUntil — 等待条件为 true
1 2 3 4 5 6 7 8 9 10 11
| bool isReady = false;
await UniTask.WaitUntil(() => isReady);
await UniTask.WaitUntil(() => isReady, cancellationToken: this.GetCancellationTokenOnDestroy());
await UniTask.WaitUntil(() => isReady, PlayerLoopTiming.FixedUpdate);
|
8.2 WaitWhile — 等待条件为 false
1 2
| await UniTask.WaitWhile(() => isLoading);
|
8.3 WaitUntilValueChanged — 等待值变化
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| var newPos = await UniTask.WaitUntilValueChanged( transform, t => t.position, cancellationToken: token ); Debug.Log($"位置变化为: {newPos}");
var newHp = await UniTask.WaitUntilValueChanged( enemy, e => e.HP, cancellationToken: token );
|
注意:对于 Unity 对象(继承自 UnityEngine.Object),WaitUntilValueChanged 内部会额外处理对象被销毁的情况(避免访问已销毁对象)。
8.4 WaitUntilCanceled — 等待取消
1 2 3
| await UniTask.WaitUntilCanceled(token);
|
9. 并发控制 WhenAll / WhenAny
9.1 WhenAll — 等待所有任务完成
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| await UniTask.WhenAll( LoadSceneAsync(), LoadAudioAsync(), LoadConfigAsync() );
int[] results = await UniTask.WhenAll( FetchScoreAsync(userId1), FetchScoreAsync(userId2), FetchScoreAsync(userId3) );
var (texture, clip, config) = await UniTask.WhenAll( LoadTextureAsync(), LoadAudioAsync(), LoadConfigAsync() );
|
9.2 WhenAny — 等待任意一个任务完成
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| int winIndex = await UniTask.WhenAny( Task1Async(), Task2Async(), Task3Async() ); Debug.Log($"第 {winIndex} 个任务率先完成");
var (hasResult, result) = await UniTask.WhenAny( LoadDataAsync(), UniTask.Delay(5000).AsAsyncUnitUniTask() ); if (!hasResult) { Debug.Log("加载超时"); }
|
10. 线程切换
UniTask 提供了简洁的线程切换 API,用于在 Unity 主线程和后台线程之间切换。
10.1 SwitchToThreadPool — 切换到后台线程
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| async UniTask HeavyComputeAsync() { Debug.Log("开始(主线程)");
await UniTask.SwitchToThreadPool();
var result = DoHeavyCompute();
await UniTask.SwitchToMainThread();
Debug.Log($"计算结果: {result}"); someText.text = result.ToString(); }
|
10.2 RunOnThreadPool — 封装后台任务
1 2 3 4 5 6 7 8 9 10
| await UniTask.RunOnThreadPool(() => { Thread.Sleep(2000); });
await UniTask.RunOnThreadPool(() => HeavyWork(), configureAwait: false);
|
10.3 ReturnToMainThread — using 作用域自动还原
1 2 3 4 5 6 7 8 9 10 11 12
| async UniTask ProcessAsync() { await using (UniTask.ReturnToMainThread()) { await UniTask.SwitchToThreadPool(); var data = ProcessData(); } UpdateUI(data); }
|
11. UniTaskCompletionSource 手动控制任务
类似 TaskCompletionSource<T>,用于将回调式 API 转换为可 await 的任务。
11.1 基本用法
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| var tcs = new UniTaskCompletionSource();
tcs.TrySetResult();
tcs.TrySetException(new Exception("失败"));
tcs.TrySetCanceled();
await tcs.Task;
var tcs2 = new UniTaskCompletionSource<string>(); tcs2.TrySetResult("Hello"); string result = await tcs2.Task;
|
11.2 封装回调 API
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
| public static UniTask<PointerEventData> WaitForClickAsync(Button button, CancellationToken token) { var tcs = new UniTaskCompletionSource<PointerEventData>();
button.onClick.AddListener(() => { tcs.TrySetResult(null); });
token.RegisterWithoutCaptureExecutionContext(() => { tcs.TrySetCanceled(token); });
return tcs.Task; }
await WaitForClickAsync(myButton, this.GetCancellationTokenOnDestroy()); Debug.Log("按钮被点击");
|
11.3 AutoResetUniTaskCompletionSource
适用于需要重复使用(Reset)的场景,内部会自动入池:
1 2 3 4 5
| var source = AutoResetUniTaskCompletionSource.Create(); source.TrySetResult(); await source.Task;
|
12. 错误处理与 Forget
12.1 标准 try-catch
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| async UniTask LoadAsync(CancellationToken token) { try { await UniTask.Delay(1000, cancellationToken: token); await LoadDataAsync(); } catch (OperationCanceledException) { Debug.Log("加载被取消"); } catch (Exception e) { Debug.LogError($"加载失败: {e}"); } }
|
12.2 Forget — 即发即弃且屏蔽警告
直接调用返回 UniTask 的方法而不 await,编译器会警告。使用 .Forget() 明确表示这是有意的:
1 2 3 4 5 6 7 8
| DoSomethingAsync();
DoSomethingAsync().Forget();
DoSomethingAsync().Forget(ex => Debug.LogException(ex));
|
12.3 UniTask.Never — 永不完成的任务
1 2 3
| await UniTask.Never(token);
|
13. UniTaskVoid — 即发即弃
在 C# 里,被 async 修饰的方法签名只能返回 Task / Task<T> / void(或自定义 awaitable,比如 UniTask / UniTaskVoid)。当你不打算 await 这个方法——即”即发即弃 (fire-and-forget)”——就需要在 async void 和 async UniTaskVoid 之间做选择。两者看起来很像,差异很大。
13.1 async void 在 Unity 里到底有什么问题
async void 是 C# 语言为”事件处理器”开的口子(典型场景是 WinForms 的 Button_Click),它不是为普通业务代码设计的。具体毛病有四类:
(1) 异常会逃出调用栈,没人能 catch
async Task 抛出的异常会被打包到返回的 Task 上,await 方或 Task.Exception 可以拿到;
async void 没有 Task 对象,异常会被原样抛到当前的 SynchronizationContext——在 Unity 里通常表现为:要么 Console 里冒一条难以定位的红色 Error,要么直接干扰主循环。
1 2 3 4 5 6 7 8
| async void Bad() { await UniTask.Delay(100); throw new Exception("boom"); }
try { Bad(); } catch { }
|
第一次 await 之后,控制权已经还给调用方了,try 块早已退出,异常发生时根本不在这个栈上。
(2) 堆分配多
async void / async Task 默认使用 .NET 的 AsyncVoidMethodBuilder / AsyncTaskMethodBuilder,每次调用都会分配状态机盒子、Task 对象、MoveNextRunner 等。按钮、Update 之类的高频路径里跑一堆 async void,GC 压力很显眼。
UniTaskVoid 走的是 UniTask 自家的 AsyncUniTaskVoidMethodBuilder,同步完成路径零分配,异步路径上也通过对象池显著少于 Task。
(3) 无法被等待、无法被组合
async void 返回 void,调用方什么句柄都拿不到:
- 不能再补一个
await 等它完成;
- 不能塞进
WhenAll / WhenAny;
- 不能链式接
ContinueWith。
UniTaskVoid 也不是为 await 设计的(它的存在意义就是即发即弃),但 UniTask 强制要求你在调用处加 .Forget(),让”我知道我不等它”这件事变成显式的代码意图,而不是一个看上去像普通函数调用的隐式语义。
(4) 它无视 UniTask 的 PlayerLoop 调度
UniTask 的核心优势是把异步续体(continuation)直接挂到 Unity 的 PlayerLoop 上跑,时序可控、不会切线程。但 async void 用的是默认 builder,续体走的是 SynchronizationContext.Post——在 Unity 里这条路径会绕一圈 UnitySynchronizationContext,时序不如 UniTask 精确,且 WebGL 上行为偶有差异。
13.2 UniTaskVoid 怎么解决这些问题
把上面四点逐一对回去:
| 维度 |
async void |
async UniTaskVoid |
| 异常去向 |
抛到 SynchronizationContext,外层 catch 不住 |
由 UniTask 的全局未处理异常钩子接管 (UniTaskScheduler.UnobservedTaskException) |
| 堆分配 |
每次调用都有 |
同步完成路径零分配;异步路径用对象池 |
| 调用语义 |
看起来像普通方法调用,意图不明确 |
必须 .Forget(),意图显式 |
| 调度 |
走 SynchronizationContext |
走 UniTask 的 PlayerLoop |
| 可被 Tracker 观测 |
否 |
是(Window → UniTask Tracker 能看到) |
要点:UniTaskVoid 不是”能被等待的 void”,它仍然是即发即弃,只是把”丢出去之后会怎样”这件事做得更安全、更可观测了。
13.3 标准用法
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| async UniTaskVoid FireAndForgetAsync() { await UniTask.Delay(1000); Debug.Log("执行完毕(没有人等我)"); }
FireAndForgetAsync().Forget();
FireAndForgetAsync().Forget(ex => Debug.LogError($"failed: {ex}"));
UniTask.Void(async () => { await UniTask.Delay(500); Debug.Log("匿名即发即弃"); });
|
13.4 该用 UniTask 还是 UniTaskVoid:决策表
| 你的场景 |
推荐返回类型 |
方法会被 await,调用方关心完成时机 |
UniTask / UniTask<T> |
| 调用方完全不关心结果、不需要等待(按钮回调、定时触发、日志上报) |
UniTaskVoid |
实现接口/重写基类,签名要求 void(如 IPointerClickHandler.OnPointerClick) |
方法体里 UniTask.Void(async () => { ... }) |
| 需要把异常打包给上层 |
绝对用 UniTask,绝不用 UniTaskVoid |
一条经验法则:只要你自己写的方法,永远不要返回 async void。async void 唯一合理的使用场景是你被迫去实现一个签名为 void 的回调(比如某些第三方库的事件签名),即便如此,更推荐在里面包一层 UniTask.Void。
13.5 配合 UnityAction(UI 按钮等)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| button.onClick.AddListener(async () => { await DoThingAsync(); });
button.onClick.AddListener(UniTask.UnityAction(async () => { await UniTask.Delay(500); DoThing(); }));
button.onClick.AddListener( UniTask.UnityAction(async token => { await UniTask.Delay(500, cancellationToken: token); }, this.GetCancellationTokenOnDestroy()) );
|
13.6 一个容易踩的坑:UniTaskVoid 不能被 await
1 2 3 4 5 6
| async UniTaskVoid FooAsync() { }
await FooAsync();
|
如果你发现自己反复想 await 一个 UniTaskVoid,那说明它本来就不该是 UniTaskVoid——改成 UniTask。
14. TimeoutController 超时控制
TimeoutController 可复用的超时控制器,避免每次超时都 new 一个 CancellationTokenSource。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42
| public class MyBehaviour : MonoBehaviour { TimeoutController _timeoutController = new TimeoutController(); async UniTask Start() { var destroyToken = this.GetCancellationTokenOnDestroy(); var timeoutController = new TimeoutController( new CancellationTokenSource() ); try { var timeoutToken = _timeoutController.Timeout(TimeSpan.FromSeconds(3)); await LoadDataAsync(timeoutToken); } catch (OperationCanceledException) { if (_timeoutController.IsTimeout()) { Debug.Log("加载超时!"); } } finally { _timeoutController.Reset(); } }
void OnDestroy() { _timeoutController.Dispose(); } }
|
15. AsyncLazy 懒加载异步
用于只需要初始化一次的异步资源,多次调用只执行一次实际工作:
1 2 3 4 5 6 7 8 9 10 11 12 13
| AsyncLazy<Config> _lazyConfig = UniTask.Lazy(async () => { await UniTask.Delay(100); return new Config { Version = "1.0" }; });
async UniTask UseConfigAsync() { var config = await _lazyConfig; var config2 = await _lazyConfig; }
|
16. UniTask Tracker 调试工具
UniTask 内置了任务追踪窗口,可以在 Editor 中查看所有正在运行的 UniTask:
- 菜单:Window → UniTask Tracker
- 可以看到所有活跃的 UniTask 及其调用栈
- 可以检测是否有泄漏的(永不完成的)任务
1 2 3 4 5
| #if UNITY_EDITOR TaskTracker.EnableTracking = true; TaskTracker.EnableStackTrace = true; #endif
|
17. 最佳实践与常见陷阱
✅ 推荐做法
1. 始终传递 CancellationToken
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| async UniTask BadExample() { await UniTask.Delay(5000); transform.position = Vector3.zero; }
async UniTask GoodExample() { var token = this.GetCancellationTokenOnDestroy(); await UniTask.Delay(5000, cancellationToken: token); transform.position = Vector3.zero; }
|
2. 异步方法命名加 Async 后缀
1 2
| public async UniTask LoadDataAsync() { } public async UniTask LoadData() { }
|
3. 合理使用 WhenAll 并行化
1 2 3 4 5 6 7
| await LoadAAsync(); await LoadBAsync(); await LoadCAsync();
await UniTask.WhenAll(LoadAAsync(), LoadBAsync(), LoadCAsync());
|
4. Start() 返回 UniTask 代替协程
1 2 3 4 5 6 7 8 9 10 11 12 13
| IEnumerator Start() { yield return new WaitForSeconds(1f); Debug.Log("完成"); }
async UniTask Start() { await UniTask.Delay(1000, cancellationToken: this.GetCancellationTokenOnDestroy()); Debug.Log("完成"); }
|
⚠️ 常见陷阱
陷阱 1:Yield 并非总是下一帧
1 2 3 4 5 6
|
await UniTask.Yield();
await UniTask.NextFrame();
|
陷阱 2:WaitForEndOfFrame 需要 MonoBehaviour
1 2 3 4 5
| await UniTask.WaitForEndOfFrame();
await UniTask.WaitForEndOfFrame(this);
|
陷阱 3:UniTask 只能 await 一次
1 2 3 4 5 6 7 8
| var task = SomeAsync(); await task; await task;
var shared = SomeAsync().Preserve(); await shared; await shared;
|
陷阱 4:忘记处理 OperationCanceledException
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26
| async UniTask RunAsync() { await UniTask.Delay(1000, cancellationToken: token); }
async UniTask RunAsync() { try { await UniTask.Delay(1000, cancellationToken: token); } catch (OperationCanceledException) { } }
RunAsync().Forget(ex => { if (ex is not OperationCanceledException) Debug.LogException(ex); });
|
陷阱 5:在非主线程访问 Unity API
1 2 3 4 5 6 7 8 9
| await UniTask.SwitchToThreadPool(); var pos = transform.position;
await UniTask.SwitchToThreadPool(); var data = HeavyCompute(); await UniTask.SwitchToMainThread(); transform.position = data;
|
快速参考表
| API |
说明 |
UniTask.Delay(ms) |
按毫秒等待 |
UniTask.DelayFrame(n) |
按帧等待 |
UniTask.Yield() |
让出到当前时机的下一次触发 |
UniTask.NextFrame() |
等待到严格的下一帧 |
UniTask.WaitForFixedUpdate() |
等待下一个 FixedUpdate |
UniTask.WaitForEndOfFrame(mono) |
等待帧结束 |
UniTask.WaitUntil(() => cond) |
等待条件成立 |
UniTask.WaitWhile(() => cond) |
等待条件不再成立 |
UniTask.WaitUntilValueChanged(target, t => t.Value) |
等待值变化 |
UniTask.WhenAll(tasks) |
等待所有任务 |
UniTask.WhenAny(tasks) |
等待任意任务完成 |
UniTask.SwitchToMainThread() |
切换到主线程 |
UniTask.SwitchToThreadPool() |
切换到后台线程 |
UniTask.RunOnThreadPool(action) |
在后台线程运行并返回主线程 |
this.GetCancellationTokenOnDestroy() |
获取绑定对象生命周期的 Token |
task.Forget() |
即发即弃,抑制警告 |
task.SuppressCancellationThrow() |
取消时返回 bool 而非抛异常 |
task.Preserve() |
允许多次 await |
UniTaskCompletionSource |
手动控制任务完成 |
TimeoutController |
可复用的超时控制 |
UniTask.Lazy(factory) |
懒加载异步 |
GetAsyncDestroyTrigger() |
等待对象销毁 |
GetAsyncUpdateTrigger() |
订阅 Update 事件流 |
GetAsyncOnTriggerEnterTrigger() |
异步等待触发器进入 |