资源加载这件事,写第一行业务代码的时候谁都觉得简单,无非是拿到一个地址、把资源取回来、用完放掉。可一旦项目里资源分成几十个包、几种加载入口混着用、异步句柄到处复制传递,问题就集中爆发了:明明加载成功了画面上却是空的、同一个资源释放之后另一处突然变白、回调里再加载一次直接卡住主线程。这篇就围绕 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 Task<IList<IResourceLocation>> 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.LoadAssetAsync<GameObject>(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.LoadAssetsAsync<GameObject>( "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.LoadAssetAsync<GameObject>(locs[0]);这个方式适合两类场景。第一类是高频访问,比如列表滚动时反复取同一批图标,把定位信息缓存起来能明显降低查找开销。第二类是需要精确控制的场景,比如你只想要某个标签下的一部分资源,用标签会把全部拉起来,用地址逐个列举又太啰嗦,这时候查一次定位信息、过滤之后再批量加载最合适。
注意:定位信息列表是有序的,但顺序不保证和你打包时的顺序一致,也不保证在不同版本间稳定。任何依赖下标取资源的代码都不可靠,必须用完再查或者用键匹配。
3.5 场景加载与实例化加载的差异
除了资源加载,还有两种特殊的加载方式:场景加载和预制体实例化加载。它们和普通资源加载的区别在于,产出的不是资源本身,而是一个运行时的对象或者一个激活的场景,所以释放方式也完全不同。
场景加载用的是专门的接口,卸载也有对应的接口,而且可以指定加载模式(单场景替换、叠加、仅加载不激活)。这里要注意的是,加载完成的场景会被自动激活,如果你需要先做一些准备工作再激活,就要用“仅加载不激活”那个模式,准备好之后再手动激活。我见过有人在场景加载完成的回调里才去创建必要的管理器对象,结果场景里的脚本已经开始执行了,报空引用,就是这个原因。
实例化加载前面提过,必须配对使用释放实例接口。还有一个细节:实例化时可以传父节点,如果父节点被销毁了,实例化出来的对象会怎样?实测下来是对象会跟着一起被销毁,但底层的资源引用计数不会自动减,所以还是得手动回收,或者干脆保证父节点存活到回收之后。这个细节文档里写得不显眼,但实际项目里父子节点频繁创建销毁的场景很多,不注意就会漏。
3.6 选型速查:什么场景用哪种加载方式
| 场景特征 | 推荐方式 | 主要理由 | 注意事项 |
|---|---|---|---|
| 配置表/服务端下发资源名 | 地址字符串 | 编译期不可知,只能查表 | 地址必须常量化并做打包前校验 |
| 预制体上挂的固定引用 | AssetReference | 编辑期可校验,引用关系清晰 | 注意别把大资源意外拉进主包 |
| 按章节/语言/画质批量预热 | 标签批量加载 | 一次加载一类,管理成本低 | 合并模式要显式指定,回调会多次触发 |
| 列表高频取图、精确控制集合 | 定位信息 | 跳过查找开销,可控性强 | 定位信息本身要释放,不能长期缓存 |
| 关卡切换 | 场景加载接口 | 自动处理依赖和激活 | 注意加载模式与激活时机 |
| 运行时生成带回收的对象 | 实例化加载 | 底层管理实例计数 | 必须用释放实例接口回收 |
4. Async Operation Handle 深度拆解
4.1 结构体的三个核心字段决定了它的行为
这个句柄看起来简单,实际上内部就三个关键信息:操作对象、版本号、以及操作的完成状态。版本号的存在是为了解决一个经典问题——操作对象被复用了。底层为了减少分配,会把完成的操作对象放回池子里,下次加载时再取出来用。如果句柄只记操作对象,那么一个旧的句柄可能指向一个已经被复用、正在执行新加载的操作,读它的结果就是错的。版本号就是用来识别“这个句柄指向的是不是当初那个操作”。
理解这一点之后,很多诡异现象就有解释了。比如为什么释放之后句柄不能再用、为什么复制的句柄释放两次会出问题、为什么在操作完成之后注册回调会立刻同步执行。这些都是版本号和状态机共同作用的结果。
我一般在代码里约定一条线:句柄只在“发起加载的方法”和“处理结果的方法”之间传递一次,中间不做长期存储。需要长期持有的,存的是资源本身或者实例对象,不存句柄。这样能避开绝大部分生命周期问题。
4.2 完成事件、状态与结果的三段式读取
标准的读取流程是三步:判断状态、取结果、处理异常。状态只有三种,未完成、成功、失败。这里要注意的是,未完成状态在操作刚开始的时候也会短暂出现,所以不要在注册了完成事件之后又去同步读状态,那样读到的是注册那一刻的状态,不是最终状态。
var handle = Addressables.LoadAssetAsync<Texture2D>(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 Dictionary<string, (AsyncOperationHandle<Object> handle, int count)> map = new(); public AsyncOperationHandle<Object> Rent(string key) { if (map.TryGetValue(key, out var entry)) { map[key] = (entry.handle, entry.count + 1); return entry.handle; } var handle = Addressables.LoadAssetAsync<Object>(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 加载性能的几个实际数字
最后分享几个实测下来的参考值,都是普通项目里的量级,不作为标准,只是让你心里有个谱。单个小资源从请求到可用的耗时通常在几毫秒到几十毫秒,取决于包的大小和是否已经在内存里。按标签批量加载几十个资源,首次耗时可能到几百毫秒,第二次因为有缓存会快很多。定位信息的查找在缓存命中时开销极小,基本可以忽略,但首次查找会触发一次目录查询,相对慢一些。
这些数字的意义不在于精确,而在于帮你判断“这个耗时是不是正常”。如果一个图标加载要几百毫秒,那肯定是哪里出了问题,要么是包太大,要么是依赖链太长,要么是根本没打进去在反复重试。有基准数字,排查才有方向。
加载这块内容看着零碎,实际核心就三件事:搞清楚键、定位信息、提供者这三层的关系,选对入口,管好句柄的生命周期。这三件事理顺了,剩下的都是细节。我自己在这个体系上踩过的坑,绝大多数都能归到这三件事里,希望对正在折腾同样问题的你有用。