Unity 协程 vs UniTask vs Awaitable:一个加载卡在90%的bug,让我把三种异步方案都重写了一遍

🔑 关键词:Unity 协程, UniTask, Awaitable, Unity 异步编程, async await Unity

📖 摘要:用真实项目里的加载卡死 bug 做引子,对比 Unity 协程、UniTask 和官方 Awaitable 在取消、异常、GC、线程上的差异,附可直接抄的代码和验证方法。

先说结论,方便你直接划走:锁 Unity 6 的新项目,默认用 Awaitable;一旦出现「要同时等 N 件事」或者「要能在任何地方掉头取消」,换 UniTask;至于协程,动画演出和一次性小流程还是它最省事。下面解释我为什么这么选。

图片

起因是个卡在 90% 的加载条

2023 年底我接了个 Unity 2021.3 LTS 的项目,主城加载到 90% 就不动了。不是崩,是进度条停在那、按钮还能点、日志一条不报。查了两天,最后定位到一个第三方 UI 框架在 OnEnable 里调了 StopAllCoroutines(),把同挂在同一个 MonoBehaviour 上的加载协程一起给停了。

对,协程就是这么脆。它绑在 MonoBehaviour 上,StopAllCoroutines 是无差别攻击,而且它不抛异常、不打日志,你只能盯着一个不再变化的进度条发呆。这事之后我花了差不多两个月,把项目里能换的异步流程都换了一遍,中间把三条路线——协程、UniTask、Unity 官方的 Awaitable——都实打实用过,这里把我的结论摊开讲。

三条路的现状(2025 年)

协程不用多说,IEnumerator + yield return,从 Unity 3.0 活到现在,不用装任何东西。

UniTask 是 Cysharp 的 neuecc 做的,包名 com.cysharp.unitask,现在 2.x。它本质是自己实现了一套 struct 版的 async method builder,绕开了 .NET 的 Task,所以 async UniTask 方法在常见路径上是零 GC 的。

图片

Awaitable 是 Unity 官方的,2023.1 进引擎,Unity 6(6000.x)里基本能用了。它跟 PlayerLoop 绑定,写法就是标准 async Awaitable,不用装包。但有个硬限制:一个 Awaitable 实例只能 await 一次,你没法把它缓存起来给两个人 await,官方文档写得很明确。

取消和生命周期:协程输得最惨

StartCoroutine 返回的 Coroutine 句柄,只有启动它的那个 MonoBehaviour 才能用来 StopCoroutine。你 A 对象启动的,B 对象停不掉。跨模块协调的时候这点就很恶心。

StopAllCoroutines() 是第二个雷。UI 预制的 OnEnable 里随手写一句,整个对象上的所有协程全灭,包括你别处塞进去的。

协程还会在你 gameObject.SetActive(false) 的时候直接停,SetActive(true) 也不会恢复,得重新 Start。这个坑在切 UI 页签的时候特别容易撞上。

UniTask 这边是 CancellationToken 全链路。可以 CancellationTokenSource.CreateLinkedTokenSource 把「场景切换」和「对象销毁」两个信号合起来,谁先来算谁的。Unity 在 2022.2 之后给 MonoBehaviour 加了 destroyCancellationToken 属性,对象被 Destroy 就自动 cancel,不用自己写 OnDestroycts.Cancel() 那套样板代码。

Awaitable 也接 CancellationToken,我上面那段示例就是这么写的。但它的取消是抛 OperationCanceledException,所以你必须包 try/catch,不然异常会往上冒。这一点跟 UniTask 一样,跟协程完全不一样。

图片

哦对,还有个 Unity 老坑:MonoBehaviour 被 Destroy 之后引用不是真 null,是所谓 fake null,== null 返回 true 但 ?. 不生效。不管用哪套异步方案,await 回来第一件事都该是 if (this == null) return; 或者干脆靠 cancellationToken 兜住。

异常处理:协程这块基本没救

协程里 try/catch 跨不过 yield。这是 C# 编译器的限制,不是 Unity 的锅。你写:

try
{
    yield return SomethingThatThrows();
}
catch (Exception e) { /* ... */ }

编译直接报错。所以协程里的异常只能靠 Unity 引擎兜底——打印一条 LogException,然后协程终止,游戏继续跑。你会在 Console 里看到一条红字,然后某个流程就静默死掉了,特别难查,尤其当它只在很小的概率下触发。

