1. 项目概述为什么Unity开发者需要UniTask如果你在Unity项目里写过协程大概率经历过这样的场景一个简单的延时操作你写了yield return new WaitForSeconds(1f);然后发现这个协程一旦启动除非你手动用StopCoroutine去停掉或者把承载它的GameObject销毁否则它就会一直存在。更头疼的是当你想并行等待多个异步操作比如同时加载三个资源协程的写法就变得异常笨拙要么嵌套yield return StartCoroutine要么用回调地狱。这还没算上内存分配的问题——每次yield return一个WaitForSeconds或者WaitForEndOfFrame都会在堆上产生垃圾对于性能敏感的项目比如移动端或者VR这就是个定时炸弹。UniTask的出现就是为了解决这些痛点。它不是一个简单的语法糖而是一套为Unity量身定制的、高性能的异步编程框架。它的核心目标很明确用C#原生的async/await语法彻底替代传统的协程Coroutine并且做到零堆内存分配Zero Allocation。这意味着你可以用写起来更清晰、逻辑更线性的异步代码同时获得比协程更好的运行时性能。简单来说UniTask让你能用写Task的感觉来写Unity的异步逻辑但底层却绕开了.NETTask的线程池和SynchronizationContext完全跑在Unity的主线程PlayerLoop上。所以它天然兼容WebGL、WASM这些不支持多线程的平台。对于新手而言从“yield return”切换到“await”学习曲线平缓但带来的代码可维护性和性能提升是巨大的。这篇文章我就以一个老Unity程序员的视角带你从最基础的协程替代开始一步步深入到UniTask的高阶用法和性能优化技巧让你不仅能“会用”更能“用好”。2. 核心思路拆解从“yield return”到“await”的思维转变在深入代码之前我们先要理解思维上的转变。协程的本质是一个状态机靠IEnumerator的MoveNext来驱动。每次yield return实际上是把控制权交还给Unity引擎等条件满足比如时间到了、帧结束了再回来继续执行。这种“被动等待”的模式让复杂的异步流控制变得困难。而async/await是一种“主动等待”的模型。当你await一个操作时当前方法会挂起但编译器会帮你生成一个状态机在操作完成后从挂起点恢复执行。这种模型更符合人类的线性思维。UniTask巧妙地将Unity的各种异步操作如AsyncOperation、资源加载、甚至每一帧都包装成了可以await的对象。2.1 UniTask的三大核心优势零堆内存分配这是UniTask的杀手锏。它通过自定义的UniTaskT值类型和AsyncMethodBuilder避免了TaskT在每次异步操作时在堆上分配对象。在Profiler里你会看到GC Alloc大幅减少这对于需要稳定帧率的游戏至关重要。与Unity深度集成你可以直接await任何AsyncOperationSceneManager.LoadSceneAsync、Resources.LoadAsync、UnityWebRequest甚至是yield return null这样的帧等待。UniTask提供了UniTask.DelayFrame、UniTask.Yield等方法来完美替代所有协程操作。强大的工具链提供了UniTask.WhenAll等待所有任务、UniTask.WhenAny等待任意任务完成、异步LINQ、Channel类似Go语言的通道等高级功能让编写复杂的异步逻辑变得轻而易举。2.2 与原生Task/Unity Awaitable的对比你可能会问C#有TaskUnity 2023 LTS也引入了自己的Awaitable为什么还要用UniTaskvs .NET Task原生的Task是基于线程池的在Unity的单线程主循环模型下使用并不友好容易引发线程安全问题且在WebGL上无法使用。Task的分配开销也较大。UniTask是专门为Unity的单线程模型设计的更轻量、更安全。vs Unity AwaitableUnity 6引入的Awaitable可以看作是UniTask功能的一个子集。它解决了从协程到await的基本需求但缺乏像WhenAll、WhenAny、异步LINQ、取消令牌链式传递、性能追踪器等关键功能。对于应用程序开发UniTask仍然是更全面、更强大的选择。如果你的代码库以库的形式发布且希望零外部依赖可以考虑使用Awaitable作为公共API的返回类型因为Awaitable可以很方便地通过.AsUniTask()转换为UniTask。3. 环境准备与基础入门3.1 安装UniTask推荐通过Unity的Package Manager使用Git URL安装这是获取最新版本最方便的方式。打开Unity进入Window - Package Manager。点击左上角的号选择Add package from git URL...。输入URLhttps://github.com/Cysharp/UniTask.git?pathsrc/UniTask/Assets/Plugins/UniTask如果需要特定版本可以在URL后加上#和版本号例如#2.5.0。安装完成后在你的C#脚本中引入命名空间using Cysharp.Threading.Tasks;3.2 你的第一个UniTask替换协程假设我们有一个用协程实现的简单倒计时功能IEnumerator CountdownCoroutine(int seconds) { while (seconds 0) { Debug.Log($倒计时: {seconds}); seconds--; yield return new WaitForSeconds(1f); } Debug.Log(时间到); }用UniTask重写后async UniTaskVoid CountdownUniTask(int seconds) { while (seconds 0) { Debug.Log($倒计时: {seconds}); seconds--; await UniTask.Delay(TimeSpan.FromSeconds(1), ignoreTimeScale: false); } Debug.Log(时间到); }变化与解释IEnumerator变成了async UniTaskVoid。UniTaskVoid类似于async void用于“即发即弃”fire-and-forget不需要等待结果的任务。切记调用UniTaskVoid方法时为了消除编译器警告需要在后面加上.Forget()例如CountdownUniTask(5).Forget();。yield return new WaitForSeconds(1f);变成了await UniTask.Delay(TimeSpan.FromSeconds(1), ignoreTimeScale: false);。ignoreTimeScale参数为false时受Time.timeScale影响为true时则使用真实时间。逻辑变得完全线性没有了yield return可读性更强。3.3 等待Unity异步操作加载场景和资源是Unity开发中的常客。用协程写你需要处理AsyncOperation.progress或者回调。用UniTask一行await搞定。async UniTask LoadSceneAndAsset() { // 异步加载场景直接await await SceneManager.LoadSceneAsync(Level2); Debug.Log(场景加载完成); // 异步加载资源直接await得到的就是TextAsset TextAsset configText await Resources.LoadAsyncTextAsset(Config/GameConfig) as TextAsset; Debug.Log($配置加载完成: {configText.text}); // 发起一个Web请求 using (UnityWebRequest webRequest UnityWebRequest.Get(https://api.example.com/data)) { await webRequest.SendWebRequest(); if (webRequest.result UnityWebRequest.Result.Success) { Debug.Log($收到数据: {webRequest.downloadHandler.text}); } } }实操心得await一个AsyncOperation会返回操作本身如AsyncOperation而await一个ResourceRequest或AssetBundleRequest会直接返回你请求的资源对象如TextAsset这比协程里需要去取request.asset方便太多。注意UnityWebRequest要放在using语句中以确保资源被正确释放。4. 核心功能深度解析与实战4.1 取消操作优雅地管理异步生命周期在游戏中一个常见的场景是玩家开始加载一个场景但在加载过程中退出了菜单。我们需要取消这个加载操作。协程要实现取消很麻烦通常需要设置一个外部标志位并在每一帧检查。UniTask通过CancellationToken提供了标准且强大的取消机制。private CancellationTokenSource _loadSceneCts; async UniTaskVoid LoadSceneWithCancel(string sceneName) { // 创建一个CancellationTokenSource并将其生命周期绑定到当前MonoBehaviour _loadSceneCts new CancellationTokenSource(); var linkedToken CancellationTokenSource.CreateLinkedTokenSource(_loadSceneCts.Token, this.GetCancellationTokenOnDestroy()).Token; try { Debug.Log($开始异步加载场景: {sceneName}); // 将CancellationToken传递给异步操作 await SceneManager.LoadSceneAsync(sceneName).WithCancellation(linkedToken); Debug.Log($场景加载完成: {sceneName}); } catch (OperationCanceledException) // 当操作被取消时会抛出此异常 { Debug.LogWarning($场景加载被取消: {sceneName}); // 这里可以做一些清理工作比如隐藏加载界面 } finally { _loadSceneCts?.Dispose(); _loadSceneCts null; } } // 在某个按钮事件或条件中取消加载 public void OnCancelButtonClicked() { _loadSceneCts?.Cancel(); // 触发取消 }关键点解析CancellationTokenSource是取消信号的源头调用其Cancel()方法会触发取消。this.GetCancellationTokenOnDestroy()是UniTask为MonoBehaviour提供的扩展方法它返回一个在该GameObject被销毁时自动触发的CancellationToken。这是管理异步任务生命周期的最佳实践能有效防止对象销毁后异步回调继续执行导致的空引用异常。CancellationTokenSource.CreateLinkedTokenSource用于链接多个取消令牌。这里我们把手动取消令牌和对象销毁令牌链接起来只要其中一个被触发整个操作就会被取消。WithCancellation()是UniTask为AsyncOperation提供的扩展方法用于注入取消令牌。取消时会抛出OperationCanceledException我们需要在try-catch块中捕获它来进行善后处理。进阶技巧性能敏感的取消检查默认情况下基于PlayerLoop的等待如UniTask.Delay,UniTask.Yield是在每一帧的特定时间点检查取消令牌的。如果你需要立即取消比如玩家瞬间点击了十次取消按钮可以设置cancelImmediately: true。await UniTask.DelayFrame(100, cancellationToken: ct, cancelImmediately: true);注意启用cancelImmediately会有额外的性能开销因为它使用了CancellationToken.Register机制。除非对取消响应速度有极致要求否则一般使用默认行为即可。4.2 进度报告让加载条动起来异步操作通常伴随着进度。UniTask提供了高效的方式来报告进度避免了new ProgressT带来的分配。async UniTask LoadAssetWithProgress(string assetPath) { // 使用Progress.Create这是一个轻量级的IProgressT实现 var progress Progress.Createfloat(p { Debug.Log($加载进度: {p:P0}); // 更新UI进度条 // loadingSlider.value p; }); // 使用ToUniTask方法并传入progress对象 var asset await Resources.LoadAsyncTexture2D(assetPath) .ToUniTask(progress: progress); Debug.Log($资产加载完成: {asset.name}); }优化建议如果进度回调函数很简单使用Progress.Create的Lambda表达式很方便。但如果这个回调会被频繁调用比如每帧更新Lambda表达式会导致闭包分配。更高效的做法是让承载脚本实现IProgressfloat接口。public class Loader : MonoBehaviour, IProgressfloat { public Slider progressSlider; public void Report(float value) { progressSlider.value value; } async UniTaskVoid StartLoading() { await Resources.LoadAsyncGameObject(Prefabs/Character) .ToUniTask(progress: this); // 直接传入this } }这样完全避免了每次调用时的委托分配。4.3 组合任务WhenAll, WhenAny 与元组这是UniTask相比协程最大的优势之一。想象一下需要同时加载玩家模型、武器模型和音效并在全部完成后初始化角色。使用 UniTask.WhenAllasync UniTaskCharacter LoadCharacterAsync() { // 同时发起三个异步加载任务 UniTaskGameObject modelTask Addressables.LoadAssetAsyncGameObject(PlayerModel).ToUniTask(); UniTaskGameObject weaponTask Resources.LoadAsyncGameObject(Weapons/Sword).ToUniTask() as UniTaskGameObject; UniTaskAudioClip soundTask Addressables.LoadAssetAsyncAudioClip(Sound/DrawSword).ToUniTask(); // 等待所有任务完成并直接通过元组解构获取结果 var (playerPrefab, weaponPrefab, drawSound) await UniTask.WhenAll(modelTask, weaponTask, soundTask); // 更简洁的元组await语法UniTask特有 // var (playerPrefab, weaponPrefab, drawSound) await (modelTask, weaponTask, soundTask); GameObject playerInstance Instantiate(playerPrefab); GameObject weaponInstance Instantiate(weaponPrefab, playerInstance.transform); AudioSource audioSource playerInstance.GetComponentAudioSource(); audioSource.clip drawSound; return playerInstance.GetComponentCharacter(); }代码解读我们并行发起了三个加载任务。Addressables和Resources的异步操作都可以被await。UniTask.WhenAll会等待所有传入的UniTask完成。它返回一个包含所有结果的元组Tuple。利用C#的元组解构语法var (a, b, c) ...我们可以一行代码拿到所有加载结果代码非常清晰。最后进行实例化和组装。整个过程是并行的总耗时约等于最慢的那个任务而不是三个任务耗时的总和。使用 UniTask.WhenAny有时候我们不需要等所有任务完成只需要等任意一个完成。比如从多个CDN源下载同一个文件谁先下完就用谁的。async UniTaskstring DownloadFromFastestCDNAsync(string[] cdnUrls) { ListUniTaskstring downloadTasks new ListUniTaskstring(); foreach (var url in cdnUrls) { var task DownloadTextAsync(url); // 假设这是一个返回string的下载方法 downloadTasks.Add(task); } // 等待任意一个任务完成 var (winningTask, winningIndex, _) await UniTask.WhenAny(downloadTasks); // 取消其他还在进行的下载任务 foreach (int index in Enumerable.Range(0, downloadTasks.Count).Where(i i ! winningIndex)) { // 这里需要你的DownloadTextAsync方法支持CancellationToken才能取消 // 假设我们给每个task关联了一个CancellationTokenSource // _tokenSources[index].Cancel(); } string result await winningTask; // 获取最快那个任务的结果 Debug.Log($最快CDN是第{winningIndex}个: {cdnUrls[winningIndex]}); return result; }UniTask.WhenAny返回一个(UniTaskT completedTask, int winArgumentIndex, (bool hasResult, T result))的结构我们可以从中知道是哪个任务最先完成了。4.4 异步流与LINQ处理连续事件协程擅长处理随时间推移的序列但代码组织依然是线性的。UniTask的异步LINQUniTaskAsyncEnumerable提供了类似Rx反应式扩展的声明式编程体验但它是基于拉取Pull模型的更符合async/await的直觉。一个经典案例处理玩家的连续点击比如三连击。using Cysharp.Threading.Tasks.Linq; // 需要引入LINQ命名空间 async UniTaskVoid WaitForTripleClick() { // 获取按钮的异步点击流 var asyncClickStream button.OnClickAsAsyncEnumerable(); // 使用LINQ操作符取前3次点击并对每次点击执行一个动作最后等待这个序列完成 await asyncClickStream .Take(3) // 只取前3次点击事件 .ForEachAsync((_) { Debug.Log(点击了一次); // 这里可以播放音效、动画等 }, cancellationToken: this.GetCancellationTokenOnDestroy()); Debug.Log(三连击完成触发特殊技能); // 执行三连击后的逻辑 }代码解读button.OnClickAsAsyncEnumerable()将UGUI按钮的onClick事件转换成了一个异步事件流IUniTaskAsyncEnumerableAsyncUnit。.Take(3)表示从这个流中“取出”前3个事件。.ForEachAsync会遍历这个流中的每一个元素即每次点击并执行我们提供的Action。它会等待这个遍历操作完成。整个过程是异步等待的代码会停在await这一行直到玩家真的点击了三次按钮。这比用协程写一个状态机来计数点击次数要清晰优雅得多。另一个实用场景EveryUpdateEveryUpdate是一个每帧产生一个元素的异步流可以完美替代在Update中写逻辑。async UniTaskVoid StartHealthRegeneration(float regenPerSecond) { float health 100f; float maxHealth 100f; // 每帧执行直到玩家生命值满格 await UniTaskAsyncEnumerable.EveryUpdate() .TakeWhile(_ health maxHealth) // 条件不满足时停止 .ForEachAsync(_ { health Mathf.Min(maxHealth, health regenPerSecond * Time.deltaTime); UpdateHealthUI(health); }, cancellationToken: this.GetCancellationTokenOnDestroy()); Debug.Log(生命值已回满); }TakeWhile操作符使得循环条件一目了然代码的意图非常明确。5. 高级技巧与性能优化5.1 PlayerLoopTiming精准控制执行时机UniTask的等待操作如UniTask.Yield,UniTask.Delay是基于Unity的PlayerLoop系统。你可以指定任务在PlayerLoop的哪个阶段恢复执行。这比协程的yield return null在ScriptRunDelayedDynamicFrameRate阶段或yield return WaitForFixedUpdate提供了更精细的控制。async UniTaskVoid AdvancedYieldExample() { Debug.Log(Frame: Time.frameCount); // 在Update循环之后但在LateUpdate之前恢复 await UniTask.Yield(PlayerLoopTiming.PreLateUpdate); Debug.Log(Yield at PreLateUpdate, Frame: Time.frameCount); // 在FixedUpdate阶段恢复等同于 WaitForFixedUpdate await UniTask.Yield(PlayerLoopTiming.FixedUpdate); Debug.Log(Yield at FixedUpdate, Frame: Time.frameCount); // 确保等待到下一帧行为最接近 yield return null await UniTask.NextFrame(); Debug.Log(NextFrame, Frame: Time.frameCount); }选择策略PlayerLoopTiming.Update这是默认值。在MonoBehaviour.Update之后、LateUpdate之前执行。如果你的逻辑需要依赖本帧Update的结果就用这个。PlayerLoopTiming.FixedUpdate在物理更新阶段执行。用于物理相关逻辑。PlayerLoopTiming.LastPostLateUpdate在一帧所有渲染后处理完成后执行。注意它不完全等同于WaitForEndOfFrame。WaitForEndOfFrame是在PlayerLoop全部执行完毕后才调用。如果需要完全等效的行为在Unity 2023.1之前请使用await UniTask.WaitForEndOfFrame(this);需要传入一个MonoBehaviour在2023.1之后可以直接使用await UniTask.WaitForEndOfFrame();。UniTask.NextFrame()这是最安全的“等到下一帧”的方法其行为与yield return null一致。5.2 Channel生产者-消费者模式的异步实现Channel是处理异步数据流的强大工具特别适合解耦生产数据和消费数据的模块。例如一个网络消息接收器生产者不断接收数据一个逻辑处理器消费者从队列中取出处理。using Cysharp.Threading.Tasks.Channels; // 需要引入Channels命名空间 public class MessageProcessor : MonoBehaviour { private Channelstring _messageChannel; private CancellationTokenSource _cts; void Start() { _messageChannel Channel.CreateSingleConsumerUnboundedstring(); _cts new CancellationTokenSource(); // 启动消费者任务 StartConsumer(_cts.Token).Forget(); // 模拟生产者 StartProducer().Forget(); } async UniTaskVoid StartProducer() { for (int i 0; i 10; i) { await UniTask.Delay(1000); string message $Message_{i}; // 生产者写入消息 _messageChannel.Writer.TryWrite(message); Debug.Log($已生产: {message}); } // 生产完毕关闭Channel _messageChannel.Writer.TryComplete(); } async UniTaskVoid StartConsumer(CancellationToken ct) { // 消费者读取消息流 await foreach (var message in _messageChannel.Reader.ReadAllAsync().WithCancellation(ct)) { Debug.Log($正在消费: {message}); await UniTask.Delay(500); // 模拟处理耗时 } Debug.Log(消费者结束Channel已关闭。); } void OnDestroy() { _cts?.Cancel(); _cts?.Dispose(); _messageChannel?.Writer.TryComplete(); // 确保Channel被关闭 } }代码解读Channel.CreateSingleConsumerUnboundedstring()创建了一个单消费者、无界容量无限的Channel。生产者通过_messageChannel.Writer.TryWrite()向Channel写入数据。消费者通过await foreach遍历_messageChannel.Reader.ReadAllAsync()来异步读取数据流。这是一个典型的异步迭代模式。生产完成后调用TryComplete()通知消费者流已结束。通过CancellationToken可以优雅地终止消费者循环。Channel非常适合日志系统、网络消息队列、事件总线等场景它能很好地处理生产速度和消费速度不匹配的问题。5.3 UniTaskTracker追踪与诊断内存泄漏异步编程的一个常见陷阱是“任务泄漏”Task Leak即一个UniTask被创建并开始运行但由于某些原因比如忘记等待、条件分支导致未等待它永远不会完成其引用一直存在于内存中。UniTask内置了一个强大的调试工具——UniTaskTracker。在Unity编辑器中打开Window - UniTask Tracker。勾选Enable Tracking和Enable StackTrace。运行你的游戏执行一些异步操作。在Tracker窗口你可以看到所有当前存活的UniTask实例包括它们的状态Pending, Succeeded, Faulted, Canceled以及创建时的调用堆栈。使用场景你发现游戏运行一段时间后内存缓慢增长怀疑有未完成的任务。某个异步操作似乎永远没回调你想知道它卡在哪里了。检查是否有本该被取消的任务还在运行。重要提示Enable StackTrace会捕获每个任务创建时的堆栈信息对性能有较大影响。仅在开发调试阶段开启发布版本务必关闭。5.4 性能调优与配置1. 自定义PlayerLoop注入默认情况下UniTask会向Unity的PlayerLoop注入所有支持的定时点PlayerLoopTiming。如果你的项目只用到Update和FixedUpdate可以通过初始化代码移除不必要的注入获得微小的性能提升。using UnityEngine; using Cysharp.Threading.Tasks; [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.BeforeSceneLoad)] public static void InitUniTaskLoop() { var loop PlayerLoop.GetCurrentPlayerLoop(); // 只注入 Update, FixedUpdate 和 LastPostLateUpdate (用于WaitForEndOfFrame) PlayerLoopHelper.Initialize(ref loop, PlayerLoopTiming.Update | PlayerLoopTiming.FixedUpdate | PlayerLoopTiming.LastPostLateUpdate); }将这段代码放在项目的某个静态类中它会在场景加载前执行。2. 池化配置UniTask通过内部对象池来实现零分配。你可以调整不同类型任务的池大小。// 查看当前池状态 foreach (var (type, size) in Cysharp.Threading.Tasks.Internal.TaskPool.GetCacheSizeInfo()) { Debug.Log(${type.Name}: {size}); } // 设置特定类型任务的最大池大小谨慎操作通常不需要调整 // TaskPool.SetMaxPoolSize(typeof(UniTaskCompletionSourceCoreAsyncUnit), 5000);3. 发布构建优化在Unity Editor的Development Build模式下C#编译器生成的异步状态机AsyncStateMachine是class会有堆分配。但在Release模式下它是struct没有分配。为了在开发时也能获得接近发布的性能可以在Player Settings中设置代码优化Edit - Project Settings - Player - Other Settings - Configuration将Script Compilation和Script Optimization*设置为Release。 或者在代码中设置#if UNITY_EDITOR UnityEditor.Compilation.CompilationPipeline.codeOptimization UnityEditor.Compilation.CodeOptimization.Release; #endif6. 常见问题与避坑指南6.1 “await”之后代码不执行了这通常是生命周期管理问题。确保承载异步方法的MonoBehaviour没有被意外销毁。最佳实践是始终传递this.GetCancellationTokenOnDestroy()作为取消令牌。这样当GameObject销毁时所有关联的异步操作都会被自动取消后续代码也不会执行避免了空引用异常。// 错误示范如果物体在LoadAsync完成前被销毁下面的Instantiate会报错。 async void Start() { var prefab await Resources.LoadAsyncGameObject(MyPrefab); Instantiate(prefab); // 可能为空 } // 正确示范 async UniTaskVoid Start() { try { var prefab await Resources.LoadAsyncGameObject(MyPrefab).WithCancellation(this.GetCancellationTokenOnDestroy()); if (prefab ! null) // 即使有Token检查一下也是好习惯 { Instantiate(prefab); } } catch (OperationCanceledException) { // 被取消了安静地退出 Debug.Log(加载被取消因为物体被销毁了。); } }6.2 UniTask和协程混用导致执行顺序错乱UniTask的PlayerLoopTiming和协程的yield阶段并不完全一致。例如UniTask.Yield(PlayerLoopTiming.Update)在ScriptRunBehaviourUpdate阶段恢复而yield return null在ScriptRunDelayedDynamicFrameRate阶段。这可能导致在同一帧内Update中启动的UniTask和协程的执行顺序与预期不符。解决方案在同一个项目中尽量统一使用一种异步方案。如果必须混用要清楚它们的执行时机差异并通过UniTask.NextFrame()或await UniTask.Yield(PlayerLoopTiming.LastPostLateUpdate)等方法来精确控制UniTask的恢复点以匹配协程的行为。6.3 WebGL平台上UniTask.Run报错UniTask.Run或UniTask.SwitchToThreadPool会尝试在线程池上执行代码而WebGL不支持多线程。在WebGL平台调用这些方法会抛出异常。解决方案对于需要后台计算的逻辑在WebGL平台使用UniTask.RunOnThreadPool的替代方案或者使用UniTask.Create来在主线程上模拟后台任务虽然会阻塞主线程。更好的做法是使用UniTask.Void或直接在主线程上使用async UniTask并利用UniTask.Delay来分割耗时操作避免卡顿。async UniTask HeavyCalculationOnWebGL() { for (int i 0; i 1000; i) { // 模拟一段计算 DoSomeCalculation(i); // 每计算100次让出一帧保持响应 if (i % 100 0) { await UniTask.Yield(); } } }6.4 如何对异步方法进行单元测试Unity的[UnityTest]属性只支持返回IEnumerator的测试方法。我们可以用UniTask.ToCoroutine将async UniTask方法转换为协程。using NUnit.Framework; using UnityEngine.TestTools; using System.Collections; [UnityTest] public IEnumerator TestMyAsyncMethod() { yield return MyAsyncTestMethod().ToCoroutine(); } private async UniTask MyAsyncTestMethod() { var result await SomeAsyncOperation(); Assert.AreEqual(expectedValue, result); }6.5 忘记调用.Forget()导致编译器警告async UniTaskVoid方法用于即发即弃的任务。调用这类方法时必须使用.Forget()来明确表示你“知道这个任务不会被等待并且接受可能出现的未处理异常”。public void OnButtonClick() { // 正确 FireAsyncEffect().Forget(); // 错误会有编译器警告 CS4014 // FireAsyncEffect(); } async UniTaskVoid FireAsyncEffect() { await UniTask.Delay(1000); PlayExplosion(); }.Forget()的另一个作用是会将方法中未捕获的异常路由到全局的UniTaskScheduler.UnobservedTaskException事件方便统一处理而不是让异常被静默吞掉。