十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Unity AssetBundle底层原理深度解析:序列化机制与内存管理

Unity AssetBundle底层原理深度解析:序列化机制与内存管理 1. 为什么值得花时间搞懂AssetBundle的底层原理很多Unity开发者对AssetBundle的认知停留在BuildPipeline.BuildAssetBundles和AssetBundle.LoadFromFile这两个API上觉得会用就行了。我刚开始做Unity资源管理的时候也是这个心态直到项目里出现了一个诡异的问题同一个AssetBundle包在编辑器里加载正常打到真机上就报Unable to read header换了一台设备又好了。当时排查了整整两天最后发现是打包时序列化格式和运行时读取方式不匹配导致的。这个经历让我意识到AssetBundle不是会用API就能驾驭的东西。它的本质是一套序列化文件系统Unity在打包时把资源对象按照特定的二进制格式写入文件运行时再按照同样的格式反序列化回内存。你如果不理解这个序列化和反序列化的过程出了问题就只能靠猜。这篇文章要聊的就是Unity原生AssetBundle的底层原理。我会从文件结构、序列化机制、加载流程、内存管理这几个维度展开把AssetBundle从打包到加载的完整链路拆开来讲。适合已经用过AssetBundle但对其内部机制不太清楚的开发者也适合正在做资源管理方案选型、需要理解AssetBundle能力边界的技术负责人。读完之后你应该能回答这些问题AssetBundle文件里到底存了什么为什么有时候加载会失败内存里的AssetBundle对象和加载出来的Asset之间是什么关系什么时候该Unload2. AssetBundle文件结构深度拆解2.1 一个AssetBundle文件到底长什么样把任何一个AssetBundle文件用十六进制编辑器打开你会看到它并不是一堆杂乱无章的二进制数据而是有严格结构的。Unity的AssetBundle文件从整体上可以分成两大块头部信息Header和数据段Data。头部信息包含了这个包的元数据比如压缩格式、是否包含类型树、数据段的大小、块信息等。数据段则是实际序列化后的资源对象数据。理解这个结构非常关键因为很多加载失败的报错比如Unable to read header或者Invalid file format本质上就是头部信息读取或解析出了问题。我用一个实际例子来说明。假设你用BuildAssetBundleOptions.ChunkBasedCompressionLZ4压缩打了一个包这个包的头部会包含以下关键字段字段大小说明Signature字符串文件签名Unity用UnityFS标识Versionuint格式版本号不同Unity版本可能不同UnityVersion字符串打包时使用的Unity版本UnityRevision字符串打包时使用的Unity修订版本Sizelong整个文件的大小CompressedBlocksInfoSizeuint压缩后的块信息大小UncompressedBlocksInfoSizeuint解压后的块信息大小Flagsuint标志位标识压缩方式等这些字段不是随便设计的。比如UnityVersion和UnityRevision的存在是为了让运行时能够判断这个包是否和当前引擎版本兼容。如果你用Unity 2021打包然后用Unity 2019的运行时去加载很可能就会因为格式不兼容而失败。这也是为什么Unity官方一直强调打包和加载必须使用相同版本的引擎。2.2 块信息BlocksInfo与压缩策略的关系块信息是AssetBundle文件结构里最容易被忽视但最重要的部分之一。它记录了数据段被切分成多少个块、每个块的压缩前后大小、以及每个块在文件中的偏移量。为什么要有块信息这跟AssetBundle支持的压缩方式直接相关。Unity原生支持三种压缩模式不压缩No Compression数据段原样存储加载时不需要解压速度最快但包体最大。LZMA压缩整体压缩压缩率最高但加载时需要一次性解压整个包内存峰值高且不支持随机读取。LZ4压缩ChunkBasedCompression分块压缩每个块独立压缩加载时可以按需解压特定块内存占用低支持随机读取。块信息就是为LZ4这种分块压缩服务的。每个块的大小通常是128KB这是Unity内部的默认值不同版本可能略有差异块信息里会记录每个块的压缩后大小和偏移量。当你只需要加载包里的某一个Asset时Unity可以根据块信息只解压包含该Asset的块而不需要解压整个包。注意LZMA压缩的包在加载时会被Unity自动转换成LZ4格式缓存在内存中这个过程叫做LZMA到LZ4的重压缩。这意味着第一次加载LZMA包时会有额外的CPU开销和内存开销。如果你的项目对加载速度敏感建议直接用LZ4打包。2.3 类型树TypeTree的作用与取舍类型树是AssetBundle里一个非常特殊的存在。它记录了序列化对象中每个字段的类型信息、名称和在对象中的偏移量。有了类型树即使运行时的C#类定义和打包时不一致比如你删了一个字段、加了一个字段Unity也能正确地跳过或填充对应的数据。听起来很美好对吧但类型树会显著增大包体。根据我的实测开启类型树后一个包含大量小对象的AssetBundle包体可能增大30%到50%。所以Unity在Player Settings里提供了一个选项叫Disable Type Tree允许你在打包时去掉类型树以减小包体。那到底该不该去掉我的经验是如果你的项目在热更新时不会修改C#类结构可以去掉如果会修改建议保留。因为去掉类型树后一旦运行时类结构和打包时不一致反序列化就会出错表现为加载出来的Asset数据错乱或者直接报错。这个坑我在一个热更项目里踩过当时为了省包体去掉了类型树结果一次热更加了个字段所有老包的资源全部加载异常。3. 序列化机制AssetBundle的核心灵魂3.1 Unity的序列化系统是怎么工作的要理解AssetBundle必须先理解Unity的序列化系统。Unity的序列化不是C#原生的BinaryFormatter或JsonUtility而是一套自研的二进制序列化框架。它的核心思想是每个可序列化的对象都有一个类型ID序列化时先写入类型ID再按照类型树定义的字段顺序写入字段数据。这套机制和C#的序列化有本质区别。C#的BinaryFormatter会把类型的完整程序集限定名写进去反序列化时通过反射创建对象。Unity的序列化则依赖于预先注册的类型ID和类型树不依赖反射速度更快但灵活性更低。具体到AssetBundle打包时Unity会遍历所有要打包的资源对象对每个对象执行序列化把序列化后的字节流写入数据段。同时Unity会构建一个对象索引表记录每个对象在数据段中的偏移量和大小。加载时Unity根据对象索引表定位到目标对象的字节流然后按照类型树反序列化。这里有一个关键点AssetBundle里的对象是相互引用的。比如一个Prefab引用了MaterialMaterial又引用了Texture。这些引用在序列化时会被转换成文件内偏移量FileID反序列化时再根据偏移量找到对应的对象。这就是为什么你不能单独加载一个AssetBundle里的某个Asset而不加载它依赖的其他Asset——因为引用关系是写在序列化数据里的。3.2 序列化格式的版本差异与兼容性陷阱Unity的序列化格式不是一成不变的。不同大版本之间序列化格式可能有细微差异。比如Unity 5.x和Unity 2017的AssetBundle格式就不完全兼容Unity 2019之后又引入了新的序列化后端。这些差异体现在哪些地方最典型的是类型ID的分配和字段的序列化顺序。Unity内部维护了一个类型ID表把常见的类型如GameObject、Transform、Mesh、Material等映射到固定的ID。但如果你用了自定义的ScriptableObject它的类型ID是根据脚本的GUID生成的不同项目、不同版本可能不同。这就导致了一个常见问题用A项目打包的AssetBundle拿到B项目里加载即使Unity版本相同也可能因为脚本GUID不同而失败。因为AssetBundle里引用的MonoBehaviour脚本是通过GUIDFileID定位的B项目里没有对应的GUID自然就找不到。实操心得如果你的项目需要跨项目复用AssetBundle一定要确保两个项目的脚本GUID一致。方法是在打包前把脚本的.meta文件一起拷贝过去或者使用Assembly Definition来管理脚本引用。3.3 反序列化的性能开销在哪里反序列化不是免费的。每次AssetBundle.LoadAsset调用Unity都需要做以下几件事根据Asset名称在对象索引表中查找对应的偏移量。从数据段中读取该偏移量处的字节流。根据类型树解析字节流创建对应的C#对象。解析对象中的引用关系递归加载被引用的对象。第3步和第4步是性能开销的大头。特别是当Asset引用了大量其他Asset时递归加载会导致大量的内存分配和类型解析。我实测过一个包含500个Prefab的AssetBundle首次加载全部Prefab耗时约1.2秒其中反序列化占了70%以上。优化的思路有几个一是减少单个AssetBundle中的对象数量把大包拆成小包按需加载二是使用LoadAssetWithSubAssets一次性加载多个相关Asset减少重复的索引查找三是避免在运行时频繁加载和卸载尽量在加载后缓存起来复用。4. 加载流程全链路解析4.1 从文件到内存LoadFromFile做了什么AssetBundle.LoadFromFile是最常用的加载API但很多人不知道它内部到底做了什么。我按照Unity源码的逻辑梳理一下第一步打开文件句柄读取头部信息。这一步会验证文件签名是否为UnityFS检查版本兼容性读取块信息。第二步根据块信息构建解压缓冲区。如果是LZ4压缩Unity会为每个块分配解压缓冲区但不会立即解压所有块而是按需解压。如果是LZMA压缩Unity会启动一个后台线程对整个包进行解压并转换成LZ4格式缓存在内存中。第三步创建AssetBundle对象。这个对象是一个托管对象持有文件句柄、块信息、解压缓冲区等资源。注意此时还没有加载任何具体的Asset。第四步返回AssetBundle对象给调用方。此时你可以调用LoadAsset来加载具体的资源。这里有一个容易被忽视的细节LoadFromFile是同步操作但LZMA解压是异步的。如果你用LZMA压缩打包第一次LoadFromFile会立即返回但后台解压线程还在跑。如果你紧接着调用LoadAssetUnity会阻塞等待解压完成。这就是为什么LZMA包的首次加载会有明显的卡顿。4.2 LoadAsset的内部实现与引用解析LoadAsset的流程比LoadFromFile复杂得多。当你调用assetBundle.LoadAsset(MyPrefab)时Unity内部会执行以下步骤首先在AssetBundle的对象索引表中查找名为MyPrefab的Asset。这个索引表是在打包时生成的记录了Asset名称到对象偏移量的映射。如果找不到返回null。然后根据偏移量从数据段中读取序列化数据。如果数据所在的块还没有解压先解压该块。接着根据类型树反序列化数据创建对应的C#对象。如果这个对象引用了其他对象比如Prefab引用了MaterialUnity会递归地解析这些引用。递归解析时如果被引用的对象在同一个AssetBundle中直接从当前包中加载如果在其他AssetBundle中则需要通过AssetBundleManifest找到对应的包并加载。最后返回创建好的对象。注意Unity会缓存已经加载过的Asset下次再调用LoadAsset加载同一个Asset时会直接返回缓存的对象不会重复反序列化。注意LoadAsset返回的对象是共享的。如果你修改了返回的Material的颜色所有引用这个Material的地方都会受影响。如果需要独立修改必须用Instantiate创建副本。4.3 依赖加载与AssetBundleManifest的运作机制AssetBundle的依赖管理是很多开发者头疼的问题。假设你有三个包A依赖BB依赖C。当你加载A中的某个Asset时Unity需要先加载C再加载B最后加载A。这个依赖链是怎么确定的答案在AssetBundleManifest里。打包时Unity会生成一个额外的AssetBundle通常叫AssetBundleManifest或你指定的名字里面包含了一个AssetBundleManifest对象。这个对象记录了每个AssetBundle的所有依赖项。加载时你需要先加载这个Manifest包然后通过manifest.GetAllDependencies(a)获取A的所有依赖。Unity不会自动帮你加载依赖你必须手动按照依赖顺序加载。如果依赖没有加载就调用LoadAssetUnity会报错或者返回null。我见过很多项目在这里出问题依赖包没有加载或者加载顺序不对导致Asset加载失败。正确的做法是写一个依赖管理模块在加载任何AssetBundle之前先递归加载它的所有依赖。// 依赖加载的典型实现 public AssetBundle LoadWithDependencies(string bundleName) { string[] dependencies manifest.GetAllDependencies(bundleName); foreach (string dep in dependencies) { if (!loadedBundles.ContainsKey(dep)) { AssetBundle depBundle AssetBundle.LoadFromFile(Path.Combine(bundlePath, dep)); loadedBundles.Add(dep, depBundle); } } AssetBundle bundle AssetBundle.LoadFromFile(Path.Combine(bundlePath, bundleName)); loadedBundles.Add(bundleName, bundle); return bundle; }这段代码看起来简单但实际项目中需要考虑循环依赖、重复加载、卸载时机等问题。循环依赖在AssetBundle里是不允许的打包时Unity会报错。重复加载会导致同一个包被加载多次浪费内存。卸载时机则需要根据引用计数来决定。5. 内存管理与卸载策略5.1 AssetBundle对象与Asset对象的内存关系这是AssetBundle内存管理里最容易搞混的地方。当你调用LoadFromFile时内存里会创建一个AssetBundle对象它持有文件句柄、块信息、解压缓冲区。当你调用LoadAsset时内存里会创建具体的Asset对象比如Texture2D、Mesh等。这两类对象的内存是分开管理的。AssetBundle对象占用的内存主要是文件句柄和解压缓冲区通常不大几KB到几MB。Asset对象占用的内存则取决于资源本身的大小可能很大比如一张4K纹理可能占几十MB。关键点来了卸载AssetBundle对象不会自动卸载已经加载的Asset对象。如果你调用assetBundle.Unload(false)AssetBundle对象会被销毁但已经加载的Asset对象会保留在内存中。如果你调用assetBundle.Unload(true)AssetBundle对象和所有从它加载的Asset对象都会被销毁。那到底该用Unload(true)还是Unload(false)这取决于你的资源管理策略。Unload(true)简单粗暴但如果有其他对象还在引用这些Asset会导致引用丢失表现为材质变粉、纹理丢失。Unload(false)更安全但需要你自己管理Asset的生命周期否则会内存泄漏。我的建议是用引用计数来管理。每个AssetBundle被引用时计数加一不再引用时计数减一计数为零时调用Unload(true)。同时确保没有任何GameObject还在使用这个包里的Asset。5.2 内存泄漏的常见原因与排查方法AssetBundle的内存泄漏是项目后期最常见的问题之一。我总结了几种典型情况第一种加载了AssetBundle但从未卸载。这种情况最常见通常是因为代码里只写了加载逻辑忘了写卸载逻辑。排查方法是打开Profiler看AssetBundle分类下的内存是否持续增长。第二种Unload(false)后Asset对象没有被释放。这种情况通常是因为Asset被其他对象引用了比如一个Material被场景里的Renderer引用着。即使你卸载了AssetBundleMaterial对象也不会被GC回收因为它还有强引用。排查方法是检查场景里是否有对象还在使用这些Asset。第三种重复加载同一个AssetBundle。如果你多次调用LoadFromFile加载同一个包内存里会有多个AssetBundle对象每个都持有自己的解压缓冲区。排查方法是维护一个已加载包的字典加载前先检查是否已经加载过。第四种LZMA包的缓存泄漏。前面提到LZMA包加载时会被转换成LZ4格式缓存在内存中。这个缓存是在AssetBundle对象内部的如果AssetBundle对象没有被正确释放缓存也不会释放。排查方法是尽量用LZ4打包避免这个问题。实操心得我习惯在开发阶段给每个AssetBundle的加载和卸载都打上日志记录加载时间、卸载时间、引用计数变化。这样一旦出现内存泄漏可以通过日志快速定位是哪个包没有被释放。5.3 卸载时机的选择与引用计数实现卸载时机的选择是一个权衡。卸载太早会导致资源丢失卸载太晚会导致内存占用过高。我的经验是采用分场景卸载的策略对于常驻资源如UI图集、字体、Shader打包时单独放在一个包里游戏启动时加载游戏退出时卸载。对于场景资源进入场景时加载离开场景时卸载。对于临时资源如特效、音效使用对象池管理池子清空时卸载对应的AssetBundle。引用计数的实现需要注意线程安全。如果你的项目有多线程加载的需求引用计数的增减必须加锁。另外引用计数只能保证AssetBundle对象被正确释放不能保证Asset对象被正确释放。Asset对象的释放依赖于GC你需要确保没有任何强引用指向它。// 简单的引用计数实现 public class BundleRef { public AssetBundle bundle; public int refCount; public void Retain() { Interlocked.Increment(ref refCount); } public bool Release() { int count Interlocked.Decrement(ref refCount); if (count 0) { bundle.Unload(true); return true; } return false; } }这段代码只是一个示意实际项目中还需要考虑加载失败、重复加载、异步加载等情况。6. 常见问题与排查技巧实录6.1 加载失败类问题的排查思路AssetBundle加载失败是最常见的问题表现形式多种多样。我整理了一个排查表报错信息可能原因排查方法Unable to read header文件损坏、版本不兼容、文件被加密检查文件大小是否为0检查Unity版本是否一致Invalid file format文件不是AssetBundle、文件被截断用十六进制编辑器查看文件头是否为UnityFSThe file can not be loaded because it was created with a newer version打包版本高于运行时版本统一打包和运行的Unity版本AssetBundle is not loaded依赖包未加载检查依赖加载逻辑Cant find assetAsset名称错误、Asset未打包检查Asset名称大小写检查打包列表排查加载失败的第一步永远是确认文件本身是否完整。我遇到过好几次因为文件传输不完整导致加载失败的情况文件大小比预期小了几KB用十六进制编辑器一看文件尾部被截断了。第二步是确认版本一致性。Unity的AssetBundle格式在不同版本之间可能有变化打包和加载必须使用相同版本的引擎。如果做不到至少要用相同的大版本。第三步是确认依赖是否加载。很多加载失败是因为依赖包没有加载导致引用解析失败。可以在加载前打印出所有依赖确认它们都已经加载。6.2 内存异常增长的定位方法内存异常增长通常表现为游戏运行一段时间后卡顿甚至崩溃。定位方法如下首先用Profiler抓取内存快照对比不同时间点的内存变化。重点关注AssetBundle、Texture2D、Mesh、Material这几个分类。然后检查是否有AssetBundle没有被卸载。可以在Profiler里查看AssetBundle分类下的对象数量如果持续增长说明有包没有被释放。接着检查是否有Asset对象没有被释放。如果Texture2D或Mesh的数量持续增长说明有Asset被加载后没有被正确释放。这时候需要检查引用计数逻辑确认是否有对象还在引用这些Asset。最后检查是否有重复加载。如果同一个AssetBundle被加载了多次内存里会有多个副本。可以在加载逻辑里加日志记录每次加载的包名和时间看看是否有重复。注意Unity的Profiler在Development Build下才能看到详细的内存信息。Release Build下只能看到总内存无法定位具体对象。所以内存排查一定要用Development Build。6.3 跨平台打包的坑与注意事项不同平台的AssetBundle是不通用的。Android和iOS的AssetBundle不能混用Windows和Mac的也不能混用。这是因为不同平台的纹理压缩格式、字节序、对齐方式可能不同。跨平台打包时需要注意以下几点纹理压缩格式Android通常用ETC2或ASTCiOS用PVRTC或ASTCPC用DXT。打包时必须为每个平台单独设置纹理压缩格式。字节序不同CPU架构的字节序可能不同虽然Unity内部会处理但某些自定义的二进制数据可能需要手动处理。Shader变体不同平台的Shader变体可能不同打包时需要确保包含了目标平台所需的变体。文件路径不同平台的文件路径分隔符不同加载时需要用Path.Combine而不是硬编码的斜杠。我踩过最深的坑是纹理压缩格式不匹配。当时用Android的包在iOS上加载纹理显示异常排查了很久才发现是ETC2格式在iOS上不被支持。后来在打包脚本里加了平台判断为每个平台单独设置压缩格式问题才解决。6.4 热更新场景下的AssetBundle策略热更新是AssetBundle最核心的应用场景之一。热更新的基本思路是把资源打包成AssetBundle运行时从服务器下载最新的包替换本地的旧包。热更新场景下有几个关键问题需要解决版本管理每个AssetBundle需要一个版本号通常用Hash值。服务器上维护一个版本清单客户端启动时对比本地版本和服务器版本下载有差异的包。差异更新如果每次更新都下载全量包流量消耗太大。可以用二进制差分算法如BSDiff生成差分包客户端下载差分包后合并成本地包。回滚机制如果更新后出现问题需要能够回滚到旧版本。所以本地要保留旧版本的包或者能够从服务器重新下载旧版本。完整性校验下载的包需要校验完整性防止下载过程中损坏。通常用MD5或SHA256校验。实操心得热更新时建议把AssetBundle分成基础包和更新包两类。基础包随安装包发布更新包从服务器下载。这样即使更新失败基础包还能保证游戏基本可运行。7. 一些实战中的经验与建议聊了这么多原理最后分享几个我在实际项目中总结的经验。关于打包粒度不要把所有资源打成一个包也不要每个资源打一个包。我的经验是按功能模块打包比如一个UI界面一个包一个角色一个包。这样既能减少包数量又能按需加载。单个包的大小控制在1MB到5MB之间比较合适。关于压缩方式如果没有特殊需求直接用LZ4。LZMA虽然压缩率高但加载时的解压开销和内存峰值都更高。LZ4的压缩率虽然低一些但加载速度快内存占用低综合体验更好。关于类型树如果项目没有热更新需求或者热更新不会修改C#类结构可以去掉类型树以减小包体。但如果有热更新需求且可能修改类结构一定要保留类型树否则会出现难以排查的反序列化错误。关于加载方式LoadFromFile是最常用的方式但在某些平台如Android的StreamingAssets上文件可能被压缩在APK里无法直接用LoadFromFile加载。这时候需要用LoadFromStream或者先把文件解压到可读写目录。关于异步加载LoadFromFileAsync和LoadAssetAsync是异步API但它们的异步是在后台线程做解压和反序列化最终的对象创建还是在主线程。所以异步加载并不能完全避免卡顿只是把卡顿分散到了多帧。如果对流畅度要求高可以配合分帧加载策略。关于调试工具Unity官方有一个AssetBundle Browser工具可以查看包的内容、依赖关系、大小等信息。我强烈建议在项目初期就引入这个工具对理解AssetBundle的结构和依赖关系非常有帮助。这些经验都是我在实际项目中踩坑总结出来的不一定适用于所有场景但希望能给你一些参考。AssetBundle的原理并不复杂复杂的是在实际项目中如何根据具体需求做出合理的取舍。理解原理之后这些取舍就有了依据而不是凭感觉拍脑袋。
返回列表