AssetBundle 这四个字,在 Unity 项目里属于那种平时没人提、一提就吵架的话题。做小游戏还好,包体小、资源少,直接塞 Resources 或者 StreamingAssets 都行;可一旦项目进入中大型规模,AB 的管理方式直接决定了你的研发效率、包体大小、加载速度,还有线上用户的内存水位。我这些年接手过好几个项目的资源管理模块,有一个非常真实的感受:依赖、异步加载、引用计数、自动卸载,这四个东西任何一个单独拿出来都有文档、有方案,但真正难的是把它们串成一个体系。单点方案再完美,接不起来,线上照样崩。
这篇文章我打算直接讲透 AssetBundle 极限管理的整体链路,从依赖树怎么构建、异步加载队列怎么写,到引用计数模型怎么设计、自动卸载怎么兜底,全部按实际可落地的方案来拆。你如果是 Unity 客户端开发、渲染相关开发,或者正在背资源管理这块的面试题,这篇文章应该能帮你把整套逻辑理顺。
1. AssetBundle 管理为什么难在"联动"而不是"单点"
1.1 四个模块天生是"彼此依赖"的
先问一个问题:为什么我们不能只用 AssetBundle.LoadFromFileAsync 加载资源,然后该卸载的时候直接 AssetBundle.Unload(false)?事情要是这么简单,就不会有这篇文章了。
真正麻烦的地方在于,一个 UI 界面往往不只是一个 AssetBundle 里的资源。比如你打开一个商城界面,它可能需要一个 UI 图集、一个模型、几段音频、一个配置表。这些资源可能分散在多个 AssetBundle 里,而这些 AB 之间又有依赖关系。你要加载商城界面,就得先把所有依赖的 AB 全部加载到位;你要卸载商城界面,得确保这些 AB 没被别人也占着。于是"依赖管理"和"引用计数"就必须同时在场。而异步加载呢?因为依赖链条很长、IO 耗时不可控,同步加载会让主线程卡得惨不忍睹,所以必须异步。异步加载又带来了新的问题:加载完成顺序不确定,回调怎么组织?重复请求怎么合并?这又需要一套任务队列来兜底。
自动卸载更不用说了,它是整个体系的"收尾"动作。如果引用计数没算对,自动卸载就会误杀;如果自动卸载太勤,资源频繁加载卸载,卡顿和 CPU 开销就上来了;如果自动卸载太懒,内存就爆了。这就是一个环环相扣的系统,任何一环掉链子,整体都会出问题。
1.2 用"资源驻留模型"来理解整套逻辑
我在实际项目中反复锤炼之后,形成了一个比较稳定的思考框架,我管它叫"资源驻留模型"。一句话概括就是:一个 AssetBundle 从被加载到被卸载,中间经历的状态一定要被统一管理,不能搞特殊。
所有 AB 都有一个驻留字典,记录了当前加载进来的 AB 对象,以及它的引用计数、依赖列表、最近使用时间。加载流程是"先查驻留字典,有就直接用,没有就建任务进队列";释放流程是"引用计数减一,减到零再进待卸载池";自动卸载则根据引用计数和最近使用时间做实际清理。你听下来可能会觉得这就是个"带引用计数的缓存系统",没错,本质上就是这个东西。Unity 的 Resources 系统其实也做了类似的事情,但它不开放控制细节,所以我们做 AssetBundle 管理,实际上就是自己写一个更可控的资源驻留层。
有了这个驻留模型,后面所有模块都往这个模型里挂就行:依赖加载负责把依赖的先拉进来,异步队列负责控制加载节奏,引用计数负责记录资源被使用的次数,自动卸载负责把彻底没人用的资源踢出去。每个模块各司其职,同时又通过同一个驻留字典互相通信。
2. 依赖清单:一切管理的地基
2.1 依赖树是 AB 系统最底层的地基
很多新手一上来就写加载逻辑,结果漏掉了依赖处理,后面就全是坑。比如你加载一个角色模型,模型材质引用了某个公共图集,公共图集放在另一个 AB 里,如果你没有先把图集 AB 加载好,模型加载出来之后,材质就是粉红色的,或者显示成"烘焙过但贴图丢失"的状态。这种问题最难排查,因为有时候资源已经加载过、驻留在内存里,视觉上看不出来;有时候重启编辑器又一切正常,就会让人觉得是玄学。
我建议在编辑器构建阶段就把 AB 的依赖树数据导出成一份清单文件,随包发布。构建时用AssetBundleManifest拿到每个 AB 的直接依赖和所有依赖,然后把这张表序列化成二进制或者 JSON,放到 StreamingAssets 里。运行时加载 AB 前,先查这张表,把依赖按顺序加载出来。为什么不能运行时再调AssetBundleManifest.GetAllDependencies?因为要加载主 Manifest 文件本身,还得先加载那个不依赖任何东西的主 AB,这其实就是个先有鸡还是先有蛋的问题。运行时拿AssetBundleManifest是可行的,但多一次前置加载,而且在 Android 包内读取时路径处理也麻烦。预生成一张依赖表,能省掉很多运行时的不确定性。
依赖表的数据结构也不复杂,本质上就是一张字典:
// 简化示例:依赖表的运行时结构 public class ABDependencyTable { public Dictionary<string, string[]> DirectDependencies; public Dictionary<string, string[]> AllDependencies; }DirectDependencies记录每个 AB 直接依赖了哪些 AB,AllDependencies则通过递归展开得到全量依赖。加载时你只需要递归遍历DirectDependencies,用深度优先或广度优先逐个加载。很多人的实现 Bug 出在"递归重复加载"上,比如 A 依赖 B,B 依赖 C,A 也依赖 C,如果递归不加判重,C 会被加进队列两次,产生重复加载。前面提到的驻留字典在这里就很重要:加载函数第一步一定是查字典,如果资源已经在加载中或已加载,就直接复用,而不是让它进队列。
2.2 主 Manifest 的正确打开方式
AssetBundleManifest是 Unity 打 AB 时自动生成的清单文件,里面记录了所有 AB 的哈希值、CRC 校验码和依赖关系。但使用它有几个关键点得注意。
首先,主 Manifest 文件本身存在于StreamingAssets目录下,文件名为你打包输出目录的名字,例如StreamingAssets/AssetBundles/AssetBundles。这个文件也是一个 AB,加载它的方式与加载其他 AB 相同,但特殊之处在于它没有依赖。你要拿到 Manifest 对象,需要先加载它并取出AssetBundleManifest类型的资源:
var manifestBundle = AssetBundle.LoadFromFile(Path.Combine(bundlePath, bundleRootName)); var manifest = manifestBundle.LoadAsset<AssetBundleManifest>("AssetBundleManifest");这里的bundleRootName是打包输出目录名,不是随便起的字符串。如果你用的是 BuildPipeline.BuildAssetBundles(outputPath, options, target),那传入的 outputPath 的最后一个文件夹名就是 bundleRootName。很多人在这卡了半天,就是因为目录名没传对。
拿到 Manifest 之后,你可以通过GetAllAssetBundles()遍历所有 AB 名字,通过GetAllDependencies(bundleName)拿全量依赖。这些接口在编辑器下很方便,但不建议在运行时全量遍历使用。一个是性能问题,另一个是上节说的加载时序问题。
我在实际项目中通常这么分工:构建阶段把依赖表导出,运行时直接用预生成的表;Manifest 组件只在编辑器工具、热更校验、CRC 检查等场景用,运行时尽量不去碰它。这算是一个从实战里沉淀下来的取舍。
2.3 依赖加载的两种策略:全量依赖树 vs 逐级依赖
依赖加载在实现上可以分两种策略。
第一种是全量依赖树加载。拿到一个要加载的 AB,直接找到它所有的依赖(包括间接依赖),按拓扑顺序全部加载。这种方案的好处是实现简单,逻辑明确,只要资源表没错,一次加载搞定;坏处是可能加载一些当前并不用到的依赖。比如角色 A 的 AB 依赖了某个特效 AB,但实际显示角色 A 时根本不用那个特效资源,全量加载也会把它拉进来,造成内存浪费。
第二种是逐级依赖加载。加载每个 AB 时只加载它的直接依赖,加载完直接依赖后,再去递归加载直接依赖的直接依赖。这种更精准,但实现复杂度高,而且加载链路上的每个环节都有失败概率,任何一个依赖环节失败,整个加载任务都要回滚。很多项目最终都会从逐级依赖演进回全量依赖,因为全量依赖虽然内存上稍微浪费一点,但代码可维护性、上线稳定性都好太多了。
如果你的项目资源粒度设计得当,AB 之间的依赖关系不会特别深(一般控制在两层以内),全量依赖树的收益远大于弊端。我建议绝大多数团队直接用全量依赖树,接口也好设计:
public AssetBundleLoadRequest LoadBundle(string bundleName, bool loadDependencies = true)loadDependencies这个开关在编辑器下调试时特别有用,关了它你能快速定位是不是依赖缺失导致的问题。
3. 异步加载:把"慢"挡住,把"请求"排队
3.1 LoadFromFileAsync 和 UnityWebRequest 到底怎么选
异步加载的第一步是选对接口。AssetBundle 的异步加载主要有两条路:AssetBundle.LoadFromFileAsync和UnityWebRequestAssetBundle.GetAssetBundle。
LoadFromFileAsync底层走的是文件 IO,而且有一个很重要的特性:如果 AB 是 LZ4 压缩的,它只会把索引数据读进内存,真正的资源数据在需要时才读;如果是 LZMA 压缩的,加载时还是要一次性解压整个包。所以压缩格式的选择会直接影响加载耗时和内存表现。LZ4 是"块级压缩",适合 Local/StreamingAssets 这种本地加载场景,加载快、可按需读取;LZMA 是"流式压缩",压缩率高但解压成本高,适合下载分发场景。客户端正式包里的 AB 建议一律用 LZ4,热更下载的资源如果是整包下载完再加载,也建议用 LZ4;只有那种必须极限压包体积的场景才考虑 LZMA。
UnityWebRequestAssetBundle走的是 URL 加载,可以加载本地文件系统里没有的远程 AB,也能配合缓存系统做二次加载优化。但它的开销比LoadFromFileAsync大,每次都要经过 UnityWebRequest 的回调链路。如果你加载的是本地文件,用LoadFromFileAsync就够了;远程资源下载到本地之后,再走LoadFromFileAsync加载。
这里有一个性能对比,我把它整理成了表格,方便你选型:
| 方式 | 适用场景 | 压缩格式建议 | 注意事项 |
|---|---|---|---|
| LoadFromFileAsync | 本地 StreamingAssets、下载后本地 AB | LZ4 | 用 LZMA 会一次性解压,注意内存峰值 |
| UnityWebRequestAssetBundle | 远程 URL 直接加载、带缓存 | LZMA 或 LZ4 | 有跨平台缓存路径问题,需要统一版本管理 |
| AssetBundle.LoadFromMemory(Async) | 加密后的 AB 解密再加载 | 不建议 | 会拷贝一份内存,峰值翻倍,能不用就不用 |
LoadFromMemory那条我多解释一句。很多人想做 AB 加密,把 AB 文件读到 byte[] 里解密,再 LoadFromMemory,结果发现内存翻倍、加载慢,还容易触发内存峰值。真要做加密,建议把 AB 拆成两个文件:一个小的索引文件负责加载时的校验和资源定位,一个大的数据文件保持原样加载,或者直接走文件级加密、解密后落盘再 LoadFromFile。别把整个 AB 塞进内存做解密。
3.2 加载队列与并发控制
异步接口解决了"卡主线程"的问题,但没解决"加载风暴"的问题。比如你开了一个新场景,同时触发了几十个加载请求,如果全部并发执行,一瞬间 IO 压力、内存压力全部爆表,低端机上必卡。
所以加载管理器一定要有一个任务队列,控制并发数。我常用的做法是维护一个 LoadTask 队列,每次最多并发执行 N 个加载(具体 N 根据目标平台和项目体量定,低端 Android 可以设 2~3,PC/高端机可以设 4~6)。
public class LoadTask { public string BundleName; public Action<AssetBundle> Callback; public Action<Exception> ErrorCallback; public bool LoadDependencies; }核心流程是:收到请求后先查驻留字典,找到了直接回调,找不到就判断这个 BundleName 是否已经在加载中。如果已经在加载中,就把回调挂到已有任务的回调列表上;如果没有,创建一个 LoadTask,进队列,尝试启动新的异步加载。这个"合并相同加载请求"的逻辑至关重要,否则同一时刻重复调用加载同一个 AB,会触发两次实质加载,回调一来一回,引用计数就乱了。
比较稳妥的实现是手持一个"加载中字典":
private Dictionary<string, BundleLoadingState> _loadingStates;BundleLoadingState里放着AssetBundleCreateRequest、已注册的回调列表、依赖加载进度等。等AssetBundleCreateRequest完成之后,统一触发所有回调,再把这个状态从加载中字典移到已加载字典。这样整个异步流程可以被理解成一个有限状态机:无状态 -> 依赖加载中 -> AB 加载中 -> 完成/失败。
3.3 回调组织的坑与注意事项
异步加载的回调设计是很容易踩坑的地方。一是回调执行线程的问题,AssetBundleCreateRequest.completed的回调默认在主线程执行,但你并不确定它是在 Update 的哪个阶段被调用的,所以在回调里做资源实例化、UI 刷新这类操作问题不大,但如果在回调里又触发了新的加载,要小心死锁。比如 A 加载完成后回调内部又同步等 A 完成,这种写法在极端情况下会把主线程卡死。
二是回调的顺序不能保证。假设你同时发起加载 A 和 B,A 的 AB 比较小完成了,但 B 的依赖包含 A,此时 A 虽然在驻留字典里,但 B 还卡在依赖加载阶段,这种交错情况需要加载管理器有完善的依赖状态检查。
我的做法是给每个加载任务记录一个依赖计数,所有依赖都加载完成后才真正开始加载 AB 本体(或者并行加载 AB 和依赖,加载完成后做一次校验)。简单场景下可以串行:先递归加载依赖,再加载本体。虽然慢一点,但逻辑清晰、容易排查问题。真遇到复杂场景需要加速,再优化并行逻辑,不要一上来就高度并行。
4. 引用计数:给每个资源一个"生命值"
4.1 一套最小可用的引用计数模型
引用计数这个概念本身很简单,每个资源对象记录自己被引用的次数,加一次引用就 +1,释放一个引用就 -1,减到 0 说明没人用了,可以卸载。但落地到 AssetBundle 管理上有不少细节。
首先要明确"引用"的粒度。AssetBundle 本身是一个资源容器,我们通常以"AB 文件"为单位做引用计数,而不是为 AB 里的单个资源做计数。原因很简单,AssetBundle 的加载和卸载是对整个文件操作的,你不可能只卸载一个 AB 里的某几个资源,剩下的还驻留。所以计数单位就是 AB。
其次要明确计数对象。加载一个 AB 时,它的依赖 AB 必须跟着加载,那么问题来了:依赖 AB 的引用计数要不要跟着 +1?我认为要。主资源的加载者在业务上需要的数据,往往也包含了依赖资源里的贴图、材质、音频;如果依赖 AB 的引用计数不跟着主资源走,就会出现主资源还在使用依赖 AB 里的贴图,但依赖 AB 的引用计数已经归零、被卸载掉的情况,到时候整个物体就变成粉红色了。
这里要分清"自动卸载器"和"业务持有者"两个角色。业务持有者只管自己加引用、释放引用;自动卸载器在资源引用计数为 0 时才真正执行卸载。
4.2 引用计数的正确触发时机
引用计数最容易出错的地方是触发时机。我在项目里总结了几个统一原则:
第一,资源加载完成回到业务层时,加载管理器自动把该资源及其依赖的引用计数 +1。不要等业务层手动调 Retain,否则很容易漏。
第二,业务层释放资源时,调用资源的 Release 接口,释放的语义是"我之前 Retain 的资源现在不用了",所以要递归释放该资源依赖的底层 AB。
第三,场景切换时,场景内的所有资源持有者要走统一的释放逻辑,不能让每个模块自己乱释放。比如你有一个 PanelBase,每个面板打开时加载了 UI 资源,关闭时调用资源管理器释放它们,这样资源生命周期就跟 UI 逻辑天然绑定在一起了。
第四,AssetBundle 加载失败时不能 +1,已经 +1 了但没有返回给业务层的资源要在失败路径上做补偿释放,否则计数就泄漏了。
public class AssetBundleRefCounter { private class RefEntry { public AssetBundle Bundle; public int Count; } private readonly Dictionary<string, RefEntry> _entries = new Dictionary<string, RefEntry>(); public void Retain(string bundleName) { if (!_entries.TryGetValue(bundleName, out var entry)) { // 这里根据实际情况决定是否自动加载 entry = new RefEntry { Bundle = LoadBundleSync(bundleName), Count = 0 }; _entries[bundleName] = entry; } entry.Count++; } public void Release(string bundleName) { if (!_entries.TryGetValue(bundleName, out var entry)) return; entry.Count--; if (entry.Count <= 0) { // 进入待卸载池 } } }这个示例是一个极简模型,真正项目里你应该把RefEntry和加载封装在一起,不要出现"先 LoadBundleSync 再 Retain"的分裂操作。统一入口是资源管理器最重要的设计原则之一,不要让业务组的人直接操作 AssetBundle 对象。
4.3 引用计数的坑:依赖本身要不要跟着加
这个问题我在 4.1 提到过结论是"要加",但实现上有几个变种值得讨论。
变种一是"主资源 +1 时,依赖全量跟着 +1"。这种最简单,依赖计数 = 所有依赖主资源的实际持有者的总和,逻辑非常直观。缺点是如果同一个依赖被多个主资源共享,引用计数会累加得很高,但实际内存驻留是一样的,没问题。
变种二是"只给主资源 +1,依赖不 +1,但依赖的卸载由主资源释放时顺带触发"。这种实现省掉了依赖的递归计数逻辑,但风险很大。一旦你加载了一个没有"主从关系"的 AB,它就是一个孤儿资源,永远不会被释放。长期跑下来内存必然泄漏。
变种三是"部分依赖共享、部分独立操作"。有些项目里 AB 被分成常驻包和加载包,常驻包比如公共 UI、公共图集、基础 Shader 永远不卸载,加载包的依赖只指向常驻包,所以不参与引用计数。这种在实践中是可行的,但需要一条严格的分层规则,不能出现加载包相互依赖。
我个人的经验是:前期业务逻辑还不稳定的时候,一定要用变种一,把"每个资源被谁持有、持有计数是多少"完全搞透明。等到了后期性能优化阶段,如果发现引用计数本身占用的计算量或者复杂性影响了体验,再考虑优化成"常驻包 + 加载包"的模式。不要一上来就觉得引用计数太重、想尽各种办法省略,最后线上资源泄漏查不出来更痛。
5. 自动卸载:让"没用的"自己走
5.1 定时巡检:引用计数归零了不代表马上卸载
引用计数归零只是说明"这个 AB 当前没有业务持有者",但如果我们即刻执行AssetBundle.Unload(false),可能带来两个问题。
第一个问题是抖动。比如玩家频繁进入离开战斗场景,战斗相关的 AB 每次归零就被卸载,下次又要重新加载,加载耗时带来的卡顿非常明显。这种场景下合理的做法是设置一个"冷却时间",资源引用计数归零后,先进入一个"待回收池",在冷却时间(比如 30 秒)内如果又被加载了,直接从池子里拿出来复用;冷却时间到了还没被用到,才真正卸载。
第二个问题是卸载本身也有成本。AssetBundle.Unload(false)会把 AssetBundle 对象标记为已卸载,但它引用的原生资源对象(Texture、Mesh、AudioClip 等)不会立刻释放,需要配合Resources.UnloadUnusedAssets()才能真正回收。每次调用Resources.UnloadUnusedAssets()都会触发一次全量资源扫描,代价不小,不能一归零就调,必须批量处理。
所以自动卸载的思路就是一套"定时巡检 + 批量回收"机制。我实现的是一个每 30 秒触发一次的协程,逻辑大概是:遍历驻留字典,找出引用计数归零且未锁定的 AB,记录最近一次使用时间,如果当前时间减去使用时间超过冷却阈值,就执行Unload(false),同时收集这些 AB 里的资源信息,最后批量调一次Resources.UnloadUnusedAssets()。
5.2 场景切换时的整体清理策略
定时巡检能解决大部分"无人使用"的泄漏,但场景切换这种大事件需要更果断的处理。一个场景加载完,上一个场景不再使用的资源基本就都失去了业务层持有者。此时如果还按冷却时间慢慢回收,内存里就会堆积大量上一场景的残留资源,下一个场景的资源一进来,内存可能就爆了。
所以我会在场景切换时主动触发一次"全量卸载检查":
public void OnSceneSwitch() { ForceCheckAllUnusedBundles(); Resources.UnloadUnusedAssets(); System.GC.Collect(); }注意这里ForceCheckAllUnusedBundles也要有短暂的冷却豁免,比如场景切换前 30 秒内刚被引用过的资源,虽然引用计数归零了,但玩家快速来回切换场景时会重新用到,豁免可以避免花式抖动。场景切换的清理逻辑通常放在自己的场景加载管理器里,不要散落在各业务模块中。
场景卸载本身也要注意,SceneManager.UnloadSceneAsync只是卸载场景对象,场景里使用的 AssetBundle 资源不会自动卸载。场景中挂载的组件如果持有资源引用,在场景卸载完成后这些引用会成为"悬空引用",如果之后又触发 AB 加载,可能拿到的是一个已卸载的旧资源。这是个很隐蔽的内存和表现双 bug。
5.3 内存水位触发的强制回收
除了定时巡检和场景切换,还有一个触发自动卸载的重要条件是内存水位。现在中低端手机的内存容量很紧张,虽然 Unity 的 Profiler 能看到总量,但在线上我们通常不直接读系统内存,而是通过监控Profiler.GetTotalAllocatedMemoryLong()、Profiler.GetTotalReservedMemoryLong()来判断当前应用的内存压不压力。
如果分配量超过阈值,比如总驻留内存超过了项目设定的 700MB,这时候定时巡检的冷却时间可以临时缩短,把引用计数归零的资源快速卸载。更进一步,可以做一个"最近最少使用"策略,即不仅卸载引用计数为零的资源,还可以释放那些仍被持有但业务上可以重建的缓存资源。不过这一步一定要业务层配合,不能系统层强制卸载仍在使用的资源,否则就是一场灾难。
内存水位管理还有个细节:不同平台、不同机型的阈值差别非常大。iPhone 内存紧张和低端 Android 内存紧张的触发时机完全不同。我建议在启动时通过设备信息做一个"内存档位"划分,比如 LowEnd、MidEnd、HighEnd,每个档位对应不同的驻留预算、巡检周期、冷却时间。这个配置不能写死在代码里,要能远程下发,方便线上出问题后做灰度调整。
6. 常见问题与排查技巧实录
6.1 高频问题速查表
做了一套资源管理系统之后,日常协助其他团队排查问题基本能覆盖下面这些高频场景。我把它们整理成了一个速查表:
| 现象 | 典型原因 | 排查思路 |
|---|---|---|
| 模型粉红色 | 依赖 AB 未加载或已被卸载 | 用依赖表检查该模型 AB 的依赖是否都在驻留字典中 |
| 内存持续上涨,Profiler 里 AB 数量不变 | Unload(false) 后没有调 Resources.UnloadUnusedAssets,实际资源没释放 | 检查巡检器是否触发了 UnloadUnusedAssets |
| 加载了同一个 AB 多次,计数异常高 | 重复加载请求没有被合并 | 查看加载中字典是否正常工作,回调是否被重复注册 |
| 场景切换后卡顿 | 资源没有预加载,或卸载后立即重载 | 看场景切换清理是否豁免了热资源,考虑做预加载预热 |
| 回调永远不触发 | 依赖加载失败且没有错误回滚 | 检查加载任务是否在依赖失败时正确走到 ErrorCallback |
| 引用计数永远不为零 | 业务层 Retain 后没有 Release | 在编辑器里打印 Retain/Release 调用栈,自动化查泄漏 |
表格里的每一行,我都在真实项目里见过。尤其是第一行"模型粉红色",几乎每个月都会被 QA 提一次。如果你能快速通过查依赖表定位到是哪个依赖 AB 没加载,排查时间能从半天压缩到十分钟。
6.2 独家排查经验分享
排查引用计数泄漏我有一个非常实用的小工具:在编辑器下给每一次 Retain 和 Release 记录调用栈,并把当前引用计数打印到 Inspector 面板。做法不复杂,在编辑器工具里写一个字典,记录每个 AB 名对应的一系列 stack trace。Release 次数多了以后,如果某个 AB 的引用计数没归零,你直接看这个 AB 的 Retain 记录,就知道是哪个模块拿了引用没释放。这个方法我用了上百次,每次都快速定位问题。
另外还有一个容易被忽略的点是热更资源的版本问题。AB 热更后,同一个名字对应的哈希值变了,但驻留字典的 key 一般还是用名字。如果加载管理器没有把"名字 -> 当前版本哈希"的映射处理好,很容易出现加载到老版本资源的情况。我建议在依赖表里记录每个 AB 的当前版本哈希,加载时校验一下,不一致就直接重新下载并替换驻留字典里的对象。
关于AssetBundle.Unload(true)这个接口,我强烈建议业务代码里不要直接调用,它会把 AB 里所有资源一起销毁,一旦有实例还在用这些资源,立刻会报 MissingReferenceException。有些人用它来强制释放内存,看起来是解决了泄漏,其实是把问题转移成了各种随机崩溃。正确做法永远是Unload(false)+Resources.UnloadUnusedAssets(),让 Unity 的引用检测去决定哪些资源真正没人引用。
6.3 从空洞到极限:这套系统还能继续优化的方向
如果你已经搞定了依赖、异步加载、引用计数、自动卸载这四件事,那你的资源管理已经超过大部分团队的水平了。但"极限管理"这四个字意味着还能再往前推一层。
可以考虑的方向之一是分帧预加载。在进入下一个玩法之前,把资源加载分散到前面的几秒钟里,每帧只加载少量 AB,避免加载风暴。我之前在战斗场景切场时做了预加载队列,切场等待时间从一两秒的卡顿变成几乎无感,原理就是提前把战斗要用的 AB 队列排好,每帧限流加载,加载完再自动进入场景。
方向之二是资源包体分层的自动化校验。构建完成后,自动扫描所有 AB 依赖,如果发现存在超过两层的深依赖、或者两个 AB 互相依赖,直接给出构建警告甚至失败。这会倒逼资源组同学优化资源打包方式,从源头减少 AB 之间的耦合,让加载管理器的压力更小。
方向之三是做"按需下载 + 本地缓存 + 定期清理"的完整链路。AssetBundle 管理不局限于游戏包里已有的 AB,在线游戏经常会远程下发新资源。你要把下载完成的 AB 缓存到本地,同时管理缓存容量,超出预算时按最近使用时间清理最旧的 AB 文件,这本质上又回到了引用计数和自动卸载的模型,只是把"加载"换成了"下载+加载"。
我自己在实际操作中的体会是,AssetBundle 管理没有银弹,也没有一劳永逸的方案。整个系统设计得好不好,不在于某一天把它写得多么花哨,而在于每一次线上问题出现时,你能不能快速定位到是依赖表的问题、加载队列的问题、计数逻辑的问题,还是卸载触发时机的问题。把这四根链条串好,让每个模块有自己的职责边界,你的游戏才能在高资源压力下依然稳定跑下去。如果你正在设计或者重构资源管理模块,建议先把依赖表和引用计数的接口定义清楚,这两个是地基,地基稳了,异步和卸载全部水到渠成。