1. 项目概述:为什么Unity开发者需要UniTask来管理复杂异步工作流?
如果你在Unity里做过稍微复杂一点的逻辑,比如一个角色从A点跑到B点,途中需要等待一个对话框弹出、玩家做出选择、然后播放一段过场动画、最后再加载下一个场景的资源,你大概率已经体会过“回调地狱”或者“协程嵌套”的酸爽了。传统的Coroutine(协程)配合yield return,代码写起来一层套一层,可读性差,错误处理更是噩梦。而原生的C#Task在Unity的MonoBehaviour生命周期和单线程主循环里又有点水土不服,用起来束手束脚。
这就是UniTask出现的背景。它不是一个简单的语法糖,而是一套为Unity量身定制的、高性能的异步/等待(async/await)解决方案。它深度整合了Unity的PlayerLoop,让你能用写同步代码一样清晰的逻辑,去处理那些 inherently asynchronous(天生异步)的游戏操作。今天要聊的“复杂异步工作流的顺序执行”,正是UniTask最能大显身手的场景。这不仅仅是“一个接一个”执行那么简单,它关乎资源加载、UI交互、动画播放、网络请求等多个离散异步操作如何优雅、可靠、高效地串联成一个完整的业务流程。
想象一下新手引导、关卡序列、剧情对话树或者一个复杂的合成系统,这些都需要严格的顺序控制。用UniTask,你可以把这些工作流写得像一本清晰的剧本,而不是一堆散落的纸条。
2. 核心思路:从“回调地狱”到“声明式工作流”
在深入代码之前,我们先理清思路。处理异步顺序执行,传统上有几种方式:
2.1 传统方式的困境
- 回调函数(Callback Hell):一个操作完成时调用下一个操作的回调。当链条变长时,代码向右无限延伸,难以阅读和维护,错误传播更是灾难。
- 协程嵌套(Coroutine Nesting):通过
StartCoroutine在一个协程里启动另一个协程。这改善了结构,但依然存在yield return等待的局限性,且取消操作和异常处理非常笨拙。 - 状态机(State Machine):手动维护一个枚举状态,在
Update里切换。这是最底层的方式,复杂且容易出错。
2.2 UniTask带来的范式转变UniTask引入了基于async/await的声明式编程模型。你不再关心“如何等待”,而是声明“等待什么”。顺序执行的核心思路变得极其直观:
- 将每个异步操作封装成一个返回
UniTask的方法。例如,UniTask LoadSceneAsync(string name),UniTask PlayCutsceneAsync(int id)。 - 使用
await关键字顺序调用它们。await会挂起当前方法的执行,直到等待的UniTask完成,然后继续执行下一行。从代码上看,它就是顺序执行的。 - 利用
UniTask提供的丰富组合器(Combinators)来处理更复杂的流程,比如UniTask.WhenAll(并行)、UniTask.WhenAny(竞速)、以及我们今天重点的UniTask.Sequence或直接await链式调用。
这种方式的巨大优势在于:
- 可读性:代码顺序就是执行顺序,一目了然。
- 可维护性:每个步骤都是独立的方法,易于复用和测试。
- 强大的错误处理:可以使用熟悉的
try-catch-finally来捕获整个工作流中任何步骤的异常。 - 便捷的取消操作:通过
CancellationToken可以轻松取消整个异步链。 - 零或低GC开销:
UniTask是值类型(struct),设计上就避免了托管堆分配,对于性能敏感的Unity应用至关重要。
3. 基础构建:将一切封装为UniTask
在构建工作流之前,我们必须把Unity中各种异步操作“翻译”成UniTask。UniTask已经为大多数常见操作提供了开箱即用的扩展方法。
3.1 资源加载Unity原生的ResourceRequest和Addressables/AssetBundle的异步加载,都可以轻松转换。
using Cysharp.Threading.Tasks; using UnityEngine; public class ResourceLoader { // 1. 使用 Resources.LoadAsync (传统Resources系统) public static async UniTask<GameObject> LoadPrefabAsync(string path) { // ToUniTask() 将 AsyncOperation 转换为 UniTask var request = Resources.LoadAsync<GameObject>(path); await request.ToUniTask(); return (GameObject)request.asset; } // 2. 使用 Addressables (推荐) public static async UniTask<GameObject> LoadAddressableAsync(string key) { // Addressables.LoadAssetAsync 本身返回 AsyncOperationHandle, // UniTask 有专门的扩展方法 WaitAsync() 或 ToUniTask() var handle = Addressables.LoadAssetAsync<GameObject>(key); // 使用 WithCancellation 支持取消 var obj = await handle.ToUniTask(); // 注意:Addressables需要管理释放,这里简化了。实际应考虑使用UniTask的返回值或封装生命周期。 return obj; } }注意:
Addressables的AsyncOperationHandle转换为UniTask后,await完成并不代表资源可以立即释放。你需要管理handle的生命周期,通常在依赖该资源的对象销毁时调用Addressables.Release(handle)。一个常见的模式是将handle与某个MonoBehaviour的生命周期绑定。
3.2 延时与帧控制替代yield return new WaitForSeconds(2)和yield return null。
public async UniTask MyTimedRoutine() { Debug.Log("开始等待"); // 等待2秒 await UniTask.Delay(TimeSpan.FromSeconds(2f)); // 或 UniTask.Delay(2000) 毫秒 Debug.Log("2秒后"); // 等待下一帧 await UniTask.NextFrame(); Debug.Log("下一帧"); // 等待直到某个条件满足 (替代 yield return new WaitUntil) await UniTask.WaitUntil(() => Input.GetKeyDown(KeyCode.Space)); Debug.Log("空格键被按下"); // 在 FixedUpdate 中等待 await UniTask.WaitForFixedUpdate(); }UniTask.Delay是基于时间的可靠等待,不受Time.timeScale影响(除非使用ignoreTimeScale: false参数)。UniTask.NextFrame比yield return null更轻量。
3.3 动画与Tween等待一个Animation播放完毕或一个DOTween动画完成。
using DG.Tweening; // 假设使用DOTween public async UniTask PlayAnimationsAsync(Animator animator, Transform target) { // 等待Animator状态播放完毕(简化示例,实际需根据状态名或哈希) animator.Play("Run"); // 使用 UniTask.WaitUntil 检查动画是否播放完毕。更严谨的做法是订阅动画事件或使用AnimatorStateInfo。 await UniTask.WaitUntil(() => animator.GetCurrentAnimatorStateInfo(0).normalizedTime >= 1.0f); // 等待DOTween动画完成 await target.DOMove(new Vector3(5, 0, 0), 1f).AsyncWaitForCompletion(); // DOTween的 AsyncWaitForCompletion() 返回的是一个可等待的 YieldInstruction 封装, // UniTask 可以很好地 await 它。也可以使用 ToUniTask() 扩展。 }3.4 UI交互(等待按钮点击)这是UniTask非常强大的一个场景,可以优雅地等待用户输入。
using UnityEngine.UI; public class DialogueUI : MonoBehaviour { public Button confirmButton; public Button cancelButton; // 返回一个UniTask,当按钮被点击时完成,并返回点击的是哪个按钮 public async UniTask<bool> WaitForPlayerChoiceAsync() { // 使用 UniTaskCompletionSource 手动控制一个 UniTask 的完成 var utcs = new UniTaskCompletionSource<bool>(); // 临时注册监听器 void OnConfirm() => utcs.TrySetResult(true); void OnCancel() => utcs.TrySetResult(false); confirmButton.onClick.AddListener(OnConfirm); cancelButton.onClick.AddListener(OnCancel); try { // 等待直到其中一个按钮被点击,结果即为true(确认)或false(取消) bool result = await utcs.Task; return result; } finally { // 无论成功还是异常,都要移除监听,避免内存泄漏 confirmButton.onClick.RemoveListener(OnConfirm); cancelButton.onClick.RemoveListener(OnCancel); } } }这个模式是处理一次性异步事件(如点击、碰撞、消息)的通用模板。UniTaskCompletionSource是你手动创建和控制UniTask生命周期的关键工具。
3.5 网络请求使用UnityWebRequest或其它HTTP客户端。
using UnityEngine.Networking; public async UniTask<string> FetchDataFromServerAsync(string url) { using (var request = UnityWebRequest.Get(url)) { // SendWebRequest 返回一个 AsyncOperation, 用 ToUniTask 等待 await request.SendWebRequest().ToUniTask(); if (request.result == UnityWebRequest.Result.Success) { return request.downloadHandler.text; } else { // 直接抛出异常,可以在上层统一捕获 throw new System.Exception($"网络请求失败: {request.error}"); } } }4. 核心实践:构建顺序执行的工作流
有了这些基础的UniTask构建块,我们就可以像搭积木一样组装复杂的工作流了。核心就是简单的await链。
4.1 基础顺序链最直观的方式,一行一个await。
public async UniTask ExecuteMainStorylineAsync() { Debug.Log("=== 主线剧情开始 ==="); // 1. 加载场景和必要资源 await SceneManager.LoadSceneAsync("Village").ToUniTask(); var hero = await ResourceLoader.LoadAddressableAsync("HeroPrefab"); Instantiate(hero, spawnPoint.position, Quaternion.identity); // 2. 播放开场动画 await PlayCutsceneAsync(101); // 3. 等待NPC对话并显示选择支 var dialogueResult = await dialogueUI.ShowDialogueAsync("OldMan", "Welcome to the village..."); if (dialogueResult == DialogueResult.AcceptQuest) { // 4. 如果接受任务,播放特定动画并更新任务状态 await PlayAnimationAsync("QuestAccepted"); questSystem.AcceptQuest(1); } // 5. 无论是否接受,都延时几秒后进入自由探索 await UniTask.Delay(3000); Debug.Log("=== 进入自由探索模式 ==="); // 工作流结束 }这段代码清晰地描述了一个剧情流程。任何一步出错(比如资源加载失败),异常会向上抛出,你可以用try-catch包裹整个ExecuteMainStorylineAsync来统一处理。
4.2 使用UniTask.Void处理“发后即忘”有些步骤你可能不想等待,但又需要它开始执行。可以用UniTask.Void或直接忽略await(不推荐,因为异常会被吞掉)。
public async UniTask StartGameWithEffectsAsync() { // 播放背景音乐(不等待其结束) _ = audioManager.PlayBGMAsync("MainTheme").Forget(); // Forget() 是 UniTask.Void 的别名 // 同时播放一个开场Logo特效,但也不等待 _ = PlayLogoEffectAsync(); // 等待核心资源加载(这是我们必须等待的) await LoadCoreAssetsAsync(); // 核心资源加载完后,继续主逻辑... }实操心得:对于“发后即忘”的任务,务必使用
.Forget()或将其赋值给一个_(丢弃变量)。这有两个好处:一是明确表达了开发者“不关心其结果”的意图;二是如果该异步方法内部发生异常,UniTask会将其发布到UniTaskScheduler.UnobservedTaskException,你可以在全局设置一个处理器来记录这些未被观察的异常,避免静默失败。直接调用而不await或不处理返回值,异常会被吞没,极难调试。
4.3 错误处理与取消这是生产级代码必须考虑的。
public async UniTask<bool> TryExecuteTutorialAsync(CancellationToken cancellationToken = default) { try { // 将CancellationToken传递给每一个支持取消的内部异步操作 await ShowWelcomeMessageAsync(cancellationToken); await UniTask.Delay(1000, cancellationToken: cancellationToken); // Delay也支持取消 await GuidePlayerMovementAsync(cancellationToken); // 如果cancellationToken在等待过程中被取消,会抛出OperationCanceledException return true; } catch (OperationCanceledException) { Debug.Log("教程被用户取消。"); // 执行一些清理工作,比如关闭打开的UI tutorialUI.CloseAll(); return false; } catch (System.Exception e) { Debug.LogError($"教程执行出错: {e.Message}"); // 可能弹出一个错误提示框 errorPopup.Show("Tutorial failed."); return false; } finally { // 无论成功、失败还是取消,finally块都会执行,适合做通用清理 Resources.UnloadUnusedAssets(); } } // 在某个MonoBehaviour中调用 private CancellationTokenSource _tutorialCts; private void StartTutorial() { _tutorialCts?.Cancel(); // 取消之前的(如果存在) _tutorialCts = new CancellationTokenSource(); _ = TryExecuteTutorialAsync(_tutorialCts.Token); // 启动新的 } private void OnPlayerPressedSkipButton() { _tutorialCts?.Cancel(); // 用户点击跳过,触发取消 }关键点:
- 传递
CancellationToken:尽可能让你封装的异步方法接受一个CancellationToken参数,并传递给内部所有可取消的UniTask操作(如Delay,WaitUntil, 网络请求等)。这是实现协同取消的基础。 - 区分取消和错误:
OperationCanceledException是正常的取消流程,不应视为错误。其他Exception才是真正的异常。 - 管理
CancellationTokenSource生命周期:谁创建,谁负责在适当的时候(如OnDestroy)调用Dispose()。通常将CTS与MonoBehaviour的生命周期绑定。
4.4 组合使用:顺序中的并行一个复杂工作流中,某些步骤可以并行以提升性能。
public async UniTask LoadLevelAsync(int levelId) { // 1. 顺序:先显示加载界面 loadingScreen.Show(); // 2. 并行:同时加载场景、UI资源和背景音乐 var loadSceneTask = SceneManager.LoadSceneAsync($"Level_{levelId}").ToUniTask(); var loadUITask = LoadUIResourcesAsync(); var loadBGMTask = audioManager.LoadAudioClipAsync($"BGM_Level{levelId}"); // 使用 UniTask.WhenAll 等待所有并行任务完成 await UniTask.WhenAll(loadSceneTask, loadUITask, loadBGMTask); // 3. 顺序:全部加载完后,隐藏加载界面并播放一个入场效果 loadingScreen.Hide(); await PlayLevelEntranceEffectAsync(); Debug.Log("关卡加载完成!"); }UniTask.WhenAll返回一个新的UniTask,当所有传入的任务都完成时,它才完成。如果其中任何一个任务失败(抛出异常),WhenAll也会立即失败,并抛出那个异常(或AggregateException)。这保证了错误能及时传递。
5. 高级模式与架构设计
当工作流变得非常复杂,比如一个包含多个阶段、每个阶段又有分支和循环的RPG任务系统时,简单的await链会变得难以维护。我们需要更结构化的设计。
5.1 状态机与UniTask结合将工作流的每个步骤抽象为一个状态(State),每个状态是一个返回UniTask的方法。由一个状态机控制器来驱动状态的转换。
public interface IGameState { UniTask OnEnterState(); UniTask OnExecuteState(); UniTask OnExitState(); } public class DialogueState : IGameState { private DialogueUI _ui; public async UniTask OnEnterState() { /* 显示对话框UI */ } public async UniTask OnExecuteState() { var choice = await _ui.WaitForPlayerChoiceAsync(); // 根据choice决定下一个状态... } public async UniTask OnExitState() { /* 隐藏对话框UI */ } } public class GameStateMachine { private IGameState _currentState; public async UniTask ChangeStateAsync(IGameState newState) { if (_currentState != null) { await _currentState.OnExitState(); } _currentState = newState; if (_currentState != null) { await _currentState.OnEnterState(); await _currentState.OnExecuteState(); // 执行状态逻辑,内部可能触发下一次状态变更 } } }这种模式非常适合游戏流程管理,如战斗状态、菜单导航等。
5.2 使用UniTask.Lazy实现延迟执行与依赖管理有时,工作流中的某些步骤是昂贵的,但可能因为条件分支而永远不会被执行。我们可以用UniTask.Lazy来延迟创建UniTask。
public class SmartAssetLoader { private Dictionary<string, UniTask<GameObject>> _assetCache = new(); public UniTask<GameObject> LoadAssetLazyAsync(string key) { if (!_assetCache.TryGetValue(key, out var task)) { // UniTask.Lazy 直到这个task被await时,内部的工厂方法才会被执行。 task = UniTask.Lazy(async () => { Debug.Log($"开始真正加载资源: {key}"); var obj = await Addressables.LoadAssetAsync<GameObject>(key).Task; return obj; }); _assetCache[key] = task; } return task; } } // 使用 public async UniTask MaybeLoadAssetAsync() { if (player.HasSpecialItem()) { // 只有满足条件时,才会触发实际的加载操作 var specialAsset = await loader.LoadAssetLazyAsync("SpecialEffect"); UseAsset(specialAsset); } // 否则,“SpecialEffect”资源永远不会被加载 }这对于优化内存和加载时间非常有用,实现了按需加载。
5.3 自定义Awaiter实现特定等待条件虽然UniTask提供了丰富的等待方法,但有时你需要等待一些非常特定的事件。你可以通过实现INotifyCompletion和ICriticalNotifyCompletion接口来创建自定义的Awaiter。不过,更简单的方式是利用现有的UniTaskCompletionSource。
例如,等待游戏内某个自定义事件:
public class GameEventAwaiter { private static Dictionary<string, UniTaskCompletionSource<bool>> _eventTasks = new(); public static UniTask WaitForEventAsync(string eventName) { if (!_eventTasks.ContainsKey(eventName)) { _eventTasks[eventName] = new UniTaskCompletionSource<bool>(); } return _eventTasks[eventName].Task; } public static void TriggerEvent(string eventName) { if (_eventTasks.TryGetValue(eventName, out var utcs)) { utcs.TrySetResult(true); // 可选:移除或创建新的CompletionSource以供下次等待 _eventTasks.Remove(eventName); } } } // 在工作流中等待 await GameEventAwaiter.WaitForEventAsync("EnemyBossDefeated"); Debug.Log("Boss被打败了,继续剧情!"); // 在Boss死亡的地方触发 void OnBossDeath() { GameEventAwaiter.TriggerEvent("EnemyBossDefeated"); }6. 性能优化与陷阱规避
UniTask性能很好,但滥用或误用仍会导致问题。
6.1 避免在Update循环中频繁创建UniTaskUniTask是值类型,本身分配在栈上,GC压力小。但如果你在Update中频繁调用一个返回new UniTask的方法,或者频繁使用UniTask.Run(在线程池上调度),仍然会有开销。
// 不佳:每帧都创建一个新的UniTask void Update() { if (Input.GetKeyDown(KeyCode.Space)) { _ = DoSomethingAsync(); // 这会在每帧按键时都产生一个任务 } } // 更佳:使用状态标志或节流 private bool _isProcessing = false; async void Update() { if (Input.GetKeyDown(KeyCode.Space) && !_isProcessing) { _isProcessing = true; await DoSomethingAsync(); _isProcessing = false; } }6.2 注意Unity对象生命周期与异步这是Unity异步编程中最常见的坑。在async方法中,你可能会在await之后尝试访问一个已经被销毁的Unity对象(如GameObject或MonoBehaviour)。
public class DangerousBehaviour : MonoBehaviour { public async UniTaskVoid DoSomethingAndDestroyAsync() { await UniTask.Delay(3000); // 等待3秒 // 危险!3秒内这个GameObject可能已经被Destroy了。 this.transform.position = Vector3.one; // 可能抛出 MissingReferenceException } private void OnDestroy() { // 如果异步操作还在进行,需要取消它 } }解决方案:
- 使用
CancellationToken:将MonoBehaviour的DestroyCancellationToken传递给所有异步方法。public async UniTaskVoid SafeCoroutineAsync() { // 使用this.GetCancellationTokenOnDestroy()获取与GameObject生命周期绑定的Token var ct = this.GetCancellationTokenOnDestroy(); try { await UniTask.Delay(3000, cancellationToken: ct); // 如果对象在等待期间被销毁,await会抛出OperationCanceledException,不会执行到这里。 this.transform.position = Vector3.one; } catch (OperationCanceledException) { // 正常取消,安静退出 } } - 在await后检查对象是否存活:
第一种方法(使用CancellationToken)更优雅、更可靠,是await UniTask.Delay(3000); if (this == null) return; // 或者 if (!this.gameObject.activeInHierarchy) this.transform.position = Vector3.one;UniTask在Unity中的最佳实践之一。
6.3 处理异步方法中的异常async方法中抛出的异常,会在该方法返回的Task/UniTask被await时重新抛出。如果这个UniTask没有被await(比如用Forget()了),异常默认会被吞没。
- 全局异常处理:可以订阅
UniTaskScheduler.UnobservedTaskException来记录所有未被观察的异常。UniTaskScheduler.UnobservedTaskException += (sender, e) => { Debug.LogError($"未观察到的异常: {e.Exception}"); }; - 局部处理:对于重要的后台任务,即使不
await,也尽量用ContinueWith或Suppress来处理异常。DoRiskyOperationAsync().Forget(); // 异常可能丢失 DoRiskyOperationAsync().ContinueWith((task) => { if (task.Status == UniTaskStatus.Faulted) { Debug.LogError($"操作失败: {task.GetAwaiter().GetResult()}"); } }).Forget(); // 这样能捕获到异常
7. 实战:一个完整的关卡加载与初始化工作流示例
让我们将所有知识点串联起来,实现一个真实的关卡加载流程。
using Cysharp.Threading.Tasks; using UnityEngine; using UnityEngine.AddressableAssets; using UnityEngine.ResourceManagement.AsyncOperations; using UnityEngine.SceneManagement; using System.Collections.Generic; using System.Threading; public class LevelManager : MonoBehaviour { [SerializeField] private LoadingScreen _loadingScreen; [SerializeField] private string _levelAddressableKey; [SerializeField] private string[] _preloadAssetKeys; private CancellationTokenSource _loadingCts; private List<AsyncOperationHandle> _assetHandles = new List<AsyncOperationHandle>(); public async UniTask<bool> LoadLevelAsync(int levelIndex) { // 0. 取消可能正在进行的上一次加载 _loadingCts?.Cancel(); _loadingCts = new CancellationTokenSource(); var ct = _loadingCts.Token; try { // 1. 显示加载界面 _loadingScreen.ShowProgress(0f); await UniTask.NextFrame(ct); // 确保UI更新一帧 // 2. 并行预加载常用资产(不阻塞主流程) var preloadTask = PreloadCommonAssetsAsync(ct); // 3. 加载关卡场景(主耗时操作) _loadingScreen.UpdateStatus("正在加载场景..."); var sceneLoadOp = Addressables.LoadSceneAsync($"Levels/Level_{levelIndex}", LoadSceneMode.Single, false); // 不自动激活 _assetHandles.Add(sceneLoadOp); // 将加载进度反馈给UI while (!sceneLoadOp.IsDone && !ct.IsCancellationRequested) { await UniTask.Yield(ct); _loadingScreen.UpdateProgress(sceneLoadOp.PercentComplete * 0.7f); // 场景加载占70%进度 } ct.ThrowIfCancellationRequested(); // 如果取消,抛出异常 // 4. 等待预加载完成 _loadingScreen.UpdateStatus("正在加载资源..."); await preloadTask; _loadingScreen.UpdateProgress(0.85f); // 5. 加载关卡专属资源 var levelSpecificTask = LoadLevelSpecificAssetsAsync(ct); await levelSpecificTask; _loadingScreen.UpdateProgress(0.95f); // 6. 激活场景(瞬间完成) var sceneInstance = sceneLoadOp.Result; await sceneInstance.ActivateAsync().ToUniTask(cancellationToken: ct); _loadingScreen.UpdateProgress(1.0f); await UniTask.Delay(300, cancellationToken: ct); // 短暂停留,让玩家看到100% // 7. 隐藏加载界面,播放入场效果 _loadingScreen.Hide(); await PlayLevelEntranceEffectAsync(ct); Debug.Log($"关卡 {levelIndex} 加载完成!"); return true; } catch (OperationCanceledException) { Debug.Log("关卡加载被取消。"); Cleanup(); return false; } catch (System.Exception e) { Debug.LogError($"关卡加载失败: {e}"); _loadingScreen.ShowError("加载失败,请重试。"); Cleanup(); return false; } } private async UniTask PreloadCommonAssetsAsync(CancellationToken ct) { var tasks = new List<UniTask>(); foreach (var key in _preloadAssetKeys) { // 使用Lazy或直接加载,这里假设直接加载 var handle = Addressables.LoadAssetAsync<GameObject>(key); _assetHandles.Add(handle); tasks.Add(handle.ToUniTask().AttachExternalCancellation(ct)); } // 并行加载所有通用资源 await UniTask.WhenAll(tasks); } private async UniTask LoadLevelSpecificAssetsAsync(CancellationToken ct) { // 加载关卡配置、敌人数据等 var configTask = Addressables.LoadAssetAsync<LevelConfig>($"{_levelAddressableKey}_Config").ToUniTask().AttachExternalCancellation(ct); // ... 其他加载 await configTask; // 应用配置... } private async UniTask PlayLevelEntranceEffectAsync(CancellationToken ct) { // 播放一个简单的淡入或动画 await _loadingScreen.FadeOutAsync(0.5f, ct); } private void Cleanup() { // 释放所有通过Addressables加载的资源的句柄 foreach (var handle in _assetHandles) { if (handle.IsValid()) { Addressables.Release(handle); } } _assetHandles.Clear(); _loadingCts?.Dispose(); _loadingCts = null; } private void OnDestroy() { // 组件销毁时,取消加载并清理 _loadingCts?.Cancel(); Cleanup(); } }这个示例涵盖了顺序与并行混合、进度反馈、错误处理、资源生命周期管理和取消操作,是一个接近生产环境的复杂工作流模板。
最后一点个人体会:从协程切换到UniTask最大的障碍不是语法,而是思维方式的转变。一旦你习惯了用async/await来思考异步流程,你就会发现代码的掌控力大大增强。开始时,可以从替换简单的WaitForSeconds和WWW/UnityWebRequest入手,慢慢尝试处理UI事件,最后再架构复杂的游戏流程。记得始终把CancellationToken和对象生命周期放在心上,这两点是写出稳健异步代码的护城河。