Unity 热更资源检出与校验:一次三周迭代的完整记录(catalog 驱动 / 快路径 / 原子提交 / 拷贝与下载分离)

封面图

项目背景:Unity 2022.3.62f2 手机 Roguelike。
代码热更走 HybridCLR(Assembly-CSharp.dll 作为热更 DLL 下发),资源热更走 Addressables。
启动器 Launcher 位于 AOT 程序集(Launcher.asmdef),在热更 DLL 加载之前运行——本文讲的就是这一段"上不着村下不着店"的资源同步链路。


一、这段流程要解决什么问题

手游热更最容易被低估的部分,不是"下载文件",而是判断该下载什么、什么时候算成功、中断了怎么办

我们的启动资源同步要同时面对三种来源:

来源 说明 典型体积
StreamingAssets 随包基线资源,首装包内就有 大(绝大多数 bundle)
hotfix 远端 热更包上传的增量 bundle / 代码包 小(通常几 MB)
baseAPKBundleUrl 远端 远端基线来源,仅当包内没有基线时使用 中~大

新用户首次启动时,需要补齐的 bundle 里绝大多数是包内基线拷贝StreamingAssetspersistentDataPath),真正的远程下载通常只占少量字节。

这就带来了三个必须回答的问题:

  1. 每次启动都要全量扫一遍 bundle 吗? —— 不能,几百上千个文件算 MD5 会拖垮冷启动。于是有了"快路径"。
  2. 中途杀进程 / 断网 / 崩溃,本地会留下什么? —— 于是有了 .download 草稿、Asset_Temp 临时目录和"catalog 即提交点"的原子提交。
  3. UI 上写"资源下载中,速度 x MB/s",但实际在做本地拷贝,算不算骗用户? —— 于是在第三周,我们把"拷贝"从"下载"里拆了出去。

二、四条核心设计原则

这三周的迭代,最后收敛成四条原则。后面所有细节都是这四条原则的推论。

原则 1:以最终 catalog 为唯一权威

不再"先下载整套 baseline 再叠加热更",而是先确定本轮的 targetCatalog,再用 catalog 引用的 bundle 集合反推缺失资源。

hasHotfix = bundleVersion != "-1" && !string.IsNullOrEmpty(bundleUrl)

hasHotfix == true  → targetCatalog = hotfix catalog = {bundleUrl}/catalog_1.bin
hasHotfix == false → BundleVersion = "0",targetCatalog = baseAPK catalog

manifest 负责归属,catalog 负责引用:manifest 只登记 bundle/代码热更包的归属和 md5,不管 catalog 自己的 md5;catalog 的一致性通过文件 MD5 比较来判定——快路径比的是 catalog 文件的 MD5,而不是版本号字符串。

一个容易漏掉的点:代码热更包 hotfix 不是 Addressables catalog 条目,由 LoadHotfixCodeStep 直接从 Asset/hotfix 加载。所以快路径只看 catalog 是不够的,必须同时校验本地代码包:

  • 有热更 → 对照 hotfix manifest 中 hotfix 的 md5

  • 无热更 → 对照 baseAPK manifest 中 hotfix 的 md5

这一条是迭代中"踩"出来的:纯代码热更会改变 manifest 里 hotfix 的 md5,即使 catalog 完全没变。早期版本因此误判命中快路径,代码更新没生效。对应提交:热更资源检出与校验流程:加入热更文件校验,不会跳过检测(8-21)。

原则 2:catalog 落地 = 本次更新成功(原子提交)

