ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

AssetBundle生命周期管理:从依赖解析到自动卸载的完整实践

AssetBundle生命周期管理:从依赖解析到自动卸载的完整实践 先抛个反直觉的结论AssetBundle 这玩意儿99% 的线上问题不是出在加载太慢而是出在卸载时机没管好。依赖、异步加载、引用计数、自动卸载这四个词单拆开看每个都挺好理解可一旦同时出现在项目里很快就变成一团乱麻界面切来切去资源紫的紫、丢的丢内存曲线像坐过山车甚至同一个 Bundle 被反复加载Profiler 里全是重复资产。我前前后后帮团队收拾过好几套资源管理方案大多数问题的根源都落在同一个点上大家把精力都放在怎么快一点加载却很少有人把加载到卸载之间那段生命周期用数据管起来。这篇文章不打算再贴一份 API 文档而是顺着一个自研管理器的完整思路走一遍先理清依赖再解决异步加载然后用引用计数记清楚谁在用最后用自动卸载处理那些兜不住的边角。适合已经玩过 AssetBundle、但被资源生命周期坑过的人也适合正准备搭一套可复用管理方案、想在动手前看清全局的团队。1. 先从两个崩溃现场说起AssetBundle 到底失控在哪1.1 崩溃 ABundle 被提前卸载画面整片变紫这是最典型的现场某个资源包在被加载后使用方因为流程结束直接把 BundleUnload(true)了。下一个界面正好也引用了这个包里的公共 Shader 或者图集于是打开新界面的一瞬间材质变成紫红色人物缺胳膊少腿模型直接消失。很多人第一反应是Shader 没打进包里或引用关系没配对检查半天发现都不是。真正的坑在于AB 和 Asset 的生命周期不是一回事。你从某个 AB 里 Load 出 GameObject如果这个 AB 被 Unload(true) 卸载Unity 会立刻释放这个 AB 管理的所有原生资源包括已经加载并实例化的材质、贴图和网格。对象还挂在场景里但底层资源已经被回收表现就是紫块、白模、消失。这不是 Shader 打包缺失是卸载方没有意识到自己卸载的包还被人依赖着。1.2 崩溃 B重复加载导致内存翻倍另一个常见现场业务方写了一个通用加载函数每次打开某个界面都直接去调用AssetBundle.LoadFromFile界面关闭时再Unload(false)。粗看逻辑没问题但同一个 UI 界面可能同时被多个系统持有比如主界面弹了个弹窗弹窗里又拉起同一个公共列表两个系统互不知道对方已经加载了同一个 Bundle。结果同一个 AB 被连续加载两次内存里出现两份资源原始数据。Profiler 里你会看到大量名字后缀带(1)的 Texture、Material、Mesh。这种问题特别隐蔽因为一度不报错、不变紫只是内存悄悄上涨直到某个低端手机上触发系统杀进程。1.3 所以真正要管的是生命周期不只是加载速度从这两个现场能提炼出 AssetBundle 管理的本质问题每一个 Bundle 从第一次被需要到最后一个使用者释放这段生命周期必须语义正确。加载快不快是另一回事前提是先保证不重复加载、不提前卸载、不把依赖弄丢。所以极限管理不是追求加载极致快而是做到下面四件事能算出某个 Bundle 的完整依赖链并保证加载顺序正确加载同一个 Bundle 的并发请求能被合并而不是各干各的每个使用方都明确标记我正在用哪些 Bundle用完之后明确释放即使有人忘了释放系统也能在一段安全时间后自动回收后面所有机制都是围绕这四件事展开。2. 依赖是根先理清 AB 之间的引用关系再谈加载和卸载2.1 依赖是打包时写进 Manifest 的不是开发期自己记的AssetBundle 的依赖关系不是运行时通过对象引用反查而是打包时 Unity 已经帮你算好了一张依赖表记录在每个平台产出的主 Manifest 文件里。主 Manifest 文件通常叫AssetBundleManifest它可以通过加载 StreamingAssets 下的同名 Bundle 获得。加载它的时候有个细节整个工程只需要加载一次主 Manifest后续所有依赖查询都复用这一个实例。代码大概是下面这样private AssetBundleManifest manifest; public IEnumerator LoadManifest(string platformFolder) { string path Path.Combine(Application.streamingAssetsPath, platformFolder); AssetBundleCreateRequest req AssetBundle.LoadFromFileAsync(path); yield return req; AssetBundle manifestBundle req.assetBundle; AssetBundleRequest assetReq manifestBundle.LoadAssetAsyncAssetBundleManifest(AssetBundleManifest); yield return assetReq; manifest assetReq.asset as AssetBundleManifest; manifestBundle.Unload(false); }这里我用了Unload(false)只卸载 Bundle 壳子不卸载已经从它里面加载出来的AssetBundleManifest对象。这个对象生命周期跟整个资源体系走不会导致后续查询失败。2.2 Manifest 的依赖查询接口怎么用拿到 Manifest 之后常用的查询接口有三个各自语义要分清楚接口返回值典型用途GetDirectDependencies(name)直接依赖列表查看一层依赖调试时更直观GetAllDependencies(name)所有依赖包含间接依赖运行时按顺序加载完整依赖链GetAllAssetBundlesWithDependencies(name)自己和所有依赖生成完整资源清单时用很多新手只调GetAllDependencies但它返回的是无序列表。虽然我们不需要手工递归但加载时不能直接按这个顺序顺序加载因为假如 A 依赖 B、B 依赖 C得到的数组可能是[B, C]也可能是[C, B]。如果先把 C 压给LoadFromFileAsync而 C 又依赖 B 的资源就会出现依赖还没就绪就加载目标的风险。更稳妥的做法是递归加载每层只加载直接依赖。为了性能要用一个字典去重保证同一个 Bundle 不会被多次加载private HashSetstring loadingSet new HashSetstring(); private IEnumerator LoadDependencies(string bundleName) { string[] directDeps manifest.GetDirectDependencies(bundleName); if (directDeps null) yield break; foreach (var dep in directDeps) { if (loadingSet.Contains(dep)) continue; loadingSet.Add(dep); yield return LoadDependencies(dep); // 先加载更深层依赖 yield return EnsureBundleLoaded(dep); // 再加载当前依赖 } }这种先子后父的递归保证依赖树从叶子往根一层层就绪。2.3 公共资源要单独分成依赖包而不是塞进每个业务包依赖管理的混乱一半出在打包策略上。常见误区是业务 UI 用了哪些图就把这些图打进 UI 包里结果同一张图存在于几十个业务包中。不仅打包体积膨胀运行时每个业务包还都有自己的那份引用依赖关系被彻底打散。我的经验是Shader 单独打一个 SharedShader 包所有材质引用它公共图集、数字字体、通用 UI 组件打一个 CommonUI 包每个业务包只放自己的资源尽量少引用其他业务包这样一来业务包数量再大依赖关系也收敛成业务包 - 公共包的单向结构。加载一个业务包时最多额外加载三四个公共依赖包不会出现A 依赖 BB 依赖 CC 又依赖 A这种要命的循环依赖。AssetBundle 本身不允许循环依赖但开发期资源互相 Drag 很容易制造出隐藏的循环打包时报错还不好查。2.4 开发期怎么快速看依赖关系如果项目里已经有 AssetBundle Browser 这个包可以在编辑器里直接右键 Bundle 查看依赖链。我更喜欢在构建脚本里把 Manifest 的依赖关系导成一份 JSON 或文本表发布时放到 StreamingAssets 下线上定位问题直接打开文件看。生产环境不建议每次运行时去掉GetAllDependencies那段逻辑直接把这份预生成的依赖表打进包里可以减少一次 Manifest 查询开销。尤其在 Bundle 数量超过几百个的项目里运行时递归查询的耗时会非常可观。3. 异步加载把加载请求排队而不是各自为战3.1 Bundle 加载和 Asset 加载是两个层级不能混在一起大多数第一批踩坑的开发者会把Bundle 加载和Asset 加载当成同一步。实际上它们是两个完全不同的异步过程AssetBundle.LoadFromFileAsync返回的是AssetBundleCreateRequest它结束时代表Bundle 文件已经被解析但里面的资源还没有进内存。bundle.LoadAssetAsyncT返回的是AssetBundleRequest它结束时才代表你想要的那个资源真正加载好了。两层之间还夹着一个前置阶段依赖加载。所以一条完整的链路应该是确保依赖 Bundle 加载完成 - 确保目标 Bundle 加载完成 - 从目标 Bundle 中 LoadAssetAsync3.2 加载同一个 Bundle 的多个请求要合并不能并发重复加载如果没有统一的入口两个模块同时加载同一个 Bundle 时Unity 底层的加载操作不会自动去重会在内存里额外保留一份。自己写管理器时最简单有效的办法是维护一个加载中字典private Dictionarystring, AssetBundleCreateRequest loadingBundles new Dictionarystring, AssetBundleCreateRequest();当加载请求进来时先查 Bundle 是否已经存在如果不存在再查loadingBundles里有没有正在进行的加载请求。如果有直接 yield 同一个请求对象等它结束再继续如果没有才发起新的LoadFromFileAsync并把请求对象放进字典。这个共享同一个加载中请求的设计是整个异步管理器的关键点之一。它既避免了同一个 Bundle 被重复加载又让多个等待方可以在同一个请求结束时一起继续而不是各自再发一次 IO。3.3 用协程还是 async/await协程是 Unity 里最顺手的异步工具但它的生命周期和 MonoBehaviour 绑定如果管理器的宿主对象被销毁协程也会中断。我建议 Manager 用常驻对象挂在一个不会随场景卸载的 GameObject 上然后所有加载流程都走协程。如果项目里已经全面铺开async/await可以用UniTask或自己封装一个适配器。但要注意一点AssetBundle 的加载回调必须回到主线程。UnityWebRequest 和大多数 AssetBundle 异步接口的回调天然回到主线程自己用线程写并发时千万别在子线程里碰 Unity 的资源对象。下面给出一个完整的协程式去重加载模板private IEnumerator EnsureBundleLoaded(string bundleName) { if (loadedBundles.TryGetValue(bundleName, out var exist) exist ! null) yield break; if (loadingBundles.TryGetValue(bundleName, out var pending)) { yield return pending; yield break; } string path Path.Combine(Application.streamingAssetsPath, bundleName); AssetBundleCreateRequest createReq AssetBundle.LoadFromFileAsync(path); loadingBundles[bundleName] createReq; yield return createReq; if (createReq.assetBundle null) { Debug.LogError($[ABManager] 加载失败: {bundleName}); loadingBundles.Remove(bundleName); yield break; } loadedBundles[bundleName] createReq.assetBundle; loadingBundles.Remove(bundleName); }3.4 本地加载用 LoadFromFileAsync远程资源要考虑缓存PC、iOS、Android 这些平台可以直接用LoadFromFileAsync读 StreamingAssets 或本地磁盘路径速度快、不占额外内存。WebGL 平台不支持这个接口需要用UnityWebRequestAssetBundle.GetAssetBundle走网络协议加载同时要自己处理缓存避免每次打开页面都全量下载。远程下载场景下加载链路会多一层版本校验 - 下载/走缓存 - 本地文件路径 - LoadFromFileAsync。不管中间过程多复杂最终交给 Manager 的还是 bundleName 到本地文件路径的映射。所以我建议把路径解析单独拆一个接口本地资源和远程下载资源都实现同一个方法Manager 内部不关心 Bundle 是从磁盘来还是从网络来。3.5 压缩格式的选择也会影响异步加载体感AssetBundle 的压缩格式有 LZMA、LZ4 和不压缩三种。格式特点适用场景LZMA压缩率最高加载时整包解压首次下载安装包时考虑LZ4压缩率比 LZMA 低一档但按需解压运行期主流格式不压缩加载最快体积最大高端平台/追求极端加载速度如果打出来的是 LZMA 包运行时LoadFromFileAsync会先把整个 Bundle 都解压到内存即使你只想加载其中一个资源也会产生明显卡顿。我更推荐发布时全部转成 LZ4尤其是 UI、场景这类需要频繁加载卸载的资源组。4. 引用计数给每个 AB 建立台账谁加载谁释放4.1 为什么要引用计数面对同一个界面打开 N 次的现实依赖关系理清了异步加载能合并了接下来要解决的就是什么时候允许卸载。最朴素的直觉是打开界面加载关掉界面卸载但真实项目不会这么简单。玩家点开背包背包界面加载了 Bundle A同时弹窗系统又打开了角色详情角色详情也要用 Bundle A。如果背包界面一关闭就把 A 卸载角色详情里的模型和贴图就会当场炸掉。引用计数就是解决一个 Bundle 被多个使用方共享的标准答案。每次有一个新的使用者提出我要用 A就给 A 的计数 1使用结束给 A 的计数 -1。只有当计数降到 0才允许 A 进入可卸载状态。4.2 计数记录的是 Bundle 的生命周期不是 Asset 对象刚开始自己写引用计数时容易犯一个方向性错误去跟踪 Asset 对象的引用。比如拿到一个 GameObject 后想着用WeakReference去监听这个对象什么时候被销毁从而决定 Bundle 能不能卸载。这条路走不通原因有两个Asset 对象销毁的时机和 Bundle 卸载的时机相互影响互相等对方容易死锁你手动Destroy一个 GameObject 时从 Bundle 里加载出来的原生资源Texture、Material并不一定被释放需要Resources.UnloadUnusedAssets兜底正确的做法是把引用计数挂在 Bundle 上。每个 Bundle 对应一个计数器计数器代表当前还有多少个正在使用该 Bundle 资源的功能模块。这个模块可能是一个 UI 界面、一个场景、一个战斗逻辑也可能是一次性的临时加载操作。4.3 最稳的计数规则先加依赖后加自身给 Bundle 加引用时不能只给目标 Bundle 1还要给它依赖链上的每一个公共依赖包 1。否则会出现A 依赖的 SharedShader 包计数为 0被自动卸载而 A 还在使用它的情况。我给团队定的规则很简单只有两条RetainBundle(name)把 name 以及它的每一个依赖包计数全部 1ReleaseBundle(name)把 name 以及它的每一个依赖包计数全部 -1如果两个 Bundle 依赖同一个 CommonShader 包那这个 CommonShader 包的计数就是 2。第一个 Bundle 释放后变成 1第二个 Bundle 也释放后变成 0这时它才能被回收。计数器用整型表示不会出现浮点误差也方便打印输出。下面是管理器的引用计数核心结构public class BundleRef { public string bundleName; public AssetBundle bundle; public string[] dependencies; public int refCount; public float lastUsedTime; public bool isUnloading; }Retain 和 Release 的实现private void RetainBundle(string bundleName) { if (!loadedBundles.TryGetValue(bundleName, out var bundleRef)) return; bundleRef.refCount; bundleRef.lastUsedTime Time.unscaledTime; foreach (var dep in bundleRef.dependencies) RetainBundle(dep); } private void ReleaseBundle(string bundleName) { if (!loadedBundles.TryGetValue(bundleName, out var bundleRef)) return; bundleRef.refCount--; bundleRef.lastUsedTime Time.unscaledTime; if (bundleRef.refCount 0) Debug.LogError($[ABManager] refCount 变负数: {bundleName}); foreach (var dep in bundleRef.dependencies) ReleaseBundle(dep); }4.4 给每个使用者一个所有权凭证而不是到处散装调用如果外部代码可以随手拿到 Bundle 引用随手调用 Retain/Release计数迟早会被破坏。我在实际项目中用的是租约模式每一个加载 Asset 的请求返回的不是裸 GameObject而是一个AssetLeaseT对象。这个对象内部持有它涉及到的所有 Bundle 名称列表并且实现Dispose()。使用方拿到 Asset 后用完就调用Dispose()它会自动完成 Release 动作。public sealed class AssetLeaseT : IDisposable where T : UnityEngine.Object { public T Asset { get; } private AssetBundleManager manager; private string[] usedBundles; private bool disposed; public AssetLease(AssetBundleManager manager, string[] usedBundles, T asset) { this.manager manager; this.usedBundles usedBundles; this.Asset asset; } public void Dispose() { if (disposed) return; disposed true; foreach (var bundleName in usedBundles) manager.ReleaseBundle(bundleName); } }这个谁加载谁释放的纪律感非常重要。不在业务层直接调 Release而是只能 Dispose从机制上防止了忘了释放和重复释放两种操作错误。4.5 引用计数排查经验做成一张可以随时打印的台账引用计数的正确性靠代码审查是不够的必须能在运行时把当前状态打出来。我在 Manager 里留了一个DebugPrint()方法遍历所有BundleRef输出包名、引用数、最后一次使用时间、是否正在卸载。配合控制台命令可以随时在线上版本里输入指令查看[AB] ui/common ref2 lastUsed12.3 [AB] ui/shop ref0 lastUsed88.2 -- 可回收 [AB] shared_shader ref3 lastUsed1.2看到某个包refCount长期不降基本可以定位到是哪个模块 Retain 了没有 Release。比对着 Profiler 猜省太多时间。5. 自动卸载引用计数的兜底保险丝不是替代方案5.1 引用计数能解决大部分问题但管不了所有场景引用计数管的是使用者明确 Retain/Release的场景。但实际项目里总有一些边角情况比如运营活动预下载了一堆包下载完成但还没到使用时机引用计数是 0场景切换时上一个场景的某个模块因为异常没有执行 Release热更结束后残留了一批旧版本 Bundle 文件这些情况不能靠继续调 Retain 来解决需要一套自动回收策略在后台兜底。5.2 触发时机场景切换、内存告警、闲置超时我采用的自动卸载策略分成三级从轻到重第一级闲置超时。每个BundleRef里都记录lastUsedTime在 Manager 的 Update 里定期遍历把refCount 0且闲置时间超过阈值的 Bundle 加入待卸载队列。这个阈值一般设置在 60 到 180 秒之间。太短容易导致频繁重新加载太长又起不到控制内存的作用。第二级场景切换。切场景时大多数旧场景的 UI 和战斗资源都不再需要。我会在场景切换脚本里手动调一次CollectUnusedBundles()强制把refCount 0的 Bundle 全部卸载。这个动作只清理真正没人用的不会出现在播放动画中还有引用被卸载的问题。第三级内存告警。移动端可以用Application.lowMemory事件做最后防线。收到告警时主动把所有refCount 0的 Bundle 全部卸载再调Resources.UnloadUnusedAssets()释放未引用的资源。这个手段比较粗暴只用来救急不指望它做常规内存管理。下面是闲置超时的核心逻辑private void Update() { if (Time.unscaledTime - lastCollectTime collectionInterval) return; lastCollectTime Time.unscaledTime; foreach (var item in loadedBundles) { var bundleRef item.Value; if (bundleRef.refCount 0 !bundleRef.isUnloading Time.unscaledTime - bundleRef.lastUsedTime unloadIdleInterval) { StartCoroutine(UnloadBundle(bundleRef.bundleName)); } } }5.3 Unload(true) 和 Unload(false) 到底怎么选自动卸载时最核心的决定是传给 Unload 的参数是true还是false。Unload(true)立即释放 Bundle 里所有已加载的 Asset以及该 Bundle 的元数据。之前从它里面 Load 出来的 GameObject、Texture、Material 会立刻变成无效对象。如果场景里还有实例在引用它们紫块和白模马上就会出现。Unload(false)释放 Bundle 的元数据但保留已经从它里面加载出来的 Asset。之后需要调Resources.UnloadUnusedAssets()才能真正回收那些不再被引用的资源。我的建议是常规卸载一律先 Unload(false)然后统一做 UnusedAssets 回收。这样即使引用计数算错了最多是资源晚一点释放不会立刻造成视觉崩溃。等看到 Profiler 里确实没有资源再被引用了再考虑在场景切换时调用Resources.UnloadUnusedAssets()配合 GC。5.4 自动卸载最容易踩的坑把还没用的公共依赖包先卸了这里有个非常隐蔽的问题一个业务 Bundle 的refCount已经变成 0但它依赖的公共 Shader 包可能refCount还是 1因为另一个业务 Bundle 正在用。自动卸载脚本如果只看当前业务包计数为 0 就卸载它本身是安全的。但如果同时把依赖包里计数为 0 的包也一口气全扫掉就危险了。比如BundleA - CommonShader BundleB - CommonShaderBundleA 和 BundleB 都释放后CommonShader 计数变 0被自动卸载。OK。但如果 BundleA 先释放CommonShader 计数从 2 减到 1B 还在用这时自动卸载去扫 CommonShader 发现refCount1就不会卸载它。所以只要严格跟随每个 Bundle 的引用计数包含自己的依赖这个坑就不会踩到。需要注意卸载一个 Bundle 时不要同时卸载它依赖里计数为 0 的包。要让依赖包的卸载也走自己的引用计数判断否则会把还活着的依赖卸掉。6. 串起来一个可以跑的极简 AssetBundle 管理器6.1 整体流程图解到这里四个核心机制的逻辑已经齐了现在把它们拧成一股绳。整个调用流程是这样外部请求LoadAssetAsyncT(bundleName, assetName)Manager 检查loadedBundles和loadingBundles保证同一个 Bundle 不会重复加载递归加载目标 Bundle 的全部依赖目标 Bundle 就绪后通过它加载 Asset加载成功后创建AssetLeaseT并给目标 Bundle 和所有依赖 Bundle 的引用计数 1业务方持有AssetLeaseT使用资源业务方调用Dispose()引用计数 -1计数为 0 的 Bundle 进入可回收状态Update 里的自动卸载每过一段时间扫一次把计数为 0 且超时的 Bundle 卸掉6.2 核心代码一个精简但完整的实现我不建议直接把这个代码无脑抄进生产项目但它的结构可以当作骨架按需扩展。using System; using System.Collections; using System.Collections.Generic; using UnityEngine; public class AssetBundleManager : MonoBehaviour { public static AssetBundleManager Instance { get; private set; } private AssetBundleManifest manifest; private readonly Dictionarystring, BundleRef loadedBundles new(); private readonly Dictionarystring, AssetBundleCreateRequest loadingBundles new(); private readonly HashSetstring loadingDeps new(); [SerializeField] private float unloadIdleInterval 120f; [SerializeField] private float collectInterval 5f; private float lastCollectTime; private void Awake() { if (Instance ! null Instance ! this) { Destroy(gameObject); return; } Instance this; DontDestroyOnLoad(gameObject); } private void Update() { if (Time.unscaledTime - lastCollectTime collectInterval) return; lastCollectTime Time.unscaledTime; CollectUnusedBundles(); } public void Initialize(AssetBundleManifest manifest) { this.manifest manifest; } public AssetLeaseT LoadAssetT(string bundleName, string assetName) where T : UnityEngine.Object { StartCoroutine(LoadAssetInternal(bundleName, assetName, out var lease)); return lease; } private IEnumerator LoadAssetInternalT(string bundleName, string assetName, out AssetLeaseT resultLease) where T : UnityEngine.Object { resultLease default; // 1. 依赖全部就绪 yield return LoadDependencies(bundleName); // 2. 目标 Bundle 就绪 yield return EnsureBundleLoaded(bundleName); // 3. 为本次使用预占引用防止在 LoadAssetAsync 期间被自动回收 RetainBundle(bundleName); // 4. 加载具体资源 var bundleRef loadedBundles[bundleName]; AssetBundleRequest assetReq bundleRef.bundle.LoadAssetAsyncT(assetName); yield return assetReq; if (assetReq.asset null) { Debug.LogError($[ABManager] 资源加载失败: {bundleName}/{assetName}); ReleaseBundle(bundleName); yield break; } // 5. 构建租约对象把预占引用转给租约持有者 Liststring usedBundles new(); CollectBundleAndDependencies(bundleName, usedBundles); resultLease new AssetLeaseT(this, usedBundles.ToArray(), assetReq.asset as T); } private IEnumerator LoadDependencies(string bundleName) { if (manifest null) yield break; string[] directDeps manifest.GetDirectDependencies(bundleName); if (directDeps null) yield break; foreach (var dep in directDeps) { if (loadedBundles.ContainsKey(dep) || loadingDeps.Contains(dep)) continue; loadingDeps.Add(dep); yield return LoadDependencies(dep); yield return EnsureBundleLoaded(dep); loadingDeps.Remove(dep); } } private IEnumerator EnsureBundleLoaded(string bundleName) { if (loadedBundles.TryGetValue(bundleName, out var exist) exist ! null) yield break; if (loadingBundles.TryGetValue(bundleName, out var pending)) { yield return pending; yield break; } string path Path.Combine(Application.streamingAssetsPath, bundleName); AssetBundleCreateRequest createReq AssetBundle.LoadFromFileAsync(path); loadingBundles[bundleName] createReq; yield return createReq; if (createReq.assetBundle null) { Debug.LogError($[ABManager] Bundle 加载失败: {bundleName}); loadingBundles.Remove(bundleName); yield break; } string[] deps manifest ! null ? manifest.GetAllDependencies(bundleName) : Array.Emptystring(); var bundleRef new BundleRef { bundleName bundleName, bundle createReq.assetBundle, dependencies deps, refCount 0, lastUsedTime Time.unscaledTime }; loadedBundles[bundleName] bundleRef; loadingBundles.Remove(bundleName); } private void RetainBundle(string bundleName) { if (!loadedBundles.TryGetValue(bundleName, out var bundleRef)) return; bundleRef.refCount; bundleRef.lastUsedTime Time.unscaledTime; foreach (var dep in bundleRef.dependencies) RetainBundle(dep); } private void ReleaseBundle(string bundleName) { if (!loadedBundles.TryGetValue(bundleName, out var bundleRef)) return; bundleRef.refCount--; bundleRef.lastUsedTime Time.unscaledTime; if (bundleRef.refCount 0) Debug.LogError($[ABManager] 引用计数异常: {bundleName} count{bundleRef.refCount}); foreach (var dep in bundleRef.dependencies) ReleaseBundle(dep); } private void CollectBundleAndDependencies(string bundleName, Liststring output) { if (!loadedBundles.TryGetValue(bundleName, out var bundleRef)) return; output.Add(bundleName); foreach (var dep in bundleRef.dependencies) CollectBundleAndDependencies(dep, output); } private void CollectUnusedBundles() { foreach (var item in loadedBundles) { var bundleRef item.Value; if (bundleRef.refCount 0 !bundleRef.isUnloading Time.unscaledTime - bundleRef.lastUsedTime unloadIdleInterval) { StartCoroutine(UnloadBundle(bundleRef.bundleName)); } } } private IEnumerator UnloadBundle(string bundleName) { if (!loadedBundles.TryGetValue(bundleName, out var bundleRef)) yield break; if (bundleRef.refCount 0 || bundleRef.isUnloading) yield break; bundleRef.isUnloading true; bundleRef.bundle.Unload(false); loadedBundles.Remove(bundleName); yield return Resources.UnloadUnusedAssets(); bundleRef.isUnloading false; } public void DebugPrint() { foreach (var item in loadedBundles) Debug.Log($[AB] {item.Key} ref{item.Value.refCount} idle{Time.unscaledTime - item.Value.lastUsedTime:F1}s); } }上面代码里有一个细节LoadAssetInternal里我先RetainBundle(bundleName)再LoadAssetAsync这样即使LoadAssetAsync比较慢自动卸载也不会在计数为 0 的时候把 Bundle 卸掉。资源加载完成后租约对象持有这个预占引用业务方使用完再调用Dispose释放。6.3 使用方示例public class BagPanel : MonoBehaviour { private AssetLeaseGameObject avatarLease; private void Open() { avatarLease AssetBundleManager.Instance.LoadAssetGameObject( ui/bag, Assets/Prefabs/RoleAvatar.prefab); avatarLease.Asset.transform.SetParent(transform, false); } private void Close() { avatarLease.Dispose(); avatarLease null; } }使用方完全不感知底层依赖、引用计数、自动卸载的逻辑它只需要按契约执行打开时加载关闭时释放。6.4 这套代码适合怎样的项目这个骨架直接面对的是商店购买、独立部署、StreamingAssets 本地加载的项目场景。如果你们的项目已经接入了热更新、远程版本管理或自己写的下载器只需要把EnsureBundleLoaded里的本地路径解析换成版本号 缓存目录的映射逻辑其他部分可以保持不动。7. 实际项目里的几个坑和我的取舍7.1 别被自动卸载四个字骗了它只是保险丝我见过有人看完上面的代码后觉得引用计数不重要反正最后有自动卸载兜底。这是本末倒置。自动卸载判断的是计数为 0 且闲置超时如果业务层 Retain 了没有 Release计数永远大于 0自动卸载永远碰不到它。真正能解决泄漏问题的还是业务代码里严格的租约生命周期。自动卸载只是用来清理那些下载完、没使用、计数为 0的资源它兜不住逻辑 bug。7.2 Resources.UnloadUnusedAssets 不能随便调这个接口在资源多的时候非常慢可能卡主线程几百毫秒到几秒不等。我见过有人把它放在 Update 里持续调用结果游戏整体掉帧。正确姿势是只在场景切换或者 loading 界面期间调用并且用yield return Resources.UnloadUnusedAssets()别和业务逻辑抢主线程。7.3 引用计数打印要留在正式版本里我把DebugPrint()做成一个可以被控制台命令触发的隐藏功能正式版本也能通过输入指令调用。资源管理这种问题线上环境出现没有日志就别想定位。有了引用台账至少能快速判断是哪类 Bundle 泄漏了计数在谁手上。这一步就能筛掉一半的排查工作量。7.4 为什么我觉得自己写一遍比直接上 Addressables 更值现在新项目默认可能都会考虑 Addressables。它其实已经把这个文章里提到的依赖、异步、计数、自动释放都封装好了而且做得更细。但问题也在这里封装得越高级出了问题越像玄学。我见过很多团队用了 Addressables 之后遇到资源加载异常只能翻引擎日志完全不清楚内部发生了什么。如果你已经把 AssetBundle 的这套底层机制亲手串过一遍再去用 Addressables你看到的是它帮我实现了哪一层哪一层需要我自己控制而不是黑盒。如果项目已经有自己的热更体系、资源服务器、版本管理逻辑自研管理器的优势是可控性出任何问题都能靠引用台账和依赖表快速定位。所以这个题的答案不是用或者不用 Addressables而是先搞明白四个核心机制怎么串再看值不值得为自研继续投入人力。给新人的一条实操建议最后分享一个我自己的习惯项目起步阶段Manager 代码尽量精简到只有一个类甚至一个文件。等 Bundle 数量超过 200 的时候再考虑拆模块。我见过很多人第一版就设计了继承、接口、事件总线结果资源管理的 bug 翻出来时定位成本反而更高。先把依赖解析、异步去重、引用计数、自动卸载这四段跑通其他全是锦上添花。AssetBundle 的极限管理说到底不是某一次加载多快而是每一次卸载都发生在这个资源真正没人在用的那一刻。能把这个节奏稳住线上资源问题至少少一半。
返回列表