一个 1x1 贴图,是怎么炸掉整个热更包的?

深度解析一次 Unity Addressables 热更事故的调查全过程。


起因:392 个 bundle 进了 patch,但只有 94 个真的变了

事情要从一份调查文档说起。

我们 iOS 的某次热更(内部编号 #195),patch 里居然有 392 个 bundle ,加起来 465 MB。这数字一出来,大家都觉得不对------这次热更内容并不多啊,就是加了个新角色立绘、新赛季房间,怎么要下这么多?

于是我开始查。查到最后,发现了一个让我自己都愣住的结论:

这 392 个 bundle 里,有 296 个的内容一个字都没改,纯粹是"换了个名字"就被塞进了 patch。玩家要因为这 296 个假更新,白白多下载 272 MB。

272 MB 是什么概念?占这次热更总下载量的 58%。一半以上的流量,下载的是玩家手机里早就有的东西。

更离谱的是,后面我们自己复现的时候,只用了一个 1x1 的占位贴图,就成功让 397 个 bundle 重命名了。

这篇文章,我把整个调查链从头到尾讲清楚。希望你读完能明白三件事:这到底是怎么发生的、为什么一个贴图能炸掉几百个包、以及以后怎么避免。


第一层:bundle 的文件名,不是随便取的

要理解这个事故,得先搞懂 Addressables 是怎么给 bundle 起名字的。

我们项目用的是 OnlyHash 命名------bundle 的文件名,直接等于一个哈希值。而这个哈希值是怎么算的呢?我翻到了 Unity 的 Scriptable Build Pipeline 源码,找到了关键一行:

scss 复制代码
bundle 文件名 = Hash( 这个 bundle 自己的内容 , 它依赖的所有 bundle 的名字集合 )

注意后半句------"它依赖的所有 bundle 的名字集合"。

也就是说,一个 bundle 叫什么名字,不光取决于它自己装了什么,还取决于它"依赖了谁"。

打个比方:你家门牌号,不光由你家房子的样子决定,还由"你邻居是谁"决定。邻居一换,你家门牌号就跟着变。

这就是所有诡异现象的源头。


第二层:两个名字,一静一动

为了讲清楚,我得先区分两个容易混的东西:

  • 内部名 :每个 bundle 有个稳定的身份证号(比如 790b910f...),跨构建永远不变。
  • 文件名 :就是上面那个哈希算出来的 xxxx.bundle,内容或依赖一变就变。

关键来了:哈希公式里那项"依赖的名字集合",装的是内部名(稳定的那个),不是文件名。

所以就有了下面这个看似矛盾、实则合理的现象:

我改了自己 bundle 的内容 → 我的文件名变了,但我的内部名没变 → 别人没受影响。

这解释了为什么"改内容"是安全的------你改了贴图像素、调了数值,只有你自己重命名,别人纹丝不动。


第三层:动手实验,结果反常识

光看代码不够,我想验证一下。于是在项目里做了三个递进的实验,结果一步步把真相逼了出来。

实验一:新建一个没人用的 prefab

我建了个 TestPrefab,引用了一张已有的图。打包一看------只多了一个 6 KB 的新 bundle,其他全没动。

我一开始还挺高兴:"看吧,级联没那么可怕。"

后来才知道,我高兴早了。这个 prefab 是个"叶子"------没人依赖它,所以它改不改变不影响任何人。

实验二:改星语者的 Spine 引用

这次我学聪明了,去改了一个"枢纽"------星语者.asset 的 Spine 字段,把它指向一个新的 prefab。

结果还是只有 2 个 bundle 变了,没有几百个。

我又懵了:明明改了枢纽,怎么还没级联?

实验三:新增一个 1x1 贴图

最后,我换了个做法:新建一个文件夹 TestNewShared,里面放了个 1x1 的 Square.png,然后让星语者引用它。

这次,397 个 bundle 重命名了。

为什么前两次不行,这次行了?

我把三个实验摆在一起,终于看懂了差异:

实验 关键差异 结果
TestPrefab 新 bundle 是叶子,没人依赖它 0 级联
改 spine 新 prefab 落进了已有 bundle 2 个变
Square.png 新贴图落进了全新 bundle,且被枢纽引用 397 个变

真相浮出水面:触发级联的开关,是"有没有新的内部名进入别人的依赖闭包"。

前两次都没有新内部名诞生(一次是叶子没人理,一次是资源并进了老 bundle),所以不级联。第三次,一个全新的 bundle 凭空出现,还钻进了枢纽的依赖闭包,于是一发不可收拾。


第四层:高扇出,把一条边放大成 397 次改名

为什么一个贴图能炸掉 397 个包?答案是"高扇出"。

我把 星语者 所在的 bundle(叫 OtherData)的依赖关系扒了出来,画成图:

scss 复制代码
              ┌─ 第1层 17 个(Data×11, Prefab×4, ...)
              │
[OtherData] ──┼─ 第2层 54 个
 (闭包多了    │
 一个名字)    ├─ 第3层 138 个  ← 爆炸点
              │
              ├─ 第4层 73 个
              ├─ 第5层 20 个
              ├─ 第6层 82 个
              └─ 第7层 13 个

OtherData 被 380 个 bundle 传递依赖,最深 7 层。

这意味着什么?意味着 OtherData 的依赖闭包里,装着它自己依赖的所有东西。当 OtherData 多依赖了一个新 bundle(那个 1x1 贴图),这个新名字就同时"空降"进了 380 个 bundle 的闭包------因为它们全都传递地依赖 OtherData。

于是:

markdown 复制代码
OtherData 闭包 += {新贴图}
     ↓ 传递
380 个 bundle 闭包 += {新贴图}
     ↓
380 个哈希全变 = 380 个重命名

一条边,被"高扇出"这个乘法器,放大成了几百次改名。

这还没完。我顺着依赖图往上摸,又发现了一个更隐蔽的东西------环。


第五层:依赖图里藏着一个环

OtherData 依赖 ArtAsset(因为角色定义引用了立绘、头像),而 ArtAsset 里又有 bundle 反过来依赖 OtherData。这就构成了一个环:

css 复制代码
[OtherData] ──依赖──▶ [ArtAsset]
     ▲                    │
     └────────依赖────────┘

我实测:OtherData 依赖 26 个 ArtAsset bundle,ArtAsset 里有 13 个反过来依赖 OtherData,其中 2 个构成真正的环。

环的存在,让依赖图从"树"变成了"网"。树的话,一个变化只沿一条链传播;网的话,变化会在网里来回扩散,闭包迅速膨胀到几百个名字,级联就变成"几乎全量"。


第六层:那 NonRecursive 开关呢?它是不是帮凶?

这里必须给 NonRecursiveDependencyCalculation(非递归依赖计算)开关正个名。

它不制造环,也不制造级联。它只决定一件事:bundle 要不要把依赖数据烤进自己体内。

  • 递归模式(关):bundle 会把间接依赖数据递归内联。遇到环,就是灾难------A 内联 B,B 内联 A,数据被重复烤进 N 个包,一个包能被撑到 10 MB。级联是"内容级"的:下游一改,所有内联它的包字节全变,又大又必须重下。
  • 非递归模式(开):bundle 不内联,只记"我依赖谁"。环就只是图里一圈边,安全了。但代价是,级联变成了"名字级"的------内容没变,只是名字跟着闭包变了。

所以这个开关,是把你从"内容爆炸"的火坑里救了出来,但没救出"名字级级联"这个水坑。我们这次踩的,就是水坑。


第七层:报告还冤枉了一个人

回到开头那份调查文档。它当时把 392 个 bundle 爆炸的锅,扣在了"新增 Spine 立绘"头上,还煞有介事地画了条"因果链 A"。

但我用数据重新比对了:Spine 立绘造成的是内容变化 (它确实改了 OtherData 等几个包的内容),真正引爆 296 个"字节不变重命名"的,是S3 赛季房间那一坨新资源------6 个新 bundle 一起钻进了 296 个 bundle 的闭包。

报告把"相关性"当成了"因果性":它看到 385 个包都依赖 OtherData,就以为 OtherData 的改动导致了它们进 patch。实际上它们进 patch,是因为 S3 赛季的 6 个新 bundle 钻进了它们的闭包。

这个教训也值得记下:分析热更爆炸,别只看"谁被依赖得多",要看"到底是谁的闭包名字集合变了"。


总结:把整条链串起来

这次调查,最终把一条完整的因果链闭环了:

复制代码
改内容 / 新增资源
   ↓
(新增才会)诞生新 bundle → 新内部名进入闭包
   ↓
高扇出 hub(如 OtherData,被 380 个包传递依赖)
   ↓
一个新名字顺着闭包传染给 380 个依赖者
   ↓
380 个哈希全变 = 字节不变却全部重命名
   ↓
玩家白白重下几百 MB

三个核心结论:

  1. 改内容不传染,新增资源才传染。 日常调数值、换贴图、改 prefab 摆放,都只影响自己一个包,安全。
  2. 级联规模 = 被引用 hub 的传递依赖者数。 你选多大的 hub 当引用点,级联就有多大。
  3. 要根治,只有一条路:fork SBP,把哈希口径从"传递闭包名字集合"改成"自身内容 + 直接依赖"。这样环再密、扇出再大,一个新增资源也只影响直接依赖它的那一个包。

一点后话

写这篇东西,是希望更多做资源、做出包、做热更的同学能绕开这个坑。我们为了查清它,翻源码、做实验、扒数据,前后折腾了好几天。但值得------因为这种"玩家白下几百 MB"的浪费,平时悄无声息,只有在你蹲下来一个个 bundle 对哈希的时候,才会露馅。

如果你们项目也用 Addressables + 非递归构建,建议拿我们的排查脚本(思路已经写进内部文档了)跑一次自己最近的热更。大概率,你也会在 patch 里找到一堆"字节没变、只改了名"的冤种 bundle。

而它们,本不该出现在玩家的下载列表里。

相关推荐
Behavior1 天前
Unity 手游动态更换 App 图标 — Android 与 iOS 双端技术方案
c#·unity3d·游戏开发
SmalBox1 天前
03-03-架构篇-Runtime加载系统架构
unity3d·游戏开发
SmalBox1 天前
03-02-架构篇-Editor打包系统架构
unity3d·游戏开发
Yeah1271 天前
当 LLM 进入游戏玩法:从收权到放权
游戏开发
Behavior1 天前
Unity 手游 iOS Deep Link 唤醒全流程:从 URL Scheme / Universal Links 到 C# 层参数投递
c#·unity3d·游戏开发
SmalBox1 天前
03-01-架构篇-整体架构总览
unity3d·游戏开发
RobinDevNotes1 天前
用 godot-rust 给 Godot 写 Rust 扩展
rust·游戏开发
SmalBox1 天前
02-08-原理篇-异步加载与性能优化
unity3d·游戏开发
字节暗面1 天前
Unity AssetBundle 热更新安全排查:从 CDN 清单到本地缓存
unity3d·图片资源