架构解析 · 设计论证

RevHotUpdate · 架构解析

配套读法:《使用说明》讲怎么用(手把手),这份讲为什么这么做、边界在哪、失败怎么收场。
想先看"要不要做、怎么做"的整体论证,见《RevHotUpdate 技术方案》(含可行性评估、平台核实、存储选型)。
一句话:两个根、一份清单、一个版本目录、两处不可见钩子。

源设计起源 · 为什么要做这个模块

一句话 包已经发到玩家手上了,但资源还想能改 —— 热更新就是"不改包、也能把新资源送到玩家手里"的那条补丁链路。

本篇只讲为什么;怎么用见同目录《使用说明》。

0三句话速览(完整用法见《使用说明》)

启动(在加载任何业务资源之前)
 └─ await RevHotUpdate.InitializeAsync(config, onProgress)
      ├─ ① 装钩子(幂等)               → 框架的加载路径与映射表从此"可被热更改写"
      ├─ ② Android:首包落地             → 把 APK 里的内置包拷到持久化目录(幂等,大小一致跳过)
      ├─ ③ 拉远端清单 + 大版本校验        → @appVersion 不匹配 → 拒绝更新并回调强更
      ├─ ④ 版本比对(远端 vs 本地基线)    → 只下 hash 变化的包(差量)
      ├─ ⑤ 下载 → 校验 → 原子落地        → .part 半成品 → 尺寸/SHA-256 → 改名到版本目录
      ├─ ⑥ 打完成标记 → 原子切版本指针    → .stamp 先于 current.txt(顺序反了会加载到半成品)
      ├─ ⑦ 清理旧版本(保留 N 份)        → 回滚能力就来自这里
      └─ ⑧ 重装资源策略(ShutdownAll → Init)→ 重读热更映射表,路径钩子按新版本解析
之后照旧:RevResManager.Load*(业务代码零改动)

分层(谁负责什么):

Facade         RevHotUpdate          状态机 + 三个动作 + 安装(业务只碰这一层)
Core           配置 / 清单 / 版本 / 计划 / 结果 / 进度 / 错误 / 平台能力表(纯逻辑,可工程外断言)
Download       下载引擎(并发 / 重试 / 多源 / 续传)+ URL 拼装 + 源列表
Pipeline       存储管家(目录 / 原子落盘 / 首包落地)+ 校验(尺寸 + SHA-256)+ 版本比对
Integration    与框架的唯一接缝(两个钩子 + 重装资源策略)
Support        进 Play 复位内存状态(不动磁盘数据)
Editor         清单生成 + 自检 + 工具窗口

1文件职责表

运行时(18 个文件 / 3309 行)

文件职责关键点
Facade/RevHotUpdate.cs门面:Check / Update / Initialize / Install / Dump + 状态机所有失败都转成 RevHotUpdateResult,业务不会收到异常
Core/RevHotConfig.cs配置 + 校验 + 派生值(生效平台 / 大版本 / 本地根 / 源列表)Validate() 把"Android 却配了直读 StreamingAssets"这类配置错误在下载前拦下
Core/RevHotManifest.cs清单解析 / 序列化剥 BOM、兼容未知字段;坏行宁可失败,绝不"尽量解析"
Core/RevHotBundleInfo.cs清单条目(hash / 大小 / 依赖 / 标签 / unityHash / unityCrc)两个 hash 分工:sha256 用于比对与校验;unityHash/crc 给小游戏引擎用
Core/RevHotVersion.cs版本号比较(按段数字比,缺段当 0)字符串比较会把 1.2.0.10 判成小于 1.2.0.9
Core/RevHotPlan.cs差量计划(新增 / 变更 / 删除 / 映射表 / 总字节)三集合 + 待下载字节,UI 提示直接用
Core/RevHotResults.csRevHotCheckResult / RevHotUpdateResult检查结果里带内部流转字段(计划、清单原文)供更新阶段使用
Core/RevHotProgress.cs状态枚举 + 进度对象 + 聚合器(120ms 节流)速度做 50/50 平滑,避免数字乱跳
Core/RevHotError.cs错误码 + 错误对象 + 内部异常RevHotException 带 Retryable,决定"重试 / 换源 / 直接失败"
Core/RevHotPlatform.cs平台能力表(是否 WebGL / 有无本地文件 / 首包是否要落地)三个平台三条路,全部收敛在这一个文件
Download/RevHotDownloader.cs下载引擎三层失败结构(见第四章);换源前删半成品
Download/RevHotUrlBuilder.csURL 拼装清单不带资源版本;资源带资源版本
Download/RevHotSource.cs源列表(主源 + 备用源,带序号便于日志)小游戏只用一个域名(白名单名额有限)
Pipeline/RevHotStore.cs盘上布局 / 版本指针 / 原子落盘 / 首包落地 / 两个钩子的实现五条纪律都在这(见第三章第 3 节)
Pipeline/RevHotVerifier.cs校验 + SHA-256 计算分帧流式(每 8MB 让一帧);ComputeSha256 供编辑器生成清单
Pipeline/RevHotPlanner.cs版本比对只认 hash:大小相同但内容变了也必须重下
Integration/RevHotResBridge.cs装钩子 + 按序重装资源策略ShutdownAll → 切版本 → Init,绝不在包被使用时覆盖文件
Support/RevHotUpdateUnityHooks.cs进 Play 复位内存状态关 Domain Reload 时静态字段不清空,必须手动复位(磁盘数据不动)

