1. 为什么桌面应用的自动升级比 Web 应用难得多
先说一个我经常被问到的场景:产品做出来了,部署在 Windows 服务器上,跑得好好的,突然客户说需要支持 Linux。好,.NET 8 项目在 Linux 上跑起来了,一切顺利。然后产品经理提了一个听起来很合理的要求:“我们是不是可以做一个自动更新?客户端每次打开的时候检查一下版本,有新版本就提示用户升级。”
听起来很简单,对吧?但你真正动手做一次就会明白,桌面应用的自动升级根本不是“检查版本、下载新文件、覆盖替换”这么简单。它背后牵扯到:更新包怎么分发、增量还是全量、跨平台文件锁定策略、签名与校验、失败回滚、安装权限、静默升级还是交互式升级……每一环都有坑。更要命的是,我们用的 .NET 天然跨平台,意味着同一个升级组件要在 Windows、macOS、Linux 三个平台上表现一致,这基本等于把三个平台的坑全部踩一遍。
这篇文章会完整梳理自动升级组件的选型、原理、落地细节和真实踩坑记录。如果你是 .NET 开发者,正在给 WinForms、WPF、Avalonia 或 MAUI 应用接入自动升级,或者你想自己写一个轻量级升级器,这篇文章应该能帮你少走不少弯路。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 选型对比:Squirrel.Windows、Velopack、AutoUpdater.NET 到底选哪家
2.1 三款主流组件各自的应用边界
目前 .NET 社区里谈到自动升级,绕不开三个方案:Squirrel.Windows、AutoUpdater.NET 和 Velopack。我先把这三者的真实边界说清楚,免得你选错方向。
Squirrel.Windows 是老牌方案,封装了安装、升级、卸载三件套,专门针对 Windows 桌面应用。它的做法是生成一个 Setup.exe,安装时把应用释放到 %LocalAppData% 下的应用目录,然后通过 Update.exe 做差值更新。优点是成熟、坑基本都被踩过一遍;缺点是它只服务 Windows,macOS 和 Linux 不在考虑范围内。
AutoUpdater.NET 更像一个“带界面的检查更新库”,它把更新逻辑嵌入到你的主程序里,定时向服务器请求 XML 或 JSON 格式的版本信息,如果有新版本就下载文件然后关闭应用执行替换。它胜在集成简单,但更新策略相对偏“整包替换”,没有内置的增量机制,而且对非 Windows 平台的支持也比较弱。
Velopack 是后来崛起的新方案,可以理解为 Squirrel 的跨平台升级版。它支持 Windows、macOS、Linux,底层做了差分更新,还附带生成安装包、发布增量包的命令行工具。它保留了 Squirrel.Windows 的安装和更新体验,同时把平台覆盖面打开了,对 Avalonia、WPF、WinForms、MAUI 都有对应的集成示例。
2.2 我为什么最终倾向 Velopack 这类新方案
如果你只是做一个内部工具,只在 Windows 上跑,那 Squirrel.Windows 完全够用。但客户一旦说“我们需要在 Ubuntu 上跑”,你再用 Squirrel.Windows 就会欲哭无泪。我自己的项目就是用 Avalonia 做界面,目标平台是 Windows 和 Linux,最终选了 Velopack。
Velopack 的发布流程是这样的:先通过 CLI 命令打出一个“稳定版”目录,里面包含对应平台的安装器和更新清单,然后你只需要把整个目录传到 HTTP 服务器或者对象存储上,客户端会定期拉取这个目录下新生成的更新包并应用。它的 update 逻辑内置了差分操作,第一次全量下载,后续只下载二进制差异部分,对带宽紧张的局域网环境特别友好。
我特别在意的一点是:Velopack 的升级是“事务式”的。它会把新版本文件写到一个临时目录,等全部就绪后再切到正式目录。这种设计大幅降低了“升级到一半程序崩溃导致主程序损坏”的概率,这一点在无人值守的服务器环境里太关键了。
2.3 从零接入时的版本管理与渠道划分
选组件只是开始,真正决定体验的是版本管理和发布渠道设计。我在项目里维护了一个简单的三段式版本号:主版本.次版本.修订号。比如 2.4.1。组件会对比客户端当前版本和服务器最新版本,如果服务器版本号高于本地,就触发更新流程。
这里有个细节容易忽略:如果你希望一部分用户先升级(内测版),一部分用户稳定运行(正式版),那就在版本号里追加一个渠道标识。我在服务器上维护两个目录:stable 和 beta,客户端通过配置文件里的渠道字段决定从哪个目录拉取更新信息。等 beta 版本验证稳定后,我再把它发布到 stable 目录,用户会自动收到最终版本。
配置示例:
json复制{
"updateUrl": "https://updates.example.com/stable",
"channel": "stable",
"currentVersion": "2.4.1",
"checkIntervalMinutes": 60
}
3. 核心机制拆解:清单校验、增量下载与事务式替换
3.1 更新清单设计:一个 JSON 能解决的事
自动升级的第一件事是检查“有没有新版本”,而不是直接下载。客户端需要拉取一份更新清单,里面写清楚当前最新版本、下载地址、文件哈希、文件大小、签名信息等。我这样设计清单结构:
json复制{
"version": "2.5.0",
"files": [
{
"path": "app.dll",
"url": "https://updates.example.com/stable/2.5.0/app.dll",
"sha256": "abc123...",
"size": 204800
},
{
"path": "app.exe",
"url": "https://updates.example.com/stable/2.5.0/app.exe",
"sha256": "def456...",
"size": 51200
}
],
"minimumVersion": "2.0.0",
"releaseNotes": "修复了若干已知问题并优化了启动速度"
}
这个清单的核心价值在于:客户端可以不下载整个安装包,而是按文件逐一下载。项目文件不多的时候,全量替换和增量替换差别不大,但如果你发布了多个静态资源文件,按文件粒度处理就能省下大量带宽。
还有一个非常重要的字段:minimumVersion。它表示“最低支持升级到当前版本的旧版本号”。如果用户本地的版本低于 minimumVersion,说明他可能缺少某个必要的升级前置条件,这时候应该让他下载完整安装包,而不是走增量逻辑。
3.2 三步替换法:为什么直接覆盖会死得很惨
我见过不少团队的第一版自动升级就是“下载新 exe,覆盖旧 exe”。这在程序没有运行的时候或许能成,但桌面应用通常运行中,文件是被锁定的,Windows 上直接覆盖必然报“文件被占用”,Linux 上虽然允许覆盖运行中的文件,但会留下一个已经被映射到旧 inode 的进程,等旧进程退出后,新文件才真正生效,行为和预期经常不一致。
我的做法是三步替换:
- 把新版本所有文件下载到临时目录,比如
app.upgrade/2.5.0/。 - 校验每个文件的哈希值,任何不匹配立即停止,不能进入下一步。
- 启动一个独立的更新进程,等主程序退出后,由更新进程执行目录切换:把旧目录改名成
app.backup,把临时目录改名为正式目录,然后重新拉起主程序。
这里的关键点是“等主程序退出”。如果更新进程和主程序同时操作文件,大概率出问题。我用的策略是主程序先通知更新进程,然后主动退出;更新进程轮询检测到主程序进程结束后,再执行替换。
3.3 增量更新的收益与实现前提
Velopack 这类组件能做到差分更新,原理是二进制级别的新旧版本对比,生成一个很小的 delta 包。比如一个 80MB 的安装包,如果只改了几行代码,差分包可能只有 5MB 左右。
这个收益在广域网环境下非常明显。但它的前提是:你能够拿到“当前版本”和“目标版本”两个文件做对比。Squirrel 的经典做法是安装时保存上一版本的副本,然后在升级时用 DeltaPackage 生成并应用差异。如果你的发布服务器上保存了所有历史版本,这就不难实现。
我自己统计过,在带宽只有 2MB/s 的客户现场网络环境里,全量 80MB 下载需要 40 秒以上,用户明显能感知到“卡住了”;而差分包 5MB,几乎眨眼就下载完。对使用体验的提升是决定性的。
3.4 静默升级与强制升级的策略设计
升级并不总是“用户点一下弹窗确认”这么简单。我的项目里分了三种升级模式:
- 静默升级:适用于补丁类更新,下载完直接替换,用户无感知。通常配合定时检查使用,比如每天凌晨执行一次。
- 提示升级:适用于常规功能更新,弹窗提示用户“有新版本,是否立即重启升级”,用户可以延后。
- 强制升级:适用于协议变更或数据格式不兼容的场景,老版本必须立刻升级,否则无法继续使用。
强制升级的实现关键在于:客户端不仅要对比版本号,还要读取服务器返回的 minimumVersion。如果本版本低于这个门槛,界面就只提供一个“立即升级”按钮,不允许关闭窗口。这块建议做进公共逻辑里,不要等出事了再补。
4. 跨平台落地细节:明明是同一个功能,Windows 和 Linux 却完全不同
4.1 Windows:文件占用、目录权限与快捷方式
Windows 上最常见的坑就是文件被占用。你双击运行了 WPF 应用,MyApp.exe 就被进程锁定了,更新程序无法覆盖它。我的解法前面提过:让主程序先退出,由独立更新进程做替换。
但还有一个容易被忽视的坑:安装目录的写权限。如果应用装到了 C:\Program Files\,普通用户对这个目录默认没有写权限,更新进程执行替换时大概率弹出“拒绝访问”。解决思路是安装时把应用装到 %LocalAppData%\Programs\MyApp 下,这也是 Squirrel 和 Velopack 的默认行为。另外一个坑是快捷方式。升级后如果主程序文件名或图标路径变了,桌面快捷方式可能指向失效。更新进程在替换完成后最好重新生成快捷方式。
4.2 macOS:签名、公证与 Gatekeeper
如果你打算支持 macOS,这里要提前给你打预防针:macOS 的升级难度比 Windows 高一个量级。
第一个门槛是签名。未签名或签名失效的应用会被 Gatekeeper 拦截,用户右键打开都未必能让程序运行,更别说自动升级了。我一开始在 macOS 上测试升级,下载完新包后直接运行,系统弹“无法验证开发者”,一度怀疑是下载逻辑的问题——其实纯粹是签名没配对。
第二个门槛是公证。即便你有开发者证书,也建议做 Apple 的 notary(公证),把发布的应用提交给 Apple 校验,拿到 stapled ticket 后用户系统才不会再拦截。这意味着自动升级组件下载的新版本,也必须是经过签名和公证的。我在 CI 里专门加了一步:构建完成后调用 notary 工具,确认成功后才发布更新清单。
第三个细节是 Contents/MacOS 目录。macOS 应用是一个 .app 目录,真正的可执行文件嵌在目录里。更新时要处理整个 .app 目录树的替换,不是单纯覆盖一个 exe。
4.3 Linux:目录权限、AppImage 与包管理器
Linux 上的坑主要来自“你用什么方式分发应用”。常见的两类情况:
如果是通过 .deb 或 .rpm 包分发,自动升级组件最好走包管理器的接口,而不是自己硬改文件。因为系统其他组件可能依赖这个包的元数据,你硬改二进制会导致 dpkg -V 校验失败。
如果是用 AppImage 分发,那就简单不少。AppImage 本质是一个自包含的压缩文件,替换起来特别直白:下载新的 AppImage,放到旧文件路径上,再加个可执行权限 chmod +x 即可。但这里有个问题:AppImage 挂载运行时,文件也是被锁定的。我的方案是下载到临时目录,然后通过 systemd 的用户服务或一个守护脚本执行重命名。
还有一个所有方式都绕不开的权限问题:如果应用装在 /opt/myapp,普通用户没写权限。最省心的方案是应用数据目录放在用户主目录下,比如 ~/.local/share/myapp,更新也更新到那里,完全避开 root 权限。
4.4 跨平台测试矩阵
跨平台组件最怕“Windows 好好的,Linux 一跑就崩”。我现在维护一张简单的测试矩阵,每次发版前跑一遍:
| 平台 | 分发方式 | 升级方向 | 验证重点 |
|---|---|---|---|
| Windows 10/11 | 本地目录安装 | 2.4.1 到 2.5.0 | 文件占用、快捷方式、静默升级 |
| Ubuntu 22.04 | AppImage | 2.4.1 到 2.5.0 | 可执行权限、重启后自动拉起 |
| Ubuntu 22.04 | systemd 服务部署 | 2.4.1 到 2.5.0 | 服务重启、无界面静默升级 |
| macOS 14 | .app 分发包 | 2.4.1 到 2.5.0 | Gatekeeper 拦截、公证状态 |
自动化测试可以覆盖 80% 的问题,但建议每个平台保留一台手工测试机。自动升级是那种“测试环境全绿,生产环境照样出幺蛾子”的活,手工过一遍才放心。
5. 自己动手实现一个最小可用的自动升级组件
5.1 整体模块划分
如果你不想引入大而全的第三方组件,或者你只想把它做成自己掌握全部细节的内部组件,完全可以自己写一个。我建议至少拆分五个模块:
- 版本检查器:负责拉取清单并比较版本。
- 下载器:负责按文件清单下载,支持断点续传和哈希校验。
- 更新包管理:负责管理临时目录、备份目录和版本目录。
- 替换执行器:独立进程,负责等待主程序退出后执行替换。
- 启动器/恢复器:主程序被更新后启动,如果发现版本目录不完整,能自动回滚。
5.2 更新服务器与版本清单
自己实现时,服务器端可以只是一个静态目录结构:
code复制/updates/
stable/
latest.json
2.5.0/
app.dll
app.exe
checksum.sha256
latest.json 就是我前面给的 JSON。客户端启动后拼上 updateUrl,如果服务器不存在这个文件就说明没更新。这个方案的好处是整个发布流程用 FTP 或对象存储工具就能完成,不需要后端团队配合开发接口。
5.3 客户端检查与下载
客户端里我会开一个后台线程,按配置文件设定的间隔检查更新。检查逻辑很简单,用 HttpClient 请求上述 JSON,反序列化后做版本比较。如果版本旧,就开始下载。
下载有几个细节建议你在初版就做好:
- 每个文件下载完成后立刻计算 SHA256,与清单中比对,不通过就删除重下。
- 记录
Last-Modified或使用 ETag,避免重复下载相同内容。 - 下载目录统一放在应用的“临时更新目录”下,启动时自动清理上次遗留的临时文件夹。
这一部分最容易出问题的是 HTTP 客户端没有设置超时时间,导致界面卡在“检查更新中”十几次。我习惯把检查更新的超时设为 10 秒,下载文件的超时单独设置,因为大文件耗时较长。
5.4 升级执行器与重启逻辑
我前面提到过“独立更新进程”。它的实现思路是这样的:
- 主线程序检测到新版本文件齐全后,启动
updater进程(这个 updater 本身不是一个普通 GUI,而是一个命令行小工具)。 - 主程序传递参数给 updater:比如旧版本目录、新版本临时目录、主程序启动路径。
- 主程序退出。
- updater 执行目录备份、替换、权限修正,然后调用
Process.Start拉起主程序。 - updater 退出。
这里有一个容易导致失败的点:如果主程序启动后立刻检查更新,会发现“服务器上还是新版本”,于是又自动升级一遍。我解决的办法是在更新清单里记录一个“上次更新时间戳”,如果更新时间在两分钟内,暂不触发自动更新。第一次写没注意这个细节,结果上线后一堆用户反馈“一打开就疯狂下载文件更新”,排查了半天才发现是自触发循环。
6. 生产环境的硬要求和踩坑经验
6.1 升级前必须做版本回滚预案
自动升级做得越多,我越意识到“能升级上去”和“能回滚回来”一样重要。升级失败或者新版本有严重 bug 时,如果用户无法回到旧版本,那就不是小事故了。
所以我建议每个更新包发布前,都自动保留上一版本的完整备份。Velopack 这类组件会把旧版本放到备份目录,升级失败或用户手动回滚时可以直接恢复。自己实现时也至少要做到:替换前把旧目录完整复制到 backup/{旧版本号},并且给 updater 加一个 --rollback 参数。
6.2 证书与签名校验
签名不是 macOS 专属需求。Windows 上,未签名的安装包会被 SmartScreen 拦截;Linux 上虽然没这么严格,但如果你通过不安全的 HTTP 通道发布更新包,攻击者完全可以中间人替换更新文件,等于给了别人一个执行代码的后门。
安全基线是:
- 更新通道必须使用 HTTPS。
- 更新清单中的每个文件哈希用于完整性校验。
- 有条件的情况下,对更新包做数字签名,客户端在替换前校验签名。
我给项目加的方案是:每发布一个版本,CI 自动用私钥对生成的 checksum 文件进行 RSA 签名,客户端内置公钥,启动更新前先验签。整个过程在代码里只有几十行,但对安全性的提升非常明显。
6.3 真实遇到过的问题清单
我在这里列几个真实踩过的坑,按影响程度排序:
- Windows Defender 拦截更新进程。新编译的 updater.exe 如果没有签名,Defender 可能把它当未知程序处理。解决方法是给 updater 加上 ev 证书签名,或者至少发布前在 Defender 里确认一遍。
- Linux 下替换 AppImage 后失去可执行权限。很多发布工具生成的 AppImage 默认权限是 644,下载下来不
chmod +x就跑不了。我后来在更新进程里强制设置了可执行位。 - 下载速度慢导致超时。客户现场网络差,下载大文件时 HttpClient 超时设置不对,导致升级反复中途失败。后来我给下载器增加了重试机制和断点续传。
- 磁盘空间不足。更新包解压需要磁盘空间,老旧机器磁盘满了下不出临时文件,升级进程没有任何提示。这个可以在检查更新前先判断临时目录所在盘符的剩余空间。
- 多实例运行。用户同时开了两个应用实例,一个实例退出时另一个还在占用 dll,更新进程去重命名目录直接报错。我的对策是升级前检查并关闭所有同进程名实例。
6.4 发布工作流:GitHub Actions 自动产出增量包
最后再说说发布侧的自动化。既然客户端是自动升级的,发布端就不能还是手动打包、手动传服务器。我用 GitHub Actions 做了这么一条流水线:
- 推送 tag 触发构建,按平台分别生成应用。
- 调用 Velopack 的 CLI 生成更新包和差分包。
- 将更新包上传到对象存储或自建更新服务器。
- 更新
latest.json。
这条流水线跑通后,发版这个动作就退化成了一个“打 tag”的操作。整个链路的关键是从构建到更新包产出再到清单上传,任何一步失败都不能更新线上清单,否则客户端会看到新版本号,但下载文件却不存在,这是最严重的更新事故。
7. 写给准备做自动升级的 .NET 开发者的几点经验
自动升级这件事,没有动手做之前会觉得“有点复杂,但也就那样”,真正做起来才会发现里面有各种细碎的小坑。我个人做完这个跨平台升级组件之后,最深的体会是:
第一,不要一上来就想着自己造轮子。如果你连 Squirrel、Velopack 这类成熟组件的机制都没研究过,那自己写的升级器大概率会踩一遍别人十年前就踩过的坑。先用成熟方案,把它跑通,再根据自己的需求做裁剪或替换,这条路最稳。
第二,一定要把“更新失败”当作常态来设计。你的代码不能假设“下载一定成功、替换一定成功、程序重启一定成功”。每一层都要有状态记录、日志输出、失败恢复机制。我见过最崩溃的现场就是用户反馈“应用打不开了”,一查发现是上一次升级把主程序文件写坏了,而旧版本备份又因为磁盘空间不足没留成。
第三,不管用哪种组件,发布侧的自动化一定要做。手动发布一个两个版本没问题,等到产品进入快速迭代期,一周发三个版本的时候,手动操作必出错。GitHub Actions、GitLab CI、甚至一个简单的 PowerShell 脚本都行,关键是把“构建、打包、签名、上传、更新清单”这条链路固化下来。
最后再分享一个小技巧:在升级组件里留一个“手动检查更新”的入口,同时把更新日志和当前版本号显示在软件主界面比较显眼的位置。这样用户在支持群反馈问题的时候,你能第一时间判断他是不是旧版本,省去一遍遍问“你用的哪个版本”的沟通成本。就凭这一点,提前做的这些升级机制就已经很值了。
