5.2 Addressables 与异步加载:物资仓库调度 本节摘要:Resources 目录"全量打包、同步阻塞",是资源管理的原罪模式;Addressables 用"地址加引用计数"实现按需供货与自动释放。本节讲清它的核心模型、与协程及 async 两套等待写法的配合,并完成一次从 Resources 到 Addressables 的改造。 Resources 的原罪 Resources 目录的问题不是"不好用",而是它的便利建立在两笔隐性债务上。第一笔,凡放进该目录的资源,无论运行时用不用,全部打进包体——三个月后没人记得哪个资源还有人引用,包体只增不减,策划想删都心虚。第二笔,Resources.
本节摘要:Resources 目录"全量打包、同步阻塞",是资源管理的原罪模式;Addressables 用"地址加引用计数"实现按需供货与自动释放。本节讲清它的核心模型、与协程及 async 两套等待写法的配合,并完成一次从 Resources 到 Addressables 的改造。
Resources 目录的问题不是"不好用",而是它的便利建立在两笔隐性债务上。第一笔,凡放进该目录的资源,无论运行时用不用,全部打进包体——三个月后没人记得哪个资源还有人引用,包体只增不减,策划想删都心虚。第二笔,Resources.Load 是同步加载,一大波资源一起读时主线程直接冻结,玩家看到的是"点下开始按钮后白屏半秒"。
Addressables 把这两笔债都清了:资源通过地址(或标签)访问,打包时只打被标记的组,加载永远异步,引用计数归零自动释放。它还内置了远程供货能力——资源组可以放在服务器上,首包只带核心内容,关卡与活动内容运行时下载,这是现在手游与长线运营游戏的标准姿势。
心智模型一句话:Resources 是"仓库敞开随便搬",Addressables 是"凭单据领货、用完销单"。凭单据访问意味着多了一层间接(要先请求、再回调),换来的是完全可控的装载节奏与内存水位。
Addressables 的加载是异步操作,C# 里有两套等待写法。协程版是 Unity 传统姿势,与第 2.3 节的知识无缝衔接;async 与 await 是现代 C# 姿势,第 2.2 节埋的伏笔在这里兑现。同一个需求(加载并实例化一个关卡预制体)两种写法对照:
using UnityEngine; using UnityEngine.AddressableAssets; using System.Collections; public class LoadByCoroutine : MonoBehaviour { public AssetReference levelRoom; // Inspector 里拖一个标记为 Addressable 的预制体 void Start() { StartCoroutine(LoadRoom()); } IEnumerator LoadRoom() { // 任务句柄:既是"取货单"也是"销货凭证" var handle = levelRoom.InstantiateAsync(); yield return handle; // 协程挂起等加载完成 Debug.Log("关卡已就位:" + handle.Result.name); // 释放统一走 levelRoom.ReleaseInstance } } public class LoadByAsync : MonoBehaviour { public AssetReference levelRoom; async void Start() { // await 把异步流程写成直线,不用回调嵌套 var op = levelRoom.InstantiateAsync(); await op.Task; Debug.Log("关卡已就位:" + op.Result.name); } }
两种写法选哪个?团队习惯优先。协程版的好处是与 Unity 生命周期深度绑定(宿主销毁协程自动死);async 版的好处是逻辑直线化、异常处理用 try catch 一目了然。新人混写最容易出的事故是 async void 里忘了处理异常——异常会静默吞掉,Console 无声无息,查问题时先怀疑这一点。
背景:关卡道具系统原来用 Resources.Load 读二十个道具预制体,包体超预算,启动加载也要三秒。目标:改造成 Addressables 按需加载,只有被点击的道具才进内存。
操作:第一步,安装 Addressables 包并初始化(创建默认设置资产);第二步,把道具预制体逐个打上 Addressable 标记,分进 Props 组(分组面板里可以按"常驻、按关卡、活动内容"划组,分组就是供货策略);第三步,改造加载代码:
using UnityEngine; using UnityEngine.AddressableAssets; using System.Collections.Generic; public class PropLoader : MonoBehaviour { // 地址列表:与旧的 Resources 路径列表一一对应 public List<AssetReference> propRefs; Dictionary<AssetReference, GameObject> loaded = new Dictionary<AssetReference, GameObject>(); public void LoadProp(int index) { var r = propRefs[index]; if (loaded.ContainsKey(r)) return; // 已加载直接用,引用计数不叠加 r.InstantiateAsync().Completed += op => { loaded[r] = op.Result; op.Result.SetActive(false); Debug.Log("道具就绪:" + op.Result.name); }; } public void UnloadProp(int index) { var r = propRefs[index]; if (!loaded.TryGetValue(r, out var obj)) return; loaded.Remove(r); Addressables.ReleaseInstance(obj); // 销单:内存与计数一起回落 } }
结果:运行后点击加载某个道具,Console 延迟零点几秒打出"道具就绪";卸载后内存监控里对应资源消失;打包体积里 Props 组独立成一个包,首包不再包含全部道具。
解读:改造的核心是"引用计数配对"。每次 InstantiateAsync 让计数加一,ReleaseInstance 让它减一,归零才真正卸载资源——这与事件订阅退订的成对纪律(第 2.4 节)是同一条原则在资源层的投影。漏写 Release 的症状是"玩久了内存只涨不跌",比崩溃更隐蔽。另一个容易忽略的细节:AssetReference 是序列化字段,Inspector 里拖引用或手填地址都行,但对象销毁顺序不可控时,先释放实例再释放句柄,顺序颠倒会报警告。
变式:把加载改成带进度条的入场流程——InstantiateAsync 的句柄上有 PercentComplete 属性,协程里逐帧读它刷新进度条 UI,加载完成再切场景。同步白屏半秒变成有反馈的等待,体验差距立现,这也是加载界面存在的全部理由。
按需不等于无限碎:加载一次的成本(寻址、读盘、依赖解析)不低,颗粒度要按"玩法节奏"划——一个关卡一个组、一套皮肤一个组,而不是一个贴图一个组。常驻内容(角色基础模型、核心 UI)进首包组随包分发,玩法内容按关卡分组随用随取,活动内容进远程组支持热更。这张供货表定好,仓库就再也乱不了。
仓库改制完成。下一节处理"多人值班"的问题:版本控制与协作纪律。