Unity 异步等待方案选型:Mono 轮询 vs 协程 vs UniTask

在 Unity 项目里,”等一段时间再做某件事”、”等加载完再回调”、”等动画播完再切场景” 这类需求几乎天天遇到。
选型不当,轻则代码难读,重则 GC 卡顿、生命周期错乱、回调地狱。本文把目前主流的三种方案放在一起做一次彻底的对比。


引子:我们到底在解决什么问题

Unity 的核心循环是单线程帧驱动的:所有游戏逻辑都跑在主线程,按 PlayerLoop 的节拍一帧一帧推进。这就意味着 “异步等待” 在 Unity 里本质上是一个时间/事件维度的问题,而不是一个 “线程切换” 的问题。

我们需要解决的典型场景有:

  • 等待 N 秒后执行某件事(技能 CD、UI 提示自动消失)
  • 等待某个资源加载完成(AssetBundleAddressables、网络请求)
  • 等待某个条件成立(敌人进入视野、玩家按下按键、动画播放到某一帧)
  • 串行/并行编排多个上述等待(连招、剧情演出、新手引导)

围绕这些需求,社区演化出了三种主流方案,下面逐一拆解。


方案一:自己编写基于 Mono 的管理器,在 Update 中轮询

思路

写一个常驻的 MonoBehaviour 单例(通常叫 TimerManagerUpdateManager 之类),把所有”等一会儿/等条件”的任务塞进一个 List,每帧 Update 里遍历检查,到点了就触发回调。

最小示例

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
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
public class TimerManager : MonoBehaviour
{
public static TimerManager Instance { get; private set; }

private class TimerTask
{
public float remaining;
public Action onComplete;
public bool useUnscaled;
}

private readonly List<TimerTask> _tasks = new(64);
private readonly List<TimerTask> _toRemove = new(16);

private void Awake()
{
if (Instance != null) { Destroy(gameObject); return; }
Instance = this;
DontDestroyOnLoad(gameObject);
}

public void Delay(float seconds, Action callback, bool useUnscaled = false)
{
_tasks.Add(new TimerTask
{
remaining = seconds,
onComplete = callback,
useUnscaled = useUnscaled
});
}

private void Update()
{
float dt = Time.deltaTime;
float unscaledDt = Time.unscaledDeltaTime;

for (int i = 0; i < _tasks.Count; i++)
{
var t = _tasks[i];
t.remaining -= t.useUnscaled ? unscaledDt : dt;
if (t.remaining <= 0f)
{
try { t.onComplete?.Invoke(); }
catch (Exception e) { Debug.LogException(e); }
_toRemove.Add(t);
}
}

if (_toRemove.Count > 0)
{
for (int i = 0; i < _toRemove.Count; i++) _tasks.Remove(_toRemove[i]);
_toRemove.Clear();
}
}
}

// 使用
TimerManager.Instance.Delay(1.5f, () => Debug.Log("到点了"));

优势

  • 完全可控:调度逻辑是你自己写的,想加优先级、想分组暂停、想统一加锁、想统一序列化保存——全在掌心。
  • 无第三方依赖:项目不引入任何外部库,体积可控,审包也方便。
  • 行为可预测:所有任务统一在一个 Update 节点跑,时序非常确定,没有”在 LateUpdate 还是 FixedUpdate 触发”之类的隐性陷阱。
  • 天然适合数据驱动:任务是普通 C# 对象,序列化、热重载、回放都好接。
  • 性能基线清晰:知道 Update 里就一个 for 循环,开销心里有数;优化也容易做(比如改最小堆、按到期时间排序)。

劣势

  • 写法是回调式的,连续等待会变成回调地狱

    1
    2
    3
    4
    5
    6
    7
    8
    9
    TimerManager.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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public class Demo : MonoBehaviour
{
private void Start()
{
StartCoroutine(Sequence());
}

private IEnumerator Sequence()
{
Debug.Log("开始");
yield return new WaitForSeconds(1f);

Debug.Log("加载资源");
var req = Resources.LoadAsync<Texture>("icon");
yield return req;

var tex = req.asset as Texture;
Debug.Log($"加载完成: {tex.name}");

yield return new WaitUntil(() => Input.GetKeyDown(KeyCode.Space));
Debug.Log("玩家按了空格");
}
}

优势

  • 官方原生支持:无依赖,所有 Unity 版本都可用,文档与社区资料丰富。
  • 顺序化写法:相比方案一的回调嵌套,协程是直线代码,读起来接近同步代码的体验。
  • 与 MonoBehaviour 生命周期联动:对象被 SetActive(false) 时协程自动暂停,被 Destroy 时协程自动停止——很多生命周期问题被官方兜底了。
  • 学习成本低:只要懂 yield,半天就能上手。
  • 与 Unity 自有异步 API 天然兼容AsyncOperationUnityWebRequestResources.LoadAsync 这些都可以直接 yield return

