Unity异步避坑指南
以我迁移《合成守卫战》资源加载流程时踩到的问题为起点,整理一套 Unity 异步、Addressables、WebGL、UniTask 相关的学习笔记。
1. 从 Resources 迁移到 Addressables
最近在开发过程中遇到了一些问题。在这里做个记录吧。
项目背景大概是这样:
- Unity 2D 竖屏塔防小游戏;
- 资源包括关卡配置、地图 Prefab、怪物 Prefab、炮塔 Prefab、子弹、特效、音频、UI 图标等;
- 原来为了快速开发,很多地方用
Resources.Load占位; - 后面要面向 WebGL / 微信小游戏 / 抖音小游戏方向;
- 资源要通过 Addressables + CDN 按需下载;
- 战斗中怪物、子弹、特效等频繁生成对象后续会接对象池。
我需要将所有使用Resources里占位的地方都替换成Addressables加载
Resources.Load
↓
Addressables.LoadAssetAsync
2. 我一开始的 Addressables 封装
我试着写了一个Addressable资源加载工具:
public class AddressableResourceUtility : IResourceUtility
{
public async Task<T> LoadAssetAsync<T>(string key) where T : Object
{
var handle = Addressables.LoadAssetAsync<T>(key);
return await handle.Task;
}
public async Task<GameObject> InstantiateAsync(
string key,
Vector3 position,
Quaternion rotation,
Transform parent = null)
{
var handle = Addressables.InstantiateAsync(key, position, rotation, parent);
return await handle.Task;
}
public void Release(Object asset)
{
Addressables.Release(asset);
}
}
这个封装有个问题在这里:
await handle.Task;
这个 handle.Task 在 WebGL 下不可用。
3. handle.Task 有什么问题?
Addressables 的加载返回的是 AsyncOperationHandle。
它本身可以通过多种方式等待:
// 方式一:Task
await handle.Task;
// 方式二:协程
yield return handle;
// 方式三:Completed 回调
handle.Completed += OnLoaded;
WebGL 出问题的是第一种:
await handle.Task;
下面两种方式仍然是可行的:
handle.Completed += operation =>
{
var result = operation.Result;
};
或者:
var handle = Addressables.LoadAssetAsync<GameObject>(key);
yield return handle;
var prefab = handle.Result;
所以不是:
Addressables 不支持 WebGL。
更准确的说法是:
AsyncOperationHandle.Task 在 WebGL平台上不可用
4. 第一个误区:异步不等于多线程
一个异步方法可以完全不创建新线程,例如:
public async Task LoadAsync()
{
await SomeNonThreadOperation();
}
await 的本质是:
执行到 await
↓
如果异步操作没有完成,当前方法先让出去
↓
记录后续要执行的位置
↓
等异步操作完成后,再继续执行 await 后面的代码
这更像是“切流程”,不是“开线程”。
例如游戏里加载资源时:
主线程发起加载请求
↓
这一帧先返回,不阻塞画面
↓
Unity / 浏览器底层继续推进下载与加载
↓
完成后回到主线程继续执行逻辑
所以:
异步 = 不阻塞当前流程
多线程 = 把代码放到其他线程执行
这两个概念有关联,但不是一回事。
5. 第二个误区:Task 不一定开线程,但 Task 生态和线程池关系很深
我们使用异步加载时完全没有开多线程,但
handle.Task依然不可用。为什么 Task 的设计一定会依赖线程池?
因为:
Task 本身不一定开线程。
但 .NET 的 Task 生态和线程池、TaskScheduler、SynchronizationContext 关系很深。
标准 .NET 的 Task 体系里还包括:
Task.Run
TaskScheduler
ThreadPool
ContinueWith
Task.WhenAll
Task.Wait
Task.Result
SynchronizationContext
这些东西在 .NET 环境里很强大,但在 Unity WebGL 这种浏览器单线程环境里就会变得麻烦。
因此官方干脆说:
AsyncOperationHandle.Task 在 WebGL 不可用。
而不是让开发者在各种 Task 组合行为里踩更隐蔽的坑。
6. 方案选择:Completed、Coroutine、TaskCompletionSource、UniTask
我在这个问题上实际面对过四种选择。
方案一:直接用 Completed 回调
示例:
public void LoadAsset<T>(string key, Action<T> onSuccess) where T : Object
{
var handle = Addressables.LoadAssetAsync<T>(key);
handle.Completed += operation =>
{
if (operation.Status == AsyncOperationStatus.Succeeded)
{
onSuccess?.Invoke(operation.Result);
}
else
{
Debug.LogError($"加载失败: {key}");
}
};
}
方案二:用协程
示例:
private IEnumerator LoadLevelFlow(int levelId)
{
var handle = Addressables.LoadAssetAsync<LevelConfig>(
$"Config/Level/Level_{levelId:000}"
);
yield return handle;
if (handle.Status != AsyncOperationStatus.Succeeded)
{
Debug.LogError("关卡配置加载失败");
yield break;
}
LevelConfig config = handle.Result;
}
方案三:TaskCompletionSource 保留 Task 外壳
我一度考虑:
方法继续返回 Task<T>,但内部不用 handle.Task。
示例:
public Task<T> LoadAssetAsync<T>(string key) where T : Object
{
var tcs = new TaskCompletionSource<T>();
var handle = Addressables.LoadAssetAsync<T>(key);
handle.Completed += operation =>
{
if (operation.Status == AsyncOperationStatus.Succeeded)
{
tcs.TrySetResult(operation.Result);
}
else
{
Addressables.Release(operation);
tcs.TrySetException(new Exception($"加载失败: {key}"));
}
};
return tcs.Task;
}
这确实避开了:
await handle.Task;
但它仍然把我的项目留在标准 Task 体系里。
理论上可行,但如果项目目标明确包含 WebGL / 小游戏,我更倾向于不要继续在资源加载主链路里依赖标准 Task。
方案四:UniTask
这是我最后更倾向的方案。
using Cysharp.Threading.Tasks;
using UnityEngine.AddressableAssets;
using UnityEngine.ResourceManagement.AsyncOperations;
public async UniTask<GameObject> LoadPrefabAsync(string key)
{
AsyncOperationHandle<GameObject> handle =
Addressables.LoadAssetAsync<GameObject>(key);
await handle;
if (handle.Status != AsyncOperationStatus.Succeeded)
{
Addressables.Release(handle);
Debug.LogError($"加载失败: {key}");
return null;
}
return handle.Result;
}
7. UniTask 到底解决了什么
UniTask 不是专门为了 Addressables 出现的。
它解决的是 Unity 异步开发里的几个长期问题。
7.1 协程返回值不舒服
协程写法:
IEnumerator Load()
{
yield return ...;
}
它不能像普通方法一样自然地:
return prefab;
也不能自然地:
try
{
await LoadAsync();
}
catch
{
}
7.2 回调容易套娃
Completed 回调简单时很好用:
LoadA(() => Debug.Log("A loaded"));
但加载一关时可能变成:
DownloadDependencies 完成
-> LoadLevelConfig 完成
-> LoadMap 完成
-> LoadEnemies 完成
-> LoadBullets 完成
-> StartBattle
这会让流程很不清楚。
7.3 标准 Task 不贴合 Unity
标准 Task 更偏 .NET 通用世界。
Unity 的很多异步对象不是标准 Task:
AsyncOperation
ResourceRequest
AssetBundleRequest
UnityWebRequestAsyncOperation
AsyncOperationHandle
UniTask 给这些 Unity 异步对象提供了 await 支持。
7.4 GC 和性能问题
Task<T> 是引用类型,很多时候会有额外分配。
UniTask 是更轻量的结构体异步类型,目标之一就是减少 Unity 项目里的异步 GC 压力。
8. UniTask 从底层为什么适合 WebGL
底层关键不是:
UniTask 让 WebGL 支持了多线程。
而是:
UniTask 不依赖多线程来驱动大多数 Unity 异步流程。
Unity 每帧都会跑 PlayerLoop:
Initialization
EarlyUpdate
FixedUpdate
Update
LateUpdate
PostLateUpdate
UniTask 把自己的调度接进 PlayerLoop。
所以当我写:
await UniTask.Yield();
它不是开一个新线程,而是:
当前帧先让出去
↓
下一次 PlayerLoop 到指定阶段
↓
继续执行 await 后面的代码
当我写:
await handle;
它不是在等 handle.Task,而是 UniTask 对 AsyncOperationHandle 做了 await 适配。
可以把它理解为更像:
yield return handle;
或者:
handle.Completed += ...;
只是写法变成了:
await handle;
所以它能避开 WebGL 下 AsyncOperationHandle.Task 不可用的问题。
但是 UniTask 也有边界。
这些不要在 WebGL / 小游戏方向使用:
await Task.Run(...);
await UniTask.SwitchToThreadPool();
await UniTask.RunOnThreadPool(...);
handle.WaitForCompletion();
这些写法要么依赖线程池,要么同步阻塞主线程。
Comments
评论区
欢迎在这里留言交流。