Unity 异步等待方案选型:Mono 轮询 vs 协程 vs UniTask
Unity 异步等待方案选型:Mono 轮询 vs 协程 vs UniTask
在 Unity 项目里,”等一段时间再做某件事”、”等加载完再回调”、”等动画播完再切场景” 这类需求几乎天天遇到。
选型不当,轻则代码难读,重则 GC 卡顿、生命周期错乱、回调地狱。本文把目前主流的三种方案放在一起做一次彻底的对比。
引子:我们到底在解决什么问题
Unity 的核心循环是单线程帧驱动的:所有游戏逻辑都跑在主线程,按 PlayerLoop 的节拍一帧一帧推进。这就意味着 “异步等待” 在 Unity 里本质上是一个时间/事件维度的问题,而不是一个 “线程切换” 的问题。
我们需要解决的典型场景有:
- 等待 N 秒后执行某件事(技能 CD、UI 提示自动消失)
- 等待某个资源加载完成(
AssetBundle、Addressables、网络请求) - 等待某个条件成立(敌人进入视野、玩家按下按键、动画播放到某一帧)
- 串行/并行编排多个上述等待(连招、剧情演出、新手引导)
围绕这些需求,社区演化出了三种主流方案,下面逐一拆解。
方案一:自己编写基于 Mono 的管理器,在 Update 中轮询
思路
写一个常驻的 MonoBehaviour 单例(通常叫 TimerManager、UpdateManager 之类),把所有”等一会儿/等条件”的任务塞进一个 List,每帧 Update 里遍历检查,到点了就触发回调。
最小示例
1 | public class TimerManager : MonoBehaviour |
优势
- 完全可控:调度逻辑是你自己写的,想加优先级、想分组暂停、想统一加锁、想统一序列化保存——全在掌心。
- 无第三方依赖:项目不引入任何外部库,体积可控,审包也方便。
- 行为可预测:所有任务统一在一个 Update 节点跑,时序非常确定,没有”在 LateUpdate 还是 FixedUpdate 触发”之类的隐性陷阱。
- 天然适合数据驱动:任务是普通 C# 对象,序列化、热重载、回放都好接。
- 性能基线清晰:知道
Update里就一个 for 循环,开销心里有数;优化也容易做(比如改最小堆、按到期时间排序)。
劣势
写法是回调式的,连续等待会变成回调地狱
1
2
3
4
5
6
7
8
9TimerManager.Instance.Delay(1f, () => {
Load("a", () => {
TimerManager.Instance.Delay(0.5f, () => {
Load("b", () => {
// ……
});
});
});
});代码缩进越来越深,业务流程被割裂在多个 lambda 里,调试栈也基本看不出原始顺序。
生命周期管理全靠自觉:发起方对象被销毁了,回调里访问
this.transform直接MissingReferenceException。你得自己在管理器里加 owner 检查,或者每个回调里手写if (this == null) return;。错误处理分散:每个 callback 内部都得自己 try/catch,否则一个异常就吞掉,难定位。
每种”等待类型”都要手写一遍:等时间、等条件、等帧数、等资源——管理器要么膨胀成大杂烩,要么拆成一堆 Manager。
没有”取消”的统一语义:要支持取消,就得自己设计 handle、token 之类的机制,相当于把 .NET 的
CancellationToken又造了一遍。
推荐使用场景
- 项目是底层框架/SDK,不希望引入 UniTask 这种较大的依赖。
- 需要极强的调度可控性:比如帧同步联机游戏,要求所有定时任务在固定的 PlayerLoop 节点执行,且要能被回放系统记录。
- 运行环境受限:某些老版本 Unity(5.x、2017 早期)、IL2CPP 兼容性敏感的平台。
- 场景非常简单:整个项目就十几个定时回调,犯不上引入异步框架。
方案二:使用 Unity 协程(Coroutine)
思路
利用 C# 的迭代器 (IEnumerator + yield return) 配合 Unity 的 StartCoroutine API,让”等待”在语法层面顺序化。底层其实就是 Unity 自己维护了一个状态机调度器,每帧遍历活跃的协程、检查 YieldInstruction 是否完成、不完成就跳过、完成就 MoveNext 一次。
最小示例
1 | public class Demo : MonoBehaviour |
优势
- 官方原生支持:无依赖,所有 Unity 版本都可用,文档与社区资料丰富。
- 顺序化写法:相比方案一的回调嵌套,协程是直线代码,读起来接近同步代码的体验。
- 与 MonoBehaviour 生命周期联动:对象被
SetActive(false)时协程自动暂停,被Destroy时协程自动停止——很多生命周期问题被官方兜底了。 - 学习成本低:只要懂
yield,半天就能上手。 - 与 Unity 自有异步 API 天然兼容:
AsyncOperation、UnityWebRequest、Resources.LoadAsync这些都可以直接yield return。
劣势
不能返回值:
IEnumerator没有泛型版本,想从协程拿结果只能塞out参数包装、塞回调、或者塞到成员变量里——非常别扭。不能 try/catch 跨 yield 的异常:
yield return之后的代码不在原调用栈,异常一旦发生,要么被吞,要么打到 Console 里,外层的 try/catch 抓不到。1
2
3
4
5IEnumerator Bad()
{
yield return new WaitForSeconds(1f);
throw new Exception("boom"); // 外层无法 catch
}GC 压力:
new WaitForSeconds(1f)每次都堆分配;yield return null触发的状态机本身也是引用类型。高频协程(比如每个子弹一个)很容易刷出 GC 曲线。缓解办法是缓存
WaitForSeconds实例、或用WaitForSecondsRealtime,但只是缓解,不是根治。取消只能 StopCoroutine:取消粒度粗糙,而且
StopCoroutine是直接终止状态机,不会执行finally(在某些 Unity 版本里 finally 会执行,但行为历来不太一致),资源清理需要靠OnDisable/OnDestroy兜底。必须挂在 MonoBehaviour 上:纯 C# 类(比如你的 Service、Model 层)不能直接开协程,要么持有一个 MB 引用,要么做一个”协程宿主”单例,相当于回退到方案一的思路。
组合困难:想要”并行等 A 和 B 然后继续”、”等 A 或 B 任一完成就继续”——协程没有原生的
WhenAll/WhenAny,得自己用计数器或者嵌套协程实现,很笨拙。不支持线程切换:完全跑在主线程,想做 CPU 密集型计算后回主线程,还是得自己用
Task+SynchronizationContext手搓。PlayerLoop 时序固定:协程只在固定几个时机推进(
Update、LateUpdate、FixedUpdate后等),想精确控制”我希望这个回调在 PreLateUpdate 触发”做不到。
推荐使用场景
- 简单的 UI/演出/教程流程:步骤短、不需要返回值、不需要复杂错误处理。
- 遗留项目:项目已经大量使用协程,团队熟悉这种风格,没有迁移收益。
- 学习/原型阶段:快速验证想法,不在乎 GC 与扩展性。
- 被某些第三方 API 强制要求:比如某些插件的回调只支持
IEnumerator形式。
方案三:使用 UniTask
思路
UniTask 是 Cysharp 开源的、专为 Unity 设计的高性能异步库。它用 struct 实现的 UniTask 类型替代 Task,把异步续体直接挂到 Unity 的 PlayerLoop 上跑,配合 C# 的 async/await 语法,让 Unity 的异步代码既顺序化、又零分配。
最小示例
1 | using Cysharp.Threading.Tasks; |
优势
async/await顺序化语法:和协程一样直线易读,但比协程更接近”普通同步代码”,try/catch、return、using 都正常工作。可返回值:
UniTask<T>直接拿结果,告别”用成员变量传值”的丑陋写法。真正的异常传播:异常会顺着 await 链一路传到能 catch 的地方,全链路 try/catch 都能抓。
1
2
3
4
5
6
7
8try
{
await LoadAsync();
}
catch (Exception ex)
{
Debug.LogError(ex); // 真的会进来
}零/极低 GC:基于 struct + 自定义 AsyncMethodBuilder + 对象池,同步完成路径完全零分配,异步路径上也远低于
Task和协程。在高频调用场景(每帧、每个子弹、每个 AI 决策)差距非常显著。强大的取消机制:标准
CancellationToken一路透传,配合this.GetCancellationTokenOnDestroy()可以让对象销毁时所有相关异步自动取消,杜绝”对象死了回调还在跑”的事故。PlayerLoop 时序可精确控制:
UniTask.Yield(PlayerLoopTiming.PreLateUpdate)这种精度,协程做不到。组合算子丰富:
WhenAll/WhenAny/Timeout/Retry一应俱全。不依赖 MonoBehaviour:纯 C# 层(服务、网络层、数据层)也能畅快写异步,不再需要”协程宿主”。
原生支持 Unity 异步 API:
AsyncOperation、UnityWebRequest、Addressables、SceneManager.LoadSceneAsync全部可以直接 await。调试工具完善:自带 UniTask Tracker,可以在 Editor 里看到当前所有活跃的 UniTask、它们卡在哪一行、由哪个对象持有。
支持线程切换:
UniTask.SwitchToThreadPool()/UniTask.SwitchToMainThread()让 CPU 密集计算→主线程回调一行代码搞定。
劣势
- 学习曲线:team 里如果有人不熟
async/await,需要培训。UniTaskvsUniTaskVoidvsForget()vsWhenAll等概念都要消化。 - 第三方依赖:要引入一个外部库(虽然作者活跃、稳定性极好,但仍是依赖)。
- 滥用风险:
async/await太顺手了,容易”事事 async”,造成不必要的状态机开销、隐藏控制流复杂度。 - 错误用法的坑:忘记
.Forget()会有警告;async void写在 Unity 里依旧危险(应该用UniTaskVoid);不传CancellationToken会导致协程式的生命周期问题重现。 - 平台兼容性:极个别老版本 Unity (2018 之前) 或某些定制 IL2CPP 平台需要确认是否完美支持,但 2019.3+ 基本无忧。
推荐使用场景
- 绝大多数中大型 Unity 项目的默认选择。
- 异步流程复杂、需要组合、需要返回值、需要错误处理的场景:登录流程、资源管线、网络请求、剧情演出、AI 决策。
- 对性能/GC 敏感:移动端、VR、大型 MMO、需要稳定 60/120 帧的项目。
- 团队已经熟悉
async/await:从后端、客户端 (WPF/Xamarin) 转来的同学几乎零成本上手。
横向对比表
| 维度 | Mono 轮询管理器 | Unity 协程 | UniTask |
|---|---|---|---|
| 语法直观度 | 回调嵌套,差 | 顺序化,好 | 顺序化 + 同步语义,最好 |
| 返回值 | 靠回调传 | 不支持 | 原生 UniTask<T> |
| 异常处理 | 手动 try/catch 每个回调 | 跨 yield 抓不到 | 全链路 try/catch |
| 取消机制 | 自己造 handle | StopCoroutine (粗糙) | CancellationToken (标准) |
| GC 压力 | 取决于实现,通常较低 | 较高(WaitForSeconds 等) |
极低(零分配路径) |
| 生命周期联动 | 手动检查 | 跟随 MonoBehaviour | 跟随 CancellationToken |
| 线程切换 | 自己实现 | 不支持 | 一行 SwitchToXxx |
| 组合 (All/Any) | 自己实现 | 难写 | 原生支持 |
| 时序精度 | 自己控制 | 固定几个时机 | 全 PlayerLoop 节点可选 |
| 调试工具 | 无 | Profiler 一般观察 | 专属 Tracker |
| 学习成本 | 低(自己写的) | 低 | 中(要懂 async/await) |
| 依赖 | 无 | 无 | 引入 UniTask 包 |
| 适合 MB 之外的纯 C# 类 | 是 | 否 | 是 |
选型决策建议
按”项目规模 × 性能要求 × 团队背景”三轴拆开,可以给出比较明确的建议:
项目体量很小、就几个定时回调
→ 直接写一个简单的 Mono 管理器。引入框架反而是负担。
遗留项目大量协程、团队习惯协程、没有性能问题
→ 维持协程,但新模块可以尝试 UniTask,逐步替换。
新项目、中大型、对 GC 敏感
→ 直接上 UniTask,没有悬念。三套方案里它是综合最优解,唯一的代价就是团队学一下 async/await。
底层 SDK / 框架 / 编辑器扩展
→ 视情况而定。如果你写的是给别人用的库,且不希望强加 UniTask 依赖,可以用 Mono 管理器 + 暴露回调 API;如果你的 SDK 用户群已经在用 UniTask,那直接用更好。
帧同步联机游戏 / 录像回放系统
→ 优先考虑自己写管理器,因为你需要对所有定时任务有完全确定的执行时序,UniTask 虽然时序可控,但要做到 bit-perfect 复现还是自己写更稳。
一些反模式提醒
无论选哪种方案,下面几条都值得记一下:
- 不要
async void写 Unity 业务方法。要么UniTask让人 await,要么UniTaskVoid+.Forget()明确即发即弃。 - 不要在协程/UniTask 里裸引用
this.transform而不检查存活。给所有跨帧异步都传CancellationToken,或者首行加if (this == null) return;。 - 不要每帧
new WaitForSeconds(0.1f)。要么缓存实例,要么改用 UniTask。 - 不要混用多套方案而不做边界。一个项目里既写协程又写 UniTask 不是不行,但建议按模块划清楚,避免一个流程里两种写法穿插。
- 不要把 UniTask 当万金油。简单的
Invoke("Foo", 1f)能解决的事,不必非要await UniTask.Delay。
结语
三种方案并不是互斥关系,而是覆盖了不同的复杂度区间:
- Mono 轮询管理器:最底层、最可控,适合简单或极特殊需求。
- 协程:Unity 内置的默认选择,简单顺手,但天花板低。
- UniTask:现代 Unity 项目的事实标准,几乎在所有维度上都是协程的超集。
如果让我用一句话总结:新项目无脑选 UniTask,老项目按模块渐进式迁移,特殊底层场景留给 Mono 管理器。
