- Authors

- Name
- Youngju Kim
- @fjvbn20031
- 引言 — 安装只是开始
- 第 1 部分 — 安装向导的 UX
- 第 2 部分 — 更新机制
- 第 3 部分 — 完整性与安全
- 第 4 部分 — 生态工具
- 第 5 部分 — 更新时的数据迁移
- 示例:一个小型更新清单
- 示例:Service Worker 更新
- 决策清单
- 结语
- 参考资料
引言 — 安装只是开始
真正的问题,从把软件交到用户手里的那一刻才开始。首次安装只发生一次,但更新会伴随产品存活的整个生命周期。要修复 bug、添加功能、堵住安全漏洞,就必须把已经部署出去的成千上万份安装安全地替换成新版本。
本文会把两件事放在一起讲。一是用户最先接触到的安装向导(wizard)的 UX 设计,二是在其背后默默运行的自动更新机制。无论是桌面应用、移动应用还是 Web PWA,构建"可更新的解决方案"的原理都出奇地相似:下载、验证、原子替换、失败则回滚。这四步就是全部。
第 1 部分 — 安装向导的 UX
多步骤流程与逐步验证
向导是把复杂配置拆成多个步骤的 UI。它不是把所有选项一股脑塞进一个屏幕,而是一步步引导用户。好的向导都有一套共同的骨架。
- 逐步验证(per-step validation):在进入下一步之前,立即验证当前步骤的输入,而不是把所有失败都攒到最后一起爆发。比如在选择安装路径的步骤,就要当场确认该路径是否有写权限、空间是否够用。
- 进度显示与返回(progress and back):要展示当前处于第几步、总共有几步,并允许用户自由返回上一步。返回时不应丢失已经填写的内容。
- 合理的默认值(sensible defaults):大多数用户只会保持默认值一路点"下一步"。所以默认值实际上就是大多数人的真实配置。默认值应当是最安全、最常见的选择。
预览、演练与失败回滚
安装或更新会改变系统。在用户点击"应用"之前展示将会发生什么变化,能提升信任感。
- 演练/预览(dry-run/preview):在真正动手改动文件之前,先汇总展示哪些文件会被新增、修改或删除。服务器配置向导可以先展示"用这份配置尝试连接"的结果。
- 失败回滚(rollback on failure):如果安装过程中途失败,绝不能留下一个半安装状态。就像事务一样,要么全部成功,要么完全恢复到原状。
可恢复性
大文件下载到一半网络可能中断,用户也可能不小心关掉窗口。每次都要求从头再来,只会让用户疲惫不堪。可恢复性(resumability)是指保存中断时刻的状态,重新打开后能从那个点继续。下载靠 HTTP Range 请求续传,向导的输入值则临时存在本地。
第 2 部分 — 更新机制
语义化版本与更新渠道
要管理更新,首先得系统地给版本编号。语义化版本(SemVer)把版本号拆成 MAJOR.MINOR.PATCH 三段。兼容性被破坏时升 MAJOR,新增功能时升 MINOR,只修 bug 时升 PATCH。客户端凭这条规则就能判断:这次更新是可以放心的小修补,还是需要谨慎对待的大变动。
在此之上再加上更新渠道(update channel)。同一款产品也可以分出 stable(稳定)、beta(测试)、nightly(每日构建)等多条流,让新版本先流向愿意承担风险的用户。大多数用户留在 stable,早期采用者则转到 beta。
全量更新 vs 增量/差分更新
最简单的做法是全量更新(full update):把新版本整个下载下来直接替换。实现简单,但如果应用有几百 MB,哪怕只改一行代码,每次也要重新下载几百 MB。
增量/差分更新(delta/differential update)消除了这种浪费。只下载旧版本与新版本之间的差异(diff),在本地合并。有几个常见工具值得一提。
- bsdiff:从两个二进制文件的差异中生成一个小补丁。是被广泛使用的经典方案。
- Courgette:Chrome 团队的方案,在汇编层面分析可执行文件,生成的补丁比 bsdiff 小得多。它利用了这样一个事实:重新编译可能让地址整体偏移,但实际的代码改动其实很小。
增量更新能大幅节省带宽,但补丁只有在"上一个版本恰好就是那个版本"时才适用。所以通常要么准备多份从各个旧版本通向最新版本的补丁,要么让增量更新在失败时自动回退到全量更新。
原子替换与回滚
更新过程中最危险的时刻,是真正替换文件的那一刻。如果在删除旧文件、写入新文件的中途断电会怎样?原子替换(atomic swap)正是为了防止这种情况。
核心思路是"先把一切完全准备好,最后再一次性切换"。把新版本整个安装在旁边(比如 v2 文件夹),验证通过后,再把指向"当前版本"的指针(符号链接或配置项)从 v1 一次性切到 v2。借助文件系统的原子 rename,在任何一个瞬间,用户看到的要么是完整的 v1,要么是完整的 v2,不存在一半新一半旧的中间态。出了问题,只需把指针指回 v1,回滚就完成了。
/app/current -> /app/versions/1.4.0 (通过原子 rename 切换)
/app/versions/1.5.0 <- 已预先安装并验证完成
切换后:
/app/current -> /app/versions/1.5.0
失败时把 current 指针指回 1.4.0 即完成回滚
分阶段/金丝雀发布
如果把新版本同时推给所有用户,潜藏的 bug 就会一次性影响到所有人。分阶段/金丝雀发布(staged/canary rollout)可以防止这种情况。先只对 1% 的用户发布,观察错误率和各项指标;没问题的话再逐步扩大到 5%、25%、100%。就像矿井里的金丝雀一样,少数用户先察觉到危险。一旦出现异常迹象,就立刻暂停或回退发布。
后台下载,重启时生效
让用户停下来盯着进度条看,是很糟糕的体验。成熟的更新器会在后台悄悄下载(background download),准备就绪后只提示"将在下次重启时生效"。等用户下次重新打开应用时,再顺畅地切换到已经下载好的新版本。Chrome、VS Code 以及不少 macOS 应用都采用这种方式。
第 3 部分 — 完整性与安全
更新渠道对攻击者来说是一个极具吸引力的目标。如果能把伪造的更新推送出去,攻击者就能在用户的每一台设备上执行代码。因此完整性与安全不是可选项,而是必需品。
代码签名与签名·哈希验证
代码签名(code signing)是指开发者用自己的私钥给发布物附加数字签名。客户端用公钥验证该签名,以确认这份文件确实来自那位开发者,并且在传输过程中没有被篡改。
铁律是先验证再应用(verify before applying)。先检查下载下来的补丁的哈希与签名,只有通过之后才继续安装。如果顺序反过来(先应用后验证),那就已经太迟了。
TUF 的核心思路
TUF(The Update Framework)是一个专门设计用来防御针对更新系统本身的攻击的框架。它不依赖单一签名密钥,而是设置了角色分离的多把密钥(root、targets、snapshot、timestamp),即便其中一把密钥泄露,整个体系也不会因此崩溃。哪怕只借鉴其中几个核心思路,也能带来很大帮助。
- 角色分离与密钥轮换:把签名权限拆分开来,一旦密钥泄露就能替换。
- 新鲜度保证(freshness):用时间戳元数据确认"刚拿到的这份元数据是不是最新的",从而阻止把陈旧信息伪装成最新信息的攻击。
HTTPS、证书锁定与防止降级
- HTTPS 与证书锁定(pinning):更新必须通过 HTTPS 获取,在重要场景下还应把服务器证书锁定(pinning)进应用里,让中间人攻击变得更困难。
- 防止降级攻击(downgrade attack):攻击者可能重新推送一个签名依然有效的旧版本、有漏洞的版本,以此复活已知的漏洞。要防止这种情况,客户端绝不能降到低于当前版本,并且要确认服务器给出的版本号并非被回退过的。
第 4 部分 — 生态工具
没必要重新造轮子。各个平台都已经有成熟的更新工具。
- Squirrel:面向 Windows 和 macOS 的更新框架。擅长增量更新和静默的后台安装。
- Sparkle:macOS 应用事实上的标准更新器。通过一种叫做 appcast 的 RSS/XML feed 通知新版本信息,并内置签名验证。
- electron-updater:负责 Electron 应用的自动更新,内部同时使用 Squirrel 与 appcast 风格的 feed。
- Tauri updater:基于 Rust 的 Tauri 应用的官方更新器,默认提供签名验证与更新清单(manifest)。
- OS 包管理器:在 Linux 上,apt、dnf 这类系统包管理器会把更新、依赖、签名验证整体接管过去,应用自己动手做更新器的必要性也随之降低。
- 移动端应用内更新(in-app updates):类似 Android 的 In-App Updates API,可以在通过应用商店的同时,在应用内引导灵活/立即更新。
- Web/PWA Service Worker:Web 上刷新页面就等于更新,但 PWA 可以通过 Service Worker 的更新生命周期,精细地控制"新版本已就绪 → 下次访问时激活"。下面会给出一个示例。
- 服务器主导的功能开关/远程配置(remote config):无需重新部署代码,只靠服务器下发的开关就能打开/关闭功能,或做渐进式发布。这是发布与即时回滚的另一条轴线。
第 5 部分 — 更新时的数据迁移
变化的不只是代码本身。本地数据库或配置文件的格式(schema)也会随之演进。这里正是事故高发地带。
- Schema/版本迁移:给数据打上版本号,应用启动时按顺序执行迁移脚本,从"当前数据版本"迁移到"目标版本"。迁移必须是顺序执行、逐步累积的。
- 幂等迁移(idempotent migration):同一份迁移脚本跑两次,结果必须一致——因为迁移中途失败后重启是常有的事。要像"如果列不存在就添加"这样先判断条件再执行。
- 前向/后向兼容性(forward/backward compatibility):为了给回滚留后路,最好让旧版本也能(哪怕不完美地)不崩溃地读取新版本写入的数据。新增字段时设为可选,与其立刻删除某个字段,不如先在一段时间内忽略它更安全。
示例:一个小型更新清单
服务器用来告诉客户端"最新版本是什么、去哪里下载、哈希值是多少"的清单,通常长这样。
{
"channel": "stable",
"latest": "1.5.0",
"minimumSupported": "1.2.0",
"releasedAt": "2026-07-03T09:00:00Z",
"artifacts": [
{
"platform": "darwin-arm64",
"url": "https://updates.example.com/app/1.5.0/app-arm64.zip",
"sha256": "9f2c1b7e0a4d6f8b3c5e7a9d1f2b4c6e8a0d2f4b6c8e0a2d4f6b8c0e2a4d6f8b",
"size": 41231884,
"signature": "MEUCIQD...base64-signature..."
},
{
"platform": "win32-x64",
"url": "https://updates.example.com/app/1.5.0/app-x64.nupkg",
"sha256": "1a3c5e7b9d0f2a4c6e8b0d2f4a6c8e0b2d4f6a8c0e2b4d6f8a0c2e4b6d8f0a2c",
"size": 39882140,
"signature": "MEQCIF...base64-signature..."
}
],
"delta": {
"from": "1.4.0",
"url": "https://updates.example.com/app/1.4.0-to-1.5.0.patch",
"sha256": "7b9d1f3a5c7e9b0d2f4a6c8e0b2d4f6a8c0e2b4d6f8a0c2e4b6d8f0a2c4e6b8d"
}
}
客户端通过 HTTPS 获取这份清单,用 minimumSupported 阻止降级,下载对应的 artifact 后验证 sha256 与 signature,通过后再原子替换。
示例:Service Worker 更新
在 PWA 中,Service Worker 会像"已安装的应用"一样被缓存,所以需要一套流程来通知用户新版本并顺畅切换。下面是一个最小示例:当新的 Service Worker 进入等待状态时通知用户,用户同意后立即激活它。
// 页面侧代码
navigator.serviceWorker.register("/sw.js").then((reg) => {
reg.addEventListener("updatefound", () => {
const newWorker = reg.installing;
newWorker.addEventListener("statechange", () => {
// 新 worker 安装完成,且旧 worker 仍在控制页面时
if (newWorker.state === "installed" && navigator.serviceWorker.controller) {
// 该向用户展示"新版本已就绪"提示条的时机
showUpdateBanner(() => {
// 用户点击"立即更新"后,让等待中的 worker 立即激活
newWorker.postMessage({ type: "SKIP_WAITING" });
});
}
});
});
});
// controller 发生变化时(=新 worker 已激活)只刷新一次
let refreshing = false;
navigator.serviceWorker.addEventListener("controllerchange", () => {
if (refreshing) return;
refreshing = true;
window.location.reload();
});
// sw.js — Service Worker 侧代码
self.addEventListener("message", (event) => {
if (event.data && event.data.type === "SKIP_WAITING") {
// 跳过等待状态,把这个 worker 立即晋升为激活 worker
self.skipWaiting();
}
});
self.addEventListener("activate", (event) => {
// 一旦激活,立刻接管所有已打开标签页的控制权
event.waitUntil(self.clients.claim());
});
决策清单
在新项目里敲定更新策略时,值得问自己以下几个问题。
- 分发路径:走应用商店/包管理器,还是自建更新器?可能的话优先考虑平台标准方案(Sparkle、Squirrel、apt/dnf、应用商店的应用内更新)。
- 更新体积:应用是否大到需要增量更新?如果用增量更新,一定要同时准备全量更新作为兜底。
- 应用安全性:原子替换与回滚是否已经就绪?是否绝不会留下半安装状态?
- 完整性:是否在应用之前验证签名与哈希?是否阻止降级?是否走 HTTPS?
- 发布:是否先对少数用户金丝雀发布并观察指标?是否有一键回滚的开关?
- 渠道:是否要拆分 stable/beta 渠道?功能开关能否在不发布代码的情况下开关功能?
- 数据:schema 迁移是否幂等、是否按顺序执行?回滚时旧版本能否承受新数据?
- 体验:是否在后台下载、重启时生效?向导是否具备逐步验证、返回、断点续传?
结语
可更新解决方案的本质其实很简单。下载、验证、原子替换、失败则回滚。 在此之上,再叠加体贴用户的向导 UX(逐步验证、预览、可恢复)、安全扩大范围的发布方式(金丝雀、渠道)、抵御攻击的完整性机制(代码签名、TUF、防降级),以及应对数据演进的迁移方案(幂等、顺序、兼容)即可。
每个平台都已经有不错的工具:Sparkle、Squirrel、electron-updater、Tauri、Service Worker、OS 包管理器。重要的是理解这些原理,并挑选出与自己产品的风险和规模相匹配的组合。安装只是开始,而良好的更新体验才是让产品长久存活下去的关键。
参考资料
- The Update Framework (TUF): https://theupdateframework.io/
- Sparkle (macOS 更新框架): https://sparkle-project.org/
- Squirrel.Windows: https://github.com/Squirrel/Squirrel.Windows
- electron-updater 文档: https://www.electron.build/auto-update
- Tauri Updater 指南: https://v2.tauri.app/plugin/updater/
- Chromium Courgette 介绍: https://www.chromium.org/developers/design-documents/software-updates-courgette/
- Semantic Versioning: https://semver.org/
- MDN Service Worker 生命周期: https://developer.mozilla.org/en-US/docs/Web/API/Service_Worker_API/Using_Service_Workers