1. 项目概述为什么异步任务取消是Unity开发的“必修课”如果你在Unity开发中用过Task、Coroutine或者UniTask那你大概率遇到过这样的场景一个加载资源的异步操作还没完成玩家就迫不及待地切换了场景。结果呢新场景里莫名其妙地出现了旧场景的UI元素或者后台还在吭哧吭哧地加载已经用不上的资源内存蹭蹭往上涨甚至直接给你来个空引用异常NullReferenceException。这背后的“罪魁祸首”往往就是那些没有被妥善管理的异步任务。今天要聊的就是如何构建一个基于场景切换的自动取消机制这可以说是Unity异步编程里最实用、最能体现工程素养的一环。简单来说这个机制的核心目标就一个当场景发生切换时自动、干净地取消掉所有属于旧场景的异步操作。这听起来像是框架该做的事但Unity本身并没有提供一个开箱即用的、完美的解决方案。MonoBehaviour的OnDestroy是个信号但它太被动了而且不是所有异步任务都挂载在GameObject上。手动管理那意味着你要在每一个异步调用点都写上一段if (cancellationToken.IsCancellationRequested)的判断繁琐且容易遗漏。所以我们需要一个更系统、更自动化的方法。这个需求在移动端、尤其是需要频繁切换场景的RPG、卡牌或者大型开放世界游戏中尤为突出。一个没取消的资源加载任务可能意味着几十MB的内存被无意义地占用着一个没取消的网络请求可能会在新场景里触发错误的回调逻辑。因此实现一个健壮的自动取消机制不是“锦上添花”而是保障应用稳定性和性能的“雪中送炭”。接下来我会从设计思路开始一步步拆解如何实现这个“终极指南”级别的方案。2. 核心设计思路从“手动挡”到“自动挡”的演进在深入代码之前我们先理清思路。一个优秀的自动取消机制我认为需要满足以下几个核心设计目标自动化开发者无需或极少需要显式地在每个异步操作后调用取消。场景切换应作为一个全局事件自动触发取消逻辑。精准性只取消属于即将卸载场景的异步任务不影响全局的、跨场景的或系统级别的后台任务比如音乐播放、数据持久化。易用性接入成本要低。理想情况下开发者只需要用一种特定的方式创建异步任务就能自动获得取消保护。可扩展性不仅能处理场景切换未来也可能需要支持其他取消条件如玩家主动取消、超时等。与流行异步方案兼容最好能同时支持Unity原生的协程Coroutine、.NET的Task以及社区流行的UniTask。基于这些目标一个最直观的思路是利用Unity的场景生命周期。我们知道在场景切换时旧场景中的GameObject会被销毁触发OnDestroy。我们可以创建一个“场景生命周期管理器”的单例它监听场景加载/卸载事件。当检测到场景即将卸载时它便触发一个与该场景绑定的“取消令牌”CancellationToken。这里的关键在于“绑定”。我们需要一种方式将一个异步任务与其所属的场景关联起来。我实践下来最有效的方式是使用场景的句柄Scene.handle或名称作为标识键。每个场景激活时管理器为其创建一个唯一的CancellationTokenSource。任何在该场景中发起的、需要受保护的异步任务都必须使用这个特定的令牌。当场景卸载时管理器调用对应CancellationTokenSource的Cancel()方法所有使用该令牌的任务都会收到取消请求。注意这里有一个常见的误区就是使用GameObject的销毁来驱动取消。虽然OnDestroy里可以取消任务但问题在于不是所有异步任务都依附于一个GameObject例如静态类中的静态方法发起的任务。因此以场景为管理单元是更通用、更彻底的做法。2.1 方案选型为什么是CancellationTokenSource在.NET和C#的异步编程中CancellationTokenSource和CancellationToken是标准的生产者-消费者取消模式。CancellationTokenSource是取消指令的“发射器”而CancellationToken是传递给异步操作的“接收器”。这种模式是线程安全的并且被Task、async/await以及许多I/O操作原生支持。对于Unity协程虽然它不直接接受CancellationToken但我们可以很容易地包装它。例如我们可以创建一个MonoBehaviour脚本在其OnDestroy中设置一个标志位然后在协程里每帧检查这个标志位。但更优雅的方式是利用CancellationToken的注册功能在令牌被取消时强制停止承载协程的MonoBehaviour上的所有协程或者停止特定的协程。至于UniTask它本身就深度集成了CancellationToken使用起来最为顺畅。因此基于CancellationTokenSource来构建我们的取消机制能够最大程度地兼容现有的异步生态也是最具前瞻性的选择。3. 核心模块实现构建SceneBoundTaskManager理论说得再多不如一行代码。我们来动手实现核心的管理器类我称之为SceneBoundTaskManager。这个类将是一个单例负责管理场景与取消令牌的映射关系。3.1 基础结构与事件订阅首先我们需要一个字典来映射场景标识到对应的CancellationTokenSource。场景标识我选择使用int类型的场景句柄Scene.handle因为它具有唯一性和稳定性。using System; using System.Collections.Generic; using UnityEngine; using UnityEngine.SceneManagement; public class SceneBoundTaskManager : MonoBehaviour { private static SceneBoundTaskManager _instance; public static SceneBoundTaskManager Instance { get { if (_instance null) { var go new GameObject(SceneBoundTaskManager); _instance go.AddComponentSceneBoundTaskManager(); DontDestroyOnLoad(go); // 使其跨场景存在 } return _instance; } } // 核心字典场景句柄 - 该场景的CancellationTokenSource private Dictionaryint, CancellationTokenSource _sceneTokenSources new Dictionaryint, CancellationTokenSource(); private void Awake() { if (_instance ! null _instance ! this) { Destroy(this.gameObject); return; } _instance this; // 订阅场景加载和卸载事件 SceneManager.sceneUnloaded OnSceneUnloaded; // 注意我们通常在场景加载后sceneLoaded为其创建令牌但这里更合适的时机是场景被激活或我们第一次为其注册任务时。 // 为了简单我们可以在GetTokenForScene方法中惰性创建。 } private void OnDestroy() { // 清理所有令牌源释放资源 foreach (var cts in _sceneTokenSources.Values) { cts?.Cancel(); // 通知所有任务取消 cts?.Dispose(); } _sceneTokenSources.Clear(); SceneManager.sceneUnloaded - OnSceneUnloaded; } }3.2 关键方法获取与场景绑定的令牌这是管理器的核心方法。它接收一个场景对象返回该场景对应的CancellationToken。如果该场景是第一次被请求则为其创建一个新的CancellationTokenSource。public CancellationToken GetTokenForScene(Scene scene) { int sceneHandle scene.handle; if (!_sceneTokenSources.TryGetValue(sceneHandle, out var cts)) { // 惰性创建第一次为该场景请求令牌时才创建TokenSource cts new CancellationTokenSource(); _sceneTokenSources[sceneHandle] cts; // 可选如果场景是DontDestroyOnLoad的我们需要特殊处理这里先不考虑。 } return cts.Token; } // 一个更常用的重载获取当前活动场景的令牌 public CancellationToken GetTokenForActiveScene() { return GetTokenForScene(SceneManager.GetActiveScene()); }3.3 场景卸载时的自动取消当场景被卸载时我们需要找到对应的CancellationTokenSource触发取消并清理资源。private void OnSceneUnloaded(Scene unloadedScene) { int sceneHandle unloadedScene.handle; if (_sceneTokenSources.TryGetValue(sceneHandle, out var cts)) { // 触发取消 cts.Cancel(); // 释放资源 cts.Dispose(); // 从字典中移除 _sceneTokenSources.Remove(sceneHandle); Debug.Log($CancellationTokenSource for scene {unloadedScene.name} (handle:{sceneHandle}) has been cancelled and disposed.); } }3.4 为协程提供支持由于协程不直接接受CancellationToken我们需要一个辅助方法。这里提供两种思路方法一包装协程内部轮询检查创建一个静态方法启动一个协程来包装用户的实际协程并在每帧检查取消令牌。public Coroutine StartCoroutineForScene(IEnumerator routine, Scene scene, MonoBehaviour runner null) { CancellationToken token GetTokenForScene(scene); if (runner null) { runner Instance; // 默认用管理器自己作为协程运行载体 } return runner.StartCoroutine(RoutineWrapper(routine, token)); } private IEnumerator RoutineWrapper(IEnumerator userRoutine, CancellationToken token) { while (true) { // 检查取消请求 if (token.IsCancellationRequested) { Debug.Log(Coroutine cancelled via token.); yield break; // 终止包装协程 } // 执行用户协程的一步 if (!userRoutine.MoveNext()) { yield break; // 用户协程执行完毕 } // 返回用户协程yield的对象可能是WaitForSeconds, null, 等 yield return userRoutine.Current; } }方法二更高效的注册取消回调利用CancellationToken.Register在令牌取消时直接停止承载协程的MonoBehaviour上的所有协程或停止特定的协程句柄。这种方法更直接但需要保存Coroutine引用。public Coroutine StartCoroutineWithCancellation(IEnumerator routine, MonoBehaviour runner, CancellationToken token) { Coroutine coroutine runner.StartCoroutine(routine); // 注册一个回调当令牌取消时停止这个协程 token.Register(() { if (runner ! null coroutine ! null) { runner.StopCoroutine(coroutine); } }); return coroutine; }在实际项目中我推荐方法二因为它更高效不需要每帧检查。你可以将StartCoroutineWithCancellation作为管理器的扩展方法使用。4. 实战应用在三种异步模式中使用管理器搭建好了现在看看怎么在具体的异步编程中使用它。我们分Task、UniTask和Coroutine三种情况来看。4.1 与 async/await 和 Task 配合这是最自然的使用方式。在任何async方法中如果你想让它与当前场景绑定只需在开始时获取令牌并将其传递给所有支持取消的内部异步调用。using System.Threading.Tasks; using UnityEngine; public class MySceneBoundService : MonoBehaviour { public async Task LoadSceneSpecificDataAsync(string dataPath) { // 关键步骤获取当前场景的取消令牌 var cancellationToken SceneBoundTaskManager.Instance.GetTokenForActiveScene(); try { // 模拟一个耗时的加载操作并传递令牌 await Task.Delay(2000, cancellationToken); // Task.Delay支持取消 // 假设我们有一个支持取消的AssetBundle加载方法 // var assetBundle await AssetBundle.LoadFromFileAsync(dataPath).ToTask(cancellationToken); TextAsset data await LoadTextAssetAsync(dataPath, cancellationToken); Debug.Log($Data loaded: {data.text}); } catch (OperationCanceledException) // 捕获任务取消异常 { Debug.Log(Data loading was cancelled due to scene change.); // 这里可以进行一些清理工作但不需要再抛出异常 } catch (System.Exception e) { Debug.LogError($Failed to load data: {e.Message}); } } private async TaskTextAsset LoadTextAssetAsync(string path, CancellationToken token) { // 这里是你的实际加载逻辑比如使用UnityWebRequest // 记得在UnityWebRequest.SendWebRequest().ToTask()时传递token await Task.Yield(); // 占位 return null; } }关键点使用try-catch捕获OperationCanceledException。这是处理任务取消的标准方式。将cancellationToken传递给所有支持取消的底层异步API如Task.Delay,HttpClient.GetAsync等。对于Unity原生不支持取消的异步操作如某些AsyncOperation需要自己包装或使用UniTask等库提供的扩展。4.2 与 UniTask 深度集成UniTask对取消令牌的支持是原生的并且性能更好。使用方式与Task类似但语法更简洁。using Cysharp.Threading.Tasks; using UnityEngine; public class MyUniTaskComponent : MonoBehaviour { public async UniTaskVoid LoadAndShowUIAsync() // UniTaskVoid 用于fire-and-forget的异步方法 { var ct SceneBoundTaskManager.Instance.GetTokenForActiveScene(); // 使用UniTask.Delay它直接接受CancellationToken await UniTask.Delay(1000, cancellationToken: ct); // 使用UniTask等待Unity的异步操作并链接取消令牌 AsyncOperation loadOp Resources.LoadAsyncGameObject(SomeUI); var prefab await loadOp.WithCancellation(ct); // WithCancellation 是UniTask的扩展方法 if (prefab is GameObject uiPrefab) { Instantiate(uiPrefab); } } private void OnEnable() { // 直接启动无需保存Task引用如果场景切换此任务会自动取消。 LoadAndShowUIAsync().Forget(); } }UniTask的WithCancellation扩展方法非常强大它可以将几乎任何YieldInstruction、AsyncOperation或自定义的IEnumerator转换为可取消的UniTask。这使得集成变得异常简单。4.3 与传统协程Coroutine配合对于遗留代码或必须使用协程的情况使用我们上面提供的StartCoroutineWithCancellation辅助方法。public class MyLegacyCoroutineScript : MonoBehaviour { private Coroutine _myCoroutine; void Start() { // 使用管理器启动一个受场景生命周期保护的协程 var token SceneBoundTaskManager.Instance.GetTokenForActiveScene(); _myCoroutine SceneBoundTaskManager.Instance.StartCoroutineWithCancellation(MyLongRunningCoroutine(), this, token); } IEnumerator MyLongRunningCoroutine() { yield return new WaitForSeconds(1); Debug.Log(Coroutine step 1); yield return new WaitForSeconds(2); // 如果在这2秒等待期间场景切换了这个协程会被StopCoroutine Debug.Log(Coroutine step 2 - this might not print if scene changed); } // 即使我们忘记在OnDestroy里StopCoroutine管理器也会帮我们停掉它。 }实操心得对于新项目我强烈建议直接使用async/awaitUniTask的模式并统一通过SceneBoundTaskManager获取取消令牌。对于老项目改造可以优先使用StartCoroutineWithCancellation来包装关键的、耗时的协程逐步迁移。5. 高级话题与边界情况处理一个健壮的系统必须考虑边界情况。下面是一些实践中会遇到的问题和解决方案。5.1 处理DontDestroyOnLoad场景和对象我们的机制是基于场景卸载来取消任务的。但如果一个GameObject或整个场景被标记为DontDestroyOnLoad它就不会被卸载那么绑定到它的任务也不应该被自动取消。解决方案我们需要区分“场景绑定”和“全局绑定”。可以为管理器增加一个“全局令牌”Global Token用于那些跨场景的持久性任务。同时在GetTokenForScene方法中如果检测到传入的场景是DontDestroyOnLoad的这需要自己维护一个列表或通过Tag判断则返回一个特殊的、不会随场景卸载而取消的令牌或者直接返回全局令牌。private CancellationTokenSource _globalTokenSource new CancellationTokenSource(); public CancellationToken GetTokenForScene(Scene scene) { // 假设我们有一个方法来判断场景是否是持久化的 if (IsPersistentScene(scene)) { return _globalTokenSource.Token; } int sceneHandle scene.handle; if (!_sceneTokenSources.TryGetValue(sceneHandle, out var cts)) { cts new CancellationTokenSource(); _sceneTokenSources[sceneHandle] cts; } return cts.Token; } private bool IsPersistentScene(Scene scene) { // 实现你的判断逻辑例如检查场景名是否在预设的持久化场景列表中 // 或者检查场景根物体是否有特定的组件或Tag。 // 这是一个简单的示例 GameObject[] rootObjs scene.GetRootGameObjects(); foreach (var obj in rootObjs) { if (obj.CompareTag(Persistent)) return true; } return false; }在游戏退出时记得取消并释放_globalTokenSource。5.2 任务取消后的资源清理取消令牌只是发出了一个信号。异步操作自身必须有良好的取消响应逻辑进行资源清理。例如网络请求在收到取消信号时应调用Abort()或Dispose()。AssetBundle加载如果使用的是AssetBundle.LoadFromFileAsync它本身不支持取消但你可以通过UniTask的WithCancellation使其在取消时提前返回并在后续代码中判断并释放可能已加载的部分资源。UnityWebRequest一定要调用Dispose()方法否则会造成内存泄漏。private async TaskTexture LoadTextureAsync(string url, CancellationToken token) { using (UnityWebRequest webRequest UnityWebRequestTexture.GetTexture(url)) { // 将取消令牌与请求关联取消时中止请求 token.Register(() webRequest.Abort()); try { await webRequest.SendWebRequest().ToTask(token); if (webRequest.result UnityWebRequest.Result.Success) { return DownloadHandlerTexture.GetContent(webRequest); } else { throw new System.Exception(webRequest.error); } } catch (OperationCanceledException) { Debug.Log(Web request cancelled.); // webRequest会在using块结束时被Dispose即使发生异常。 throw; // 可以选择重新抛出或吞掉异常 } // 其他异常处理... } }5.3 性能考量与令牌源管理频繁创建和销毁CancellationTokenSource会有微小开销。如果你的游戏场景切换极其频繁比如每帧都在加载卸载小场景可能需要一个对象池来管理CancellationTokenSource。但对于绝大多数游戏直接new和Dispose()的开销是可以接受的。另一个重点是确保CancellationTokenSource在被取消后一定要调用Dispose()。我们的OnSceneUnloaded方法中已经做了这件事。对于全局令牌源在游戏退出时如OnApplicationQuit也需要处理。6. 常见问题排查与调试技巧即使有了完善的机制异步任务相关的Bug依然可能发生。下面是一些常见问题的排查清单。问题现象可能原因排查步骤与解决方案场景切换后旧任务仍在执行导致空引用或逻辑错误1. 任务没有使用场景绑定的令牌。2. 任务内部没有正确响应取消请求例如一个不支持取消的AsyncOperation。3. 任务运行在DontDestroyOnLoad对象上但错误地使用了场景令牌。1. 检查异步方法开头是否通过SceneBoundTaskManager.Instance.GetTokenForActiveScene()获取了令牌。2. 检查所有内部的异步调用是否都传递了这个令牌。对于不支持取消的操作考虑用Task.WhenAny配合一个Task.Delay(Timeout, token)来实现超时或取消。3. 确认任务所属对象的生命周期必要时使用全局令牌。任务被取消了但资源没有释放内存泄漏取消令牌只发出信号资源清理逻辑在任务内部没有执行。在catch (OperationCanceledException)块中或token.IsCancellationRequested检查后添加资源释放代码如Dispose()、Destroy临时对象等。确保使用using语句包裹可释放资源。收到大量“OperationCanceledException”日志影响性能这是正常现象但过多可能说明取消太频繁或任务粒度太细。1. 可以考虑将多个小任务合并为一个大的可取消任务。2. 对于不关心取消原因的任务可以使用token.ThrowIfCancellationRequested()而不是await一个会抛出异常的操作或者直接检查IsCancellationRequested然后return。3. 在开发阶段可以保留日志用于调试发布时可以减少此类日志的输出级别。编辑器播放模式下停止运行但后台任务似乎没停止Unity编辑器停止播放时所有托管线程会被中止但某些非托管资源或静态事件可能残留。确保在管理器的OnDestroy或一个单独的OnApplicationQuit方法中取消所有令牌源包括全局的。这会给所有任务一个执行清理逻辑的机会如果它们有catch的话。虽然Unity会清理大部分资源但这是一个好习惯。调试技巧给令牌源命名在创建CancellationTokenSource时可以给它一个名称通过反射或在自定义类中包装这样在调试日志中就能清晰看到是哪个场景的任务被取消了。使用调试可视化可以写一个简单的编辑器窗口实时显示SceneBoundTaskManager中所有活跃的场景令牌源及其状态是否已取消。这对于复杂项目的调试非常有帮助。记录任务生命周期在关键任务的开始和结束或取消点添加详细的日志带上场景名和时间戳可以帮助你理清异步任务的执行顺序和生命周期。7. 扩展与优化让机制更强大基础的自动取消机制已经能解决80%的问题。如果你想让它更强大、更贴合你的项目可以考虑以下扩展方向1. 优先级取消为不同的异步任务类型设置优先级。例如场景切换时立即取消UI动画加载但允许场景模型加载多进行几帧以便平滑过渡。这可以通过在管理器里维护多个不同触发条件的令牌源来实现。2. 超时机制集成将超时作为另一个取消条件。可以创建一个扩展方法将场景令牌和超时令牌组合成一个“链接令牌源”CancellationTokenSource.CreateLinkedTokenSource。public static CancellationToken CreateSceneTokenWithTimeout(Scene scene, int timeoutMilliseconds) { var sceneToken SceneBoundTaskManager.Instance.GetTokenForScene(scene); var timeoutCts new CancellationTokenSource(timeoutMilliseconds); var linkedCts CancellationTokenSource.CreateLinkedTokenSource(sceneToken, timeoutCts.Token); // 注意需要管理timeoutCts和linkedCts的生命周期避免泄漏。 // 一种方法是将linkedCts也交给管理器管理在任务完成或场景卸载时释放。 return linkedCts.Token; }3. 与Addressables或AssetBundle管理系统集成现代Unity项目常用Addressables进行资源管理。你可以编写一个自定义的ResourceProvider在加载资源时自动注入当前场景的取消令牌实现资源加载层的自动取消。4. 单元测试为SceneBoundTaskManager编写单元测试非常重要。你可以使用Unity Test Framework模拟场景的加载和卸载验证令牌是否正确创建、取消和清理。测试异步任务在取消时是否真的停止了以及资源是否被正确清理。实现一个可靠的、基于场景切换的异步任务自动取消机制是迈向高质量Unity应用的重要一步。它不仅能减少难以追踪的Bug还能提升游戏的性能和稳定性。这套方案的核心思想——将异步任务的生命周期与其创造者的生命周期绑定——是一种普适的设计模式理解了它你就能举一反三将其应用到UI窗口、网络会话、粒子系统等任何需要生命周期管理的场景中。