UniTask 和 Awaitable 都是真正的 async/await,try/catch 正常工作。区别在未捕获异常去哪:UniTask 默认走 UniTaskScheduler.UnobservedTaskException,可以自己接管;async UniTaskVoid 这种「发射后不管」的,得显式 .Forget() 或者 .Forget(Debug.LogException) 才会打日志,不然就真丢了。

图片

Awaitable 的未捕获异常路径我没仔细测过,不敢瞎说,用的时候建议还是老老实实包 try/catch。

性能和 GC:别信「协程很慢」这种话

网上很多老文章说协程每次都产生 GC,这个说法不太准确,分情况:

  • yield return null 基本不产生托管堆分配,Unity 内部有处理。
  • yield return new WaitUntil(() => condition) 里的 lambda 如果捕获了外部变量,闭包每帧都会分配,这个是真的会掉帧,尤其在 60fps 目标手机上更明显。
  • StartCoroutine 本身有一次性分配,具体多少跟 Unity 版本、Mono 还是 IL2CPP 都有关系,你自己开 Profiler 看 GC Alloc 列最准。

UniTask 的卖点就是零 GC,UniTask.Delay 走的是 PlayerLoop 上的定时器,不 new 对象。但你要是用 UniTask.Run 把活丢到线程池,那还是有分配和线程切换开销。

Awaitable 是池化的,Unity 引擎内部维护了一个 Awaitable 对象池,重复使用。不过因为「只能 await 一次」这个限制,你也不太会拿它做长链缓存。

图片

真实项目里,异步方案带来的 GC 通常根本不是瓶颈,比你的 Update 里那些 GetComponent 和 LINQ 小得多。别本末倒置。

几个可能跟你听到的不一样的观点

第一,协程不是过时技术。 它确实是异步方案里最弱的,但它绑定 MonoBehaviour 生命周期这一点,在做一次性演出的时候反而方便。「播个 0.3 秒的淡入、等两帧、触发下一个动画」这种,协程写起来比 async 短一半,还不用管取消。

第二,PlayerLoopTiming 是 UniTask 最大的心智负担。 UniTask.YieldUniTask.Delay 都能指定 PlayerLoopTiming.Update / FixedUpdate / LateUpdate / LastPostLateUpdate。选错了会出现「我明明 Delay 了 1000ms,实测过了 1080ms」这种问题,因为计时器是在哪一帧被检查的完全取决于你传的 Timing。团队里如果有人不懂这个,很容易写出偶发不同步的 bug,而且很难复现。

第三,Awaitable 缺 WhenAll 是硬伤。 官方到 Unity 6 似乎也没给个 Awaitable.WhenAll。你要并行加载三个资源再一起往下走,得自己用计数器或者 UniTaskCompletionSource 拼。所以我说只要出现「同时等 N 件事」,就直接上 UniTask。

第四,混用可以,但要有规矩。 我现在的项目里就是三套都在:加载流程 UniTask、UI 演出协程、新写的小工具 Awaitable。听着乱,实际上只要定死「哪些场景用哪套」,可读性反而比硬统一更好。千万别在 async 方法里混 yield。

最后给个能直接抄的写法

图片

Awaitable 版本(Unity 6,接 destroyCancellationToken 自动取消):

public class LoadingScreen : MonoBehaviour
{
    void OnEnable() => RunAsync();

async Awaitable RunAsync()
    {
        try
        {
            await Awaitable.NextFrameAsync(destroyCancellationToken);
            await Awaitable.WaitForSecondsAsync(0.5f, destroyCancellationToken);
            await Awaitable.EndOfFrameAsync(destroyCancellationToken);
            SceneManager.LoadScene("Main");
        }
        catch (OperationCanceledException)
        {
            // 对象被销毁了,正常退出
        }
    }
}

UniTask 版本,需要并行的时候用:

async UniTaskVoid LoadAsync(CancellationToken ct)
{
    await UniTask.WhenAll(LoadConfigAsync(ct), LoadTablesAsync(ct));
    await UniTask.Delay(500, cancellationToken: ct);
    await SceneManager.LoadSceneAsync("Main").ToUniTask(cancellationToken: ct);
}

想自己验证性能差距的话,别信任何人的数字,包括我的。开 Profiler,把 Deep Profile 关掉(它本身会严重干扰异步代码的耗时),用 ProfilerMarker 在关键节点埋两个点,跑同样的流程对比 GC AllocTime ms,五次取平均。这个流程花不了半小时,比看十篇对比文章有用。

🏷️ 标签: