Unity AssetBundle内存管理:引用计数与卸载时机实战
1. 从一个崩溃日志说起为什么AB的内存处理值得单独拎出来讲做过Unity项目的人大概率都经历过这样的场景真机上跑着跑着内存曲线一路往上飙最后被系统一刀切掉留下一个冷冰冰的崩溃日志。排查半天发现罪魁祸首不是代码里的死循环也不是贴图太大而是AssetBundle后面统一简称AB的加载和卸载没处理好。这个坑我踩过不止一次早期做一个中重度项目的时候光是AB这块的内存泄漏就折腾了将近两周最后靠着一套“加载-引用-卸载”的闭环管理才把内存压下来。AB的内存处理说白了就是搞清楚三件事资源从磁盘读进内存之后到底占在哪块区域、被谁引用着、什么时候才能真正释放。这三件事任何一环没想明白内存就会像漏水的水管一样一点点往外渗直到把设备撑爆。它解决的核心问题是资源生命周期的可控性——你希望加载进来的东西在不用的时候能干净利落地走而不是赖在内存里不走。这套东西适合所有用Unity做中大型项目的人尤其是做手游、需要频繁切换场景、或者资源量动辄几个G的项目。哪怕你现在做的是小体量项目早点把AB内存管理的思路建立起来后面扩展的时候会省掉大量返工。我见过太多团队AB打包打得很溜加载也跑得通但一问“这个AB什么时候卸载”“卸载之后贴图内存降了没有”就答不上来了。这篇文章就把AB内存处理这件事从头到尾拆一遍包括底层的内存分布、引用计数的机制、卸载时机的判断、以及我自己在实际项目里总结出来的一套可复用的管理方案。不玩虚的全是能直接抄作业的东西。2. AB内存到底占在哪先把内存地图画清楚2.1 原生内存与托管内存的分界线要讲AB的内存处理绕不开Unity的内存分区。很多人一提到内存就只盯着Profiler里的Total其实那个数字是好几块拼起来的。跟AB直接相关的主要是Native内存这一块具体又分成几个子区域。AB文件本身从磁盘读进来的时候走的是Native的SerializedFile缓冲区这块内存存放的是AB的头部信息、类型树、以及资源的元数据。你可以把它理解成AB的“目录”它不大但每个加载进来的AB都会有一份。然后是Asset内存这才是大头——贴图、Mesh、AudioClip这些实际资源解压之后占的空间。贴图在Native侧是纹理数据Mesh是顶点和索引缓冲音频是PCM数据它们都不归C#的GC管。托管内存这边你通过AssetBundle.LoadAsset拿到的那个GameObject或者Texture2D引用是一个托管堆上的壳它本身很小但它握着Native资源的句柄。这就是为什么有时候你看到托管堆很小但Native内存居高不下——壳还在里面的东西就释放不掉。提示在Profiler里看内存一定要切到详细模式把Native和Managed分开看。只看Total会被误导因为Managed那点量根本反映不出AB的真实占用。2.2 AB加载过程中的内存翻倍现象这里有个特别容易被忽略的点AB在加载的瞬间内存会出现一个峰值接近文件大小的两倍。原因在于Unity读取AB时会先把压缩的数据读进一块缓冲区然后解压到另一块内存里。如果你的AB是LZMA压缩的这个峰值会更明显因为LZMA解压需要额外的字典空间。我实测过一个场景一个80MB的LZMA压缩AB加载瞬间Native内存涨了差不多150MB等加载完成后回落到90MB左右。这个峰值在低端机上足以触发OOM。所以后来我养成了一个习惯大AB一律用LZ4压缩虽然包体大一点但加载时的内存峰值和速度都好很多。LZ4是块压缩解压时不需要一次性把整个文件展开内存曲线平滑得多。具体怎么选可以看下面这个对照压缩方式包体大小加载速度内存峰值适用场景LZMA最小最慢最高下载用的AB不直接加载LZ4中等快较低运行时直接加载的AB不压缩最大最快最低本地常驻资源实际项目里我一般这么干发布到CDN的AB用LZMA压客户端下载下来之后转存成本地的LZ4版本这样既省流量又省运行时内存。Unity提供了AssetBundle.RecompressAssetBundleAsync这个接口可以在下载完成后重新压缩虽然会多花一点CPU时间但换来的是后续加载的稳定。2.3 引用计数AB内存释放的总开关AB的内存能不能释放取决于两个层面的引用AB本身的引用计数和Asset的引用计数。这两个是独立的很多人搞混。AssetBundle.Unload(false)只释放AB文件本身的SerializedFile内存也就是那个“目录”但通过它加载出来的Asset还在内存里。AssetBundle.Unload(true)则会把AB和它加载出来的所有Asset一起干掉不管这些Asset还有没有人在用。后者非常危险如果你场景里还有个GameObject在用这个AB里的贴图卸载之后贴图会变成紫红色因为底层纹理数据被抽走了。正确的做法是AB用Unload(false)Asset靠Resources.UnloadUnusedAssets来回收。但UnloadUnusedAssets是个全量扫描会遍历所有托管对象开销不小不能频繁调用。所以真正靠谱的方案是自己在业务层维护一套引用计数谁用了这个Asset就加一不用了就减一减到零再标记为可卸载。我自己的做法是封装一个AssetManager内部维护两个字典一个记录AB的引用计数一个记录Asset的引用计数。每次LoadAsset的时候先看Asset缓存里有没有有就计数加一直接返回没有就从AB里加载同时AB计数加一。释放的时候反向操作Asset计数减到零就Unload(false)对应的AB然后等一个合适的时机统一调UnloadUnusedAssets。3. 卸载时机的判断什么时候该动手3.1 场景切换是最自然的卸载窗口场景切换是AB卸载的黄金时机。旧场景销毁的时候它引用的所有Asset理论上都应该被释放。但这里有个坑如果旧场景的Asset被新场景的常驻对象引用了卸载就会出问题。比如你有个全局的音效管理器它持有某个AB里的AudioClip切场景的时候这个管理器不销毁那这个AudioClip就不能卸载。所以我在项目里会区分场景资源和常驻资源。场景资源跟着场景走切场景时统一卸载常驻资源单独放在一个常驻AB里全程不卸载。这个划分要在打包阶段就定好不能等到运行时再判断否则很容易出现“以为卸载了其实没卸载”的情况。具体操作上我会在场景加载完成后延迟一帧调用一次资源清理。为什么延迟一帧因为Unity销毁对象是延迟到帧末的如果你在LoadScene之后立刻调卸载有些对象还没被标记为Destroy引用计数还没减卸载会漏掉。延迟一帧之后该销毁的都销毁了引用计数也更新了这时候卸载最干净。3.2 内存压力触发的主动卸载除了场景切换还有一种情况是内存吃紧的时候主动卸载。这个在移动端特别重要因为移动设备的内存上限很低iOS上超过一定值直接杀进程连崩溃日志都不给你留。我的做法是监听Application.lowMemory回调这个回调在系统内存紧张的时候会触发。触发之后我会做一轮“非活跃资源”的清理把所有引用计数为零的Asset标记为待卸载然后调用UnloadUnusedAssets。注意这里不能卸载正在使用的资源否则会出问题。另外我会在运行时监控内存占用设定一个阈值比如超过设备内存的60%就触发一次主动清理。这个阈值不能设太高否则来不及也不能设太低否则频繁清理影响性能。我一般设55%到65%之间具体看项目类型。重度游戏可以设高一点因为资源加载本身就占内存轻度游戏设低一点留足余量。注意UnloadUnusedAssets是异步操作返回一个AsyncOperation。如果你在它执行期间又加载了新资源可能会干扰它的判断。所以调用之前最好先锁住资源加载等它完成再放开。3.3 卸载时机的常见误判我见过最常见的误判是以为Unload(false)之后内存就降了。实际上Unload(false)只释放AB文件本身的内存Asset内存还在。如果你不调UnloadUnusedAssets那些Asset会一直挂着直到下一次全量扫描。还有一个误判是以为Resources.UnloadAsset能卸载AB里的Asset。这个接口只能卸载通过Resources.Load加载的资源对AB加载的资源无效。AB里的Asset只能通过UnloadUnusedAssets或者Unload(true)来释放。第三个误判是在Asset还在使用的时候调Unload(true)。这个前面说过了会导致资源丢失。但还有一种隐蔽的情况Asset被一个已经Destroy的GameObject引用了但那个引用还没被GC回收。这时候Unload(true)会认为Asset没人用直接干掉然后GC回收的时候发现引用了一个已销毁的对象报空引用。所以Unload(true)我基本不用除非确定这个AB里的所有资源都不再需要。4. 一套可复用的AB内存管理方案4.1 核心数据结构设计这套方案的核心是三个东西AB缓存、Asset缓存、引用计数。我用C#的Dictionary来实现Key是AB的路径或者Asset的路径Value是一个包装类里面存实际的AB或Asset对象以及一个int型的引用计数。public class BundleRef { public AssetBundle bundle; public int refCount; public string[] dependencies; } public class AssetRef { public UnityEngine.Object asset; public int refCount; public string bundleName; }AB缓存和Asset缓存分开管理因为它们的生命周期不一样。AB的引用计数跟着Asset走Asset加载的时候AB计数加一Asset卸载的时候AB计数减一。Asset自己的引用计数则由业务层控制谁用了谁加一不用了减一。这里有个细节AB的依赖关系也要计入引用计数。比如AB_A依赖AB_B加载AB_A的时候要先把AB_B加载进来这时候AB_B的计数要加一。卸载AB_A的时候AB_B的计数减一减到零才能卸载AB_B。这个依赖关系在打包的时候会生成一个manifest文件运行时读这个manifest就能拿到依赖列表。4.2 加载流程的完整实现加载一个Asset的流程是这样的先查Asset缓存如果命中引用计数加一直接返回。没命中查这个Asset属于哪个AB从manifest里拿到AB路径和它的依赖列表。递归加载所有依赖的AB每个AB的引用计数加一。从AB里LoadAsset把Asset存入缓存引用计数设为一。返回Asset。public UnityEngine.Object LoadAsset(string assetPath) { if (assetCache.TryGetValue(assetPath, out var assetRef)) { assetRef.refCount; return assetRef.asset; } string bundleName GetBundleName(assetPath); LoadBundleWithDependencies(bundleName); var bundle bundleCache[bundleName].bundle; var asset bundle.LoadAsset(assetPath); assetCache[assetPath] new AssetRef { asset asset, refCount 1, bundleName bundleName }; return asset; }LoadBundleWithDependencies这个方法要递归处理依赖并且要防止循环依赖导致的死循环。我一般用一个HashSet来记录正在加载的AB如果发现已经在加载中就直接跳过避免无限递归。4.3 卸载流程的完整实现卸载一个Asset的流程从Asset缓存里找到对应的AssetRef引用计数减一。如果计数大于零直接返回说明还有人在用。计数等于零把Asset从缓存里移除标记为待卸载。对应的AB引用计数减一如果AB计数也归零把AB从缓存移除调Unload(false)。所有依赖的AB也做同样的计数减一操作。public void ReleaseAsset(string assetPath) { if (!assetCache.TryGetValue(assetPath, out var assetRef)) return; assetRef.refCount--; if (assetRef.refCount 0) return; assetCache.Remove(assetPath); ReleaseBundle(assetRef.bundleName); } private void ReleaseBundle(string bundleName) { if (!bundleCache.TryGetValue(bundleName, out var bundleRef)) return; bundleRef.refCount--; if (bundleRef.refCount 0) return; bundleRef.bundle.Unload(false); bundleCache.Remove(bundleName); foreach (var dep in bundleRef.dependencies) { ReleaseBundle(dep); } }注意这里Unload(false)之后Asset内存还没释放需要等一个统一的时机调UnloadUnusedAssets。我一般是在场景切换或者内存压力触发的时候调不会每次Release都调因为那个开销太大。4.4 引用计数的边界情况处理引用计数最怕的就是计数不匹配。加多了资源永远释放不掉加少了资源被提前卸载出现丢失。我在项目里加了一层保护每次加载和释放都打日志记录当前的计数。在Editor下跑的时候如果发现某个Asset的计数变成负数直接报错这样能在开发阶段就发现问题。还有一种情况是同一个Asset被多次加载。比如两个不同的系统都加载了同一个贴图这时候引用计数应该是2。如果其中一个系统释放了计数变成1Asset还在两个都释放了计数变成0Asset才卸载。这个逻辑看起来简单但实际项目里经常有人直接LoadAsset然后Unload完全不管计数结果就是资源被提前卸载。另外AB的依赖计数也要小心。如果AB_A依赖AB_BAB_A加载的时候AB_B计数加一。但如果AB_B也被单独加载了它的计数应该是2。卸载AB_A的时候AB_B减一变成1AB_B还在再卸载单独加载的那个AB_B减到0才真正卸载。这个逻辑必须严格保证否则依赖的AB被提前卸载AB_A里的资源就会出问题。5. 常见问题与排查技巧实录5.1 内存不降反升的几种情况情况一AB加载了但没卸载。这是最常见的。排查方法是看Profiler里的AssetBundle列表如果有一堆AB一直挂着说明没卸载。原因可能是引用计数没归零或者压根没调卸载。情况二Asset卸载了但AB没卸载。这种情况是AB的引用计数没减。检查一下是不是有地方加载了AB但没走统一的Release流程比如直接AssetBundle.LoadFromFile然后忘了Unload。情况三卸载了但内存没降。这通常是因为UnloadUnusedAssets没调或者调了但Asset还被引用着。用Profiler的Memory Detail看如果Asset内存还在说明还有引用如果Asset内存没了但Total没降可能是内存碎片或者Native的延迟释放。情况四内存碎片导致无法复用。频繁加载卸载大AB会导致Native内存碎片化即使总空闲内存够也分配不出连续的大块。这种情况只能通过减少加载卸载频率、或者用对象池来缓解。5.2 排查工具与手段Profiler是基础工具但光看Profiler不够。我一般会配合Memory Profiler这个包它能拍内存快照看到每个Asset的引用链。比如你发现一个贴图没释放用Memory Profiler能看到是谁在引用它直接定位到代码。还有一个技巧是在Editor下用Resources.FindObjectsOfTypeAll来查某个类型的资源在内存里有多少个实例。比如你怀疑贴图泄漏就查Texture2D看看数量对不对。这个方法在真机上用不了但在Editor下排查很方便。对于真机我一般会打一个内存日志定期输出当前AB数量、Asset数量、总内存占用。这样即使没有Profiler也能通过日志判断内存趋势。日志不用太频繁每30秒或者每次场景切换打一次就行。5.3 常见问题速查表问题现象可能原因排查方法解决方案内存持续上涨AB未卸载Profiler看AB列表检查引用计数确保归零后Unload切场景后内存不降Asset被常驻对象引用Memory Profiler看引用链区分常驻和场景资源常驻资源单独管理加载时OOM压缩方式导致峰值过高看加载瞬间内存曲线改用LZ4压缩或分帧加载资源变紫红AB被提前Unload(true)检查Unload调用改用Unload(false)靠UnloadUnusedAssets回收卸载后内存不降UnloadUnusedAssets未调用看Asset内存在合适时机调用UnloadUnusedAssets引用计数为负加载释放不匹配打日志监控计数统一走AssetManager禁止直接Load/Unload5.4 我踩过的几个坑第一个坑是在AB加载过程中调UnloadUnusedAssets。当时想的是加载完顺便清理一下结果UnloadUnusedAssets把正在加载的Asset的依赖给卸了导致加载出来的资源缺依赖运行时报错。后来改成加载和清理分开加载完成后延迟几帧再清理。第二个坑是AB的依赖计数没处理好。有个AB_A依赖AB_BAB_B又被另一个AB_C依赖。卸载AB_A的时候AB_B计数减一但AB_C还在用AB_B结果AB_B被提前卸载了AB_C里的资源出问题。后来在ReleaseBundle里严格递归处理依赖确保每个依赖的计数都正确。第三个坑是LZMA压缩的AB在低端机上加载崩溃。前面说过LZMA加载时内存峰值接近两倍低端机内存本来就紧张直接OOM。后来把所有运行时加载的AB都改成LZ4问题解决。第四个坑是Asset缓存没有清理机制。早期Asset缓存只加不减跑久了缓存越来越大即使引用计数为零的Asset也留在缓存里。后来加了定期清理把计数为零的Asset从缓存移除内存才稳定下来。6. 进阶优化让AB内存管理更丝滑6.1 分帧加载与异步加载大AB加载的时候主线程会卡住因为LoadFromFile是同步的。解决方法是分帧加载把一个大AB拆成多个小AB每帧加载一个这样卡顿感会小很多。或者用LoadFromFileAsync这个是异步的不会阻塞主线程但加载速度会慢一点。我一般对超过10MB的AB用异步加载小于10MB的用同步。异步加载的时候要注意加载完成之前不能访问AB里的资源否则会报错。所以异步加载的AB要有一个状态标记加载中、已加载、加载失败业务层根据状态决定能不能用。6.2 AB的预加载与常驻策略有些资源是全程都要用的比如UI图集、字体、常用的音效。这些资源我一般放在常驻AB里游戏启动的时候加载一次全程不卸载。常驻AB的数量要控制不能太多否则内存一直占着。我一般把常驻AB的总大小控制在50MB以内超过这个量就要考虑是不是真的需要常驻。预加载则是另一种策略在场景加载之前提前把下一个场景要用的AB加载进来这样切场景的时候不会卡。预加载的AB在切场景之后如果不再需要就可以卸载。预加载的时机要把握好太早占内存太晚来不及。6.3 内存预算与监控体系最后说一个体系化的东西内存预算。给每个模块分配一个内存上限比如场景资源200MB、UI资源50MB、音效30MB运行时监控每个模块的实际占用超过预算就报警。这个体系建立起来之后内存问题就能在开发阶段暴露而不是等到上线之后被用户投诉。监控的话我一般用Profiler.GetTotalAllocatedMemoryLong和Profiler.GetTotalReservedMemoryLong这两个接口前者是实际使用的内存后者是Unity向系统申请的内存。两个的差值就是内存碎片。如果碎片太大说明需要优化加载卸载策略。这套东西搭起来之后AB内存管理就从“凭感觉”变成了“看数据”出问题能快速定位优化也有明确方向。我在最近一个项目里用这套方案把峰值内存从1.2G压到了800M低端机上的崩溃率降了将近七成。这些数字不是吹的是实打实调出来的。提示内存预算不是一成不变的随着项目迭代要定期review。我一般每个大版本更新的时候重新评估一次预算根据新增内容调整分配。这套方案的核心思想其实就一句话谁加载谁负责计数归零才释放释放之后等时机统一回收。听起来简单但真正落地需要业务层严格遵守不能有人绕过AssetManager直接调Unity的接口。我在项目里加了代码审查规则发现直接调AssetBundle.LoadFromFile的直接打回强制走统一入口。刚开始有人嫌麻烦但内存问题少了之后大家都觉得值。