编辑器(2 个文件 / 427 行)

文件职责关键点
Editor/RevHotManifestBuilder.cs扫产物 → 生成 RevHotManifest.txt / RevHotBuiltin.txt + 自检依赖与 CRC/hash 复用 Unity 的 .manifest 文本,不自己实现打包分析
Editor/RevHotUpdateWindow.cs工具窗口(平台 / 版本 / 生成 / 自检 / 打开目录)默认目录与版本号取框架打包配置,少填两个字段

2与框架的接缝:为什么是这两个钩子

结论

热更包不换框架的策略与加载器,只往框架里塞两个"默认为 null"的静态钩子:

钩子为什么需要它
RevABLoader.BundlePathResolver框架的加载根是编译期写死的(StreamingRoot,private static)——不改它,热更下来的包永远不会被读到
RevResBootstrap.ResMapOverride映射表在包体的 Resources 里(运行时只读)——不改它,"新增资源"永远加载不到
这两个钩子"业务侧怎么写" 自己接 CDN / 自研映射表时的完整代码、装配时机、5 个新手坑:见《RevHotUpdate 使用说明》§二 的 「这两个钩子怎么用(面向小白,含可照抄示例)」—— 本节只解释为什么必须有它们,那边解释怎么用。

为什么不自己写一个加载器(自研 IRevResPolicy + IRevResLoader)

因为包的生命周期绑在框架内部,外部拿不到:

用钩子只有 2 处、十几行,且两个平台形态(文件型 / URL 型)都只需要"换一个根"。

钩子的两条契约(写在框架侧注释里,也是断言守护的内容)

  1. 不设置时(默认 null)行为与从前逐字节一致 —— 对不装热更包的工程零影响;
  2. 返回 null / 空串 = "我不接这个包",框架走默认逻辑 —— 覆盖式降级的实现方式就是"该我接的返回路径,不该我接的返回 null"。

装配时序(错了会出问题)

Install()(设钩子,幂等)
   ↓
CheckAsync(读配置、装存储、读本地基线、拉远端清单、比对)
   ↓
UpdateAsync(下载 → 校验 → 落地 → .stamp → 切指针 → 清理旧版本)
   ↓
RevResBootstrap.Instance.ShutdownAll()   ← ① 先卸载:避免"替换正在被加载的包"
RevResBootstrap.Instance.Init()          ← ② 再初始化:重读热更映射表、按新版本解析路径

3核心机制(清单 / 差量 / 原子性 / 首包落地 / URL 模式 / 挂起与等待)

1. 清单:为什么是行式文本

方案体积解析成本依赖人可读
JSON + JsonUtility大中Unity 内置,但不支持字典,要写一堆 DTO一般
JSON + Newtonsoft大中第三方依赖(框架坚持零依赖)一般
行式文本(本实现)小低(`Split('')`)无高(和 ResMap.txt 同族,记事本能比一比)

清单里三类信息:

@appVersion|1.2.0        ← 大版本锚点:不匹配 → 拒绝更新(跨大版本必须重出包)
@resVersion|1.2.0.37     ← 资源版本:玩家看到的就是它
@resmap|ResMap.txt|hash|size          ← 映射表也是热更文件
@bundle|hero|hash|字节数|依赖|标签|unityHash|unityCrc

大版本锚定带来的简化:资源层不需要考虑"新资源配老代码"——那是"被禁止发生"的情况,于是清单不必支持兼容区间、灰度矩阵与多版本在线查询。