劣势

  • 不能返回值IEnumerator 没有泛型版本,想从协程拿结果只能塞 out 参数包装、塞回调、或者塞到成员变量里——非常别扭。

  • 不能 try/catch 跨 yield 的异常yield return 之后的代码不在原调用栈,异常一旦发生,要么被吞,要么打到 Console 里,外层的 try/catch 抓不到。

    1
    2
    3
    4
    5
    IEnumerator 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 时序固定:协程只在固定几个时机推进(UpdateLateUpdateFixedUpdate 后等),想精确控制”我希望这个回调在 PreLateUpdate 触发”做不到。

推荐使用场景

  • 简单的 UI/演出/教程流程:步骤短、不需要返回值、不需要复杂错误处理。
  • 遗留项目:项目已经大量使用协程,团队熟悉这种风格,没有迁移收益。
  • 学习/原型阶段:快速验证想法,不在乎 GC 与扩展性。
  • 被某些第三方 API 强制要求:比如某些插件的回调只支持 IEnumerator 形式。

方案三:使用 UniTask

思路

UniTask 是 Cysharp 开源的、专为 Unity 设计的高性能异步库。它用 struct 实现的 UniTask 类型替代 Task,把异步续体直接挂到 Unity 的 PlayerLoop 上跑,配合 C# 的 async/await 语法,让 Unity 的异步代码既顺序化、又零分配。

最小示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
using Cysharp.Threading.Tasks;

public class Demo : MonoBehaviour
{
private async UniTaskVoid Start()
{
Debug.Log("开始");
await UniTask.Delay(1000);

Debug.Log("加载资源");
var tex = await Resources.LoadAsync<Texture>("icon");
Debug.Log($"加载完成");

await UniTask.WaitUntil(() => Input.GetKeyDown(KeyCode.Space));
Debug.Log("玩家按了空格");
}

private async UniTask<int> LoadScoreAsync()
{
await UniTask.Delay(500);
return 100; // 协程做不到的"带返回值"
}
}

优势

  • async/await 顺序化语法:和协程一样直线易读,但比协程更接近”普通同步代码”,try/catch、return、using 都正常工作。

  • 可返回值UniTask<T> 直接拿结果,告别”用成员变量传值”的丑陋写法。

  • 真正的异常传播:异常会顺着 await 链一路传到能 catch 的地方,全链路 try/catch 都能抓

    1
    2
    3
    4
    5
    6
    7
    8
    try
    {
    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 异步 APIAsyncOperationUnityWebRequestAddressablesSceneManager.LoadSceneAsync 全部可以直接 await。

  • 调试工具完善:自带 UniTask Tracker,可以在 Editor 里看到当前所有活跃的 UniTask、它们卡在哪一行、由哪个对象持有。

  • 支持线程切换UniTask.SwitchToThreadPool() / UniTask.SwitchToMainThread() 让 CPU 密集计算→主线程回调一行代码搞定。

劣势

  • 学习曲线:team 里如果有人不熟 async/await,需要培训。UniTask vs UniTaskVoid vs Forget() vs WhenAll 等概念都要消化。
  • 第三方依赖:要引入一个外部库(虽然作者活跃、稳定性极好,但仍是依赖)。
  • 滥用风险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 复现还是自己写更稳。


一些反模式提醒

无论选哪种方案,下面几条都值得记一下:

  1. 不要 async void 写 Unity 业务方法。要么 UniTask 让人 await,要么 UniTaskVoid + .Forget() 明确即发即弃。
  2. 不要在协程/UniTask 里裸引用 this.transform 而不检查存活。给所有跨帧异步都传 CancellationToken,或者首行加 if (this == null) return;
  3. 不要每帧 new WaitForSeconds(0.1f)。要么缓存实例,要么改用 UniTask。
  4. 不要混用多套方案而不做边界。一个项目里既写协程又写 UniTask 不是不行,但建议按模块划清楚,避免一个流程里两种写法穿插。
  5. 不要把 UniTask 当万金油。简单的 Invoke("Foo", 1f) 能解决的事,不必非要 await UniTask.Delay

结语

三种方案并不是互斥关系,而是覆盖了不同的复杂度区间:

  • Mono 轮询管理器:最底层、最可控,适合简单或极特殊需求。
  • 协程:Unity 内置的默认选择,简单顺手,但天花板低。
  • UniTask:现代 Unity 项目的事实标准,几乎在所有维度上都是协程的超集。

如果让我用一句话总结:新项目无脑选 UniTask,老项目按模块渐进式迁移,特殊底层场景留给 Mono 管理器。