提交顺序:
1. 移动所有 Asset_Temp/*.bundle 和 Asset_Temp/hotfix 到 Asset/
2. 移动 manifest
3. 守卫:确认所有计划中的 bundle 在 Asset/ 都已就位,缺任何一个则中止提交
4. 提交点:catalog 最后落地(Asset_Temp/catalog_1.bin → Asset/catalog_1.bin)
5. 版本号紧随 catalog 落地写入 SaveLastBundleVersion()
6. 删除 Asset_Temp/

因为 catalog 是最后落地的,catalog 之前中断 → 快路径必然 miss → 下次启动重新收敛;catalog 之后中断 → 只剩删临时目录的清理动作。整个流程天然幂等,不需要额外的"中断标记"。

原则 3:信任上一次成功提交,能不算就不算

快路径命中条件:

Asset/catalog_1.bin == targetCatalog
且(无 hotfix,或 Asset/hotfix 的 md5 == hotfix manifest["hotfix"].md5)
→ SkipSyncSteps = true
→ ValidateCatalogStep / DownloadHotfixStep 直接 return
→ 不扫 bundle、不解析 catalog

注意这里是逐字节比较 catalog 文件,而不是比版本号——版本号可能因为中断而没写成,字节不会骗人。

原则 4:做减法,而不是加机制

迭代过程中我们删掉了两个看起来很"稳"的机制:

  • FileMd5Dict cookie 机制:原本把 MD5 结果缓存起来避免重复计算。问题是它既带来崩溃窗口风险,又无法检测文件损坏。最终改为对本地文件计算真实 md5。

  • 中断标记机制(Rebuild / Download marker):改为依赖 PrepareTemporaryDirectory() 清理残留 + catalog 提交点来保证一致性。

少一个需要持久化的状态,就少一类"状态和现实不一致"的 bug。


三、迭代时间线:关键节点与事件

三周里这条链路经过 33 次提交,可以清楚看到三个阶段:架构落地 → 定版收敛 → 职责分离

日期 关键提交 阶段
08-19 15:59 热更资源检出与校验流程:架构文档 ① 设计
08-19 17:12 落地实现和第一轮重构
08-19 17:44 兼容性校验
08-19 18:21 保证 CommitTempToAsset原子性,可自愈、可中断
08-19 18:41 打包:添加选项跳过 bundle 拷贝入 StreamingAssets
08-20 09:28 快路径实现 ② 收敛
08-20 09:57 落实 bundle 文件的存在去重下载,不用持久化的方法
08-20 12:00 基线 bundle 生成后生成 manifest
08-20 12:22 基线 bundle 生成后压缩 catalog.bin
08-20 14:31 基线 bundle 生成后拷贝热更代码文件
08-20 16:43 回退旧版本使用快路径,跳过后续多余步骤
08-20 17:37 修复 hotfix 代码热更拷贝、catalog_1.hash 拷贝
08-21 11:03 加入热更文件校验,不会跳过检测 ② 加固
08-21 14:39 补全错误码
08-21 14:40 hotfix 解包工具
08-21 15:20 热更本地化
08-24 09:43 热更过程优化:去除多余的语义文件 ③ 定版
08-24 11:27 中途断点,可以复用上次下载过的 bundle 包
08-24 12:09 拷贝时候展示拷贝的提示,不显示下载
08-24 14:42 热更下载:提示和日志优化
09-01 12:01 cherry-pick:CommitGuard 保护打 apk 时候的基线 ④ 工程化
09-01 12:02 加入基线固定按钮
09-01 12:03 cherry-pick:apk bundle 并发下载
09-09 11:35 切分拷贝基线文件,这样可以更精准地展示下载速度 ⑤ 职责分离
09-09 14:42 bugfix:修复弹窗重试点击无反应

两个"文档定版日期"正好落在时间线上:8-19(catalog 驱动首启同步)和 8-24(草稿转正 + 正式目录语义)。而 9-9 的方案文档,是在这次迭代的最后一天落地的。

三个最值得复盘的节点

节点一(8-19):把"下载 baseline 再叠热更"改成"catalog 驱动"。

旧思路是"两层":先铺一整套基线,再把热更盖上去。问题是基线体积巨大且大部分和本地重复,首启体验完全不可控。新思路反过来——先确定这一轮最终要用的 catalog,再反推差集。这个反转是整条链路的根,后面所有机制都建立在它之上。

节点二(8-24):区分"草稿"与"正式"。

这一版明确了目录语义:

Asset/<file>            正式文件
Asset/<file>.download   下载草稿(启动时统一清理)
Asset_Temp/             catalog / manifest 的临时提交区

bundle 和 hotfix 只认正式文件,半成品一律躺在 .download 里;md5 通过后才"转正"。这样"半截图文件混入正式目录"这一类问题从根上消失了。

节点三(9-9):拷贝不是下载。

即使流程已经稳定,UI 上依然存在一个"事实与展示不符"的问题——这在下一节展开。


四、最终架构:九步流水线

LauncherStartup 顺序 await 每一步,通过共享的 LauncherModel + HotfixAssetModel 传递状态;步骤的 gameObject.activeSelf == false 则跳过。

1 SplashStep
2 FetchDynamicConfigStep      拉配置,区分 profiles == null 的合法无热更 与 profile 漏配
3 ForceUpdateStep
4 CheckHotfixManifestStep     决定 hasHotfix / baseSource / targetCatalog;执行快路径
5 ValidateCatalogStep         获取必要 catalog/manifest,做归属校验,产出下载/拷贝计划
6 CopyBaselineStep            【9-9 新增】只处理 StreamingAssets 计划 → 完成后原地移除
7 DownloadHotfixStep          只跑剩余远程下载项 + catalog 刷新/提交尾巴
8 LoadHotfixCodeStep
9 InvokeEntryStep

整条启动链路的决策全貌:

这张流水线不是"大概齐",而是场景 YAML 里手写序列化的——每个步骤是父节点 LauncherStartup 下的独立子 GameObject,执行顺序由 LoadingSteps 数组父 Transform 的 m_Children 两处引用共同决定。

所以新增一个步骤,必须在两处同时插到正确位置。落地方案里给出的自查清单是:

Splash → FetchDynamic → ForceUpdate → CheckHotfixManifest → ValidateCatalog
       → CopyBaseline → Download → LoadHotfixCode → InvokeEntry

最终场景中 LoadingSteps 确实是 9 个元素,CopyBaselineStep(fileID 1333208537)位于第 6 位,夹在 ValidateCatalogStep1256645833)与 DownloadHotfixStep527452395)之间,且 m_IsActive: 1方案文档里写的接线要求,被一比一落地了。

各步职责

步骤 职责
FetchDynamicConfigStep 拉动态配置,区分 profiles == null 的合法无热更与 profile 漏配错误,选出 CurrentSelected.Asset
CheckHotfixManifestStep 决定有没有热更,选出 targetCatalogbaseSource,处理 buildID 变化;快路径需同时校验 catalog 与代码热更包;清理 *.download 残留
ValidateCatalogStep 快路径未命中时获取必要 catalog/manifest,做归属校验,把 catalog bundle 和 hotfix 代码包一起生成计划
CopyBaselineStep 从计划表筛出 StreamingAssets 项并发拷贝,成功后原地移除这些项
DownloadHotfixStep 按计划下载远程 bundle,校验 md5,提交 Asset_Temp/Asset/
LoadHotfixCodeStep 只依赖已经提交完成的 Asset/hotfixAsset/catalog_1.bin 与资源缓存

一个容易被忽略的工程决定:编辑器里全部跳过

四个同步相关步骤(CheckHotfixManifestStep / ValidateCatalogStep / CopyBaselineStep / DownloadHotfixStep)都带 #if UNITY_EDITOR 短路,编辑器下直接完成。

好处是在编辑器里跑游戏时不会被资源同步卡住,热更 DLL 可以直接调试。代价是:资源同步只能在真机上验证。这一点后来被明确写进了 9-9 的验证清单——“编辑器跳过同步,故必须真机”。


五、关键机制详解

5.1 归属校验:bundle 到底该从哪儿来

对 catalog 引用到的每个 bundle,必须能在 manifest 里找到归属:

hotfix catalog 引用的每个 bundle
  → 必须存在于 hotfix manifest 或 baseAPK manifest
  → 同名时 hotfix 优先

baseAPK catalog 引用的每个 bundle
  → 必须存在于 baseAPK manifest

这里有个容易踩的坑:随包 bundle(InternalBundleNameMode = RuntimePath)的校验权威源,是包内基线 catalog 引用的集合,而不是 baseAPK manifest。实现上由 LoadPackagedLocalBundleSet()StreamingAssets/Asset/catalog_1.bin 提取 RuntimePath bundle 集合作为权威源。

边界情况:若 baseSource = baseAPKBundleUrl(无包内基线),返回空集合,所有 RuntimePath bundle 都判定为 missing。

5.2 失败分类:这是全文最重要的一张表

"出错"并不都是一回事。把失败分类,直接决定了能不能回退——能回退的错误必须能自愈,不能回退的错误必须尽早炸出来

失败类型 典型场景 处理策略
配置 / 产物错误 manifest JSON 解析失败、catalog 解析失败、catalog 格式不符预期、profiles != null 但 profile 匹配不到有效 Asset 明确抛出,禁止回退。目的是上线前尽早暴露配置/打包/上传产物错误
bundle 归属失败 catalog 引用了 manifest 未登记的 bundle hotfix 场景:允许回退(旧 Asset 或 baseAPK);baseAPK 作为 targetCatalog 时:硬失败
同步失败 catalog/manifest 获取失败、bundle 下载/拷贝失败、MD5 不一致、Asset_Temp 提交失败 必须重试或退出,不能用旧 Asset 进游戏

配置错误的第三行尤其值得说:profiles != null 但当前 profile 匹配不到,或 profile.asset == null——这可能是配置错误,也可能是该平台确实不热更。为了避免灰度发布时某个小众平台漏配导致全员无法启动,我们选择 fallback 到包内资源,同时打告警日志和埋点供运维监控。

设计取舍:阻断启动的代价 > 配置错误暴露的代价,所以这里宁可 fallback;但 fallback 必须"吵闹"——日志 + 埋点,测试阶段就能发现。

5.3 回退路径

hotfix 归属失败时的回退只处理"资源闭包问题":

本地 Asset 可用
→ RevertHotfixVersion(),BundleVersion = GetLastBundleVersion(),使用旧 Asset 进入

本地 Asset 不可用
→ 切换 baseAPK 回退模式:BundleVersion = "0",hasHotfix = false
→ targetCatalog = baseAPK catalog,只使用 baseAPK manifest
→ 成功同步后 SaveLastBundleVersion()

"本地 Asset 可用"的判定刻意做得很轻:

Asset/catalog_1.bin 存在
且 Asset 中记录的 installSignature == GetCurrentInstallSignature()

不扫所有 bundle,信任上一次成功提交——这正是原则 3 的直接应用。

另一个细节:SwitchToBaseAPKMode() 会递归再跑一次 Validate()。因为此时 HasHotfix 已经被置为 false,二次失败会直接落到硬失败分支——递归深度天然被限制为 1,不会出现无限回退。

5.4 APK 变更:签名变化就整体重来

public static string GetCurrentInstallSignature()
{
    return $"{Application.version}|{Application.buildGUID}";
}

启动时比较当前安装签名与 Asset/ 中记录的签名:

签名一致 → 允许复用现有 Asset/
签名不同 → 整体删除 persistentDataPath/Asset/,从当前基线来源重新构建

于是旧 bundle 不需要在每次热更成功后立刻清理——缓存回收只在 APK buildID 变化时通过整体删除完成。这砍掉了一整套"增量清理"的复杂度。

5.5 版本号与提交点绑定

hotfix 成功            → BundleVersion = 真实 bundleVersion → SaveLastBundleVersion()
进入无热更/基线模式成功 → BundleVersion = "0"              → SaveLastBundleVersion()
hotfix 归属失败且旧 Asset 可用 → RevertHotfixVersion(),不保存坏版本

版本号与 catalog 提交点严格绑定,并且快路径命中处也会校正版本号,专门兜住"catalog 已提交但版本号未写入"的中断窗口。

5.6 报错方式本身也是设计

错误码不通过自定义异常类型传递,而是塞进 Exception.Data

throw new InvalidDataException(...)
    .SetLauncherError(LauncherErrorCode.DownloadedFileMd5Mismatch, plan.FileName);
// 写入 Exception.Data 的 launcherErrorCode / launcherErrorArguments 两个键

好处是全链路只需要 try/catch(Exception) 就能拿到结构化错误码 + 参数,不需要为每种失败定义一个异常类。

全局兜底 LauncherStartup.ShowFatalError 拿错误码去查本地化文案表,并按其中的 IsNetwork 标记决定弹窗形态:

IsNetwork == true  → 【重试 / 退出】,重试 = 整体重载 Launcher 场景
非网络错误         → 只有【退出】,且弹窗不可关闭

一个值得记住的细节:20 个错误码里只有 CatalogFetchFailed 被标记为网络类。这个标记直接决定玩家有没有"重试"这个选项——标错一次,玩家就会在一个纯本地错误上反复点重试。

重试本身是用户点击驱动、无次数上限、无退避while(true) + 弹窗,网络异常判定集中在 IsNetworkException(覆盖 HttpRequestException / TaskCanceledException / WebException / TimeoutException)。

这里也有取舍:移动端"自动重试 N 次再弹窗"会让玩家干等,把决定权交给玩家更符合直觉——代价是失败路径完全依赖用户操作。

5.7 一些具体的常量

传输并发上限 MaxConcurrency = 5
流式下载 / 写盘缓冲区 1 MB
UI 采样间隔 100 ms(UniRx Observable.Interval
文案刷新节流 每 10 个 tick ≈ 1 s
HTTP 超时 30 s
步骤收尾停顿 await Task.Delay(200)
进度权重(Validate 步骤) 0.1 → 0.3 → 0.5 → 0.6 → 0.9 → 1.0

拷贝与下载各持一个 BundleTransferExecutor 实例和各自的字节计数(工作线程 Interlocked.Add、主线程 Interlocked.Read),互不污染——所以"拷贝进度"和"下载速度"天然不会串台。

5.8 已知取舍:MD5 是整份读进内存的

诚实记一笔:当前 per- file MD5 用 File.ReadAllBytes 全量读入再哈希。对小 bundle 无感,但几十 MB 的大 bundle 会造成一次明显的内存尖峰。

Utils 里其实已经有一个流式的 CalculateFileStreamMD5,只是这条链路还没用上——属于"知道该优化、但优先级还没排到"的账。

还有两点边界写在这里,免得被误解:

  • 校验算法只有 MD5:没有 CRC,也没有 size 正确性校验。size 只用于总量统计、UI 显示和移动网络流量提示,不参与正确性判定。

  • 转正是"先删目标再 Move",没有 .bak 备份:所以单文件级别没有回滚,回滚的粒度是"整目录清缓存"(安装签名变化时)。


六、最后一公里:拷贝不是下载

流程稳定之后,问题从"能不能跑通"变成了"跑通的样子对不对"。

9-9 的方案文档开头列了三个用户可见的问题:

  1. 展示与事实不符:提示长期停在「资源下载中:…速度 x」,而多数时间实际在做本地拷贝;下载总量还把拷贝字节也算进去了。
  2. 互相饿死:拷贝与远程下载共用一把 SemaphoreSlim(5)。拷贝占满并发槽时,远程下载被饿着,看不到真实下载速度。
  3. 流量口径误导:蜂窝网络下的「需下载 N MB」确认弹窗,把不耗流量的本地拷贝也算了进去。

根因不是性能,是职责

根因是职责混淆:基线拷贝(StreamingAssets → 本地)与网络下载(远程 → 本地)是两种不同语义的工作,却由一个"下载"步骤混排执行。

这个判断决定了方案走向——不是去优化调度,而是拆步骤

拍板记录

方案评审时把 11 个决策点全部拍板留档,几个关键的:

# 决策点 结论
1 编排方式 严格串行:先跑完所有拷贝,再统一并行下载
2 抽象程度 独立 CopyBaselineStep 步骤,而非下载步骤内部两阶段或纯工具类
3 职责边界 严格分离:下载步骤遇到非远程项(StreamingAssets)即报错,不做兜底
4 共享执行机制 抽共享执行器 BundleTransferExecutor,拷贝/下载复用同一套 per-file 流程
5 数据移交协议 原地移除:拷贝完成后 DownloadPlans.RemoveAll(StreamingAssets),下载步骤仍读同一张表
9 失败语义 拷贝失败无网络重试,直接抛 → 全局兜底(本地 IO 错重试没意义)

数据移交:同一张表,原地移除

ValidateCatalogStep   写入单张合并扁平表 HotfixModel.DownloadPlans
                      (拷贝项 + 下载项 + hotfix 代码项混排)——产出端不改

CopyBaselineStep      筛出 bundleSourceType == StreamingAssets 的项执行拷贝
                      全部成功后 RemoveAll 移除这些项 ←── 这就是给下载步骤的"交接"
                      任一项失败则抛,表保持原样,下次启动幂等重来

DownloadHotfixStep    消费同一张表(此时只剩远程项)
                      CommitTempToAsset 的存在性校验只遍历下载项

因为没有新增共享字段、没有中间状态,跨步骤交接的"协议"就是一次 RemoveAll。这也让蜂窝弹窗自动正确:表里只剩远程项,总量天然等于网络字节,纯拷贝场景根本不会弹。

防呆:允许来源白名单

拆步骤带来一个新风险:万一场景没接好,CopyBaselineStep 没执行,下载步骤就会拿到残留的拷贝计划

于是 BundleTransferExecutor 接受一个 BundleSourceType[] allowed 参数,遇到白名单外的来源直接抛 NotSupportedException

  • 拷贝步骤 → 只允许 StreamingAssets

  • 下载步骤 → 只允许 HotfixRemote | BaseRemote

故意不兜底。如果接线漏了,真机上会立刻报"仅允许远程来源"的自检错——这不是 bug,是设计。落地方案的验证清单里甚至专门留了一条:

刻意不改场景跑一次:下载步骤对残留拷贝计划报"仅允许远程来源"自检错(验证严格分离生效)。

为什么不是其它做法

备选方案 结论
下载步骤内部两阶段拆分 改动最小,但拷贝仍归属"下载"步骤,职责抽象不彻底;曾作为草稿写入工作区,被最终方案取代并在落地第一步回退
独立纯类由下载步骤调用 不改场景、可保整步单调进度;但拷贝仍由下载步骤编排,职责不彻底
新增显式共享字段交接 更显式,但要给模型加字段、下载步骤改读它;原地移除复用现有表,改动面最小且无残留状态

七、复盘:几条可复用的经验

1. 先定"什么是权威",再谈"怎么同步"。
整条链路的复杂度,八成来自"catalog / manifest / 版本号 / 本地目录"谁说了算没定清楚。一旦确立"catalog 为唯一权威、manifest 只负责归属、版本号只是展示态",很多分支自然消失了。

2. 提交点必须是单一文件。
把 catalog 定为提交点,让"中断"这件事从"需要状态机处理"退化成"下次启动重新收敛"。能用幂等解决的,不要用状态机解决。

3. 把失败分类,而不是统一 catch。
配置/产物错误必须炸、归属失败可条件回退、同步失败必须重试——三类失败三种策略。混在一起的结果通常是:该炸的没炸(线上才发现配置错),不该回退的回退了(玩家拿着坏资源进游戏)。

4. 主动做减法。
删掉 FileMd5Dict cookie 缓存和中断标记机制,是这次迭代里最有价值的两次改动。每一个需要持久化的中间状态,都是一类潜在的不一致 bug。

5. 展示口径不正确,本质上是领域模型没拆干净。
“拷贝"和"下载"混在一个步骤里,代码上是 MaxConcurrency 共享一个信号量,产品上是"速度只在拷贝时不动”。拆完步骤,UI 文案、流量弹窗、重试语义全都自动正确了——不需要为它们各写一套特判。

6. 给"接线类"改动配一个自检。
Unity 场景是二进制/半文本资产,逻辑拆分后最容易漏的就是"新步骤没接到场景里"。用一个允许来源白名单把这类错误从"线上诡异现象"变成"真机启动即报错",收益极高。

7. 文档跟着代码走,而且是带日期的。
8-198-249-9 三份文档各自记录了当时的形态和被否决的备选方案。回头看,被否决的方案比被采纳的方案更有信息量——它解释了"为什么不那样做"。


八、小结

这条"热更资源检出与校验"链路,最终形态是:

  • 检出:catalog 驱动,用最终要用的 catalog 反推差集,而不是先铺基线再叠加;

  • 校验:manifest 归属校验 + 本地文件真实 MD5 + 代码热更包单独校验,快路径只比字节;

  • 提交Asset_Temp 临时区 + .download 草稿,catalog 作为唯一提交点,天然幂等可中断;

  • 回退:失败三分类,能自愈的自愈,不能自愈的尽早炸;

  • 编排:九步流水线,拷贝与下载严格分离,靠共享执行器消除重复、靠白名单自检防止接线漏配。

三周迭代下来最大的体会是:热更的难点从来不在"下载"这两个字里,而在"权威、提交点、失败分类"这三件事上。 把这三件事定清楚,代码会自己变简单。


posted @ 2026-09-15 12:34  鑫鑫哥Adam  阅读(10)  评论(0)    收藏  举报