资源加载这件事写第一行业务代码的时候谁都觉得简单无非是拿到一个地址、把资源取回来、用完放掉。可一旦项目里资源分成几十个包、几种加载入口混着用、异步句柄到处复制传递问题就集中爆发了明明加载成功了画面上却是空的、同一个资源释放之后另一处突然变白、回调里再加载一次直接卡住主线程。这篇就围绕 Addressable 体系里的三个核心点展开——根据资源定位信息加载资源、几种加载 Addressable 方式怎么选、以及Async Operation Handle这个看起来像语法糖、实际上是整个异步体系地基的结构体。内容适合已经能把资源打进包、但对加载链路还停留在“抄示例”阶段的开发者也适合想把这套东西吃透、以后排查线上问题不靠猜的人。下面所有结论都来自实际项目里的反复折腾代码片段可以直接改改就用。1. 加载体系整体设计与思路拆解1.1 为什么加载入口要设计成“多入口单内核”刚接触这套东西的人常有一个疑问既然最后都是把资源取出来为什么不让大家统一用一个接口非要搞出地址字符串、AssetReference、Label、IResourceLocation 这么多种写法我一开始也觉得是设计过度直到项目做大了才明白这几种入口服务的是完全不同的使用场景而它们的底层其实是同一套内核。地址字符串面向的是“运行时才知道要什么”的场景比如配置表里写了一个图标名、服务端下发了一个活动资源 ID。这类需求的特点是编译期完全不可知只能靠字符串查表。AssetReference 面向的是“编译期就确定、但要延迟加载”的场景比如某个预制体里挂着的特效引用你希望它跟着主包走引用关系但又不想让它被一起打进主包本体。Label 面向的是“成组操作”的场景比如按语言、按章节、按画质档位批量预热或批量卸载。IResourceLocation 则是给那些需要绕过“键值查找”这一步、直接操作定位信息的高级用法准备的。提示这四种入口最终都会汇聚到同一个资源定位体系上理解这一点比记住 API 名字重要得多。你排查问题时顺着“键 → 定位信息 → 提供者 → 实际加载”这条链路走基本不会迷路。真正让我改变看法的是一个加载耗时的问题。项目里有个界面打开要 800 毫秒Profile 之后发现大头不在 IO而在反复的键值查找和依赖解析。改法是把界面需要的资源定位信息提前取出来缓存运行时直接用定位信息加载省掉了每次打开界面都重新查一遍地址表的开销。这件事让我意识到所谓“多种加载方式”本质上是把同一条链路的不同阶段暴露给你让你可以选择从哪一步切入。1.2 键值查找与定位解析的分层决定了性能上限把这套加载拆开看其实是三层。第一层是键也就是你给资源起的地址或者打的标签它是给人看的、可读的、可以随时改的。第二层是定位信息它是给系统看的里面记录了资源的内部标识、提供者类型、依赖列表、资源类型这一层在打包时就固化了。第三层是提供者负责真正去 AssetBundle 或者本地文件里把字节读出来、实例化成对象。绝大多数项目停留在第一层直接用这没问题因为前两层之间的转换有缓存第二次查同一个键几乎无开销。但有两种情况必须往下走一层一是同一个键要重复加载成百上千次比如列表项图标二是你需要精确控制“加载哪几个”而不是“加载某个键对应的全部”。前者的收益是省掉查找开销后者的收益是避免误加载。分层设计带来的另一个好处是可测试性。你可以把定位信息当成一个纯数据对象来构造和传递在单元测试里塞一个假的定位列表进去验证上层逻辑不需要真的打一个包出来。这一点在实际项目里非常有用尤其是那种资源量巨大、每次打包要十几分钟的项目。1.3 异步句柄为什么必须是结构体这是最容易被忽略的设计决策。异步句柄如果设计成类每次传递都是引用看起来更方便但会带来一个致命问题你无法区分“同一个操作的多个持有者”和“同一个引用的多次复制”。设计成结构体之后每次赋值都是一份独立的值系统可以精确地通过内部的版本号和操作对象来判断这个句柄是否还有效、是否已经被释放过。代价是使用上必须小心。结构体复制不会自动增加引用计数也不会自动减少所以你必须自己管理“谁负责释放”。我见过最典型的翻车场景是这样的一个方法里拿从句柄传给另一个方法另一个方法里觉得“我用完了”就 Release 了一次调用方回来又 Release 一次第二次直接抛异常或者把还在被别人用的资源干掉。注意句柄是值类型复制它不等于多一份所有权。项目里最好约定一条规矩——谁发起加载谁负责释放中间传递只读不释放。2. 资源定位信息这套体系的“地址簿”到底怎么用2.1 从地址字符串到定位信息的完整链路当你写下一句按地址加载的调用时内部大概发生了这些事先在地址表里用这个字符串查查到一个或多个定位信息然后看这些定位信息的资源类型和你请求的类型是否匹配不匹配就报类型错误接着把定位信息里的依赖项展开递归地把依赖也解析成定位信息最后交给对应的提供者去执行真正的加载。这条链路里有两个点值得单独拎出来说。第一个是类型匹配。同一个地址如果被标成了预制体你按贴图类型去请求结果不会是“尽力而为”而是直接失败。这一点经常在新人身上出问题尤其是资源是从别处复制过来、类型标签没改的情况。第二个是依赖展开。依赖是在打包时算好的运行时只是读出来所以它不会因为你在编辑器里改了引用关系就自动更新必须重新打包。这就解释了一个经典现象编辑器里跑得好好的出了包就缺材质。我在项目里做法是凡是涉及到“这个资源会带出哪些依赖”的判断一律以打包后的分析结果为准不以编辑器里的直观感受为准。分析窗口里能看到每个包的依赖关系打包前扫一遍比出了包再回头查要省事得多。2.2 主动获取定位信息列表的正确姿势想拿到定位信息用的是按类型查位置的那个接口它返回的同样是一个异步句柄结果是一个定位信息列表。这里有个极易踩的坑这个句柄本身也要释放。很多人只记得释放资源句柄忘了释放查位置产生的句柄结果就是内存里堆着一堆已经没用的定位信息长时间运行后内存缓慢上涨还很难定位。正确的写法是嵌套的外层查位置拿到列表后遍历或者直接交给批量加载接口批量加载的句柄和查位置的句柄各自释放。我一般会把这个过程封装成一个方法内部保证两个句柄都配对释放外部只看到一个资源句柄。public async TaskIListIResourceLocation GetLocationsAsync(string label, Type type) { var handle Addressables.LoadResourceLocationsAsync(label, type); await handle.Task; if (handle.Status ! AsyncOperationStatus.Succeeded) { Addressables.Release(handle); return null; } // 注意这里不能立刻释放调用方还要用这个列表 // 约定由调用方在使用完毕后释放或者在这里复制一份再释放 return handle.Result; }上面这段其实是个反面教材因为它把释放责任抛给了调用方很容易忘。更稳的做法是在方法内部把列表内容复制成普通数组然后立刻释放句柄返回数组。虽然多一次分配但释放责任清晰不会漏。资源列表通常不大这点开销完全值得。2.3 手工组合定位信息的适用场景有一种情况必须手工操作定位信息你需要加载的资源集合无法用标签表达也不能用地址逐个列举。比如一个关卡需要“当前章节的所有背景图 玩家当前皮肤 全局公共音效”这三类资源的组织方式完全不同但你想用一次批量加载把它们的进度合并成一个。做法是先分别查出各自的定位信息列表然后合并成一个新列表交给批量加载接口。这个用法的好处是进度是统一的加载完成的回调也是统一的界面上只需要一个进度条。坏处是依赖关系不会自动合并去重如果两个列表里有同一个依赖资源底层会自己去重但你在上层看不到这个信息所以不要试图自己算“总共要加载多少个”那个数字不准。提示手工合并定位信息时列表里的类型必须和目标类型一致或者可兼容混着放不同类型会直接失败。我一般会给每个列表单独做类型校验出问题能立刻定位到是哪一段。2.4 定位信息的生命周期与失效判断定位信息是打包产物的映射它在你更新目录之后可能整体失效。这在做热更新相关的流程时尤其要注意旧目录下拿到的定位信息在新目录加载完成之后不一定还能用。我的做法是凡是跨越了目录更新的操作之前缓存的定位信息一律作废重新查一遍。虽然多花一点时间但省掉了一整类“资源明明存在却加载失败”的诡异问题。判断定位信息是否还有效没有直接的 API只能靠句柄的有效性来判断。句柄上的有效性标志能告诉你这个操作对象是否还活着但它不能告诉你背后的定位信息是否还指向正确的包。这一层只能靠流程约束不能靠运行时探测。3. 几种加载方式的实操与选型3.1 按地址字符串加载最通用也最容易滥用这是最直观的方式一个字符串进去一个句柄出来。它的优点是解耦彻底加载方完全不需要知道资源在哪、属于哪个包只要知道名字就行。这也正是它最危险的地方——字符串写错了编译期发现不了只能运行时暴露。我在项目里给这个方式加了两道保险。第一道是常量类所有地址集中定义不允许在业务代码里出现裸字符串。第二道是编辑器校验打包前扫一遍所有常量确认每个地址在地址表里都能查到查不到就报错阻断打包。这两道加上之后地址写错导致的问题基本绝迹。public static class AssetKeys { public const string UiIconCommon ui_icon_common; public const string EffectHit fx_hit; } var handle Addressables.LoadAssetAsyncGameObject(AssetKeys.EffectHit); handle.Completed op { if (op.Status ! AsyncOperationStatus.Succeeded) { Debug.LogError($加载失败: {op.OperationException}); return; } var prefab op.Result; // 使用 prefab };需要注意的是这个接口的参数类型是 object也就是说你传进去的不一定是字符串也可以直接传定位信息。这一点在需要绕过查找的时候很有用但也会让代码可读性下降因为看调用点不知道传的到底是键还是定位信息。我的习惯是只在明确需要优化的热点路径上用定位信息其他一律用字符串保持可读性。还有一点重复调用同一个地址的加载接口底层不会真的加载两次而是返回同一个操作的句柄。这意味着你调用两次、只释放一次资源不会被释放调用两次、释放两次第二次释放会报错或者提前释放。所以配对这件事必须严格不能靠“反正底层有缓存”这种侥幸心理。3.2 用 AssetReference 做引用式加载编译期安全的代价AssetReference 的核心价值是把“字符串”变成了“可在 Inspector 里拖拽的对象引用”。它在编辑器里记录的是资源的唯一标识打包时会转成地址所以运行时的查找和字符串方式是一样的但编译期和编辑期都能校验存在性拖错了当场就能看出来。用法上分两种。一种是普通的资源引用调用它自己的加载方法拿到资源另一种是专门针对预制体的变体它提供实例化方法实例化出来的对象必须用对应的释放实例接口回收不能直接销毁。这一点我踩过坑用普通销毁方式处理实例化出来的对象表面上没问题实际上底层记录的实例计数没减导致资源一直不卸载内存居高不下。public AssetReferenceGameObject enemyRef; async void SpawnEnemy() { var handle enemyRef.InstantiateAsync(transform); var go await handle.Task; // 回收时必须走这个接口 // Addressables.ReleaseInstance(go); }它也有明显的局限。第一它必须在编辑器里预先建立引用关系运行时动态决定的资源用不了。第二它会把引用关系写进序列化数据如果引用了一个很大的资源可能会意外地把这个资源拉进主包除非你明确把它标成可寻址并且允许走远程。这一点在项目初期最容易被忽视等到发现主包体积异常才回头查往往要花半天时间。我的选型原则是预制体上挂的特效、音效、子预制体这类“结构上固定、加载时机不定”的引用用 AssetReference配置表驱动的、服务端下发的、按名字拼出来的用字符串。3.3 按标签批量加载一把双刃剑标签加载的写法很简洁传一个标签字符串和一个回调回调会对每一个加载完成的资源调用一次。它的典型用途是预加载——比如进入战斗场景前把当前关卡所有会用到的资源按标签一次性拉起来避免战斗中途出现卡顿。var handle Addressables.LoadAssetsAsyncGameObject( chapter_01, null, Addressables.MergeMode.Union); handle.Completed op { if (op.Status ! AsyncOperationStatus.Succeeded) return; foreach (var go in op.Result) { // 预热处理比如读一次材质属性触发着色器编译 } };这里有个关键参数合并模式。当标签有多个、或者你要同时按多个键加载时合并模式决定了结果是并集、交集还是取第一个。默认是取第一个匹配很多人不知道有这个参数结果标签写了好几个实际只有一个生效还以为是标签没打对。并集是最常用的交集适合表达“同时满足两个条件的资源”用好了能省掉不少筛选逻辑。还有一个坑是回调的调用次数。批量加载时每加载完一个资源就会调用一次回调回调里的代码会被执行很多次。如果回调里有创建 UI、播放音效这种副作用就会变成一堆重复对象。我一般只在真正需要“逐个处理”的时候才用回调否则传空等全部完成之后统一遍历结果。关于标签本身的设计我有个经验不要用标签表达筛选条件。见过有人把“稀有度”“品质”“是否可出售”都做成标签然后靠标签组合来筛资源结果标签数量爆炸每次加载都要查一大堆。正确做法是标签只表达资源和加载策略的关系业务属性放配置表从配置表里拿到地址再去加载。3.4 用定位信息直接加载把控制权拿到手里前面说过传入定位信息可以跳过查找这一步。写法上和字符串加载几乎一样只是参数换成定位信息对象。批量加载也接受定位信息列表这时候就不需要标签了你想加载哪几个就传哪几个。var locHandle Addressables.LoadResourceLocationsAsync(AssetKeys.EffectHit); var locs await locHandle.Task; var assetHandle Addressables.LoadAssetAsyncGameObject(locs[0]);这个方式适合两类场景。第一类是高频访问比如列表滚动时反复取同一批图标把定位信息缓存起来能明显降低查找开销。第二类是需要精确控制的场景比如你只想要某个标签下的一部分资源用标签会把全部拉起来用地址逐个列举又太啰嗦这时候查一次定位信息、过滤之后再批量加载最合适。注意定位信息列表是有序的但顺序不保证和你打包时的顺序一致也不保证在不同版本间稳定。任何依赖下标取资源的代码都不可靠必须用完再查或者用键匹配。3.5 场景加载与实例化加载的差异除了资源加载还有两种特殊的加载方式场景加载和预制体实例化加载。它们和普通资源加载的区别在于产出的不是资源本身而是一个运行时的对象或者一个激活的场景所以释放方式也完全不同。场景加载用的是专门的接口卸载也有对应的接口而且可以指定加载模式单场景替换、叠加、仅加载不激活。这里要注意的是加载完成的场景会被自动激活如果你需要先做一些准备工作再激活就要用“仅加载不激活”那个模式准备好之后再手动激活。我见过有人在场景加载完成的回调里才去创建必要的管理器对象结果场景里的脚本已经开始执行了报空引用就是这个原因。实例化加载前面提过必须配对使用释放实例接口。还有一个细节实例化时可以传父节点如果父节点被销毁了实例化出来的对象会怎样实测下来是对象会跟着一起被销毁但底层的资源引用计数不会自动减所以还是得手动回收或者干脆保证父节点存活到回收之后。这个细节文档里写得不显眼但实际项目里父子节点频繁创建销毁的场景很多不注意就会漏。3.6 选型速查什么场景用哪种加载方式场景特征推荐方式主要理由注意事项配置表/服务端下发资源名地址字符串编译期不可知只能查表地址必须常量化并做打包前校验预制体上挂的固定引用AssetReference编辑期可校验引用关系清晰注意别把大资源意外拉进主包按章节/语言/画质批量预热标签批量加载一次加载一类管理成本低合并模式要显式指定回调会多次触发列表高频取图、精确控制集合定位信息跳过查找开销可控性强定位信息本身要释放不能长期缓存关卡切换场景加载接口自动处理依赖和激活注意加载模式与激活时机运行时生成带回收的对象实例化加载底层管理实例计数必须用释放实例接口回收4. Async Operation Handle 深度拆解4.1 结构体的三个核心字段决定了它的行为这个句柄看起来简单实际上内部就三个关键信息操作对象、版本号、以及操作的完成状态。版本号的存在是为了解决一个经典问题——操作对象被复用了。底层为了减少分配会把完成的操作对象放回池子里下次加载时再取出来用。如果句柄只记操作对象那么一个旧的句柄可能指向一个已经被复用、正在执行新加载的操作读它的结果就是错的。版本号就是用来识别“这个句柄指向的是不是当初那个操作”。理解这一点之后很多诡异现象就有解释了。比如为什么释放之后句柄不能再用、为什么复制的句柄释放两次会出问题、为什么在操作完成之后注册回调会立刻同步执行。这些都是版本号和状态机共同作用的结果。我一般在代码里约定一条线句柄只在“发起加载的方法”和“处理结果的方法”之间传递一次中间不做长期存储。需要长期持有的存的是资源本身或者实例对象不存句柄。这样能避开绝大部分生命周期问题。4.2 完成事件、状态与结果的三段式读取标准的读取流程是三步判断状态、取结果、处理异常。状态只有三种未完成、成功、失败。这里要注意的是未完成状态在操作刚开始的时候也会短暂出现所以不要在注册了完成事件之后又去同步读状态那样读到的是注册那一刻的状态不是最终状态。var handle Addressables.LoadAssetAsyncTexture2D(key); if (handle.IsDone) { // 已经完成Result 可以直接读 Apply(handle.Result); } else { handle.Completed op { if (op.Status AsyncOperationStatus.Succeeded) { Apply(op.Result); } else { // 一定要打日志不然线上只能靠猜 Debug.LogError(op.OperationException); } }; }上面这段里的完成判断很有必要。因为如果操作已经完成注册完成事件会立即同步执行回调而不是等到下一帧。这个行为本身不算错但它会让代码的执行顺序和你预期的不一样尤其是在回调里又触发了别的加载时很容易形成意外的嵌套调用。我在项目里养成的习惯是注册回调之前先判断完成状态两种情况分开写逻辑清晰也不会被同步回调打个措手不及。关于结果的类型句柄是泛型的取出来的就是对应类型不需要强转。但如果用非泛型的句柄结果就是 object需要自己转。我的建议是尽量用泛型版本类型错误在编译期就能发现比运行时抛异常好得多。4.3 同步等待的真实代价句柄提供了一个阻塞等待的方法调用之后会一直卡到操作完成。这个方法在工具代码、编辑器脚本、初始化流程里确实好用但绝对不能出现在正常的游戏逻辑里。原因很直接加载本身是通过异步任务推进的主线程阻塞之后推进异步任务的时机被推迟形成“我在等我自己”的情况。在支持多线程的平台上可能表现为卡顿几十毫秒在不支持多线程的平台上直接就是死锁整个画面冻住不动。注意这个阻塞等待的接口只适合在确定不会影响主循环的场合使用比如编辑器工具、打包脚本、启动阶段的一次性初始化。业务代码里看到它基本可以判定是设计问题。如果确实需要“等加载完再继续”的写法用异步任务配合 await或者把后续逻辑放进完成回调里。这两种方式都不会阻塞主线程虽然代码结构上要多一层嵌套但行为是正确的。4.4 释放与引用计数的配对规则释放这件事规则其实就一条每一次成功的加载对应一次释放。但实际项目里往往做不到这么干净因为加载和释放经常不在同一个模块里。我的处理办法是把加载和释放封装进一个小的管理器外部只调用“取”和“还”内部维护一个字典记录每个键被取了多少次减到零才真正释放。这样即使业务层写错了也不会把别人还在用的资源干掉。public class AssetHolder { readonly Dictionarystring, (AsyncOperationHandleObject handle, int count) map new(); public AsyncOperationHandleObject Rent(string key) { if (map.TryGetValue(key, out var entry)) { map[key] (entry.handle, entry.count 1); return entry.handle; } var handle Addressables.LoadAssetAsyncObject(key); map[key] (handle, 1); return handle; } public void Return(string key) { if (!map.TryGetValue(key, out var entry)) return; if (entry.count 1) { map[key] (entry.handle, entry.count - 1); return; } Addressables.Release(entry.handle); map.Remove(key); } }这段代码解决的是“谁先释放”的问题但没解决“加载失败怎么办”。加载失败时返回的句柄状态是失败结果为空这种句柄也要释放否则会留下悬挂的操作。我在实际项目里会在返回之前检查状态失败就立刻释放并把错误往上抛让调用方知道这次没取到。还有个容易忽略的点批量加载的句柄释放一次会释放它加载的所有资源。所以如果你对批量加载的结果做了单独引用释放之后这些引用全部失效。这听起来理所当然但实际写代码时经常会有人从批量结果里挑一个对象存起来过一会儿批量句柄被释放了那个对象就成了空壳。4.5 依赖信息与下载大小的查询句柄上还能查两个有用的东西依赖列表和下载大小。依赖列表能告诉你这次加载会牵扯到哪些包在做资源预检或者包体分析时很有用。下载大小能告诉你从远端拉这个资源需要多少字节做更新提示、流量提醒时必备。这两个查询本身也返回句柄同样要释放。下载大小的查询在资源已经本地存在时返回零这个返回值的语义是“还需要下载多少”不是“这个资源总共多大”理解错了会做出很奇怪的提示文案。我在项目里遇到过一次明明资源在包里提示却说需要下载几十兆查了半天发现是查询的是另一个没下载的变体。5. 常见问题与排查技巧实录5.1 加载成功但对象是空的这是出现频率最高的问题原因通常有三类。第一类是类型不匹配按错误的类型请求底层返回的是失败而不是空对象但如果你没有检查状态直接用了结果看到的就是空。第二类是资源本身在打包时被剔除或者引用丢失这种情况在编辑器里正常、出包后异常特征非常明显。第三类是加载完成时对象还没实例化比如预制体加载完成了但里面的组件要等实例化之后才有。排查顺序我一般是这样的先看状态和异常信息确认是成功还是失败成功了再看结果是否为 null都不为空再检查使用时机。这个顺序能过滤掉九成的问题。5.2 释放之后别处变成白块或者报空这就是引用计数的问题。表现是资源被提前释放了持有该资源引用的地方全部失效。排查方法是给加载和释放都加上日志记录键名和调用栈然后对比同一个键的加载次数和释放次数是否一致。数量对不上问题就找到了。这类问题在多人协作的项目里特别常见因为每个人都在自己的模块里加加载、加释放没人能看到全局。我的建议是统一走前面那个管理器禁止业务代码直接调用底层加载和释放接口用代码规范约束比靠人自觉靠谱。5.3 回调里再加载导致卡顿或者死循环完成回调执行在哪个线程、什么时机这个细节很多人没想过。回调是在主线程的更新循环里被调用的如果在回调里又发起一个加载并立刻去读它的结果读到的必然是未完成状态。如果代码逻辑依赖于“读到结果”就会走进循环等待表现出来就是卡死。正确做法是回调里只做“处理结果”这一件事需要串联加载的话把下一个加载的完成回调也注册好让它们各自独立完成。如果链路很长用异步方法串起来会清晰很多。5.4 编辑器里正常出包后加载失败这个现象背后基本就是几个固定原因包没打进去、目录没更新、远程路径配置不对、依赖丢失。排查的时候先看打包分析结果确认资源确实在某个包里再看运行时的目录是否包含这个包最后看路径配置。这里分享一个很省时间的办法出包之后不要急着跑完整流程先做一个“资源自检”界面把所有关键资源的地址列出来逐个尝试加载并输出状态。这个界面在编辑器里也能用两边一对比问题立刻定位到是打包问题还是运行时问题。项目里加上这个东西之后排查资源问题的时间至少缩短一半。5.5 常见问题速查表现象最可能原因快速验证方式处理办法加载成功但结果为空类型不匹配 / 资源被剔除打印状态和异常信息核对类型标签重新打包资源突然变白/丢失引用计数减到零被提前释放统计加载与释放次数统一走托管封装禁止裸释放打开界面卡顿明显每次都用字符串查找未缓存记录查找耗时缓存定位信息热点路径直接加载回调执行顺序异常操作已完成回调同步触发注册前判断完成状态完成与未完成分支分开写阻塞等待后画面冻结主线程被阻塞异步无法推进看是否在不支持多线程平台改用异步任务或回调串联批量加载数量不对合并模式默认值不是并集检查合并模式参数显式指定合并模式出包后资源缺失依赖未重新打包 / 目录未更新对比编辑器与包内分析结果重新打包并校验目录5.6 几条不带文档的实践经验第一条不要在完成回调里做重活。回调的时机是在更新循环里做重活会直接拖慢这一帧。需要处理的逻辑多就把结果塞进队列下一帧再处理。第二条句柄不要跨场景保存。场景卸载会带走一批对象保存的句柄可能指向已经失效的操作用的时候各种异常。需要跨场景的数据存资源本身或者存键名不要存句柄。第三条加载失败的日志一定要打全。把键名、请求类型、异常信息一起打出来线上出问题时这一条日志能省掉几小时的排查。我见过很多项目日志里只有一句“加载失败”什么信息都没有等于没打。第四条批量加载的进度不要自己算。句柄上有进度百分比但批量加载时这个百分比的粒度很粗用来做进度条会一跳一跳的。想要平滑的进度得自己按资源数量估算但估算值不能用来判断是否完成完成判断只能看状态。第五条释放的时机尽量靠近使用结束。有些项目喜欢在场景切换时统一释放所有资源看起来省事实际上会把一些常驻资源也干掉切换回来又要重新加载体验很差。常驻资源单独管理场景资源跟着场景走这个边界划清楚后面省很多事。6. 一套可以直接抄走的加载封装思路6.1 封装边界怎么划聊完前面这些回到工程落地。我的做法是把加载分成两层底层是纯封装只负责把键变成句柄、把句柄变成资源不做任何业务判断上层是业务门面按模块划分每个模块知道自己需要哪些资源、什么时候取、什么时候还。两层之间通过接口通信底层不依赖上层方便替换和测试。底层封装要暴露的方法其实很少按类型取单个资源、按标签取一组、按定位信息取一组、释放、查询状态。多了就是过度设计少了就得在业务层重复写样板代码。这个粒度是我试了几次之后定下来的基本能覆盖九成以上的需求。6.2 初始化顺序的坑加载系统本身也需要初始化这个初始化是异步的而且必须在任何加载之前完成。如果顺序错了第一次加载会失败或者拿到空结果。常见错误是在初始化还没完成时就去加载首屏资源看起来是偶发问题实际上是必然问题只是时序上有时快有时慢。我的处理办法是把初始化做成一个显式的前置步骤完成之后才允许进入主流程。业务代码里所有加载都发生在这个步骤之后从结构上杜绝时序问题。如果有人问为什么不自动等待答案是自动等待会让加载的时序变得不可预测出问题时更难排查。6.3 加载性能的几个实际数字最后分享几个实测下来的参考值都是普通项目里的量级不作为标准只是让你心里有个谱。单个小资源从请求到可用的耗时通常在几毫秒到几十毫秒取决于包的大小和是否已经在内存里。按标签批量加载几十个资源首次耗时可能到几百毫秒第二次因为有缓存会快很多。定位信息的查找在缓存命中时开销极小基本可以忽略但首次查找会触发一次目录查询相对慢一些。这些数字的意义不在于精确而在于帮你判断“这个耗时是不是正常”。如果一个图标加载要几百毫秒那肯定是哪里出了问题要么是包太大要么是依赖链太长要么是根本没打进去在反复重试。有基准数字排查才有方向。加载这块内容看着零碎实际核心就三件事搞清楚键、定位信息、提供者这三层的关系选对入口管好句柄的生命周期。这三件事理顺了剩下的都是细节。我自己在这个体系上踩过的坑绝大多数都能归到这三件事里希望对正在折腾同样问题的你有用。
阅读完成 · 觉得有帮助?