ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

AssetBundle资源管理:依赖、异步加载、引用计数与自动卸载串联

AssetBundle资源管理:依赖、异步加载、引用计数与自动卸载串联 做过 Unity 项目的人几乎都躲不开 AssetBundle 这道坎。尤其是项目做到中后期资源量上来依赖关系复杂加载逻辑分散你会发现自己不是在写业务代码而是在跟资源生命周期打一场持久战。标题里问的“依赖、异步加载、引用计数、自动卸载到底怎么串起来”说白了就是一套能覆盖资源从加载到卸载全过程的完整管理方案。这篇文章我把这些年踩过的坑、验证过的做法按一条线串起来讲清楚适合那些已经用 AssetBundle 做过基础打包但被依赖和卸载问题反复折磨的开发者参考。AssetBundle 这套东西单看每一个点都不难。打包出 AB、加载 AB、实例化资源、卸载 AB官方文档也都写了。但真正把四套机制拼在一起跑起来你会发现到处是暗坑依赖 AB 被提前卸载导致贴图变紫、加载还没完成就被引用计数减到零、场景切完资源还驻留在内存、小游戏平台下内存直接爆掉。这些问题不是某一个环节做错了而是整条链路没有统一管理。下面我按依赖管理、异步加载、引用计数、自动卸载四块展开最后给一套能直接落地的串接方案。1. AssetBundle 管理到底难在哪先说清楚整条链路1.1 一次完整的 AssetBundle 生命周期先把标准流程过一遍。假设你有一个角色模型模型用了一张共享贴图。打包之后你会得到至少两个 AB角色模型 AB 和贴图 AB。运行时你想看到这个角色需要先把贴图 AB 加载进内存再加载角色模型 AB然后才能从模型 AB 里加载出 GameObject 或预设实例。这个流程本身不复杂复杂的是“先加载依赖”这个先后顺序要由谁保证。在写代码的时候如果你在场景 A 加载了角色切到场景 B 之后把贴图 AB 卸载了再回场景 A 重新加载角色你会发现角色虽然还能加载出来但贴图已经变成了洋红色。原因是依赖 AB 不在了Unity 不会自动帮你重新拉取贴图。这就是 AssetBundle 管理的第一个难点依赖是隐式的、跨模块的。你在 A 模块里写的加载逻辑很难知道它依赖的对象是否已经被其他模块管理起来了。所以依赖管理必须放在最底层所有加载操作都知道“我这次加载了什么依赖”并且确保依赖的引用计数被正确加一。还有一个点容易被忽略就是 AssetBundle 的加载结果不是一次性就能拿到的。Unity 提供了 AssetBundle.LoadFromFileAsync、AssetBundle.LoadAssetAsync 这类异步接口加载本身是异步的如果你在 LoadFromFileAsync 还没完成时就发起 LoadAssetAsync就会报错或者拿到空引用。这意味着依赖加载完成的通知机制必须有顺序保证而不是简单靠回调嵌套硬堆。1.2 依赖、异步、计数、卸载为什么总打架这四个概念放在一起冲突点其实很集中。依赖管理要求“加载一个资源前先加载它的所有依赖”这天然是一个递归的、有先后顺序的过程。异步加载是满足这个过程的最佳手段但异步带来了时序问题回调什么时候触发、加载中的资源能不能被安全卸载、依赖在加载完成前被另一个模块释放了怎么办。引用计数解决的正是“这个资源有多少人正在用”的问题。只要有模块还在用就不应该被卸载这本身没问题。但引用计数和卸载策略一旦没配合好就会出现计数已经归零、但资产还在异步加载途中的情况这时候强行卸载正在加载的句柄就悬空了。自动卸载更像是一个“最后兜底”的动作它要把引用计数为零且长时间不用的资产从内存中清理掉。但卸载动作本身也有依赖关系你不能只卸载父 AB 而不卸载它的依赖 AB也不能在某个资产的引用计数还大于零时强制回收。说白了这四个点不是四个独立模块而是一条流水线上的四个环节。依赖决定了加载顺序异步决定了执行方式计数决定了生死状态卸载决定了内存水位。任何两两之间没有对齐出问题的概率都极高。2. 依赖管理整套体系的根基2.1 手动收集依赖为什么不行很多项目早期是手动收集依赖的。打包 AB 的时候把某个预制体依赖的所有资源手动指定进同一个 AB或者打包时用 AssetBundleBuild 指定哪些资源进哪些包然后代码里手动列一份依赖清单。这种做法在项目小的时候没问题资源数量几十个依赖关系一眼能看穿。但项目一旦过了一百个 AB手动维护依赖清单就成了灾难。你新增一个材质材质引用了新的贴图忘了加进清单运行时就等着看紫贴图。更麻烦的是手动清单没法处理“多个 AB 共享同一个依赖”的场景比如五个模型共用一张贴图这个贴图到底归哪个包管清了哪个包就出问题全是隐藏炸弹。所以建议从一开始就基于构建报告生成依赖关系而不是手写。Unity 提供了 BuildReport 接口在打包完成之后可以遍历所有 AssetBundleManifest拿到每个 AB 的直接依赖列表。把这个列表序列化成一个依赖配置表运行时加载任何 AB 前先查这张表按顺序加载依赖就能从机制上规避手动维护的遗漏问题。2.2 依赖配置表的生成与校验依赖配置表我建议直接用 ScriptableObject 或者 JSON 存。结构很简单一个字典AB 名映射到一个依赖数组。但生成这张表有几个细节要注意。打包后遍历 AssetBundleManifest.GetAllAssetBundles()然后对每个 AB 调用 manifest.GetDirectDependencies(abName)拿到的是直接依赖。这里的关键是不要用 GetAssetBundleHash 或者 GetAllDependencies依赖传递会让加载顺序变得不可控直接依赖就够了因为加载依赖的 AB 时它的直接依赖也会被加载递归自然展开。比较坑的一个点是构建 AB 时如果没有开启 BuildAssetBundleOptions.DisableWriteTypeTree依赖表里会混入一些类型依赖这在某些平台上会导致包体变大。打包的时候建议把不需要的选项关掉只保留必要的。我一般用 AppendHashToAssetBundleName 加 ChunkBasedCompression确保包名唯一且体积可控。依赖表生成之后要做一次校验遍历所有 AB确认每个 AB 引用的资源都出现在对应依赖列表中。这一步可以用编辑器脚本做把所有 AB 的依赖关系可视化输出交叉验证几次基本能把打包时配置错误的资源暴露出来。2.3 实战中的依赖加载陷阱依赖加载最大的陷阱是“重复加载”。两个模块同时加载同一个角色模型它们都去查依赖表发现需要贴图 AB如果没有任何队列机制就会同时发起两次 LoadFromFileAsync两个 AB 句柄指向同一份内存资源。这种情况下引用计数如果只算一次另一个句柄卸载时会把内存里的资产释放掉导致第一个句柄变成悬空引用。所以依赖加载必须有一个全局的“加载中/已加载”状态表。AB 第一次加载时记录状态后续请求直接等待同一个加载任务完成而不是重新发起加载。这块逻辑放在后面异步加载的部分详细说这里先提醒一下依赖加载和引用计数必须共用同一个资源状态表不能各做各的。还有一个容易踩的坑是资源重名导致依赖串包。比如两个目录下各有一张命名为 icon 的贴图打包时如果没有给资源名加路径前缀依赖表里就会有两个同名 AB查表时不知道加载哪一个。这类问题最好的解决方案是打包时统一资源命名规则建议直接以相对路径作为资源标识不要用资源的短名。3. 异步加载从“能加载”到“可控制”3.1 异步加载的基础封装思路AssetBundle 的异步加载接口其实就一个核心方法AssetBundle.LoadFromFileAsync。这个方法在不同平台上的实现差异比较大在 PC 和移动端上走的是文件流加载性能不错在 WebGL 和微信小游戏平台上则要用 AssetBundle.LoadFromFileAsync 配合后端 CDN 下载这个后面单独说。封装异步加载时我强烈建议不要直接用 Unirx 或者协程裸写回调而是封装一个返回自定义 LoadHandle 的异步接口。LoadHandle 内部持有 AssetBundleCreateRequest 或者 AssetBundleRequest暴露给外部一个完成事件或者轮询状态。这样做的好处是方便统一管理资源和加日志。等到项目后期排查加载问题时你就知道有一个统一的入口有多重要。核心数据结构大概长这样public class AssetBundleLoadRequest { public string AbName { get; set; } public AssetBundle Bundle { get; set; } public AssetBundleRequest Request { get; set; } public bool IsDone { get; set; } public System.ActionAssetBundleLoadRequest OnCompleted; }外部调用时向管理类发起请求管理类查状态表如果 AB 已加载就直接完成如果正在加载则挂到等待队列否则发起新的加载。所有请求通过管理类统一调度这样就可以做到依赖、计数、异步全串起来。3.2 加载队列与并发控制异步加载多了以后并发控制就成了必须考虑的事情。AssetBundle.LoadFromFileAsync 在移动端是线程池调度但大量并发加载会导致 IO 抢占尤其在使用 mr 加资源加载密集的场景下加载速度反而更慢。我的做法是给加载流程加全局并发上限。比如限制同时最多加载 3 个 AB超出部分排队等待。实现上用一个简单的队列加一个计数每次请求进入时如果并发槽没满就直接执行满了就加入等待队列。这个方案看起来简单但实测能显著降低加载时的卡顿峰值尤其在低端机上表现明显。另外异步加载和依赖加载的顺序之间要设计一个状态机。一个 AB 的加载状态可以包括尚未加载、等待依赖、依赖加载中、加载本体中、加载完成、加载失败。每个状态之间都有明确的转移条件代码里所有加载请求都围绕这个状态机流转可以避免很多奇奇怪怪的时序 bug。3.3 微信小游戏平台上的加载差异热词里好多人关心 Unity 微信小游戏打包和资源加载方案这里专门提醒一下。微信小游戏平台上AssetBundle 不能直接用 LoadFromFileAsync因为根本没有本地文件系统。常规方案是先用 UnityWebRequest 把 AB 字节流下载下来然后通过 AssetBundle.LoadFromMemoryAsync 或者直接加载远程路径。这里有个关键点微信小游戏环境下同一个 AB 的下载缓存策略要跟 CDN 配合好。建议用微信的缓存目录做文件缓存第二次加载时优先检查缓存目录有就直接走本地文件加载没有才发网络请求。如果缓存没做好每次进游戏都重新下载 AB用户流量耗不起加载速度也会被拖垮。还有一个容易忽略的问题是小游戏平台的内存限制。小游戏的堆内存通常是有限的AssetBundle 加载进去之后如果不能及时释放非常容易触发内存告警。这种情况下自动卸载策略就必须做得更激进一些不能等引用计数归零再卸载要做“引用计数归零 超过最短存活时间”的双条件控制。4. 引用计数把资源生命周期管起来4.1 引用计数的核心实现引用计数是实现自动卸载的基础但它的难点不在计数本身而在计数更新的时机管理。我建议给每个 AB 维护一个 RefCount 字段加载资源时加一释放资源时减一减到零时进入可卸载状态。这个逻辑本身很简单可一旦跟异步加载混在一起问题就来了加载请求发出去时 RefCount 加一但加载还没完成另一个模块也发起了同一个资源的加载请求。如果第二个模块不识别“资源正在加载中”直接再发起一次加载就会造成重复句柄。所以加载前必须先查状态表无论资源是否加载完成只要“被请求过”就统一加计数同时让新的调用方复用同一个加载请求。这里我建议把引用计数和加载状态合并存在一个资源句柄结构体里不要分开管理。比如public class AssetBundleHandle { public AssetBundle Bundle; public int RefCount; public bool isLoading; public ListSystem.ActionAssetBundle PendingCallbacks; }结构体里同时包含资产本体、加载状态、引用计数和等待回调。这样任何一个调用方发起请求时看到的都是统一的状态而不是分散的几份数据。4.2 引用计数更新的几个关键时机引用计数在哪几个时机更新是最容易出 bug 的地方。我梳理下来最核心的有四个时机。第一个是请求加载时。无论这个 AB 是否已经加载只要被新模块引用RefCount 就加一。第二个是加载完成回调时。如果加载失败了要把之前加上的计数减回去否则失败的资源会一直驻留在状态表里变成“永远无法卸载”的死资源。第三个是在模块释放时。模块持有 AB 资源的句柄调用 Release 时减一。第四个是场景切换时。场景 A 里的资源如果只在场景 A 显示切场景时统一释放。这一项如果依赖人工逐处释放一定会漏所以建议用场景绑定或者逻辑模块生命周期绑定的方式批量减计数。另外提一个细节加载成功后的资源实例化比如 Instantiate 出来的 GameObject也会持有 AB 的资源引用。这部分实例如果没被销毁AB 就不应该落到零计数。我的处理方式是给实例加一个脚本在 OnDestroy 里回调资源管理类的 Release这样实例销毁时资源计数能自动减下来。4.3 引用计数与异步竞态的博弈异步竞态是引用计数里比较头疼的问题。场景是这样的模块 A 加载了一个资源RefCount 加一。模块 A 切换场景主动调用了 ReleaseRefCount 减回零。但这时候加载请求还没完成资源仍在异步加载中。如果处理逻辑简单粗暴地说“RefCount 等于零就卸载”就会在加载过程中强制卸载回调直接拿不到 Bundle整个流程就断了。应对这个问题的核心思路是引入“加载中”状态。卸载操作不能只看计数还要看加载状态。资源仍处于加载中时哪怕 RefCount 已经归零也不能执行卸载要等加载完成后立即进入卸载流程。这个逻辑需要在状态机里实现。还有一种竞态是依赖 AB 的计数更新。你加载一个模型 AB它的依赖贴图 AB 计数加一。模型 AB 加载完成并实例化后模型释放计数减一。但依赖贴图的计数是跟着模型一起减的还是单独减我的习惯是“谁发起加载谁负责释放”。模型加载请求一开始就自动把依赖贴图的计数加上模型释放时统一减去模型和直接依赖这样依赖的计数不会乱。5. 自动卸载让内存不爆的最后一环5.1 卸载的触发时机自动卸载不是“一有空就卸载”而是要在合适的时机做批量清理。我实践下来比较稳妥的触发时机有三个场景切换完成、UI 面板完全关闭、内存水位超过预警值。场景切换是最大的资源替换窗口。旧场景的资源在切换完成之后大概率不再使用这时把旧场景绑定的资源统一减计数并尝试卸载能释放一大块内存。UI 面板关闭这个时机适合通过 UI 框架统一接管面板关闭后把面板专属图集和相关 AB 释放。内存水位预警适合做兜底当 Profiler 显示内存涨幅超过阈值时主动调用一次全量回收把满足卸载条件的资源全部清掉。卸载动作不要在 Update 里做也不要在资源加载密集期做。建议用一个独立的回收循环每隔一定时间检测一次或者由管理器在场景切换后显式触发一次回收。这样卸载带来的微小卡顿可控不会跟加载的 IO 抢占冲突。5.2 卸载策略与优先级卸载策略上我见过比较合理的方案是分两级标准回收和强制回收。标准回收要求资源引用计数为零并且距离上次使用时间超过了阈值比如 60 秒。这个时间阈值很重要可以防止玩家频繁进出某个界面时资源被反复加载卸载造成体验上的卡顿。强制回收则是在内存告警时触发只要引用计数为零就立即卸载不再等时间窗宽限。对于不同优先级的资源可以做差异化处理。模型、粒子特效和 UI 图集建议使用不同生命周期。UI 图集比较“重”一张图集几 MB 很常见而且 UI 面板关闭后图集基本不再复现这种资源很适合关闭即回收。粒子特效和音效这类加载成本也不低但使用频率较高可以留一个弱缓存窗口过一段时间再回收。值得强调的是自动卸载应该只负责“引用计数为零”的资源。资源正在使用中无论如何都不应该被自动回收。如果项目里出现资产生命周期没管理好、导致资源卸载后还在被引用的情况自动卸载策略做得再精妙也救不了一定要回到引用计数环节排查。5.3 与 Unity 内置卸载机制的配合Unity 提供了一套内置的资源卸载机制包括 Resources.UnloadUnusedAssets 和 AssetBundle.Unload(bool unloadAllLoadedObjects)。这两个接口不能乱用否则很容易弄出怪现象。Resources.UnloadUnusedAssets 是一个比较重的操作它会扫描整个资源系统在自动化管理中我建议只在场景切换后的隐式回收流程里调用一次不要频繁调用。而且它的效果对 AssetBundle 资源不是立竿见影的AB 若还持有引用UnloadUnusedAssets 也不会帮你卸载。AssetBundle.Unload(false) 和 Unload(true) 的区别很多人大学时就背过但实操里经常踩坑。Unload(true) 会直接卸载所有从该 AB 实例化出来的资产对象如果你的业务代码还在用这些对象就会变成空引用。我的原则是所有资源释放都走引用计数计数归零后先由对象池清掉各业务模块持有的实例再调 Unload(false)最后再做一次 UnloadUnusedAssets 兜底。这样既不会破坏业务逻辑也能把残留资产清干净。6. 常见问题与排查实录6.1 资源重复加载、依赖被提前卸载怎么排查重复加载是最常遇到的现象。表现是 Profiler 里同一个资源出现两个 AssetBundle 实例内存翻倍。这种问题一般出在两个地方一是调用方没有走管理类接口直接 new 了 AssetBundle.CreateFromFile二是加载请求没有复用“加载中”的任务对象两个请求各自开了新任务。排查时先全局搜索是否还有直接调用 AssetBundle.LoadFromFile 或 AssetBundle.LoadFromMemory 的地方把这类调用全部收敛到管理类。然后检查状态表的命中逻辑当 AB 处于加载中状态时后续请求是否挂到了同一个 PendingCallbacks 列表而不是再起一个加载任务。这两处对齐之后重复加载基本能杜绝。依赖被提前卸载的表现是运行时资源变成紫色或丢失。排查步骤比较固定拿到报错的 AB 名称查依赖表定位它的所有依赖项然后看这些依赖项的 RefCount 和卸载时间戳确认是否在依赖加载之后、模型加载之前被回收了。如果卸载时间早于模型加载时间说明卸载触发时机太激进需要把依赖的存活窗口拉长。6.2 内存不回收和加载卡顿的平衡另一个高频问题是自动卸载做了一个月内存还是高居不下。这通常是“回收条件太苛刻”导致的比如引用计数归零的条件本身就不成立因为业务模块没有正确释放句柄计数一直卡在一卸载自然永远不会触发。处理这种问题要先给所有加载请求加日志把模块名、资源名、计数变化全部打出来。线上跑一轮后分析日志找出计数长期不为零的资源定位到是哪个模块没有释放。这块代码虽然简单但收益很大。我见过最快的定位方式是在管理器里加一个“资源存活报告”按钮运行中点击就能打印全部资源当前引用计数和最近操作调用栈比看 Profiler 直观得多。加载卡顿的根源往往是同步加载和 IO 竞争。场景切换时如果用了同步加载接口极容易卡顿。排查时可以先用 Profiler 的 CPU 模块看主线程耗时占比如果发现加载函数占大头就改造成异步加载加队列限流。还有一个优化手段是错峰加载把资源按使用频率分成高优和低优两批高优资源场景切换时立刻加载低优资源延迟加载放到玩家进入场景后的空闲帧里。6.3 各平台适配的避坑记录微信小游戏平台的坑集中在文件缓存和内存。AB 下载后建议用本地缓存管理同时注意缓存目录的容量上限必要时做 LRU 淘汰。小游戏平台对文件读取次数也有限制频繁加载同样的内容建议从内存里保留一份引用而不是反复做 IO。Pico4 这类 VR 一体机平台的坑在渲染内存和立体渲染。VR 场景下眼睛渲染两次纹理和 RT 显存消耗翻倍资源卸载比移动端更激进。实测下来VR 平台建议在资源加载流程里加一个“加载预览”机制玩家看向某区域前先加载离开后立刻回收体验会比全部预加载好很多。数字孪生项目通常全是超大模型和地形单 AB 可能上百兆。这种情况下依赖加载顺序特别重要一个地形 AB 的依赖可能有几十个贴图和网格。建议断点续传和按需加载结合先把地形网格加载出来再按 LOD 渐进式加载贴图细节避免一次性拉取全部依赖造成黑屏或者内存撑爆。7. 把四件事串起来的最终方案到这里四个点的坑和实现思路都说得差不多了。最后把这套方案整体收束一遍。资源管理类全局维护一张资源状态表表里记录每个 AB 的加载状态、引用计数、依赖列表和最后使用时间。调用方发起加载时管理器查表如果资源已加载则直接回调如果在加载中则挂到等待队列如果未加载则先递归加载所有直接依赖然后加载本体。所有加载完成后回调业务层同时把所有依赖和自己的计数加上。业务层使用完资源后调用 Release管理器减计数计数归零时进入可回收状态。回收循环定时扫描状态表对满足“计数为零、非加载中、超过最短存活时间”的资源执行卸载卸载时先释放依赖再卸载本体。场景切换时强制触发一次回收循环把所有只归当前场景所有的资源快速释放。这套方案的关键点是把“加载请求”“引用计数”“依赖关系”“回收判断”全部收敛在一个管理类里不让业务层直接操作 AssetBundle 对象。业务层永远只跟资源句柄和加载回调打交道看不到 AssetBundle 的类型信息。这样做的好处不只是避免踩坑更重要的是任何时刻你都能说清楚“某个 AB 现在是谁在用、用了多少次、生命周期边界在哪”。最后再分享一个项目落地时容易忽略的细节给资源管理类补一套可视化调试界面。运行时能看到 AB 名称、内存占用、引用计数、当前状态、最近访问时间再配合录制一段操作录屏排查问题效率会高非常多。我一开始不做这个界面线上反馈资源加载异常全靠猜后来补上之后绝大多数资源问题五分钟内能定位到具体模块和调用点。AssetBundle 管理没有一劳永逸的方案项目形态变了平台变了策略都需要跟着调。但“依赖先行、异步加载、计数管理、按需回收”这套骨架是通用的。把这四件事串成一条完整的生命周期流水线再针对自己的项目特性调整细节基本就能把资源管理这件事做到可控了。
返回列表