ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

Unity AssetBundle 底层原理详解:序列化、依赖管理与打包参数

Unity AssetBundle 底层原理详解:序列化、依赖管理与打包参数 AssetBundle 这套东西刚接触的时候很容易被它那一堆 API 和打包参数绕晕。我见过太多团队项目前期随便打几个包跑通了就完事等到资源量上来、热更需求变复杂才发现包体膨胀、依赖错乱、内存居高不下回头再改成本极高。这篇就专门把 Unity 原生 AssetBundle 的底层原理掰开揉碎讲一遍从它到底是什么、序列化文件长什么样、依赖关系怎么记录到打包时那些参数到底在干什么。不管你是刚上手资源管理的新人还是已经用过 AB 但没深究过原理的老手看完应该都能对为什么这么设计有个清晰的认识。理解了原理后面无论是做热更方案、包体优化还是内存治理心里都有底。1. AssetBundle 到底是个什么东西1.1 从资源到包的本质转变很多人对 AssetBundle 的理解停留在把资源打个包运行时加载这个层面这没错但太浅了。要真正理解它得先想清楚一个前提Unity 编辑器里的资源比如一张 PNG、一个 FBX 模型、一个 Prefab和最终游戏运行时用的资源根本不是同一套东西。编辑器里的资源是源文件 导入设置的组合Unity 通过 AssetDatabase 这套系统来管理它们你改一下导入设置它会重新导入、生成新的内部表示。但到了运行时AssetDatabase 是不存在的游戏不可能带着一整个编辑器资源系统跑。所以运行时需要的是另一种形态的资源——已经按照目标平台处理过、序列化好的二进制数据。AssetBundle 就是承载这种运行时资源的容器。你可以把它类比成一个资源集装箱里面装的是已经按平台烘焙好的 Asset 数据外加一份目录manifest告诉你箱子里有什么、每个东西在哪个位置、依赖了哪些别的箱子。运行时通过加载这个集装箱把里面的资源还原成可用的对象。这里有个关键点容易被忽略AssetBundle 里存的不是原始文件而是序列化后的对象数据。一张 PNG 打进去出来的是 Unity 处理过的纹理数据可能已经压缩成目标平台的格式一个 Prefab 打进去出来的是它引用的所有组件、GameObject、以及它们之间的引用关系。这也是为什么同一个资源打给 Android 和打给 iOS 的包不能混用——底层数据格式不一样。1.2 AssetBundle 与 Resources 文件夹的根本差异既然都是运行时加载资源为什么不用 Resources 文件夹这是新手最常问的问题。Resources 确实简单扔进去就能Resources.Load但它有几个致命问题。第一Resources 文件夹里的所有资源不管用不用都会被打进安装包并且在游戏启动时全部加载进内存准确说是加载资源的索引和部分元数据。项目一大启动就卡内存就爆。第二Resources 不支持热更你没法在发版后往里面加东西。第三Resources 的资源没有明确的依赖管理容易重复打包。AssetBundle 恰恰相反它是按需加载的你不 Load 它就不占运行时内存它可以从服务器下载支持热更它有显式的依赖关系能精确控制打包粒度。代价就是你需要自己管理加载、卸载、依赖、版本复杂度上来了。我个人的经验是Resources 只适合放极少量、全局必需、几乎不变的基础资源比如一个全局配置、一个默认字体。其余一律走 AssetBundle。这不是教条是无数项目踩坑后的共识。1.3 一个 AssetBundle 在磁盘上的真实结构理解 AB 的磁盘结构对排查问题特别有用。一个.ab文件或者 Unity 默认的无扩展名文件大致由几部分组成文件头Header记录了这个包的标识、压缩方式、是否包含类型树等信息。数据块Data Blocks实际序列化的资源数据可能被分成多个块支持流式读取。目录信息Directory记录包内每个 Asset 的名称、在数据块中的偏移和长度。类型树Type Tree可选记录了序列化对象的类型结构用于跨版本兼容。这里要重点说类型树。默认情况下Unity 打包时会带上类型树这样即使加载端的代码版本和打包端不完全一致也能正确反序列化。但类型树会显著增大包体。所以很多项目会关掉它在打包参数里设置DisableWriteTypeTree代价是加载端必须保证类型定义完全一致否则会读出错乱的数据。这是个典型的空间换兼容性的取舍。还有一个概念叫LZ4 和 LZMA 压缩。LZMA 压缩率高但解压慢且不支持随机读取——要读包里的某个资源得先整个解压。LZ4 是块压缩压缩率低一些但支持随机访问加载快。所以常见做法是打包时用 LZMA 压到最小用于传输加载时用 LZ4 重新压缩存到本地。Unity 提供了AssetBundle.RecompressAssetBundleAsync来做这件事这是热更方案里的标准操作。2. 序列化机制AssetBundle 的底层语言2.1 Unity 的序列化文件格式演进要理解 AB 里数据怎么存的得先知道 Unity 的序列化格式。Unity 早期用的是自己的二进制格式后来发展出几种模式Force Text文本YAML 风格可读但大、Force Binary纯二进制小但不可读、Mixed混合默认。AssetBundle 内部用的是二进制序列化但它的组织方式遵循 Unity 的序列化规则。每个对象被序列化成一段字节流字段按声明顺序排列引用关系用FileID PathID来表示。FileID 标识这个对象属于哪个文件在 AB 场景里就是哪个包PathID 是对象在该文件内的唯一标识。这个机制是理解依赖的关键。假设 Prefab A 引用了一个 Material B而 B 在另一个包里。那么 A 的序列化数据里对 B 的引用就记录成FileID 指向 B 所在的包PathID 指向 B 在该包内的 ID。运行时加载 A 时Unity 看到这个引用就知道需要先把 B 所在的包也加载进来否则引用会变成 missing。2.2 引用解析与依赖加载的时机这里有个非常容易踩的坑AssetBundle 的依赖不会自动加载。你 Load 了包 A如果 A 依赖包 BUnity 不会帮你把 B 也 Load 进来。你必须自己先 Load B再 Load A否则 A 里那些引用 B 的资源会出问题——轻则材质丢失变粉重则直接报错。为什么这么设计因为自动加载依赖会导致不可控的连锁加载一个包可能拖出一大串依赖内存瞬间失控。Unity 把控制权交给开发者让你显式管理。这也是为什么几乎所有项目都会自己封装一层 AB 管理器把依赖关系预先算好、按顺序加载。依赖关系从哪来就是打包时生成的manifest 文件。每个包会生成一个.manifest里面记录了它的依赖列表Dependencies、包含的资源Assets、以及哈希值Hash和 CRC。加载前先读 manifest把依赖树理清楚这是标准流程。2.3 类型树与跨版本兼容的取舍前面提到类型树这里展开说。类型树本质上是把 C# 类的字段结构类型、名称、偏移序列化进包里。加载时Unity 用类型树来指导反序列化即使运行时的类定义和打包时略有差异也能按类型树里的结构去读跳过不认识的字段。这带来一个好处热更时资源包和代码版本可以有一定程度的解耦。比如你发了个新版本代码加了个字段但资源包还是旧的带类型树的话旧包还能正常读。但代价是包体。类型树可能占到包体的 10% 到 30%资源越碎、类型越多占比越高。所以很多成熟项目在版本稳定后会关掉类型树前提是保证代码和资源的版本严格对齐。我的建议是开发期开着方便迭代发布期如果包体敏感评估后关掉但一定要建立严格的版本管理流程否则线上出问题很难查。3. 打包机制从资源到包的完整链路3.1 BuildPipeline 的核心流程拆解Unity 打包 AB 的入口是BuildPipeline.BuildAssetBundles。这个 API 看起来简单背后做的事不少。整个流程大致是收集资源根据你设置的 AssetBundle 名称在资源的 Inspector 底部那个 AssetBundle 标签里设把所有标记了同一个包名的资源收集起来。分析依赖扫描这些资源引用了哪些其他资源构建依赖图。去重与分配决定哪些资源打进哪个包处理共享资源被多个包引用的资源。序列化把资源按目标平台序列化成二进制数据。压缩按指定压缩方式压缩数据块。写入文件生成.ab文件和对应的.manifest。这里面最复杂、最容易出问题的是第 3 步。共享资源怎么处理直接决定了包体大小和加载效率。3.2 共享资源的处理策略与陷阱假设有 Prefab A 和 Prefab B它们都引用同一个 Material M。如果你把 A 和 B 分别打成包M 会怎样默认情况下Unity 会把 M分别复制进 A 包和 B 包。这就是所谓的资源冗余。两个包各存一份 M包体翻倍运行时如果同时加载 A 和 B内存里会有两份 M 的实例虽然 Unity 有实例化去重但磁盘和加载开销是实打实的。解决办法是把 M 单独打成一个共享包A 和 B 都依赖它。这样 M 只存一份A 和 B 的 manifest 里记录对 M 包的依赖。加载时先 Load M 包再 Load A、B。但这里有个度的问题共享包拆得太细会导致依赖链过长、加载次数暴增。比如你把每个 Material 都单独打一个包那加载一个场景可能要 Load 几百个包IO 开销和 manifest 解析开销都很可观。所以拆包策略要平衡高频共享、体积较大的资源单独成包低频共享、体积小的资源可以考虑冗余。我一般会按这个思路来先统计资源被引用的次数和体积被引用超过 N 次比如 3 次且体积超过阈值比如 100KB的抽成共享包其余的允许冗余。这个 N 和阈值要根据项目实际情况调没有万能值。3.3 打包参数逐个说清楚BuildAssetBundleOptions里有一堆参数很多人是照着别人的配置抄不知道每个是干嘛的。我挑几个最关键的讲。参数作用什么时候用ChunkBasedCompression使用 LZ4 块压缩需要随机读取、加载速度优先时UncompressedAssetBundle不压缩本地加载、追求极致速度或后续自己压缩DisableWriteTypeTree不写类型树包体敏感、版本严格对齐时DeterministicAssetBundle确定性打包需要可复现构建、增量更新时ForceRebuildAssetBundle强制全量重建怀疑增量打包出错时排查用AppendHashToAssetBundleName包名带哈希配合 CDN 缓存策略时DeterministicAssetBundle值得单独说。开启后同样的输入会生成完全一样的输出哈希一致。这对增量更新至关重要——如果每次打包哈希都变那即使资源没改CDN 上也会被认为是新文件用户要重新下载。开启它配合资源哈希比对才能做到真正的增量更新。ChunkBasedCompression和UncompressedAssetBundle的选择取决于你的加载场景。如果是移动端、包体敏感用 LZ4如果是主机或 PC、追求加载速度且磁盘不是瓶颈可以不压缩。实测下来LZ4 在移动端的综合表现最均衡。4. 加载与卸载运行时的资源生命周期4.1 同步与异步加载的适用场景AB 的加载 API 分同步和异步两套。同步的LoadFromFile、LoadFromMemory会阻塞主线程异步的LoadFromFileAsync不会。很多人图省事全用同步结果加载大包时卡顿明显。我的原则是小包、启动必需、可以接受短暂卡顿的用同步大包、场景切换、可能影响帧率的一律异步。异步加载要注意协程或 async/await 的写法别在异步回调里做重活。还有一个细节LoadFromFile系列是从磁盘加载LoadFromMemory是从内存字节数组加载。后者适合你已经把包下载到内存、或者包很小的情况。但LoadFromMemory会额外占一份内存原始字节 解压后的数据大包慎用。4.2 AssetBundle 与 Asset 的两级卸载这是 AB 内存管理里最容易搞错的地方。AB 的卸载分两个层级AssetBundle.Unload(false)卸载 AB 文件本身占用的内存头部、目录、压缩数据但已经 Load 出来的 Asset 对象保留。这些 Asset 会继续引用它们的数据但失去了和 AB 的关联。AssetBundle.Unload(true)卸载 AB 以及所有从它 Load 出来的 Asset。如果这些 Asset 还在被使用比如场景里的 GameObject 引用着就会变成 missing出现粉红材质、丢失贴图。那到底用哪个这取决于你的资源管理策略。Unload(true)简单粗暴但要求你确保没有活着的引用。Unload(false)安全但会导致 Asset 和 AB 脱钩之后无法通过 AB 再卸载这些 Asset容易造成内存泄漏。主流做法是引用计数 Unload(false)给每个 AB 和 Asset 维护引用计数计数归零时 Unload(false)同时确保没有 Asset 还活着。这套机制需要自己实现但可控性最好。Unity 官方后来推出的 Addressables 本质上就是把这套东西封装好了。4.3 引用计数与内存泄漏的排查思路引用计数听起来简单做起来坑很多。最常见的泄漏场景是Asset 被 Load 出来后被某个长期存活的对象引用着但引用计数没算到。比如一个全局单例缓存了某个 Material你卸载 AB 时没考虑这个引用结果 Material 还在但它依赖的 Texture 被卸了渲染就出问题。排查这类问题我一般用这几个手段Unity Profiler 的 Memory 模块看 AssetBundle 和 Texture2D、Mesh 等对象的数量变化加载前后对比。Resources.UnloadUnusedAssets手动触发一次看能不能回收。如果回收不掉说明还有引用。自己打日志在 Load 和 Unload 的地方记录引用计数变化出问题时能追溯。有个经验卸载 AB 前先确保场景里没有对象引用它的 Asset。切换场景时是卸载的好时机因为旧场景的对象都销毁了。但要注意DontDestroyOnLoad的对象它们跨场景存活容易成为泄漏源。5. 依赖管理与 manifest 的实战用法5.1 manifest 文件里到底有什么每个 AB 打包后会生成一个同名.manifest文件文本格式可以直接打开看。里面主要包含Assets这个包里包含的所有资源路径。Dependencies这个包依赖的其他包名列表。Hash包的哈希值用于版本比对。CRC循环冗余校验用于完整性校验。另外还会生成一个总 manifest名字和输出目录同名记录了所有包的列表和它们之间的依赖关系。加载前读这个总 manifest就能构建出完整的依赖图。实战中我通常会在打包后解析这些 manifest生成一份自己的依赖配置表比如 JSON运行时直接读这份表比每次解析 manifest 快。这份表也是热更时判断哪些包需要更新的依据。5.2 依赖加载顺序的正确姿势加载一个有依赖的包正确顺序是读总 manifest拿到目标包的所有依赖递归展开。先加载所有依赖包去重同一个依赖只加载一次。再加载目标包。从目标包 Load 出需要的 Asset。这里的关键是依赖去重。如果包 A 和包 B 都依赖包 C加载 A 和 B 时C 只能加载一次。所以需要一个全局的已加载包字典来管理。还有个坑依赖包加载后不能马上 Unload否则目标包里的引用会失效。依赖包的生命周期要跟目标包绑定目标包卸载时如果依赖包没有其他引用者才能卸载。这就是引用计数要覆盖到依赖关系的原因。5.3 循环依赖为什么必须避免如果包 A 依赖包 B包 B 又依赖包 A就形成了循环依赖。Unity 打包时可能不会直接报错但运行时会出问题——加载 A 需要 B加载 B 需要 A死锁或者加载顺序错乱。循环依赖通常源于资源之间的相互引用。比如 Prefab A 引用 Prefab BPrefab B 又引用 Prefab A。这种情况在 UI 里特别常见两个界面互相跳转、互相引用。解决办法是打破循环把互相引用的部分抽出来放到第三个包里让 A 和 B 都依赖它。或者重新设计资源结构避免双向引用。打包后一定要检查依赖图有没有环我一般会写个脚本遍历 manifest检测循环依赖打包流程里加一道校验。6. 那些文档不会告诉你的实战经验6.1 包粒度不是越细越好新手容易走极端要么全打一个大包要么每个资源一个包。前者加载慢、更新粒度粗改一个资源要重下整个包后者包数量爆炸、依赖管理复杂、IO 次数多。合理的粒度是按功能模块和更新频率划分。比如角色相关资源一个包某个场景的资源一个包UI 图集按界面分组。更新频率高的比如活动配置、运营图单独成包方便热更稳定的基础资源可以合并成大包减少加载次数。我一般会控制单包体积在几百 KB 到几 MB 之间包数量在几百个量级。具体数字看项目但有个原则热更频繁的资源包要小稳定的资源包可以大。6.2 打包机与本地打包的差异本地打包和打包机打包结果可能不一样。原因有几个Unity 版本、平台、脚本执行顺序、文件系统差异。最坑的是文件系统差异导致的哈希不一致——某些系统下文件遍历顺序不同打包结果就不同。所以一定要开DeterministicAssetBundle并且在打包机上做最终构建。本地打包只用于开发调试别拿本地包去发版。另外打包机的 Unity 版本要和团队统一别有人用 2021 有人用 2022序列化格式可能有差异。6.3 版本号与哈希热更判断的依据热更的核心是判断哪些包需要更新。判断依据通常是哈希比对服务器上存每个包的哈希客户端本地也存一份启动时比对不一致就下载。但哈希比对有个前提打包必须是确定性的否则资源没改哈希也变会导致无谓的下载。这就是前面强调 DeterministicAssetBundle 的原因。另外版本号的管理也很重要。我一般用两级版本资源版本号整体 包哈希单个。资源版本号变了才去比对包哈希版本号没变直接跳过。这样能减少启动时的比对开销。6.4 常见报错与快速定位报错/现象可能原因排查方向材质变粉依赖包没加载检查 manifest 依赖确认依赖包已 LoadThe AssetBundle cant be loaded包损坏或平台不匹配校验 CRC确认打包平台内存持续增长Asset 未卸载Profiler 看对象数量检查引用计数加载卡顿同步加载大包改异步或拆分大包哈希每次都变未开确定性打包开启 DeterministicAssetBundle这些是我这些年遇到最多的几类问题。材质变粉几乎百分百是依赖问题先查依赖准没错。内存增长则要耐心用 Profiler 定位别瞎猜。6.5 从 AssetBundle 到 Addressables 的过渡思考Unity 后来推的 Addressables 系统本质上是把 AB 的这套管理逻辑封装了一层提供了更友好的 API 和自动化的依赖、引用计数管理。但它底层还是 AB。我的看法是新项目如果没特殊需求可以直接上 Addressables省去自己造轮子的成本。但理解 AB 原理依然重要因为 Addressables 出问题时你还是得回到 AB 层面去排查。而且有些定制化需求比如特殊的加密、特殊的下载策略Addressables 不一定能满足还是得自己控制 AB。理解了原生 AB 的序列化、依赖、加载卸载机制再看 Addressables 的文档会有种原来它是在这层做的封装的豁然开朗感。这也是我坚持先讲原理的原因——工具会变原理不会。最后分享一个我踩过的坑早期做热更时没开确定性打包结果每次构建哈希都变用户每次启动都要下载一堆新包流量和体验都很差。后来排查了很久才定位到是打包参数的问题。所以打包参数这东西一定要逐个搞懂别抄配置。
返回列表