--- title: Unity 热更新的隐形命门:一篇讲透"确定性构建管线" - 知乎 source: https://zhuanlan.zhihu.com/p/2028488903561163102 created: 2026-05-09 15:35 tags: [zhihu, article, unity, assetbundle, deterministic-build] --- # Unity 热更新的隐形命门:一篇讲透"确定性构建管线" - 知乎 **来源:** [https://zhuanlan.zhihu.com/p/2028488903561163102](https://zhuanlan.zhihu.com/p/2028488903561163102) --- 写在前面 文章是由AI生成的,最开始是为了当个人笔记,让AI补全一下相关内容 核心观点是确定性构建管线,这个在6000.x的官方文档里已经有明确说明 Deterministic builds 如发现错误,以官方文档为准 起因:看到 Unity Partner Engineer 发的一篇帖子 Understanding Old Dependency Reuse in Unity AssetBundles,通篇在反复强调一个短语——deterministic pipeline。这个概念在中文社区里几乎没人讲透,但它恰恰是 AssetBundle 热更新体系的根基。本文尝试把它讲清楚。 一、一个看似简单的问题,藏着一个深坑 先抛一个场景给你,你试着回答一下: 线上已经下发了 Bundle A 给玩家。现在你新打了一个 Bundle B,B 依赖 A。你想让玩家继续用本地那个旧的 A,不要重新下载,可以吗? 凭直觉回答,多数人会说”当然可以,A 没改嘛”。 但真相是:在”确定性构建管线”下可以,在”非确定性构建管线”下不行。 Unity 官方的说法原话是: It might work, but do not rely on it. (可能能用,但别指望它。) 这个”能不能依赖”的分界线,就是Deterministic Build Pipeline。 二、到底什么是”确定性构建”? 一句话定义: 给定完全相同的输入(资源 + 代码 + 配置 + 工具链 + 环境),无论构建多少次、在哪台机器、谁来构建,输出的二进制文件字节级完全一致。 写成公式: same input + same pipeline = same output (byte-identical) 初学者常常会有这样一种错觉: “我重新打了一次包,文件哈希没变,那我的管线肯定是确定性的。” 这个理解方向对,但不够精准。我们需要区分两个概念: 概念 性质 描述 “输出未变” 结果现象 两次构建 AB 的 MD5/SHA 相同 “确定性管线” 管线属性 只要输入不变,就必然输出不变的能力 换句话说——“输出未变”只是一次幸运的观测;“确定性”是一种架构保证。后者才是工程意义上的可依赖性。 三、为什么它是热更新的命门? 让我们把场景画出来: 上线日: Bundle A (已推送给 1000 万玩家) 次月更新: Bundle B (依赖 A),本次不想动 A 你希望玩家的流量账单是这样: 下载 B + 复用本地 A = 省下一整个 A 的流量 要做到这点,你必须回答一个问题:B 里记录的那个”指向 A 的引用”,今天还指得到吗? 这就要讲 AB 的内部机制了。 AB 是怎么记录跨包引用的 每个 AssetBundle 内部是一个 SerializedFile,开头有一张 External References 表: External References path(1): "Library/unity default resources" path(2): "Resources/unity_builtin_extra" path(3): "archive:/CAB-35fce856128a6714740898681ea54bbe/CAB-35fce856128a6714740898681ea54bbe" 然后对象内部的引用长这样: m_Material PPtr m_FileID : 3 ← 指向上面 path(3) m_PathID : 6 ← 目标文件里的第 6 号对象 所以 B 里的一个引用本质是一个二元组: (file, object) = (CAB 路径, PathID) 关键点:CAB 路径里那个 hash,是由”构建出的 SerializedFile“决定的,不是抽象的”Bundle A”。 现在你就能看懂这个”坑”了 如果管线是确定性的: A 没改 → 重新打也一定得到字节完全一致的 A → CAB 路径不变 → PathID 不变 B 里的 (CAB-xxx, PathID=6) 仍然能 100% 命中旧 A 玩家本地的旧 A 可以安全复用,不用重新下载 如果管线是非确定性的: A 没改,但重新打出来的 A’ 可能CAB 哈希变了、或者对象顺序变了、或者PathID 漂移了 B 里记的引用指的是”新 A’“的结构 你让玩家复用旧 A → 可能看起来正常,也可能随机 crash 这就是为什么 Unity 官方只敢说 “might work, but do not rely on it”。 构建管线的确定性,决定了你整个热更体系的可靠上限。 四、哪些行为会破坏确定性?踩雷清单 下面我把最容易踩的坑按四个层级整理出来。每个坑都用 “现象 → 原因 → 怎么修” 的格式来讲,方便直接对照排查。 A. 构建管线层 —— 你选错了工具或用错了姿势 1. 🔥 搞混了几种 hash,把”输入 hash”当成了”内容指纹” 现象:明明包内容变了,你的版本对比系统却说没变;或者反过来,内容没变,hash 却变了。 原因:Unity 内置 BuildPipeline.BuildAssetBundles 生成的 hash 是基于输入估算的,不是基于最终产物的内容。里面其实混着好几种 hash: hash 类型 它代表什么 IncrementalBuildHash 用来判断”要不要重新打这个包”的输入指纹 TypeTreeHash 用来判断”类型结构有没有变” 缓存 version hash 给 Caching 系统用的版本标识符 最终二进制内容 包文件实际的字节 这四个东西完全不是一回事。如果你把前面某个 hash 直接当成 CDN 版本号,或者当成”两个包一不一样”的判断依据,就会出错。 Unity 6.6 手册也明确提醒了: The AssetBundle input hash isn’t an ideal value for tracking file versions. 怎么修: 如果你的发布系统需要精确判断”包内容到底变没变”,优先考虑 SBP 或 Addressables — 它们的 hash 是基于构建输出算的,更准确 如果继续用内置 API,就别拿它的 hash 直接当内容校验,自己对产物算一遍 SHA256 更靠谱 2. 以为开了 DeterministicAssetBundle 就万事大吉 现象:明明开了这个 flag,不同机器打出来的包还是不一样。 原因:BuildAssetBundleOptions.DeterministicAssetBundle 是老时代的知识。它只解决 PathID 分配的确定性问题。但在现代 Unity(6.x)里,破坏确定性的主要原因早已不是这个 flag,而是: 收集资源时没排序 构建脚本里塞了时间戳、随机数 不同机器的导入缓存不一致 构建环境(OS、Unity 版本)不一样 怎么修:不要只盯这一个开关。真正要盯的是整个构建流程的输入稳定性。 3. 打包前忘了切 Build Target 现象:CI 上打 Android 包,但编辑器的 Active Target 还停留在 iOS。打出来的包和手动打的不一致。 原因:如果你调用 BuildPipeline.BuildAssetBundles 时指定的平台和当前 Active Target 不一样,Unity 会悄悄触发资源重导入和脚本重编译。更坑的是,正在跑的编辑器脚本(比如你的打包工具、构建回调)是按旧 Target 编译的,可能出现平台条件判断错乱。 怎么修:CI 脚本里,打包之前先显式切换 Active Target: EditorUserBuildSettings.SwitchActiveBuildTarget(BuildTargetGroup.Android, BuildTarget.Android); B. 资源数据层 —— 你的资源本身就不稳定 4. AB 名字里带了日期或构建编号 现象:每天打包,即使资源没变,包名都在变,hash 自然跟着变。 坏例子 vs 好例子: ❌ ui_mainmenu_20260417.bundle ← 每天都变 ❌ ui_mainmenu_build_2841.bundle ← CI 编号会变 ✅ ui_mainmenu ← 稳定标识 ✅ ui_mainmenu_v1.2.3 ← 语义化版本 Unity 6.3⁄6.6 文档的原话: Avoid timestamps or build-date suffixes in AssetBundle names and use static identifiers or semantic versions. 5. ⭐ 收集资源时没排序(最常见的坑!) 现象:同一台机器连续打两次,包的 hash 居然不一样。更常见的场景是 A 电脑打出来的包和 B 电脑的不一样。 原因:AssetDatabase.FindAssets() 和 Directory.GetFiles() 返回的顺序不保证一致。不同机器、不同文件系统、甚至同一台机器在不同时间调用,顺序都可能飘。一旦资源添加顺序飘了,序列化数据的排列就飘了,hash 自然就变了。 坏写法: // ❌ 直接遍历,顺序不可控 var guids = AssetDatabase.FindAssets("t:Prefab", new[] { "Assets/UI" }); foreach (var guid in guids) { group.AddAsset(guid); } 好写法: // ✅ 加一行排序,问题消失 var guids = AssetDatabase.FindAssets("t:Prefab", new[] { "Assets/UI" }) .OrderBy(x => x, StringComparer.Ordinal) .ToArray(); 记住这条规则:凡是涉及 FindAssets、GetFiles、EnumerateFiles 的地方,必须手动排序。 6. 往 ScriptableObject 里塞数据时,塞入顺序不稳定 现象:和上面一样,不同机器或不同时间构建出的包不一致。 原因:如果你在构建前用 IPreprocessBuildWithReport 之类的回调,动态往 SO 的 List 字段里塞东西,塞的顺序不稳定,序列化出来的字节就不稳定。 Unity 6.6 文档说得很明白: If the order of objects in that array isn’t explicitly sorted, the serialized data might differ between machines or consecutive builds. 怎么修:喂给 SO 的数据,在写入之前先排好序。 7. 资源里写入了随机值、时间戳、机器名 现象:每次构建,某些 SO 文件的内容必定不同。 坏写法: // ❌ 每次构建都不一样 so.buildGuid = Guid.NewGuid().ToString(); so.buildTime = DateTime.UtcNow.Ticks; so.machine = Environment.MachineName; 这类字段一旦进了序列化数据,确定性就直接被你自己亲手打穿了。 C. 环境与工具链层 —— 你以为机器都一样,其实不一样 8. Unity 版本不一致 一句话:哪怕只差一个小版本号,序列化格式、导入器行为、Shader 编译都可能有细微差异。 怎么修:CI 锁死 Unity 版本,精确到补丁号(比如 6000.0.32f1),团队所有人用同一个版本。 9. 操作系统或 CPU 架构不一致 现象:Windows 的 CI 和 Mac 的 CI 打出来的包不一样,尤其是包含动画、粒子的包。 原因:浮点数运算在不同 CPU 架构上的舍入行为不同。Unity 6.6 文档原话: Floating-point rounding behavior differs between architectures, which can change serialized float values especially in AnimationClips or imported Mesh data. 怎么修:所有 CI 构建机用同一种 OS + 同一种 CPU 架构。 10. Git 自动换行没关掉 现象:Windows 开发者提交的文本文件是 CRLF,Mac 开发者的是 LF。文本资源的 hash 莫名其妙对不上。 怎么修: git config --global core.autocrlf false 再在 .gitattributes 里统一声明所有文本文件用 LF。 11. 不同机器的 Library 缓存状态不同 现象:同样的项目,A 机器是全新 clone(Library 是新生成的),B 机器已经用了很久(Library 有大量缓存)。两台机器打出来的包不一样。 原因:Unity 的资源导入结果会受 Library 缓存状态影响。Unity 曾修过一个著名 bug(UUM-114616):Shader AB 在”干净 Library”和”有缓存 Library”下产出不同。 怎么修:用 Unity Accelerator 让所有构建机共享同一份导入结果。但注意:Accelerator 只缓存导入产物(imported assets),不缓存打出来的 AB 包本身。它帮你统一了导入阶段,但不是万能保险。 D. 脚本与代码层 —— 改了代码,资源也跟着动 12. 脚本改动的涟漪效应 现象:明明没动资源,一堆 AB 的 hash 都变了。 原因:脚本的某些改动会导致 MonoScript 身份信息变化,进而影响引用了这些脚本的 AB。但具体是哪些改动有影响,需要搞清楚: 会影响 AB 的脚本改动 不会影响 AB 的脚本改动 脚本移到另一个 asmdef 下 只改注释 asmdef 改名 只改方法内部实现(不涉及序列化字段) 改类名或命名空间 只改 private 字段(非序列化的) 改 [SerializeField] 字段的类型/名称 丢失 .meta 文件后重新生成 13. 构建回调里偷偷写入了变化数据 现象:每次构建的 Scene AB 都不一样,但场景文件明明没动。 原因:你(或某个插件)在 IProcessSceneWithReport 之类的回调里写了类似这样的代码: // ❌ 每天构建出来的 name 都不一样 public void OnProcessScene(Scene scene, BuildReport report) { foreach (var go in scene.GetRootGameObjects()) { go.name = $"Built_{DateTime.Now:yyyyMMdd}"; } } 怎么修:构建回调里严禁使用 DateTime.Now、Guid.NewGuid()、Environment.MachineName 等非确定性 API。 14. Shader 变体集合不稳定 现象:不同机器或不同时间打的包里,Shader 变体数量不一样。 原因:如果你的 Shader Variant Collection (SVC) 是通过运行时录制收集的,那它完全取决于”测试的人跑了哪些路径”。不同人、不同时间跑出来的变体集合自然不同。 怎么修:大型项目应该用离线枚举 + 手动维护的方式管理 SVC,而不是依赖”录制式收集”。 五、如何保障确定性:一份能落地的清单 工程约定 [ ] 所有自动化脚本收集到的资源集合,统一做稳定排序 [ ] 禁止把时间戳、构建编号、机器名写进 AB 名称或序列化资源 [ ] 明确区分输入 hash、缓存 version hash、内容校验 hash,避免混用 [ ] Git 关闭自动换行转换,并用 .gitattributes 固定文本换行策略 [ ] 构建回调中禁止使用 DateTime.Now、Guid.NewGuid 等非确定性来源 [ ] 若项目强依赖内容级哈希语义,优先评估 SBP 或 Addressables [ ] Addressables 项目里,catalog 是依赖身份的权威来源,不要手工替换 bundle CI/CD 约定 [ ] 锁定 Unity Editor 版本,精确到补丁号 [ ] 固定构建机的 OS 与 CPU 架构 [ ] 构建前对齐 active target 或 build profile [ ] 使用 Unity Accelerator 统一 imported assets [ ] 发布构建尽量采用 clean build [ ] ProjectSettings、构建配置全部纳入版本控制 Code Review Checklist 所有 FindAssets、GetFiles、EnumerateFiles 的结果是否稳定排序 所有写入 SO 的数组、列表、映射是否有稳定顺序 是否写入了时间、随机数、机器相关元数据 是否有 build callback 在构建时改写资源 是否把某种 hash 误当成了内容校验或发布版本号 六、最后的防线:双跑比对 在 CI 里做双跑比对: 不修改任何资源 在相同参数下连续构建两次 对每个 AB 文件计算 SHA256 任何哈希不一致,立刻告警并定位差异 定位阶段,用 UnityDataTools 的 textdumper 把两个版本的 SerializedFile 导出成可读文本后直接 diff。 七、一些延伸思考 写到这里,回头看 Unity 那篇原帖里一段意味深长的话: The reason I initially missed the point is that this question is only interesting when that assumption breaks. (我一开始没 get 到这个问题的点,因为这个问题只有在”确定性假设被打破时”才有意义。) 这句话其实在说一个很工程哲学的事情: 构建的确定性是一种”隐形的契约”。它平时不存在、不被感知,但一旦破裂,会带着你整个热更体系一起塌方。 很多商业项目上线多年都没踩过这个坑,不是因为管线真的确定性,而是因为: 每次版本更新都全量重推所有依赖包(成本掩盖了问题) 或者依赖关系极简(运气好到没引用跨包的老资源) 但一旦你想要做差分热更、按需下载、跨版本依赖复用这些更精细的运营动作,确定性就是你绕不开的地基。 八、结语 一篇帖子引出的概念,最后会延展成一整套工程规范。回到最初的问题: 打包出来的 AB 完全没变,就是确定性管线吗? 答案是: “输出没变”是确定性管线的一种观测现象;”确定性管线”是只要输入不变就保证输出不变的架构能力。在热更场景下,后者才是你能信赖的东西。 如果你负责的是一个需要长期运营的 Unity 项目,强烈建议今天就做两件事: 在 CI 上加一次”双跑比对”,看看你们的管线现在到底是什么状态 把本文的”踩雷清单”打印出来,贴在构建平台的 PR 模板里 地基打牢之前,所有的热更优化都是在流沙上盖楼。 参考资料 Unity 官方讨论:Understanding Old Dependency Reuse in Unity AssetBundles Unity 6.3 LTS 手册:AssetBundle and Addressables determinism Unity 6.6 手册:Deterministic builds introduction 工具:UnityDataTools 包:Scriptable Build Pipeline 如果这篇内容对你有帮助,欢迎点赞/收藏。