AssetBundle资源管理:依赖、异步加载、引用计数与自动卸载串联
2026/9/19 15:04:52 网站建设 项目流程

做过 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.Action<AssetBundleLoadRequest> 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 List<System.Action<AssetBundle>> PendingCallbacks; }

结构体里同时包含资产本体、加载状态、引用计数和等待回调。这样任何一个调用方发起请求时,看到的都是统一的状态,而不是分散的几份数据。

4.2 引用计数更新的几个关键时机

引用计数在哪几个时机更新,是最容易出 bug 的地方。我梳理下来最核心的有四个时机。

第一个是请求加载时。无论这个 AB 是否已经加载,只要被新模块引用,RefCount 就加一。

第二个是加载完成回调时。如果加载失败了,要把之前加上的计数减回去,否则失败的资源会一直驻留在状态表里,变成“永远无法卸载”的死资源。

第三个是在模块释放时。模块持有 AB 资源的句柄,调用 Release 时减一。

第四个是场景切换时。场景 A 里的资源如果只在场景 A 显示,切场景时统一释放。这一项如果依赖人工逐处释放,一定会漏,所以建议用场景绑定或者逻辑模块生命周期绑定的方式批量减计数。

另外提一个细节:加载成功后的资源实例化,比如 Instantiate 出来的 GameObject,也会持有 AB 的资源引用。这部分实例如果没被销毁,AB 就不应该落到零计数。我的处理方式是给实例加一个脚本,在 OnDestroy 里回调资源管理类的 Release,这样实例销毁时资源计数能自动减下来。

4.3 引用计数与异步竞态的博弈

异步竞态是引用计数里比较头疼的问题。场景是这样的:模块 A 加载了一个资源,RefCount 加一。模块 A 切换场景,主动调用了 Release,RefCount 减回零。但这时候加载请求还没完成,资源仍在异步加载中。如果处理逻辑简单粗暴地说“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 管理没有一劳永逸的方案,项目形态变了,平台变了,策略都需要跟着调。但“依赖先行、异步加载、计数管理、按需回收”这套骨架是通用的。把这四件事串成一条完整的生命周期流水线,再针对自己的项目特性调整细节,基本就能把资源管理这件事做到可控了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询