2. 差量:只认 hash

远端有、本地没有            → 新增
两边都有、hash 不同          → 变更(重下)
两边都有、hash 相同          → 跳过(不下、不校验、不落盘)
本地有、远端没有              → 记下来,切完版本随旧版本目录清理
映射表 hash 变了             → 必须下(否则新增资源加载不到)

为什么不看文件大小:大小相同但内容变了(改了图但压缩后恰好同长)必须重下,只比大小会直接把错版发给玩家。

为什么不做二进制差量:LZ4 包本身就是块压缩,改一张图会波及多个块,bsdiff 类方案收益不稳定;包级差量已经拿走绝大部分带宽收益。

3. 原子性:五条纪律(每条都在防一个真实故障)

纪律防什么
下载写 .part,校验通过才改名"半个包"被当成好包加载(随机崩溃 / 资源缺失)
版本目录全部就位才打 .stamp半新半旧(一部分包新、一部分旧)
current.txt 用"临时文件 + 替换"写断电/被杀进程写出半行文本 → 版本指针损坏
保留 N 个历史版本新版本有问题回不去(线上事故的第一道逃生门)
切换前先 ShutdownAllWindows 文件锁导致"替换失败"、以及"内存里还是旧包"

为什么不是"逐包直接覆盖":逐包覆盖看起来更简单,但会出现"新 A + 旧 B"的窗口期——如果 A 用到 B 的新资源,玩家就会在那一瞬间报错;而且一旦玩家在覆盖到一半退出,磁盘上就是一份自相矛盾的资源。版本目录 + 完成标记让"生效"变成一个原子动作。

4. 首包落地:Android 的物理现实

5. URL 模式:小游戏没有文件系统

文件型(Android / iOS / PC)URL 型(WebGL / 小游戏)
下载UnityWebRequest + DownloadHandlerFile 落盘不落盘:AB 由引擎按 URL 拉取并缓存
校验尺寸 + SHA-256(分帧流式)清单里的 unityHash / unityCrc 交给引擎
续传Range + .part不做(缓存由引擎管,失败重试整包)
版本本地版本目录URL 里的版本段就是目录
我们的活下载 + 校验 + 落地 + 切指针清单 + URL 拼装 + 版本记录 + 进度

也就是说:小游戏上"热更"= 把 URL 换成新版本的 URL;其余交给引擎。(清单里那两列 unityHash / unityCrc 就是为它准备的。)

6. 挂起与等待:一轮热更只允许跑一次

"一轮热更"是异步长任务(要过网络、要落盘),必须回答两个问题:

① 重复调用怎么办?(业务在两处都调了 InitializeAsync,或玩家手快点了两下)
→ 第二次调用不会跑第二遍:门面把"进行中的一轮"登记成挂起源(_pending),后来的调用直接等它 —— 两边拿到同一次更新的结果,不会下载两遍、不会把资源策略重装两次。

② 别处的代码想等它结束怎么办?(首场景业务要等热更完成才加载资源)
→ WaitReadyAsync():不发起新热更,只等"正在进行的那一轮";一轮结束时把结果回填给所有等待者。

回填是必须的动作

如果一轮跑完没人唤醒等待者,就是"静默卡死"——这类 bug 极难查。 门面把回填写在流程收尾处(含失败与异常路径),任何出口都会完成挂起源。

4下载与容错

三层结构(从里到外):

TryDownloadOnceAsync   一次尝试 = 断点续传下载 → 校验 → 原子提交
   · 半成品比目标还大 → 删掉重来(内容已经没救了)
   · 服务器不支持 Range(返回 200 而不是 206)→ 删掉 .part 并回退为整包重下
   · 校验失败 → 删掉 .part 并标记"可重试"(重试即从头下载)

DownloadOneAsync       一个文件 = 换源(外层)× 重试退避(内层)
   · 失败按 500ms → 1s → 2s → 4s 指数退避
   · 换源前删 .part:不同源内容不保证一致,拼接必然校验失败

DownloadPlanAsync      整批 = N 个工人从共享游标领任务
   · 任何失败 → 停止领新任务,等在跑的收尾后统一上抛(第一个错误最有诊断价值)

其它容错点:

