尧图网络科技YAOTU DIGITAL 获取报价
获取报价
首页 / 资讯中心 / 文章详情

Unity Addressables 异步操作句柄详解:加载、释放与内存管理实践

发布时间:2026/9/24 19:07:50

资讯中心
01
ARTICLE

Unity Addressables 异步操作句柄详解:加载、释放与内存管理实践

Unity Addressables 异步操作句柄详解:加载、释放与内存管理实践
从 AssetBundle 时代靠手写加载流程、自己维护依赖树和引用计数到切到 Addressables 之后只需要对着一个异步句柄操作这个过渡期最容易让人懵掉的就是“Handle”到底是个什么东西。AssetBundle 那套逻辑里我们习惯了“先加载 bundle再 GetAsset再 Instantiate”的同步思维而 Addressables 把所有资源操作全部统一成了异步操作句柄接口返回值从 GameObject、Sprite 这些具体资源对象变成了一个到处在传的AsyncOperationHandleT。这一篇就把这个句柄彻底讲透再把最常用的几种加载方式挨个过一遍看完你基本就能在日常项目里放心地用 Addressables 管资源了。这篇文章适合正在用或者准备用 Addressables 的 Unity 开发者尤其是已经被异步回调、资源释放和内存问题折磨过的同学。如果你刚接触 Addressables建议先把前面两篇关于资源分组、本地/远程配置的内容过一遍再回来看这篇实战向的内容。1. 从 AssetBundle 到 Addressables为什么异步操作句柄是核心1.1 资源加载为什么必须“异步”很多人刚接触异步加载第一反应是嫌麻烦“我直接 LoadFromFile 同步加载它不香吗GameObject 我直接用不用等回调。”这个想法在项目前期确实能跑毕竟资源量小、加载时间短卡一下肉眼也看不出来。但做真实项目的时候资源量一上去同步加载的卡顿就压不住了尤其是远程资源还在本地没有缓存的时候同步加载甚至不是“卡一下”而是直接卡死几秒、十几秒。说白了一个核心原因文件 IO、资源反序列化、Shader 编译、贴图解码这些都是耗时操作Unity 的主线程做这些事的时候画面就停在那里。加载一个 50MB 的场景包你放在Start()里同步 load用户看见的就是一个假死的画面。异步加载的意义不是让加载变快而是把“等待加载完成”的时间还给主线程让游戏该渲染渲染、该播放动画播放动画加载完毕后再通过回调把资源交给你。1.2 Addressables 把“异步”抽象成了什么手写 AssetBundle 的时候异步通常是自己开协程、用AssetBundleRequest轮询isDone和progress一套流程写下来模板代码非常多。而且 AssetBundle 的卸载是反向递归的bundle 的依赖关系、引用计数都要自己维护动不动就出现资源只能加载一次、第二次加载就异常的问题。Addressables 把这一整套复杂逻辑封装掉了暴露给开发者的核心就是AsyncOperationHandle。这个句柄到底是个什么东西你可以把它理解成一张“操作凭证”。你调用了Addressables.LoadAssetAsync()它并不会立刻返回加载好的资源而是返回一个句柄这个句柄代表“这次加载操作”的当前状态和最终结果。你可以去轮询IsDone可以在上面挂Completed回调可以yield return等它完成也可以await它。加载完毕后从Result属性里拿出资源对象。它和 AssetBundle 时代最大的区别在于你不再直接管理资源对象本身而是管理操作句柄句柄再去跟 Addressables 的资源引用计数系统挂钩。这个抽象带来的好处是Addressables 可以自动处理依赖资源的加载、缓存、引用计数和内存管理你拿到一个 GameObject 不再需要关心它的依赖是否还活着。1.3 一句话理解句柄我在团队内部分享的时候常用一个类比把资源加载想象成叫外卖。你下单之后后台先给你一个订单号这个订单号就是AsyncOperationHandle外卖员配送的过程就是异步执行过程你可以去 app 上看订单状态IsDone、PercentComplete也可以等着系统给你推送消息Completed回调外卖到了之后你从快递员手里拿到的餐Result才是你真正要用的资源对象。外卖你不能一直不收不收它就一直占着骑手的时间句柄你不能一直不释放不释放 Addressables 内部就一直认为这个资源还被引用着内存就不会被回收。搞明白这个逻辑后面的释放规则、加载方式组合就都顺畅了。2. AsyncOperationHandle字段、回调和释放规则的深入拆解2.1 句柄上到底有哪些信息开发中我经常看到有人把AsyncOperationHandleT当成一个黑盒拿到就直接yield完拿Result其他字段一概不看。其实这个结构体上挂了好几个关键字段出了问题排查就靠它们。IsDone操作是否已完成。注意它只表示“加载流程跑完了”不代表“加载成功”。加载失败的时候IsDone 同样是 true所以别把它当成功标志。StatusAsyncOperationStatus枚举None、Succeeded、Failed。判断成不成功看这里最准确。PercentComplete0 到 1 的浮点数滚进度条靠它。Result加载的结果对象。只有Status Succeeded才能安全访问失败的时候访问 Result 会抛异常。OperationException失败的时候这个字段会给到异常详情比如 key 找不到、资源破坏都会在这里看见。ReferenceCount句柄内部的引用计数。调试内存不释放问题时非常有用。平时用的时候我习惯写一个通用的回调处理工具对 Status 做判断成功才取 Result失败就输出handle.OperationException这样错误信息会清晰很多。void OnLoadComplete(AsyncOperationHandleGameObject handle) { if (handle.Status AsyncOperationStatus.Succeeded) { GameObject go handle.Result; // 实例化后的逻辑 } else { Debug.LogError($资源加载失败: {handle.OperationException}); } }2.2 三种等待方式协程、async/await、Completed 回调AsyncOperationHandle设计得比较灵活至少三种方式都能拿到加载结果。先说我个人最常用的协程yieldIEnumerator LoadAndInstantiate(string key) { AsyncOperationHandleGameObject handle Addressables.LoadAssetAsyncGameObject(key); yield return handle; if (handle.Status AsyncOperationStatus.Succeeded) { Instantiate(handle.Result); } }这种方式适合大多数使用场景代码直观而且yield的时候不会阻塞主线程。第二种是 async/await 方式。Unity 2020.3 以上版本配合Awaitable或者引入UniTask都可以很优雅地写async void LoadAsync(string key) { AsyncOperationHandleGameObject handle Addressables.LoadAssetAsyncGameObject(key); await handle.Task; if (handle.Status AsyncOperationStatus.Succeeded) { Instantiate(handle.Result); } }需要注意的是用 async/await 遍历多个资源的时候别忽略句柄的释放。await完成之后你还要手动Release句柄或者借助封装好的辅助类统一管理否则很容易泄漏。第三种是挂回调AsyncOperationHandleGameObject handle Addressables.LoadAssetAsyncGameObject(key); handle.Completed OnLoadComplete;Completed回调里做的操作要尽量轻量别在里面再同步加载大资源否则主线程照样卡。我见过有同事在Completed里处理 UI 刷新的同时顺手又LoadAssetAsync了一个大纹理结果该卡还是卡异步的意义就没了。2.3 释放规则什么时候用 Release什么时候用 ReleaseInstance这一节是 Addressables 里最容易被忽略、也是最容易出内存问题的部分。我的经验是只要把释放规则搞明白Addressables 基本就算入门了。先说全局原则每一个成功的加载操作最终都要有对应的释放操作。加载了多少次就要释放多少次。Addressables 内部维护的是引用计数加载一次 1释放一次 -1减到 0 资源才会真正从内存里清掉。不同加载方式对应的释放方法加载方式正确释放方式说明LoadAssetAsyncAddressables.Release(handle)也可以Release(handle.Result)效果相同InstantiateAsyncAddressables.ReleaseInstance(handle)释放实例化出来的 GameObjectLoadSceneAsyncAddressables.UnloadSceneAsync(handle)异步卸载场景LoadAssetsAsync遍历handle.Result逐个Release批量加载的每个资源都要单独释放特别注意InstantiateAsync和LoadAssetAsync再Instantiate的区别。前者返回的句柄可以直接ReleaseInstance后者在加载完实例化出的对象如果用普通的Destroy销毁资源本身还占着内存因为这个资源是通过LoadAssetAsync加载出来的并没有被释放过。所以我的做法是能直接用InstantiateAsync的场景就不用LoadAssetAsync再手动Instantiate从跟上减少“忘了释放”的可能性。3. 加载方式逐一拆解从单资源到批量、实例化、场景3.1 LoadAssetAsync把资源读进内存Addressables.LoadAssetAsyncT是使用频率最高的接口适合加载单个资源比如一个面板预制体、一个角色模型、一个音频片段。public void LoadWeapon(string weaponKey) { AsyncOperationHandleGameObject handle Addressables.LoadAssetAsyncGameObject(weaponKey); handle.Completed op { if (op.Status AsyncOperationStatus.Succeeded) { _weaponPrefab op.Result; } }; }这个接口的 Key 可以是 Addressable 资源的 Address路径也可以是 Label甚至可以是一个AssetReference对象。平时配置的时候Address 通常填一个可读的字符串路径比如Assets/Prefabs/Weapons/Weapon01.prefab用的时候直接传这个字符串就行或者用AssetReference做强引用编辑器里拖拽赋值避免拼错字符串。3.2 LoadAssetsAsync一次拿一批资源MergeMode 三兄弟需要批量加载一组资源的时候用Addressables.LoadAssetsAsyncT。这个接口的重载比较多我建议固定使用带MergeMode参数的那个版本AsyncOperationHandleIListGameObject handle Addressables.LoadAssetsAsyncGameObject( new Liststring { Weapon01, Weapon02, Weapon03 }, addressable { Debug.Log($单个资源加载完成: {addressable}); }, Addressables.MergeMode.Union );MergeMode决定了多个 Key 的匹配结果如何合并。这里用一个表格展示太合适了MergeMode行为典型应用场景Union合并所有 Key 的匹配结果去重按多个 Label 或地址批量加载UseFirst只使用第一个 Key 的匹配结果只取一个代表资源时用IgnoreException加载所有 Key 的匹配结果但如果某个 Key 加载失败忽略失败的那个继续返回成功项存在部分 Key 可能找不到的场景对比之后你就明白了Union是日常最多的选择。比如你的 UI 界面有大量图集资源全部挂在同一个 Label “UI_Atlas” 下一条LoadAssetsAsync就把这个 Label 下所有图集都拉回来。AsyncOperationHandleIListSprite handle Addressables.LoadAssetsAsyncSprite( UI_Atlas, _ { }, Addressables.MergeMode.Union );记得批量加载的释放handle.Result是一个IListT里面每项都要Release一次。我一般写一个批量释放工具方法遍历列表逐项Addressables.Release(item)再在列表清空后释放句柄本身。3.3 InstantiateAsync加载即实例化的讲究Addressables.InstantiateAsync对“创建场景内物体”这种需求是一个近乎完美的接口。它把“加载资源”和“创建实例”合并为一步而且内部因为知道资源是用于实例化的释放逻辑也做了对应优化。使用方式public void SpawnEnemy(string enemyKey, Vector3 spawnPos, Quaternion spawnRot) { AsyncOperationHandleGameObject handle Addressables.InstantiateAsync( enemyKey, spawnPos, spawnRot ); handle.Completed op { if (op.Status AsyncOperationStatus.Succeeded) { GameObject enemy op.Result; enemy.name enemyKey; // 后续游戏逻辑 } }; }销毁的时候不要再执行Destroy()而是Addressables.ReleaseInstance(handle);有一点要注意ReleaseInstance(handle)调用的时机。如果你是“加载完先缓存实例后面在某个条件满足时才销毁”那么缓存的一定要连同句柄一起缓存。只缓存GameObject、把句柄丢了一样的后面没法对实例做释放。我见过不少坑都是这样踩出来的实例列表里塞了一堆 GameObject程序想清理的时候找不到对应句柄只好搁置内存就慢慢涨上去了。3.4 LoadSceneAsync场景加载与卸载场景管理是 Addressables 里另一大块。Addressables.LoadSceneAsync的签名跟 Unity 原生SceneManager.LoadSceneAsync很像但是场景资源本身是 Addressable 的。AsyncOperationHandleSceneInstance handle Addressables.LoadSceneAsync( Assets/Scenes/Main.unity, LoadSceneMode.Single, activateOnLoad: true ); handle.Completed op { if (op.Status AsyncOperationStatus.Succeeded) { SceneInstance instance op.Result; Scene loadedScene instance.Scene; // SceneManager.SetActiveScene(loadedScene); } };参数activateOnLoad很关键。设置为 false 时可以先把场景加载完、但先不激活等待你的加载进度动画播完再调用instance.Activate()正式切换场景这样段位高的做法可以在切场景时完全无黑屏。单场景模式下旧场景会自动卸载但旧场景里通过 Addressables 加载出来的非场景资源不会自动释放需要开发者自己控制。我一般会做一个统一的场景切换管理器切换前先遍历场景内所有 Activity 的 Addressable 对象统一释放完毕后再加载新场景。场景卸载对应接口是Addressables.UnloadSceneAsync返回一个AsyncOperationHandleSceneInstance也要等它完成才算卸载完。另外注意场景加载之后SceneInstance.Scene是实际的Scene结构体把它传给SceneManager.SetActiveScene用完全没问题。3.5 其他辅助接口GetDownloadSizeAsync、DownloadDependenciesAsync、LoadResourceLocationsAsync这几个接口虽然不如前面几个“高调”但在实际项目中每个都非常有用。GetDownloadSizeAsync用来查询指定 Key 的资源在远程端需要下载的数据量单位是字节。它通常用在加载前判断“资源还没缓存需要先下载而且下载量超过某个阈值时先弹一个下载进度界面”。返回的是long转换成 MB 很方便展示。AsyncOperationHandlelong sizeHandle Addressables.GetDownloadSizeAsync(key); yield return sizeHandle; double downloadSizeMB sizeHandle.Result / (1024f * 1024f);DownloadDependenciesAsync则直接执行所有依赖资源的下载典型用法是配合进度条。这个接口也返回一个句柄轮询它的PercentComplete就能给你一个相对准确的下载进度。LoadResourceLocationsAsync更偏底层它返回一个IListIResourceLocation里面是资源的定位信息。我常用它做“游戏启动时扫描所有可用资源”的需求拿到 location 列表之后再批量LoadAssetAsync。这样可以通过代码动态枚举某个地址下的所有资源而不用在编辑器里手动维护资源清单。4. 实际项目的组合用法加载进度、预下载、动态标签讲完单个接口再聊聊这些接口在项目里怎么组合。真实项目里很少会只调一个LoadAssetAsync就完事几乎都会遇到“加载前检查远程资源要不要更新”“加载过程展示进度条”“某个界面需要同时加载一批资源”这类复合需求。4.1 加载进度条从 GetDownloadSizeAsync 到轮询 PercentComplete游戏启动时常见的场景检查远程资源有更新就先下下载期间 UI 显示进度下载完成后再加载进入主菜单。这个流程可以用协程串起来IEnumerator LoadHomeResourceRoutine(string homeSceneKey) { AsyncOperationHandlelong getSizeHandle Addressables.GetDownloadSizeAsync(homeSceneKey); yield return getSizeHandle; long downloadSize getSizeHandle.Result; if (downloadSize 0) { AsyncOperationHandle downloadHandle Addressables.DownloadDependenciesAsync(homeSceneKey); while (!downloadHandle.IsDone) { _loadingSlider.value downloadHandle.PercentComplete; yield return null; } _loadingSlider.value 1f; } AsyncOperationHandleSceneInstance loadSceneHandle Addressables.LoadSceneAsync(homeSceneKey); yield return loadSceneHandle; }有一点值得提醒DownloadDependenciesAsync的句柄不会自动释放用完要Addressables.Release(downloadHandle)。很多人写进度条的时候一高兴就忘了释放这个句柄虽然影响不大但积少成多总归是问题。4.2 动态 Label 批量加载资源按用途打组Label 是 Addressables 中特别实用的分类维度。一个预制体可以同时拥有多个 Label比如“Weapon”“Level1”“BossDrop”。我们可以在代码里利用 Label 组合做动态加载无需修改任何代码只要在编辑器里为资源打上对应标签即可。public void LoadAllBossWeapons(int bossLevel) { string bossLabel $Boss{bossLevel}; AsyncOperationHandleIListGameObject handle Addressables.LoadAssetsAsyncGameObject( new Liststring { bossLabel, BossDrop }, null, Addressables.MergeMode.Union ); handle.Completed op { if (op.Status AsyncOperationStatus.Succeeded) { foreach (GameObject prefab in op.Result) { // 先缓存起来不实例化 _cachedPrefabs.Add(prefab); } // 这里只缓存了prefab后面对每个prefab都要Release } }; }动态 Label 组合这套配合后端的活动配置表就非常灵活。活动策划只需要在配置表里填 label客户端直接根据 label 加载资源新增资源不用改代码改配置就行。数字孪生项目里我也见过这样的用法模型资源按楼栋、楼层打上 Label摄像机漫游到某栋楼时按 Label 批量加载附近楼栋模型离开时再批量释放内存表现非常理想。4.3 用 AssetReference 做强引用减少拼写错误字符串 Key 用多了难免手滑一个字母拼错就是加载失败失败。AssetReference可以让你在 Inspector 里直接拖拽资源避免字符串出错。public class WeaponSpawner : MonoBehaviour { public AssetReference weaponReference; public void Spawn() { weaponReference.InstantiateAsync(transform.position, transform.rotation); } }注意AssetReference的InstantiateAsync返回后释放时用Addressables.ReleaseInstance(handle)而不是WeaponRef.ReleaseInstance()。虽然 AssetReference 自己也有释放方法但我更倾向于统一走 Addressables 顶层的 Release 方法让管理模式更单一、不容易混乱。4.4 通用句柄管理组件为每个加载操作绑定释放时机最后分享一个项目里真正用了很久的小组件思路。资产加载常有“这个对象销毁时要释放它加载的资源”的需求我封装了一个小组件挂在所有通过 Addressables 创建出来的对象上它在OnDestroy里自动释放对应句柄。public class AddressableReleaser : MonoBehaviour { private ListAsyncOperationHandle _handles new ListAsyncOperationHandle(); public void AddHandle(AsyncOperationHandle handle) { _handles.Add(handle); } private void OnDestroy() { foreach (AsyncOperationHandle handle in _handles) { Addressables.Release(handle); } _handles.Clear(); } }然后在加载实例化完成后把句柄注册进去GameObject go handle.Result; var releaser go.AddComponentAddressableReleaser(); releaser.AddHandle(handle);这样即使别的逻辑忘了释放至少对象被销毁时资源会跟着释放掉。这套思路让我在排查内存问题时少掉了一半的头发。5. 我踩过的坑句柄误用、释放遗漏与回调时序5.1 访问已释放句柄的 ResultInvalidOperationException 前兆加载完成的句柄释放之后再访问ResultAddressables 会直接抛异常。我自己碰到过一次很典型的场景游戏里角色死亡后表现层逻辑访问角色模型资源句柄的Result来重新计算某个动画参数结果句柄早就被池系统释放掉了屏幕上直接报InvalidOperationException。排查这种问题时首先要看是不是有多个地方在使用同一个句柄。一个句柄只应该有一个“负责人”负责加载、负责释放、负责访问结果。如果多个系统都要用到同一个资源对象正确做法是先各取所需取出真正的资源引用然后管理者负责释放句柄而不是所有人都持着句柄不放谁都能碰它。5.2 Completed 回调注册太晚错过了加载完成的时机Completed回调有一个很隐蔽的特性如果操作已经完成你再来注册回调回调不会立即执行。这是很多异步系统都有的设计但非常容易坑人。比如你在某个异步流程里提前启动了加载中间过了几帧才记得注册回调结果回调永远不会触发代码看起来像“没生效”。处理方式有两种。第一永远在调用加载接口后立刻注册回调第二注册之后主动检查一下handle.IsDone如果已完成就手动执行回调逻辑。我个人更偏向用一个ExecuteAfterComplete的辅助方法处理“已完成则立即执行未完成则挂回调”的分支逻辑。5.3 Destroy 实例却不 ReleaseInstance内存只增不减对象池系统是 Addressables 内存泄漏的重灾区。很多团队用 Addressables 做对象池化从池里取出对象用放回池里隐藏。问题出在用InstantiateAsync生成的实例回收到对象池后并不销毁自然也不会触发释放逻辑等到游戏运行几十分钟内存里堆了一堆显式加载出来的资源副本。这种场景下我建议用ReleaseInstance是完全销毁而不是复用实例。如果你一定要做池化建议使用LoadAssetAsync加载原始预制体用原始预制体做Instantiate和Destroy资源本体一直常驻内存。这样资源只有一份实例销毁不影响资源本体池化的对象也不再和 Addressables 句柄纠缠在一起。显然这个方案牺牲了动态释放资源的能力但它适合“某个界面的预制体常驻内存、复用频繁”的场景。5.4 排查资源加载失败的通用流程操作异常输出是个好东西资源加载失败是最常见的报错但排查路径往往很长。我建议你有任何问题先看输出日志如果日志报KeyNotFoundException先检查 Address 是否拼写正确、资源是否在 Addressables Groups 窗口里。如果日志报InvalidKeyException检查类型参数T跟资源实际类型是否一致比如你加载一个 Sprite 却写成了LoadAssetAsyncGameObject。如果加载是慢但最终成功看PercentComplete是否迟迟不动基本就是在做远程资源的下载检查本地缓存和远程配置。这些错误在 Addressables 的日志里都有比较明确的提示关键是别只看一眼就丢。把所有日志打开认认真真读一遍报错信息绝大多数问题五分钟内能定位。我见过太多人一报错就开始怀疑是“缓存问题”或者“版本问题”结果最后是资源压根没打进 AssetBundle 里。另外强烈建议在开发期开启Event Viewer窗口Window Asset Management Addressables Event Viewer它能把所有句柄的加载、释放、引用计数变化都可视化出来。查内存泄漏的时候直接看 Event Viewer 里哪个句柄一直没释放一目了然。6. 一些个人经验这几种加载习惯让我少踩了很多坑最后再分享几个实战中沉淀下来的习惯权当参考。第一加载、释放写在同一个地方的代码不一定是好代码但“谁加载谁负责释放”这个规则一定要定死。如果资源是一个 Manager 加载的但是场景里的物体在使用那就必须有一个明确的流程Manager 持有的句柄在 Manager 自己的逻辑里释放使用方拿到的只是资源对象它不具备释放句柄的能力更不应该去碰Release。第二加载操作尽量集中在启动流程或 UI 切换流程里做不要想加载就加载。一次加载几十个资源的开销可以从 UI 阶段“预加载”来分散而不是在新关卡开始的第一帧集中爆发否则推进会瞬间卡顿。第三多用AssetReference少用裸字符串。字符串 Key 在重构资源目录的时候不会跟着变而 AssetReference 在资源移动时会自动更新引用两者在维护成本上的差距是数量级的。Addressables 的异步操作句柄这一层确实是上手时的拦路虎但一旦把句柄的生命周期和加载接口的选型逻辑理顺资源管理的体验就会好非常多。希望这篇能让你少花几天在调试句柄和内存上多把时间留给真正的游戏逻辑和表现优化。
02
RELATED NEWS

相关资讯

更多网站建设与数字化升级内容

03
WHY YAOTU

想打造同款高转化官网?

懂行业、懂生意,从建站到增长一站式陪跑

场景化定制

不做模板站,围绕你的业务场景量身设计,小众不撞款。

营销型架构

以转化目标组织内容与路径,让官网真正带来询盘。

全周期服务

设计、开发、运营、运维一体,上线只是开始。

免费获取你的建站方案

留下需求,专属顾问 24 小时内为你输出方案建议。