1. 从加载一个Prefab说起:AB包管理的真正难点在哪
很多人第一次接触Unity的AssetBundle,注意力都放在“怎么把资源打进包里”这件事上。打AB包的代码网上一搜一大把,BuildPipeline.BuildAssetBundles一调,文件夹里哗啦啦出来一堆文件,感觉已经成功了。但真正把项目推到上线阶段,你会发现打包只是整个链条里最简单的一环,真正让人掉头发的是加载、引用计数、依赖关系、卸载时机这四件事。
我见过不少项目,AB包打得好好的,运行起来也没报错,但玩上十几分钟内存就一路飙升,切场景的时候卡顿明显,甚至偶发资源丢失导致的模型变白、贴图糊掉。这些问题几乎都不是打包环节出的错,而是资源管理策略没设计好。这篇内容就接着上一部分,专门聊AB包加载之后的管理问题——也就是资源从磁盘进到内存、被实例化、被引用、最后被释放的完整生命周期。
如果你正在做中大型项目,或者你的项目已经出现了内存只涨不降、切场景卡顿、资源重复加载的情况,那这篇内容基本就是为你准备的。我会把引用计数怎么设计、依赖包怎么处理、同步和异步加载怎么选、卸载的时机怎么把握这几个核心问题拆开讲,每个点都会给出可以直接抄的代码结构和踩坑经验。读完你应该能搭出一套稳定、可维护的AB包管理框架,而不是停留在“能加载出来就行”的阶段。
2. 资源加载的三种姿势与它们的适用边界
2.1 同步加载:简单但会吃掉主线程
同步加载是最直观的方式,AssetBundle.LoadFromFile配合bundle.LoadAsset,两行代码资源就到手了。它的优点是逻辑简单、不需要考虑回调嵌套、加载完立刻能用。但代价是整个加载过程会阻塞主线程,包越大、资源越复杂,卡顿越明显。
我实测过一个大概40MB的模型包,在PC上同步加载耗时大约在120到180毫秒之间,在移动端中低端机型上能到400毫秒以上。这个时间足够让玩家明显感觉到画面卡住。所以同步加载的适用场景其实很窄:启动阶段的必要资源、体积极小的配置包、以及编辑器工具链里的批处理。游戏运行过程中的动态加载,尤其是玩家操作触发的加载,基本不应该用同步。
// 同步加载的典型写法,注意加载完要立刻做引用登记 var bundle = AssetBundle.LoadFromFile(bundlePath); var prefab = bundle.LoadAsset<GameObject>("Assets/Prefabs/Hero.prefab"); var instance = Object.Instantiate(prefab); // 这里必须把bundle和asset都登记到管理器里,否则后面没法正确卸载上面这段代码有个容易被忽略的点:LoadAsset返回的资源本身不持有对bundle的强引用关系记录,如果你不手动登记,后面调用bundle.Unload(true)的时候,已经实例化的对象会变成“丢失引用”的状态,表现为材质变粉、贴图丢失。这是新手最常踩的坑之一。
2.2 异步加载:把卡顿摊到多帧里
异步加载用LoadFromFileAsync和LoadAssetAsync,返回的是AssetBundleRequest或AssetBundleCreateRequest,通过协程或者completed回调来拿结果。它的核心价值是把一次性的长阻塞拆成多帧的小开销,让主线程有机会渲染和响应输入。
但异步加载不是银弹。它有两个必须注意的点:第一,异步加载期间资源并没有真正可用,你必须等isDone为真才能实例化,否则会拿到null;第二,异步加载的包如果在中途被卸载,请求会失败,所以加载中的包必须被引用计数保护起来,不能让它被其他逻辑卸载掉。
IEnumerator LoadAsync(string bundlePath, string assetName) { var createReq = AssetBundle.LoadFromFileAsync(bundlePath); yield return createReq; var bundle = createReq.assetBundle; // 关键:加载中的包先加一次引用,防止被并发卸载 manager.RetainBundle(bundlePath); var assetReq = bundle.LoadAssetAsync<GameObject>(assetName); yield return assetReq; var prefab = assetReq.asset as GameObject; // 实例化并登记 var instance = Object.Instantiate(prefab); manager.RegisterInstance(instance, bundlePath, assetName); }2.3 预加载与懒加载的取舍
预加载是在进入场景前就把可能用到的包全部加载好,优点是运行中零等待,缺点是内存峰值高、启动慢。懒加载是用到才加载,内存友好但会有首次使用的延迟。
我的经验是按资源的使用确定性来分:确定马上要用的(比如当前关卡的地形、主角模型)走预加载;不确定什么时候用的(比如某个NPC的对话立绘、某个技能的粒子特效)走懒加载。判断标准很简单——如果这个资源在接下来30秒内大概率会被用到,就预加载;否则懒加载。这个30秒不是拍脑袋,而是根据玩家在场景里的平均停留时长和操作节奏估出来的,你可以根据自己的项目节奏调整。
3. 引用计数:AB包管理的命门
3.1 为什么必须有引用计数
引用计数解决的是一个核心问题:一个包被多个地方同时使用,什么时候才能真正卸载?假设英雄模型包被主角、敌人、UI展示三个地方同时引用,如果任何一方用完就卸载,另外两方立刻出问题。引用计数就是记录“当前有多少个逻辑在使用这个包”,只有计数归零才允许卸载。
没有引用计数的项目,通常会出现两种极端:要么谁都不敢卸载,内存只涨不降;要么卸载太激进,资源频繁丢失。这两种情况我都遇到过,最后都是靠引入引用计数解决的。
3.2 引用计数的设计结构
一个可用的引用计数管理器,核心数据结构大概是这样的:
public class BundleManager { // 包路径 -> 包信息 private Dictionary<string, BundleInfo> _bundles = new Dictionary<string, BundleInfo>(); // 实例ID -> 它依赖的包路径集合 private Dictionary<int, HashSet<string>> _instanceRefs = new Dictionary<int, HashSet<string>>(); public void RetainBundle(string path) { if (!_bundles.TryGetValue(path, out var info)) { info = new BundleInfo { path = path, refCount = 0 }; _bundles[path] = info; } info.refCount++; } public void ReleaseBundle(string path) { if (!_bundles.TryGetValue(path, out var info)) return; info.refCount--; if (info.refCount <= 0) { info.bundle.Unload(true); _bundles.Remove(path); } } }这里有个关键设计:引用计数是加在包上的,不是加在单个资源上的。因为AB包的卸载粒度就是整个包,你没法单独卸载包里的某一个资源。所以哪怕你只用了包里一个贴图,卸载时也是整个包一起走。这也是为什么打包策略要尽量让“同时使用”的资源放在同一个包里,避免为了用一个资源而被迫加载一堆用不到的东西。
3.3 实例与包的引用关系怎么维护
光有包的引用计数还不够,你还得知道“哪个实例引用了哪个包”。因为玩家销毁一个GameObject的时候,你需要反查它依赖了哪些包,然后逐个释放。
public void RegisterInstance(GameObject go, params string[] bundlePaths) { var id = go.GetInstanceID(); var set = new HashSet<string>(); foreach (var p in bundlePaths) { set.Add(p); RetainBundle(p); } _instanceRefs[id] = set; } public void UnregisterInstance(GameObject go) { var id = go.GetInstanceID(); if (_instanceRefs.TryGetValue(id, out var set)) { foreach (var p in set) ReleaseBundle(p); _instanceRefs.Remove(id); } }调用时机很关键:RegisterInstance在实例化之后立刻调用,UnregisterInstance在Destroy之前调用。注意是之前,因为Destroy之后GetInstanceID可能已经失效,拿不到对应的记录了。
提示:如果你的项目用了对象池,实例不会被真正Destroy,那引用计数的释放就要跟着对象池的回收逻辑走,而不是跟着Destroy走。这一点在接入对象池的时候特别容易漏。
4. 依赖关系:一个包背后的隐形网络
4.1 依赖是怎么产生的
AB包的依赖关系来自资源之间的引用。比如英雄的Prefab引用了公共的材质包、公共的贴图包、公共的动画包,那么英雄包就依赖这三个包。加载英雄包的时候,如果这三个依赖包没加载,英雄就会显示异常。
Unity在打包时会生成一个manifest文件,里面记录了每个包的依赖列表。你可以通过AssetBundleManifest.GetAllDependencies拿到某个包的所有依赖。这个信息是运行时处理依赖的依据。
4.2 加载时自动拉取依赖
一个健壮的加载流程,应该在加载目标包之前,先把它的所有依赖包加载好。这个过程是递归的,因为依赖包自己也可能有依赖。
public void LoadWithDependencies(string path) { if (_bundles.ContainsKey(path)) { RetainBundle(path); return; } var deps = _manifest.GetAllDependencies(path); foreach (var dep in deps) { LoadWithDependencies(dep); // 递归加载依赖 } var bundle = AssetBundle.LoadFromFile(GetFullPath(path)); _bundles[path] = new BundleInfo { path = path, bundle = bundle, refCount = 0 }; RetainBundle(path); }这里有个性能陷阱:递归加载依赖是同步阻塞的,如果依赖链很深,一次性加载会卡很久。优化思路是把依赖列表先展开成扁平数组,去重后按顺序异步加载,加载完再加载目标包。这样虽然总时间差不多,但卡顿被摊开了。
4.3 依赖包的引用计数要单独算
依赖包的引用计数和目标包是分开的。目标包被引用一次,它的所有依赖包也各被引用一次。这样当目标包释放时,依赖包的计数也会相应减少,只有所有依赖它的包都释放了,依赖包才会真正卸载。
这个逻辑听起来简单,但实现时容易出错的地方在于:同一个依赖包可能被多个目标包共享,计数必须累加而不是覆盖。我见过一个实现,用字典存依赖关系时直接赋值而不是累加,结果两个包共享一个材质包,第一个包释放时材质包就被卸载了,第二个包立刻出问题。
5. 卸载时机:什么时候放手才是对的
5.1 卸载的三种粒度
AB包的卸载有三个粒度,从粗到细分别是:卸载整个包(AssetBundle.Unload)、卸载未使用的资源(Resources.UnloadUnusedAssets)、卸载单个资源(Resources.UnloadAsset)。它们的使用场景完全不同。
AssetBundle.Unload(true)会卸载包本身和它加载出来的所有资源,这是最彻底的。AssetBundle.Unload(false)只卸载包的容器,资源还留在内存里,但失去了和包的关联,容易造成资源泄漏。Resources.UnloadUnusedAssets是全局扫描,把没有任何引用的资源清掉,开销大但能兜底。
我的建议是:日常卸载用Unload(true),配合引用计数精确控制;切场景时调一次Resources.UnloadUnusedAssets做兜底。不要依赖Unload(false),它带来的问题比解决的问题多。
5.2 切场景时的卸载策略
切场景是卸载的最佳时机,因为这时候大部分资源确实不再需要了。但要注意,跨场景常驻的资源不能卸,比如全局的UI、音频管理器、玩家数据相关的资源。这些资源应该在启动时就标记为常驻,引用计数永远不归零。
public void OnSceneChange() { // 先释放当前场景的所有实例引用 foreach (var kv in _instanceRefs) { foreach (var p in kv.Value) ReleaseBundle(p); } _instanceRefs.Clear(); // 常驻包重新加引用,防止被误卸 foreach (var p in _persistentBundles) RetainBundle(p); // 兜底清理 Resources.UnloadUnusedAssets(); }5.3 卸载时机的常见误判
最常见的误判是在资源还在被使用时就卸载了。典型场景是:玩家打开一个界面,界面加载了某个包,玩家关闭界面,界面销毁触发卸载,但此时可能还有一个异步加载请求在途,或者有延迟销毁的动画还在引用这个资源。结果就是资源被卸载后,动画播放到一半模型变白。
避免这个问题的办法是给卸载加一个延迟,比如界面关闭后延迟2到3秒再真正释放引用,给在途请求和延迟逻辑一个缓冲期。这个延迟不需要很长,但能挡掉大部分时序问题。
6. 内存监控:让问题在爆发前被发现
6.1 需要监控哪些指标
光有管理逻辑还不够,你得能看到运行时的真实状态。我一般会监控这几个指标:已加载包数量、总引用计数、每个包的引用计数、纹理内存占用、AB包内存占用。这些数据通过Profiler.GetTotalAllocatedMemoryLong和AssetBundle.GetAllLoadedAssetBundles可以拿到。
public void LogMemoryStatus() { var bundles = AssetBundle.GetAllLoadedAssetBundles(); long total = 0; foreach (var b in bundles) { total += (long)b.GetType().GetProperty("m_CachedData")?.GetValue(b, null); } Debug.Log($"Loaded bundles: {bundles.Count()}, estimated size: {total / 1024 / 1024} MB"); }6.2 引用计数泄漏的排查方法
引用计数泄漏的表现是:明明所有实例都销毁了,但包的计数还是大于零,包一直不卸载。排查方法是在计数变化时打日志,记录是谁加的、谁减的、当前值多少。跑一段时间后对比日志,就能找到那个只加不减的地方。
我遇到过一次泄漏,最后定位到是某个UI的动画事件里重复调用了加载,但关闭时只释放了一次。这种问题靠肉眼看代码很难发现,必须靠日志。
6.3 一个实用的内存看板
在开发阶段,我会在屏幕上挂一个简单的内存看板,实时显示包数量和内存占用。这样测试的时候一眼就能看出异常。上线前把这个看板关掉就行。这个投入很小,但能省下大量排查时间。
7. 打包策略对管理的反向影响
7.1 粒度太细的代价
有些人为了“精确控制”,把每个资源都打成单独的包。结果就是包数量爆炸,依赖关系复杂到无法维护,加载一个角色要拉十几个包,每个包都有独立的文件头和加载开销。这种策略在小项目上可能看不出问题,一旦资源量上来,加载性能会急剧下降。
7.2 粒度太粗的代价
反过来,把所有资源打成一个包,加载任何一个资源都要把整个包拉进内存,内存浪费严重,而且没法单独卸载。这种策略只适合资源量极小的项目。
7.3 合理的分组原则
我的分组原则是按使用场景和生命周期分组:同一个界面用到的资源放一组,同一个角色的资源放一组,全局共享的放一组。这样加载和卸载的粒度刚好和业务逻辑对齐,引用计数也最好管理。判断标准是:如果两个资源几乎总是一起出现、一起消失,就放同一个包;如果一个资源被很多地方共享,就单独成包。
8. 几个我踩过的真实坑
第一个坑是异步加载的包被提前卸载。当时没给加载中的包加保护引用,结果玩家快速切换界面时,前一个界面的加载请求还没完成,包就被卸载了,回调里拿到null直接报错。解决办法就是前面说的,加载开始时就加引用,加载完成后再根据实际情况决定是否释放。
第二个坑是依赖包的循环引用。理论上AB包不应该有循环依赖,但打包配置出错时确实会产生。循环依赖会导致递归加载死循环,栈溢出。解决办法是在递归加载时用一个visited集合记录已经处理过的包,遇到重复的直接跳过。
第三个坑是**Unload(true)之后材质变粉**。原因是材质被卸载了,但使用它的Renderer还在。这通常是因为卸载时机太早,或者引用计数算错了。排查时重点看材质的引用是不是被正确登记了。
第四个坑是编辑器下正常,真机上崩溃。原因是编辑器下AB包的加载路径和真机不同,编辑器可能走了AssetDatabase的路径,真机才真正走AB包。所以AB包相关的逻辑一定要在真机上测,编辑器下的表现不能作为依据。
9. 写在最后的一点个人体会
AB包管理这件事,代码量其实不大,难的是把生命周期想清楚。每个资源从哪来、被谁用、什么时候不用了、不用之后怎么释放,这四个问题想明白了,代码自然就写对了。我见过太多项目,代码写得很花哨,各种设计模式堆上去,但连最基本的引用计数都没做对,最后还是一堆内存问题。
如果你现在正在搭这套东西,我的建议是先用最简单的引用计数跑通,别一上来就搞复杂的对象池、LRU缓存、分帧加载。等基础逻辑稳定了,再根据实际性能数据做优化。过早优化只会让问题更难定位。另外,日志一定要打够,加载、卸载、计数变化都记下来,出问题的时候这些日志就是你的救命稻草。