Unity WebGL异步编程实战:解决async/await失效与性能优化

发布时间:2026/7/12 2:22:39
Unity WebGL异步编程实战:解决async/await失效与性能优化 1. 项目概述当Unity的优雅异步遇上WebGL的“水土不服”如果你是一名Unity开发者最近正尝试将你的游戏或应用发布到WebGL平台并且在其中使用了C#的async/await来处理网络请求、资源加载或者复杂的逻辑流程那么你很可能已经遇到了一个令人困惑的局面代码在编辑器里跑得好好的一到WebGL上就“罢工”了。要么是回调不执行要么是状态卡死甚至直接导致页面崩溃。这绝不是你的代码逻辑有问题而是Unity的异步编程模型在WebGL这个特殊环境下的“水土不服”。今天我们就来深入这个坑看看为什么你的async/await在WebGL里不工作以及如何系统地解决和规避这些问题。WebGL本质上是一个将C/C代码通过Emscripten工具链编译成JavaScript/WebAssembly并在浏览器中运行的技术。Unity的WebGL构建目标就是将你的C#游戏逻辑编译成WebAssembly模块。在这个过程中.NET运行时更具体地说是Mono或IL2CPP被一同编译进去但浏览器环境与操作系统原生环境存在根本性差异尤其是在线程模型和事件循环机制上。async/await的魔力依赖于底层的任务调度器TaskScheduler和同步上下文SynchronizationContext而在WebGL中这些基础设施要么被大幅修改要么根本不存在这就导致了我们熟悉的异步模式在这里失灵。理解并解决这个问题对于希望拥抱网页端、实现“一次开发多端部署”的Unity团队至关重要。这不仅关乎功能是否正常更直接影响WebGL版本的用户体验——没有人愿意面对一个因为资源加载卡住而一直白屏的网页游戏。接下来我们将从原理到实践一步步拆解这个难题。2. WebGL环境下异步编程的核心限制与原理剖析要解决问题首先得知道“病根”在哪。WebGL对Unity异步编程的支持受到几个底层架构的硬性约束。2.1 单线程的JavaScript世界与Unity的模拟多线程这是最核心的限制。浏览器的主线程UI线程是单线程的JavaScript本身也是单线程语言通过事件循环Event Loop来处理异步任务。Unity WebGL构建的代码也运行在这个主线程上。虽然C#语言和.NET框架支持多线程但在WebGL目标下Unity默认禁用了真正的多线程Thread类创建新线程会失败或行为异常因为WebAssembly目前对线程的支持Web Workers仍不够成熟和统一且与Unity的引擎架构整合复杂。那么Unity如何模拟多线程呢答案是协程Coroutine和基于主线程的异步调度。当你使用async/await或者Task.Run时在WebGL上这些任务并不会被派发到另一个线程执行而是被转换成在主线程的事件循环中未来某一帧执行的回调。这就引出了第一个大坑所有async方法内部的代码实际上都是在主线程上执行的。如果一个async方法内部包含一个耗时的同步操作比如复杂的计算、死循环它会直接阻塞主线程导致整个页面卡死这与在原生平台上使用多线程的预期完全不同。2.2 缺失的SynchronizationContext与TaskScheduler在标准的.NET环境中async/await的魔力在于SynchronizationContext。当一个await操作完成时后续代码continuation默认会尝试捕获当前的同步上下文并在这个上下文中恢复执行。例如在WPF或WinForms中这能确保后续代码回到UI线程安全地更新控件。Unity编辑器和非WebGL平台有自己的UnitySynchronizationContext来确保await后的代码在游戏主线程恢复这对于访问Unity API如GameObject,Transform是必须的因为绝大多数Unity API都不是线程安全的。然而在WebGL构建中这个关键的UnitySynchronizationContext可能无法正常工作或者其行为不一致。有时await之后的代码不会在主线程恢复导致尝试调用Unity API时抛出异常或者更隐蔽地状态不一致。此外默认的线程池任务调度器TaskScheduler.Default在单线程环境下的行为也可能出乎意料。2.3 IL2CPP编译与AOT限制WebGL构建强制使用IL2CPP作为后端编译器它将C#的中间语言IL转换为C代码再编译成WebAssembly。IL2CPP采用预先编译Ahead-Of-Time, AOT这意味着所有可能执行的代码路径必须在编译期就确定。而.NET的异步状态机生成是动态且复杂的。虽然现代Unity和IL2CPP对async/await的支持已经大大改善但在一些边缘情况或复杂的嵌套异步、使用反射动态创建任务时仍可能遇到AOT编译错误导致在WebGL上运行时抛出NotSupportedException或链接错误。错误信息可能非常晦涩例如指向某个自动生成的状态机类方法缺失。2.4 网络请求与定时器的差异在原生平台System.Net.Http.HttpClient或UnityWebRequest可能依赖操作系统的网络栈。在WebGL中所有网络请求都必须通过浏览器的XMLHttpRequest或Fetch API进行。Unity已经对UnityWebRequest做了适配但如果你直接使用标准的HttpClient它很可能在WebGL上无法工作或行为异常。同样Task.Delay在WebGL内部的实现也依赖于浏览器环境的setTimeout其精度和可靠性可能与系统定时器有差异尤其是在浏览器标签页处于后台时定时器可能会被节流throttled导致延迟比预期长很多。注意理解这些限制不是让你避免在WebGL上使用异步而是为了更安全、更高效地使用它。接下来的部分我们将聚焦于实战解决方案。3. 实战解决方案让Async/Await在WebGL上稳健运行知道了原理我们就可以有的放矢地制定策略。以下方案按照推荐度和普适性排序。3.1 首选方案拥抱UniTask对于Unity项目尤其是在面向WebGL时UniTask几乎是处理异步问题的“标准答案”。它并非简单地替代Task而是为Unity引擎量身定制的、零分配Zero Allocation、高性能的异步方案并且对WebGL有非常好的支持。为什么UniTask更适合WebGL无GC分配标准的Task和async/await在每次异步操作时会产生垃圾GC Alloc在性能敏感的WebGL环境下频繁GC会触发垃圾回收导致卡顿。UniTask通过值类型struct实现任务极大减少了内存分配。与Unity PlayerLoop深度集成UniTask有自己的PlayerLoopTimer和任务调度机制它直接挂钩到Unity的每帧更新循环中完全绕开了可能存在问题的标准TaskScheduler和SynchronizationContext因此在WebGL上行为高度一致且可靠。专为Unity设计的API它提供了UniTask.Delay、UniTask.Yield、UniTask.WaitUntil等大量贴合游戏开发需求的辅助方法并且可以方便地切换到主线程await UniTask.SwitchToMainThread()。对WebGL的特殊处理UniTask的内部实现已经考虑了WebGL的单线程限制其异步原语在该环境下是安全且高效的。基本使用示例using Cysharp.Threading.Tasks; using UnityEngine; using UnityEngine.Networking; public class WebGLAsyncDemo : MonoBehaviour { async UniTaskVoid Start() { // 使用UniTask进行延迟替代Task.Delay await UniTask.Delay(1000); // 延迟1秒不产生GC // 使用UniTask封装UnityWebRequest更简洁 var json await GetJsonAsync(https://api.example.com/data); Debug.Log(json); // 执行一个可能耗时的操作但确保在主线程 await UniTask.RunOnThreadPool(() { // 这里进行一些计算密集型操作 int result HeavyCalculation(); return result; }).ContinueWith((calcResult) { // ContinueWith后的代码默认在主线程可以安全操作Unity对象 gameObject.name $Result: {calcResult}; }); } async UniTaskstring GetJsonAsync(string url) { using (var request UnityWebRequest.Get(url)) { // SendWebRequest返回的是AsyncOperation用ToUniTask转换为UniTask await request.SendWebRequest().ToUniTask(); if (request.result UnityWebRequest.Result.Success) { return request.downloadHandler.text; } else { throw new System.Exception($Request failed: {request.error}); } } } }实操心得在WebGL项目中建议全局使用UniTask替代System.Threading.Tasks.Task。将项目中的async Task方法签名改为async UniTaskTask返回值改为UniTaskTaskT改为UniTaskT。虽然有一定迁移成本但带来的性能和稳定性提升是巨大的。3.2 确保代码在主线程执行如果你暂时无法引入UniTask或者需要维护旧代码那么首要原则是确保所有访问Unity对象或API的代码都在主线程上执行。使用MainThreadDispatcher模式你可以创建一个简单的工具类用于将任务派发到主线程执行。using System; using System.Collections.Concurrent; using UnityEngine; public class MainThreadDispatcher : MonoBehaviour { private static readonly ConcurrentQueueAction _executionQueue new ConcurrentQueueAction(); private static MainThreadDispatcher _instance; [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.BeforeSceneLoad)] private static void Initialize() { if (_instance null) { GameObject go new GameObject(MainThreadDispatcher); _instance go.AddComponentMainThreadDispatcher(); DontDestroyOnLoad(go); } } public static void ExecuteOnMainThread(Action action) { if (action null) return; _executionQueue.Enqueue(action); } void Update() { // 每帧清空并执行队列中的所有任务 while (_executionQueue.TryDequeue(out var action)) { try { action?.Invoke(); } catch (Exception e) { Debug.LogError($Error executing action on main thread: {e}); } } } }然后在你的异步方法中如果需要更新Unity对象就这样做public async Task LoadAssetAsync(string path) { // 假设这是在某个后台任务中 var heavyData await ProcessDataOffMainThread(); // 将更新UI或场景的操作派发到主线程 await Task.Run(() { MainThreadDispatcher.ExecuteOnMainThread(() { // 现在可以安全地访问GameObject、Instantiate等 GameObject.Instantiate(prefab); textComponent.text heavyData; }); }); // 注意这里需要一种机制等待主线程操作完成可以使用SemaphoreSlim或UniTask的桥接。 }更简单的await后手动检查对于简单的async方法你可以在await之后立即使用一个简单的检查或强制切换到主线程的等待。private async Task SomeAsyncMethod() { await SomeWebRequest(); // 在WebGL上await之后可能不在主线程 // 使用一个辅助方法确保在主线程 await ForceMainThreadIfNeeded(); // 现在安全了 transform.position new Vector3(0, 0, 0); } private async Task ForceMainThreadIfNeeded() { // 一个粗糙但有时有效的办法等待下一帧 // 因为Unity的主线程逻辑每帧执行等待一帧通常能回到主线程上下文 var tcs new TaskCompletionSourcebool(); // 使用协程或者Singleton MonoBehaviour来在Update中完成tcs // 这里简化表示 MainThreadDispatcher.ExecuteOnMainThread(() tcs.SetResult(true)); await tcs.Task; }注意事项这种手动派发的方式会增加代码复杂性和潜在的竞态条件。它更适合作为临时解决方案或处理少量边界情况。对于全面的异步重构UniTask的SwitchToMainThread()是更优雅的选择。3.3 避免在Async方法中进行阻塞性同步操作牢记WebGL的单线程本质。在async方法中即使你没有显式创建新线程一个耗时的同步循环也会阻塞整个应用。反面教材public async Taskint CalculateBigDataAsync() { int result 0; // 这个循环会完全阻塞主线程 for (int i 0; i 1000000000; i) { result i; } await Task.Delay(100); // 这行代码要等上面循环结束才会执行页面早已卡死 return result; }改进策略分帧计算将大任务拆分成小块每帧执行一部分。可以使用协程yield return null或者UniTask的PlayerLoopTiming来实现。// 使用UniTask分帧处理 public async UniTaskint CalculateBigDataInFramesAsync() { int result 0; for (int i 0; i 1000000; i) { result i; // 每计算10000次让出一帧防止卡顿 if (i % 10000 0) { await UniTask.Yield(); // 或 await UniTask.NextFrame(); } } return result; }使用UniTask.RunOnThreadPool谨慎虽然WebGL没有真线程但UniTask.RunOnThreadPool在WebGL上会被模拟成在主线程上延迟执行它至少不会阻塞当前的异步流但计算本身仍然在主线程进行。它主要的意义是保持代码模式的一致性。3.4 正确处理网络请求与UnityWebRequest在WebGL上务必使用UnityWebRequest进行网络通信而不是System.Net.Http.HttpClient。UnityWebRequest是Unity对浏览器Fetch/XMLHttpRequest API的封装是跨平台包括WebGL网络操作的首选。最佳实践示例using UnityEngine; using UnityEngine.Networking; using System.Threading.Tasks; public class NetworkService { public async Taskstring GetTextAsync(string url) { using (UnityWebRequest request UnityWebRequest.Get(url)) { // 使用SendWebRequest的异步扩展或配合UniTask var asyncOp request.SendWebRequest(); // 方法一使用Unity的AsyncOperation完成回调非async/await模式 // asyncOp.completed (op) { ... }; // 方法二转换为Task (需注意WebGL上下文问题) // 可以封装一个辅助方法 await WaitForAsyncOperation(asyncOp); if (request.result UnityWebRequest.Result.ConnectionError || request.result UnityWebRequest.Result.ProtocolError) { throw new System.Exception($Network error: {request.error}); } return request.downloadHandler.text; } } // 将AsyncOperation转换为Task的辅助方法 private Task WaitForAsyncOperation(AsyncOperation asyncOp) { var tcs new TaskCompletionSourcebool(); asyncOp.completed (op) tcs.SetResult(true); return tcs.Task; } }使用UniTask的扩展方法代码更简洁using Cysharp.Threading.Tasks; using UnityEngine.Networking; public async UniTaskstring GetTextWithUniTaskAsync(string url) { using (var request UnityWebRequest.Get(url)) { // ToUniTask() 是UniTask提供的扩展方法完美处理了WebGL的上下文 await request.SendWebRequest().ToUniTask(); return request.downloadHandler.text; } }常见坑点UnityWebRequest在WebGL上默认不处理重定向redirect需要手动设置redirectLimit属性。此外由于浏览器的同源策略CORS跨域请求可能会失败需要在服务器端配置正确的CORS头。4. 深入排查诊断与调试WebGL异步问题当问题发生时盲目的修改不如有效的诊断。以下是一些在WebGL环境下调试异步问题的技巧。4.1 使用浏览器开发者工具Sources面板与调试Unity WebGL构建会生成source map需在Player Settings中启用Debug Symbols允许你在浏览器中调试原始的C#代码。你可以像在Visual Studio中一样设置断点、单步执行观察async方法的状态机执行流程。这是最直接的调试手段。Console面板关注警告和错误信息。WebGL特有的错误如“Unable to execute code in a background tab”后台标签页节流或“WebGL context lost”上下文丢失都可能影响异步操作。IL2CPP的AOT编译错误也会在这里显示为晦涩的JavaScript异常。Performance / Profiler面板录制一段时间的性能数据查看主线程的活动。如果发现长时间的任务阻塞长任务Long Task通常就是同步耗时操作或不当异步导致的。观察调用栈找到问题根源。4.2 添加详细的日志与状态追踪由于WebGL调试不如原生方便增加日志输出至关重要。但要注意Debug.Log本身也是一个跨边界调用在复杂循环中大量使用可能影响性能。建议使用条件编译和自定义日志级别public static class WebGLLogger { [System.Diagnostics.Conditional(DEVELOPMENT_BUILD), System.Diagnostics.Conditional(UNITY_EDITOR)] public static void LogAsyncState(string message, [System.Runtime.CompilerServices.CallerMemberName] string caller ) { Debug.Log($[Async][{caller}] {message} on frame:{Time.frameCount}, thread:{System.Threading.Thread.CurrentThread.ManagedThreadId}); } } // 在异步方法中关键点添加日志 public async UniTask LoadSomething() { WebGLLogger.LogAsyncState(开始加载); await UniTask.Delay(100); WebGLLogger.LogAsyncState(延迟结束); // ... }通过记录帧数和模拟的线程ID你可以清晰地看到异步流程是否如预期在主线程上延续。4.3 检查IL2CPP AOT编译问题如果遇到运行时错误提示某个泛型方法或反射调用缺失这很可能是AOT编译问题。IL2CPP需要提前知道所有可能被调用的代码。解决方案使用link.xml文件在Assets目录下创建link.xml文件告诉IL2CPP保留特定的类型、程序集或命名空间防止它们被裁剪掉。这对于使用了反射、动态加载或泛型特化较多的异步库如某些HTTP客户端尤其重要。!-- link.xml -- linker assembly fullnameSystem.Runtime.CompilerServices.Unsafe preserveall/ assembly fullnameMyAsyncLibrary namespace fullnameMyAsyncLibrary.Internal preserveall/ type fullnameMyAsyncLibrary.* preserveall/ /assembly /linker在Player Settings中启用Managed Stripping Level为Low或Minimal这会减少代码裁剪的激进程度但会增加构建大小。对于WebGL包体大小敏感需要权衡。确保所有通过反射调用的异步相关类型在静态代码中有显式引用有时在代码中写一句System.Runtime.CompilerServices.AsyncMethodBuilderCore.ForceAsyncMethodBuilderAOT();假设存在或引用一下相关类型可以“欺骗”编译器保留它们。5. 进阶优化与架构考量解决了“能用”的问题后我们还要追求“好用”和“高效”。在WebGL环境下进行异步编程需要在架构层面做一些思考和优化。5.1 采用异步初始化与加载屏幕WebGL模块的初始加载和实例化可能较慢。利用异步流式加载和友好的加载界面至关重要。实现一个异步的启动器using Cysharp.Threading.Tasks; using UnityEngine; using UnityEngine.UI; public class GameBootstrapper : MonoBehaviour { [SerializeField] private Slider loadingSlider; [SerializeField] private GameObject loadingPanel; [SerializeField] private GameObject mainGamePanel; async UniTaskVoid Start() { loadingPanel.SetActive(true); mainGamePanel.SetActive(false); try { // 1. 初始化基础系统无依赖 await InitializeCoreSystems(); UpdateProgress(0.2f); // 2. 加载必要的配置数据 await LoadConfigData(); UpdateProgress(0.4f); // 3. 预加载关键资源使用Addressables或Resources await PreloadCriticalAssets(); UpdateProgress(0.7f); // 4. 初始化游戏逻辑 await InitializeGameLogic(); UpdateProgress(1.0f); // 切换场景 await UniTask.Delay(300); // 短暂停留让进度条走完 TransitionToGame(); } catch (System.Exception e) { Debug.LogError($启动失败: {e}); // 显示错误界面 ShowErrorScreen(e.Message); } } private void UpdateProgress(float progress) { if (loadingSlider ! null) loadingSlider.value progress; // 也可以更新进度文本 } private async UniTask InitializeCoreSystems() { // 例如音频管理器、本地化、输入系统等 await UniTask.Yield(); // 模拟工作 } private void TransitionToGame() { loadingPanel.SetActive(false); mainGamePanel.SetActive(true); // 销毁自身或禁用 this.enabled false; } }这种模式将漫长的初始化过程分解为可感知的步骤并通过await让主线程保持响应因为每一步初始化内部也是异步的避免了启动时的白屏或卡死。5.2 管理并发任务与取消在WebGL单线程环境下虽然不能真并发但管理多个异步任务的启动、等待和取消仍然很重要尤其是处理用户交互如快速点击按钮触发多次加载。使用CancellationTokenusing System.Threading; using Cysharp.Threading.Tasks; using UnityEngine; public class AssetLoader : MonoBehaviour { private CancellationTokenSource _currentLoadCts; public async UniTaskVoid LoadAssetOnClick(string assetId) { // 取消上一次可能还在进行的加载 _currentLoadCts?.Cancel(); _currentLoadCts new CancellationTokenSource(); var token _currentLoadCts.Token; try { // 将CancellationToken传递给异步加载方法 var asset await LoadAssetAsync(assetId, token); OnAssetLoaded(asset); } catch (System.OperationCanceledException) { Debug.Log(加载被取消); // 正常取消无需处理错误 } catch (System.Exception e) { Debug.LogError($加载失败: {e}); } } private async UniTaskGameObject LoadAssetAsync(string id, CancellationToken token) { // 模拟一个耗时的异步加载 await UniTask.Delay(2000, cancellationToken: token); // Delay也支持取消 // 如果token被取消Delay会抛出OperationCanceledException // 假设这里是从Addressables加载 // var handle Addressables.LoadAssetAsyncGameObject(id); // handle.ToUniTask(cancellationToken: token); // UniTask的扩展也支持取消 // return handle.Result; return new GameObject($Loaded_{id}); } void OnDestroy() { _currentLoadCts?.Cancel(); _currentLoadCts?.Dispose(); } }在WebGL上取消操作是通过协作式实现的即任务需要定期检查token.IsCancellationRequestedUniTask.Delay和UniTask对Unity异步操作的封装已经内置了对取消令牌的支持。5.3 性能分析与内存管理WebGL应用运行在浏览器沙箱中内存管理比原生应用更敏感。异步操作中产生的大量短期对象即使是UniTask也难免有少量分配可能触发更频繁的GC而GC在JavaScript中尤其是Unity的WebGL内存模型可能引起明显的卡顿。优化建议使用对象池对于频繁创建和销毁的、与异步操作相关的对象如网络请求的包装类、回调委托考虑使用对象池复用。监控GC行为在Unity Profiler连接WebGL播放器中观察GC的触发频率和耗时。如果发现某段异步逻辑后频繁触发GC审查代码看是否能减少闭包捕获、避免不必要的装箱boxing操作。警惕闭包和捕获的上下文async方法会生成一个状态机类它捕获的局部变量会成为该类的字段。如果捕获了一个大型对象图可能会无意中延长其生命周期导致内存泄漏。确保异步方法只捕获必要的最小数据集。// 潜在问题largeData被整个状态机捕获生命周期被延长 public async Task ProcessData(MyLargeClass largeData) { var id largeData.Id; // 只提取需要的数据 await SendToServerAsync(id); // largeData 可能不会被及时释放 }6. 常见问题速查与解决方案实录这里汇总了在WebGL异步开发中高频出现的问题及其应对策略你可以像查字典一样快速定位。问题现象可能原因解决方案await后的代码没有执行游戏逻辑中断。1. 异步操作本身抛出未捕获的异常。2. 在WebGL上SynchronizationContext丢失后续代码无处恢复。3.Task被垃圾回收在某些错误用法下。1. 用try-catch包裹整个async方法并记录异常。2.切换到UniTask它不依赖默认上下文。3. 确保持有对未完成Task的引用例如赋值给类字段。调用Unity API如访问GameObject时抛出“UnityException: get_gameObject can only be called from the main thread”。await之后代码未在主线程恢复执行。1. 使用UniTask.SwitchToMainThread()。2. 使用MainThreadDispatcher派发任务。3. 在await后使用await Task.Yield()或await UniTask.NextFrame()有时能“碰巧”回去但不保证。WebGL构建后异步相关功能完全失效甚至无法启动。IL2CPP AOT编译时必要的异步运行时库或泛型代码被裁剪。1. 检查link.xml配置确保System.Runtime.CompilerServices、System.Threading.Tasks等相关程序集被保留。2. 降低Managed Stripping Level。3. 在代码中显式引用可能被裁剪的类型。网络请求在编辑器正常在WebGL上失败或超时。1. 使用了HttpClient等不兼容API。2. CORS跨域问题。3. 服务器响应格式或协议不被WebGL支持。1.统一使用UnityWebRequest。2. 确保服务器返回正确的CORS头如Access-Control-Allow-Origin: *。3. 检查请求URL是HTTPS很多浏览器要求且端口正确。Task.Delay或UniTask.Delay在浏览器标签页切到后台后延迟时间变得极长。浏览器为节省资源对后台标签页的setTimeout/setInterval进行节流通常降至1fps或更低。1. 对于游戏核心逻辑的计时使用基于Time.deltaTime的自定义计时器而不是依赖Delay。2. 使用UniTask.Delay(..., ignoreTimeScale: false, cancellationToken: ...)但需知它仍受节流影响。3. 考虑使用Visibility API检测页面是否可见并暂停非关键的后台延迟逻辑。异步加载大量小资源时页面卡顿或崩溃。1. 同时发起的异步操作太多主线程被回调塞满。2. 每个异步操作都产生GC压力。3. 内存占用过高。1. 实现并发控制例如使用信号量SemaphoreSlim限制最大并发数如最多同时加载5个。2. 使用UniTask减少GC分配。3. 对资源进行合并打包减少请求数量。使用Addressables的依赖加载和分包策略。在async void方法中发生异常导致游戏静默崩溃。async void方法的异常无法被调用者捕获会直接抛到同步上下文在WebGL上可能被吞掉或导致未定义行为。尽量避免使用async void除非是事件处理器且要做好异常处理。对于其他情况使用async UniTask或async UniTaskVoidUniTask提供了更好的异常传播机制。在async void方法内部务必用try-catch。最后一点个人体会WebGL平台的异步编程与其说是一个技术问题不如说是一个思维模式的转换。我们必须从“多线程并行”的思维切换到“单线程协作式并发”的思维。这意味着要更精细地管理任务粒度、更主动地让出主线程控制权、更严格地处理线程安全尽管只有主线程。拥抱像UniTask这样为Unity和单线程环境深度优化的工具不仅能解决问题还能写出更高效、更健壮的代码。开始一个新项目时就把UniTask和WebGL的约束考虑进去会比后期重构轻松得多。在实际开发中我习惯为WebGL构建单独写一个PlatformHelper类里面封装所有与环境相关的异步操作和检查这样核心业务代码能保持相对干净也便于针对不同平台做优化。