Files
obsidian-notes/InBox/zhihu_deterministic_build.md
Build Bot f7310caea0 同步
2026-05-18 01:20:38 +08:00

378 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

---
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 BB 依赖 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<Material>
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 isnt an ideal value for tracking file versions.
怎么修:
如果你的发布系统需要精确判断”包内容到底变没变”,优先考虑 SBP 或 Addressables — 它们的 hash 是基于构建输出算的,更准确
如果继续用内置 API就别拿它的 hash 直接当内容校验,自己对产物算一遍 SHA256 更靠谱
2. 以为开了 DeterministicAssetBundle 就万事大吉
现象:明明开了这个 flag不同机器打出来的包还是不一样。
原因BuildAssetBundleOptions.DeterministicAssetBundle 是老时代的知识。它只解决 PathID 分配的确定性问题。但在现代 Unity6.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.36.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<Object> 字段里塞东西,塞的顺序不稳定,序列化出来的字节就不稳定。
Unity 6.6 文档说得很明白:
If the order of objects in that array isnt 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 开发者提交的文本文件是 CRLFMac 开发者的是 LF。文本资源的 hash 莫名其妙对不上。
怎么修:
git config --global core.autocrlf false
再在 .gitattributes 里统一声明所有文本文件用 LF。
11. 不同机器的 Library 缓存状态不同
现象同样的项目A 机器是全新 cloneLibrary 是新生成的B 机器已经用了很久Library 有大量缓存)。两台机器打出来的包不一样。
原因Unity 的资源导入结果会受 Library 缓存状态影响。Unity 曾修过一个著名 bugUUM-114616Shader 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
如果这篇内容对你有帮助,欢迎点赞/收藏。