做过客户端分发的同学知道痛点:发一个新版本,用户要下载整个安装包重装。yyzTools 的安装包约 150MB(含 FFmpeg、OCR 模型等),每次更新都让用户重下不可接受。解法是分包 + 增量更新:把产物拆成 9 个独立版本号的子包,只下变更的。本文复盘这套发版工程:分包策略、版本模型、镜像分发、缓存不失效约束。适合做客户端分发、CI/CD、版本工程的读者参考。
一、为什么要分包
yyzTools 的产物构成(约 150MB):
- 主程序(C++ 编译的 exe)
- 前端页面(Vite 构建的 web 资源)
- 命令模块库(379 个 .zenmod)
- 第三方引擎(FFmpeg、ImageMagick、pdfcpu、Aria2、OpenSSL、7-Zip)
- OCR 模型(RapidOCR 的 ONNX 模型)
- 文件搜索(Everything)
- 动态壁纸(独立 exe)
如果打成一个大包,每次发版用户重下 150MB。实际发版经常只改了前端或某个模块,主程序和 FFmpeg 没动--让用户为一次前端 hotfix 重下 150MB 是浪费。
分包的收益很直接:只下有变化的子包,未变的沿用历史版本。一次只改前端的发版,用户只下几 MB 的前端包,其余 8 个子包不动。
二、版本模型:release 与子包版本解耦
yyzTools 的版本模型有两个层次,这是整套工程的关键:
2.1 release:发布身份号
release 字段(当前 1.0.3.1300)是对外发布身份:
- GitHub release tag 用它
- 安装包文件名用它(
yyzTools-setup-1.0.3.1300.exe) - 更新日志版本号用它
每次发版必递增 release。用户看到的「版本号」就是这个。
2.2 子包版本:各自独立
9 个子包各有独立版本号,没改不升:
{ "release": "1.0.3.1300", "main": "1.0.3.1300", // 主程序包,本次变更 "web": "1.0.3.1300", // 前端,本次变更 "modules": "1.0.3.1300", // 命令模块,本次变更 "yyztools": "1.0.3.1300", "ffmpeg": "1.0.0.1000", // 未变,沿用 "rapidocr": "1.0.0.1000", // 未变 "tools": "1.0.0.1000", // 未变(第三方引擎集合) "everything": "1.0.0.1000", // 未变 "wallpaper": "1.0.0.1000" // 未变 }
关键设计:main 只是主程序子包的版本,不再当发布号。以前 main 和 release 耦合,导致每次发版都要 bump main(哪怕主程序没改)来对齐 release。解耦后,main 没改就不升,release 独立递增。
这次发版(1.0.3.1300)实际变更的只有 4 个子包(main/web/modules/yyztools),其余 5 个未变。用户只下这 4 个的增量。
三、update.json 的生成:url 派生自各包版本号
update.json 是客户端检查更新的清单。关键约束:每个子包的 url 派生自「该子包自己的版本号」,不是统一绑 release。
# 变更包(版本 = release): https://.../v1.0.3.1300/main.7z https://.../v1.0.3.1300/web.7z # 未变包(版本 = 历史版本): https://.../v1.0.0.1000/ffmpeg.7z https://.../v1.0.0.1000/rapidocr.7z
这个设计的妙处:未变包的 url 指向历史 release 目录,不动。镜像缓存命中,下载快。如果统一绑 release,未变包也要挪到新目录,缓存全失效。
由 gen_update_json.ps1 负责生成,每个包的 url 拼成 v<该包版本号>/<文件名>。
四、打包与上传:未变包不重传
build_app.bat 的打包逻辑:
- 遍历 9 个子包,对每个判断版本号是否 = 本次 release
- 变更包(版本号 = release):打到
v<release>/目录,重新打包上传 - 未变包(版本号 < release):命中历史目录直接
[SKIP],不重打
release_app.bat 的上传逻辑:
- 只遍历
v<release>/里实际存在的.7z(即变更包)+ installer - 未变包沿用历史 release 的 asset,不重传
- 用
gh release view预检查,release 已存在直接[SKIP],绝不覆盖
「绝不覆盖」是硬约束:GitHub release asset 一旦覆盖,CDN/镜像缓存会失效,全球用户重新下载,流量浪费且变慢。所以发版是「只加不改」--新版本新建 release,老版本的 asset 保持不动供历史引用。
五、两条硬约束(违反则 url 404)
这套分包分发有两条硬约束,违反任意一条就会导致客户端下载 404:
约束一:变更包版本号必须 = release
变更包如果不把版本号设为 release,gen_update_json.ps1 会把它打进 v<自己版本>/ 目录,但 release_app.bat 遍历 v<release>/ 上传--找不到这个包,漏传。客户端按 update.json 的 url 去 v<release>/ 找,404。
所以变更包必须显式 bump 版本号 = release。
约束二:未变包的版本号历史上必须作为 release 发过
未变包的 url 指向 v<历史版本>/,这个目录必须在历史上作为某次 release tag 上传过。解耦前每次全量传,所有历史 release(如 v1.0.0.1000)含全部 9 个 asset,天然满足。全新包首次需跟某次 release 对齐完整发一次,之后才能作为「未变包」被引用。
这两条约束的本质是:update.json 的每个 url 都要真实存在。版本号和目录的映射是契约,违约就 404。
六、镜像分发:9 源容灾
yyzTools 在国内分发,GitHub raw 直连慢。解法是多镜像源 + 客户端自动选:
update.json 的来源有 9 个镜像(jsdelivr 4 个、raw github 3 个、国内代理 3 个),客户端依次尝试,取第一个可达的。下载 .7z 资产时,也有镜像列表(ghfast.top、ghproxy.com、gh-proxy.com 等代理)做容灾。
工程要点:
- 镜像只代理 raw / release asset,不改文件--所有镜像指向同一份 GitHub 内容,保证一致性。
- 客户端有 fallback 逻辑--一个镜像挂了自动切下一个,不卡死。
- 镜像缓存依赖「绝不覆盖」约束--只要 release asset 不覆盖,各镜像缓存一致有效。
这套是国内分发的常见模式,yyzTools 的实现是 9 源 + 客户端自动容灾。
七、更新流程的进程管理
客户端更新不只是下载,还要替换运行中的文件。yyzTools 的处理:
packageKillProcesses:每个子包声明更新时要杀的进程。比如更新yyztools包要杀壁纸、录屏、浏览器等辅助进程;更新tools包要杀 aria2c/7z/openssl/pdfcpu/magick/gswin64c 等第三方进程;更新everything包需要管理员权限杀 Everything.exe。- 分包独立更新:只更新变更包,只杀受影响的进程。改前端只杀 web 相关,不影响 FFmpeg 进程。
- 更新后再拉起:主程序负责协调杀进程 -> 替换文件 -> 重启受影响进程。
这块设计让「更新某个子包」的影响面可控,不会因为改一个模块动整个进程树。
八、和传统安装包分发的对比
| 维度 | 传统整包重装 | 分包增量(yyzTools) |
| 用户下载量 | 整个安装包(150MB) | 只下变更包(几 MB~几十 MB) |
| 版本模型 | 单一版本号 | release + 子包独立版本 |
| 缓存友好 | 否(每次新包) | 是(未变包 url 不变) |
| 镜像容灾 | 难(大文件单点) | 易(小包多镜像) |
| 进程影响 | 全量重启 | 只杀受影响进程 |
| 工程复杂度 | 低 | 高(版本契约 + url 派生 + 杀进程表) |
核心权衡:用发版工程的复杂度(版本契约、url 派生、杀进程表、镜像列表),换用户的下载量和镜像缓存命中率。对高频迭代 + 大体积产物的客户端,这个权衡划算。
九、对后端版本工程的启示
yyzTools 是客户端样本,但它的版本模型对后端微服务分发有对应:
- 服务版本与发布身份解耦:微服务各有版本号,发布身份(release)独立。不要为了「发版」去 bump 一个没变的服务。
- 资源 url 派生自服务版本:镜像 / 构建产物的 url 指向「该服务自己的版本」,不统一绑发布号。这样未变服务的镜像缓存不失效。
- 绝不覆盖:已发布的产物不动,新版本新建。CDN 缓存才稳定。
- 变更面控制:更新某个服务只动它的依赖进程,不全量重启。
这套「release 身份 + 子包独立版本 + url 派生 + 不覆盖 + 变更面控制」的模式,是版本工程的通用思路,不止用于客户端。
十、小结
分包增量更新的核心:把产物拆成独立版本号的子包,未变不重传,url 派生自各包版本号保证镜像缓存不失效,变更面可控只杀受影响进程。
代价是发版工程的复杂度--版本契约、url 派生规则、杀进程表、镜像列表、两条硬约束。收益是用户下载量从 150MB 降到几 MB、镜像缓存命中、发版对运行进程影响最小。
对大体积 + 高频迭代 + 跨地域分发的客户端,这套工程是必要的。核心判断标准:用户为一次更新要下多少、镜像缓存命中率多高、更新影响面多大。整包重装在这三点上都差,分包增量都好,付的是工程复杂度,这是值得的。
项目地址:yyztools.com 发版工程:9 子包独立版本 · 未变不重传 · 9 源镜像容灾 · 永久免费