源设计起源 · 为什么要做这个模块
- 没有它,包里的资源就是"编译期写死"的:框架的加载根是编译期写死的、映射表在包体的 Resources 里(运行时只读)。所以热更不去换框架的策略与加载器,而是在框架里塞两个默认为 null 的静态钩子(RevABLoader.BundlePathResolver / RevResBootstrap.ResMapOverride):不装钩子,热更下来的包永远读不到、"新增资源"也永远加载不到。
- 为什么不自己写一个加载器:因为包的生命周期绑在框架内部:RevResHandle 的 BundleLoader / BundleName / BundleAcquired 是 internal、BundleLoader 是具体类 RevABLoader,外部加载器收不到"这个包没人用了"的通知,只能反向扫缓存去猜存活集合 —— 那是语义退化,不是等价实现。
- 热更最怕两件事:半成品和版本指针损坏:所以下载先写 .part、校验通过才改名;版本目录全部就位才打 .stamp;current.txt 用"临时文件 + 替换"写;保留 N 个历史版本当回滚逃生门;切换前先 ShutdownAll。这套原子性就是不让玩家加载到"半新半旧"的资源。
- 换来了什么:业务改造量 0 行(仍然是 RevResManager.Load*);只下 hash 变化的包(差量);Android 首包自动落地;小游戏走 URL 模式;以及"一轮热更只允许跑一次"(重复调用直接等同一轮,避免静默卡死)。
本篇只讲为什么;怎么用见同目录《使用说明》。
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.cs | RevHotCheckResult / RevHotUpdateResult | 检查结果里带内部流转字段(计划、清单原文)供更新阶段使用 |
Core/RevHotProgress.cs | 状态枚举 + 进度对象 + 聚合器(120ms 节流) | 速度做 50/50 平滑,避免数字乱跳 |
Core/RevHotError.cs | 错误码 + 错误对象 + 内部异常 | RevHotException 带 Retryable,决定"重试 / 换源 / 直接失败" |
Core/RevHotPlatform.cs | 平台能力表(是否 WebGL / 有无本地文件 / 首包是否要落地) | 三个平台三条路,全部收敛在这一个文件 |
Download/RevHotDownloader.cs | 下载引擎 | 三层失败结构(见第四章);换源前删半成品 |
Download/RevHotUrlBuilder.cs | URL 拼装 | 清单不带资源版本;资源带资源版本 |
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 里(运行时只读)——不改它,"新增资源"永远加载不到 |
为什么不自己写一个加载器(自研 IRevResPolicy + IRevResLoader)
因为包的生命周期绑在框架内部,外部拿不到:
- 资源释放时,框架调的是
handle.BundleLoader.ReleaseBundle(handle.BundleName); - 而
RevResHandle.BundleLoader / BundleName / BundleAcquired三个字段是internal,且BundleLoader的类型是具体类RevABLoader; - 于是自研加载器无法被通知"这个包没人用了",只能反向扫描缓存去猜存活集合 —— 那是语义退化,不是等价实现(热更包会把"包什么时候能卸"这件事重新变成谜)。
用钩子只有 2 处、十几行,且两个平台形态(文件型 / URL 型)都只需要"换一个根"。
钩子的两条契约(写在框架侧注释里,也是断言守护的内容)
- 不设置时(默认
null)行为与从前逐字节一致 —— 对不装热更包的工程零影响; - 返回
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 个历史版本 | 新版本有问题回不去(线上事故的第一道逃生门) |
切换前先 ShutdownAll | Windows 文件锁导致"替换失败"、以及"内存里还是旧包" |
为什么不是"逐包直接覆盖":逐包覆盖看起来更简单,但会出现"新 A + 旧 B"的窗口期——如果 A 用到 B 的新资源,玩家就会在那一瞬间报错;而且一旦玩家在覆盖到一半退出,磁盘上就是一份自相矛盾的资源。版本目录 + 完成标记让"生效"变成一个原子动作。
4. 首包落地:Android 的物理现实
- 桌面 / iOS:
StreamingAssets是真实文件目录 →AssetBundle.LoadFromFile直接能读,不必落地; - Android:
streamingAssetsPath是jar:file:///…apk!/assets,不是文件路径;压缩过的 AB 用LoadFromFile读不了 → 必须先把内置包拷到persistentDataPath; - 落地是幂等的:目标已存在且大小一致就跳过(首启中断、二次启动都能续上);整包来自自家 APK,默认只比大小(
VerifyBuiltinCopy可打开哈希校验)。
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 的对照
| 维度 | YooAsset | RevHotUpdate |
|---|---|---|
| 定位 | 独立资源框架(含收集器、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)。