目录一、这段流程要解决什么问题二、四条核心设计原则原则 1:以最终 catalog 为唯一权威原则 2:catalog 落地 = 本次更新成功(原子提交)原则 3:信任上一次成功提交,能不算就不算原则 4:做减法,而不是加机制三、迭代时间线:关键节点与事件三个最值得复盘的节点四、最终架构:九步流水线各步职责一个容易被忽略的工程决定:编辑器里全部跳过五、关键机制详解5.1 归属校验:bundle 到底该从哪儿来5.2 失败分类:这是全文最重要的一张表5.3 回退路径5.4 APK 变更:签名变化就整体重来5.5 版本号与提交点绑定5.6 报错方式本身也是设计5.7 一些具体的常量5.8 已知取舍:MD5 是整份读进内存的六、最后一公里:拷贝不是下载根因不是性能,是职责拍板记录数据移交:同一张表,原地移除防呆:允许来源白名单为什么不是其它做法七、复盘:几条可复用的经验八、小结项目背景:Unity 2022.3.62f2 手机 Roguelike。代码热更走 HybridCLR(Assembly-CSharp.dll作为热更 DLL 下发),资源热更走 Addressables。启动器Launcher位于 AOT 程序集(Launcher.asmdef),在热更 DLL 加载之前运行——本文讲的就是这一段"上不着村下不着店"的资源同步链路。一、这段流程要解决什么问题手游热更最容易被低估的部分,不是"下载文件",而是判断该下载什么、什么时候算成功、中断了怎么办。我们的启动资源同步要同时面对三种来源:来源说明典型体积StreamingAssets随包基线资源,首装包内就有大(绝大多数 bundle)hotfix远端热更包上传的增量 bundle / 代码包小(通常几 MB)baseAPKBundleUrl远端远端基线来源,仅当包内没有基线时使用中~大新用户首次启动时,需要补齐的 bundle 里绝大多数是包内基线拷贝(StreamingAssets→persistentDataPath),真正的远程下载通常只占少量字节。这就带来了三个必须回答的问题:每次启动都要全量扫一遍 bundle 吗?—— 不能,几百上千个文件算 MD5 会拖垮冷启动。于是有了"快路径"。中途杀进程 / 断网 / 崩溃,本地会留下什么?—— 于是有了.download草稿、Asset_Temp临时目录和"catalog 即提交点"的原子提交。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 catalogmanifest 负责归属,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:做减法,而不是加机制迭代过程中我们删掉了两个看起来很"稳"的机制:FileMd5Dictcookie 机制:原本把 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:40hotfix 解包工具②08-21 15:20热更本地化②08-24 09:43热更过程优化:去除多余的语义文件③ 定版08-24 11:27中途断点,可以复用上次下载过的 bundle 包③08-24 12:09拷贝时候展示拷贝的提示,不显示下载③08-24 14:42热更下载:提示和日志优化③09-01 12:01cherry-pick:CommitGuard 保护打 apk 时候的基线④ 工程化09-01 12:02加入基线固定按钮④09-01 12:03cherry-pick:apk bundle 并发下载④09-09 11:35切分拷贝基线文件,这样可以更精准地展示下载速度⑤ 职责分离09-09 14:42bugfix:修复弹窗重试点击无反应⑤两个"文档定版日期"正好落在时间线上: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整条启动链路的决策全貌:
