
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.LoadAssetGameObject(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.LoadAssetAsyncGameObject(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 Dictionarystring, BundleInfo _bundles new Dictionarystring, BundleInfo(); // 实例ID - 它依赖的包路径集合 private Dictionaryint, HashSetstring _instanceRefs new Dictionaryint, HashSetstring(); 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 HashSetstring(); 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缓存、分帧加载。等基础逻辑稳定了再根据实际性能数据做优化。过早优化只会让问题更难定位。另外日志一定要打够加载、卸载、计数变化都记下来出问题的时候这些日志就是你的救命稻草。