AssetBundle极限管理:依赖、异步加载、引用计数与自动卸载全链路解析

发布时间:2026/9/19 12:25:26
AssetBundle极限管理:依赖、异步加载、引用计数与自动卸载全链路解析 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 Dictionarystring, string[] DirectDependencies; public Dictionarystring, string[] AllDependencies; }DirectDependencies记录每个 AB 直接依赖了哪些 ABAllDependencies则通过递归展开得到全量依赖。加载时你只需要递归遍历DirectDependencies用深度优先或广度优先逐个加载。很多人的实现 Bug 出在递归重复加载上比如 A 依赖 BB 依赖 CA 也依赖 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.LoadAssetAssetBundleManifest(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、下载后本地 ABLZ4用 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~3PC/高端机可以设 4~6。public class LoadTask { public string BundleName; public ActionAssetBundle Callback; public ActionException ErrorCallback; public bool LoadDependencies; }核心流程是收到请求后先查驻留字典找到了直接回调找不到就判断这个 BundleName 是否已经在加载中。如果已经在加载中就把回调挂到已有任务的回调列表上如果没有创建一个 LoadTask进队列尝试启动新的异步加载。这个合并相同加载请求的逻辑至关重要否则同一时刻重复调用加载同一个 AB会触发两次实质加载回调一来一回引用计数就乱了。比较稳妥的实现是手持一个加载中字典private Dictionarystring, BundleLoadingState _loadingStates;BundleLoadingState里放着AssetBundleCreateRequest、已注册的回调列表、依赖加载进度等。等AssetBundleCreateRequest完成之后统一触发所有回调再把这个状态从加载中字典移到已加载字典。这样整个异步流程可以被理解成一个有限状态机无状态 - 依赖加载中 - AB 加载中 - 完成/失败。3.3 回调组织的坑与注意事项异步加载的回调设计是很容易踩坑的地方。一是回调执行线程的问题AssetBundleCreateRequest.completed的回调默认在主线程执行但你并不确定它是在 Update 的哪个阶段被调用的所以在回调里做资源实例化、UI 刷新这类操作问题不大但如果在回调里又触发了新的加载要小心死锁。比如 A 加载完成后回调内部又同步等 A 完成这种写法在极端情况下会把主线程卡死。二是回调的顺序不能保证。假设你同时发起加载 A 和 BA 的 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 Dictionarystring, RefEntry _entries new Dictionarystring, 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 管理没有银弹也没有一劳永逸的方案。整个系统设计得好不好不在于某一天把它写得多么花哨而在于每一次线上问题出现时你能不能快速定位到是依赖表的问题、加载队列的问题、计数逻辑的问题还是卸载触发时机的问题。把这四根链条串好让每个模块有自己的职责边界你的游戏才能在高资源压力下依然稳定跑下去。如果你正在设计或者重构资源管理模块建议先把依赖表和引用计数的接口定义清楚这两个是地基地基稳了异步和卸载全部水到渠成。