点做法
清单拉取失败 / 内容为空保持当前版本(玩家照常玩旧版),可重试;换源也会试
磁盘预检下载前检查可用空间(多留 64MB),不足直接报 DiskFull,不留下半成品
取消RevCancellationToken:取消时立刻停下载(.part 保留,下次续传)
更新中途退出版本目录没有 .stamp、指针没动 → 下次启动重新算差量,已下好的包 hash 一致自然跳过
失败不破坏现状所有失败路径都不改 current.txt;Dump() 能看到当前状态与错误

5性能与内存

关注点做法
内存下载用 DownloadHandlerFile 直接落盘,几百 MB 的包也不会出现等量内存峰值
卡帧哈希校验分帧流式(1MB 缓冲、每累计 8MB 让出一帧)
启动开销清单一次请求(几十 KB);没有更新时不下载任何东西,只做一次比对
清单规模行式文本按行解析;几万行的表在启动时一次解析完(一次性的,不在热路径上)
进度节流进度回调 120ms 节流 + 速度平滑,不给 UI 与日志添负担
明确没做的优化(触发条件到了再说)表规模极大时的二进制紧凑清单、并行哈希、ulong 键的清单查找 —— 当前量级都不是瓶颈

6边界:明确不做的六件事

不做理由
代码热更另一条技术线(IL2CPP / 程序集拆分),iOS 审核风险最高;资源热更与它互不冲突,可以叠加
AB 加密 / 防篡改AB 天生可解包,加密只提高门槛,代价是体积、加载耗时与排查难度
二进制差量见第三章第 2 节
灰度 / 分渠道分流运营后台能力;本包只提供 Channel 目录段
按需下载(tag)阶段 4 能力;当前策略是"清单里有就下"
跳商店 / 整包下载 / 渠道分发发行层的事;框架只负责"识别大版本不匹配 + 一次回调"

7验证方式

层做了什么
编译工程外编译校验:用 UnityEngine 桩把"框架真实 RevABLoader + RevTask + 热更包全部源码"一起编译 —— 钩子契约(签名与调用方式)由真实代码交叉验证
纯逻辑37 项断言:清单解析(BOM / CRLF / 坏行拒绝 / 未知字段兼容 / 序列化往返)、差量计划(新增/变更/跳过/删除/映射表/空计划/无基线)、版本规则、URL 拼装、配置校验
真机(验收清单)① 首启落地;② 只下变化的包;③ 下载中断网 → 重连续传;④ 杀进程 → 下次续传;⑤ 改坏已下好的包 → 校验失败重下;⑥ 篡改清单 hash → VerifyFailed;⑦ 磁盘写满 → DiskFull 且不留半成品;⑧ 清单被删 → 保持旧版本可玩

真机验收的 8 项里,第 ⑤~⑧ 项是"故意搞破坏"——热更这类模块的价值就体现在这些场景下的表现,建议每次改完都跑一遍。

8与 YooAsset 的对照

维度YooAssetRevHotUpdate
定位独立资源框架(含收集器、4 种运行模式、报告系统)框架的补丁包:只补"远端下载 + 版本管理 + 路径重定向"
业务改造量加载 API 全换0 行(仍是 RevResManager.Load*)
运行模式EditorSimulate / Offline / Online / Host只做 Online(另加"首包兜底")
清单格式自研二进制清单行式文本(人可读、可 diff)
差量 / 加密 / 灰度包级差量、加密、灰度齐全只做包级差量
代码量数万行约 3700 行(含注释与编辑器工具)

结论:不是替代关系。已经用 Revolution 的工程想加一条热更链路,用它最省;要重做资源层、需要加密/灰度,再考虑 YooAsset。

9后续可加(不做也能用)

能力价值成本
按需下载(tag / 包名白名单)首包更小、试玩包体验更好清单加标签语义 + 业务侧"用到才下"的 API
清单签名防篡改升级(现在是信任清单 + hash 校验)服务端私钥签 + 客户端公钥验,约 40 行
灰度 / 分渠道小流量验证新版本资源主要是运营后台与 CDN 的事,客户端只需带 Channel
CDN 预热脚本大版本首发首屏更快编辑器工具加一个按钮(调云厂商 CLI)
ResMap 解析抽公共现在框架与热更包各一份解析代码抽个小工具类,两边共用

对应代码版本:运行时 Assets/Revolution.HotUpdate/Runtime/(18 个 .cs / 3309 行)· 编辑器 Assets/Revolution.HotUpdate/Editor/(2 个 .cs / 427 行)· 框架侧钩子 RevABLoader.BundlePathResolver 与 RevResBootstrap.ResMapOverride(各十几行,默认 null)。