说个挺讽刺的事:很多人的电脑设置里第一件事就是关掉自动更新,但轮到我们自己交付 .NET 桌面应用时,最头疼的偏偏就是怎么让用户自动更新。以前在官网放一个安装包,用户不会主动去下载;发群文件,版本一多自己都分不清;到了客户现场,你甚至能遇到还在跑三个月前版本的机器。桌面应用没有自动更新,等于把用户丢在半路,尤其在 .NET 桌面应用交付越来越频繁的今天,更新通道已经不只是加分项,而是必须项。
这篇文章我把这些年实际用过的几种 .NET 桌面应用自动更新方案都翻出来,从一两百行自研 HttpClient 更新器,到增量更新,再到 Squirrel.Windows / Velopack 这类成熟框架,最后聊聊官方 ClickOnce 和 MSIX 的适用边界。适合给 WinForms、WPF、.NET 6/8 桌面项目做更新系统的人参考,不管你是给公司内部做工具,还是做对外发布的商业软件,都能找到一条能落地的路线。
1. 动手前先回答三个问题:你的应用凭什么能更新
选更新方案之前,别急着写代码。先回答三个问题,因为很多坑在第一步没想清楚,后面全都会爆出来。
1.1 你的应用是单文件还是多文件?
.NET 桌面应用现在的发布方式很分裂:有人用PublishSingleFile打成一个 exe,有人还是传统的一堆 dll、资源文件、配置文件。这两种项目对更新系统的复杂度要求完全不同。
单文件场景最简单的更新逻辑是“下载新 exe,替换旧 exe”。但注意,单文件不是真的只生成一个 exe,.NET 6+ 的 single file 默认会把部分 native 库解压到临时目录,如果你更新的是主程序,必须保证运行中的进程没被占用,否则File.Replace直接抛异常。
多文件场景要考虑整个目录的一致性。更新过程中如果只覆盖了一半文件,用户下次启动可能因为某个 dll 版本不匹配直接崩掉。所以多文件更新一般要配合文件清单(manifest),记录每个文件的路径、大小、哈希,更新完成后整体校验一遍。
除了文件数量,还要考虑是绿色免安装还是需要安装。绿色版放进%LocalAppData%\Programs\MyApp,当前用户就能写,更新器不需要管理员权限;但如果装到 Program Files,每次更新都要处理 UAC 提权,复杂度直接上一个台阶。
1.2 客户端有没有管理员权限?
这是最容易犯的错误。很多自研更新器在开发环境跑得好好的,拿到用户电脑上就失败,原因就是安装目录没有写权限。WinForms/WPF 默认启动时不提权,运行在普通用户权限下,更新器自然没有权限替换 Program Files 里的文件。
对策一般是两条路:
- 把应用默认安装到用户目录,比如
%LocalAppData%下,当前用户完全可控,更新无需 UAC。 - 更新器 manifest 里使用
requireAdministrator,每次更新弹一个 UAC 框。对内部工具可以接受,但对面向普通用户的软件,频繁弹 UAC 会非常烦。
很多成熟框架默认选第一条路。Squirrel.Windows 和 Velopack 都默认设计成 per-user 安装,核心原因就是让更新过程尽量不需要管理员权限。
1.3 更新包放在哪、怎么校验?
更新服务器可以很简单,一个支持静态文件托管的 Web 服务器就行。客户端拉一个version.json,里面写最新版本号、更新包下载地址、包哈希、发布日期,然后下载 zip 解压替换。
但服务器地址只是开始,重点是校验。只靠版本号判断远远不够,更新通道至少要做两层校验:
- 哈希校验:下载完成后计算 SHA-256,与
version.json里的值比对,防止传输过程中文件损坏或被替换。 - 签名校验:更新包最好用 Authenticode 证书签名,客户端在安装前检查发布者。哈希只能防普通文件损坏,签名才能防中间人攻击。没有签名的更新通道,等于把一个可执行文件裸奔在公网上。
这三个问题确认完,再往下选方案就有数了:内网小工具用简单更新器,对外产品用成熟框架,大体积应用考虑增量更新。
2. 轻量自研路线:用 HttpClient 写一个两百行的更新器
如果你的应用是公司内部工具、测试环境工具,用户几十人,花几个小时做一个自更新器完全够用。别一上来就上 Squirrel,很多内网场景没有 Release 目录,没有 delta 更新需求,也没有外网访问条件,自己写反而更可控。
2.1 核心流程:版本清单、下载、校验、替换、重启
自研更新器再简单,整个链路也必须是完整的:
- 启动时或定时触发,拉取服务器上的
version.json。 - 比较本地版本与远程版本,如果远程版本更新,开始下载更新包
update.zip。 - 下载完后计算 SHA-256,和清单里的哈希比对,不一致就放弃更新并打日志。
- 把当前版本文件备份到
backup目录,然后解压新文件替换。 - 替换过程中如果主程序正在运行,需要延迟替换或者借助外部脚本。
- 替换完成后重启主程序。
这个流程里最容易翻车的其实是第 4 步。因为更新器往往就是主程序的一部分,比如在主程序启动时检查更新,然后下载、解压,然后发现自己正在被占用,替换失败。解决方案我之前用过好几种,最简单的是:下载和校验由主程序完成,实际替换动作交给一个update.cmd脚本,脚本先杀掉主进程,再复制文件,最后重新拉起来。
2.2 一个最小可用的版本清单和更新检查代码
服务器上version.json大概长这样:
{ "version": "1.2.3", "url": "https://download.example.com/myapp/update_1.2.3.zip", "sha256": "57a30bd2e54b9c8f8e4d4b7f8f1a8a5c0641e62e5d0c3f3e2a12b2f2e3c64a029", "releaseNotes": "修复了更新任务失败的 bug" }客户端核心代码,一个UpdateChecker类就够了:
using System; using System.IO; using System.Net.Http; using System.Reflection; using System.Text.Json; using System.Threading; using System.Threading.Tasks; public class UpdateInfo { public string Version { get; set; } public string Url { get; set; } public string Sha256 { get; set; } } public class UpdateChecker { private readonly HttpClient _http; private readonly string _manifestUrl; public UpdateChecker(string manifestUrl) { _http = new HttpClient { Timeout = TimeSpan.FromSeconds(30) }; _manifestUrl = manifestUrl; } public async Task<UpdateInfo?> CheckAsync(CancellationToken ct = default) { var json = await _http.GetStringAsync(_manifestUrl, ct); var info = JsonSerializer.Deserialize<UpdateInfo>(json); if (info is null || string.IsNullOrWhiteSpace(info.Version)) { return null; } var current = Assembly.GetExecutingAssembly().GetName().Version; if (current is null) { return null; } if (!Version.TryParse(info.Version, out var latest)) { return null; } return latest > current ? info : null; } }这段代码只做了检查版本,下载和替换需要自己补,但核心思路已经出来了。重点在于:版本号不要只依赖AssemblyVersion,很多项目部署时的版本和 assembly 版本对不上,最好在version.json里显式放一个版本号,以服务器下发为准。
2.3 替换阶段的延迟处理脚本
最简单的延迟替换脚本,很多老项目到现在还在用:
@echo off ping 127.0.0.1 -n 3 > nul taskkill /IM MyApp.exe /F > nul 2>&1 timeout /t 1 /nobreak > nul xcopy /E /Y /Q "C:\Users\dev\AppData\Local\Temp\MyApp.Update\*" "%~dp0" start "" "%~dp0MyApp.exe"这段批处理启动后先等 3 秒,给主进程一点退出时间,然后强制结束 MyApp.exe,把临时目录里的新文件复制到当前脚本所在目录,再启动新版本。
在实际生产中,我更建议用一个独立的UpdateStub.exe来做替换,而不是批处理。批处理的缺点是没法精细控制失败后的回滚,也没有 UI 进度。但作为内网工具,这个方案足够“短平快”。
轻量方案最大的优点是可以完全按自己业务定制,比如更新前检查网络、更新后上报版本、按用户组灰度。缺点是所有维护成本都是你的。如果用户量上来了,或者应用体积变大,你就需要开始考虑增量更新和回滚机制了。
3. 增量更新路线:把几百 MB 的升级包压成几十 MB
很多 WPF 应用打包出来动辄 300MB,资源文件、图片、依赖库一大堆。如果每次发版本都让用户全量下载,体验非常差,服务器流量也扛不住。增量更新的思路就是只下载变化的部分。
3.1 三种增量粒度,按需选择
- 文件级增量:对比本地和远程的文件列表,只下载有变化的文件。适用于资源文件多、核心程序集变化少的场景。
- 二进制级增量:对单个大文件做二进制 diff,比如
App.exe从 1.0.0 变成 1.0.1,可能只差了几 KB。常见算法有 BSDiff、HDiffPatch。 - 目录快照差分:服务器按版本生成完整目录快照,客户端拿本地快照做 diff,下载缺失和变化的文件。实现最复杂,但灵活性最高。
对大多数 .NET 桌面应用,文件级增量已经能解决 80% 的问题。因为我们的依赖库通常很稳定,变的只是主程序集、配置文件和少量资源文件。
3.2 文件级增量的服务端清单设计
服务器上放两个文件:一个全量文件清单manifest.full.json,一个当前版本增量清单。增量清单的思路是记录“从上一个版本到当前版本,新增了哪些文件,删除了哪些文件,哪些文件内容变了”。
清单结构可以设计成:
{ "version": "2.0.0", "baseVersion": "1.9.0", "files": [ { "path": "MyApp.dll", "sha256": "9a1f2e7c8b7d6f5e4a3b2c1d0e9f8a7b6c5d4e3f2a1b0c9d8e7f6a5b4c3d2e1f", "size": 204800 }, { "path": "Resources/theme.xaml", "sha256": "6f5e4a3b2c1d0e9f8a7b6c5d4e3f2a1b0c9d8e7f6a5b4c3d2e1f0a1b2c3d4e5f", "size": 4096 } ], "deleted": [ "OldModule.dll" ] }客户端获取到清单后,逐个计算本地文件哈希,匹配上就跳过,哈希不一致就下载,本地没有就下载,清单里标记删除的就在本地删除。更新完成后整体重新校验一遍。
3.3 增量更新最常见的大坑
第一个坑是“只支持上一个版本”。我见过一些团队做增量更新,清单里写死了 baseVersion,用户从 1.5 直接升级到 1.9 时,中间差了好几个版本,增量包根本没法用。要么服务器端支持任意 baseVersion 生成 diff,要么客户端强制要求先升到指定基线版本,再继续升。比较简单的做法是:每次发版时生成从上一个版本到最新版本的增量包,如果本地版本离得太远,就回退到全量包。
第二个坑是更新包制作没有纳入 CI。人工手动打增量包,很难保证每次都准。建议在 CI 里跑脚本:编译成功后,用上次发布目录做基准,生成文件清单和增量包,再传到更新服务器。
第三个坑是备份和回滚。增量更新比全量更新更容易因为漏文件而失败。更新前一定要把把涉及变化的文件完整备份到一个backup目录,如果启动时检测到新版本起不来,自动从 backup 恢复。回滚不是可选功能,是增量更新必须要有的一层保险。
如果团队没有太多精力去维护增量算法,我建议能用到现成库就用现成库,比如HDiffPatch,不要自己从零写二进制 diff。文件级增量自己写倒是可以,但也要留足时间测试跨版本升级链路。
4. 成熟框架路线:Squirrel.Windows 和 Velopack 怎么选
自研更新器写起来很爽,但当你开始考虑证书校验、原子替换、失败回滚、安装和卸载快捷方式、增量发布、多平台支持时,就会觉得重复造轮子是真累。这个时候应该看看成熟框架。
4.1 框架到底帮你解决了什么
Squirrel.Windows 是老牌 .NET 桌面应用更新框架,很多开源项目在用。它把应用打包成 nuget 包,安装时生成快捷方式,更新时通过 Squirrel 的事件钩子完成启动前安装、更新后清理。用户无管理员权限也能安装到用户目录,这一点解决了前面说的权限问题。
Velopack 是 Squirrel 的继任者,核心作者 Carry 参与过 Squirrel.Windows,后来做了 Velopack,支持 .NET 6/8,支持 Windows、Linux、macOS,更新速度更快,API 也更现代化。如果你的项目是 .NET 8,直接选 Velopack 会比 Squirrel.Windows 省很多心。
4.2 Velopack 的集成步骤
首先在项目里注册 Velopack 启动钩子,在Main方法最开始调用:
using Velopack; [STAThread] static void Main(string[] args) { VelopackApp.Build() .WithFirstRun((v) => { /* 首次安装后运行 */ }) .WithAfterUpdate((v) => { /* 更新完成后运行 */ }) .Run(); }然后把更新服务器地址配置好,在主程序某个入口触发检查:
using Velopack; public static async Task CheckForUpdates() { using var mgr = new UpdateManager("https://releases.example.com/myapp"); var newVersion = await mgr.CheckForUpdatesAsync(); if (newVersion == null) { return; } await mgr.DownloadUpdatesAsync(newVersion); mgr.ApplyUpdatesAndRestart(newVersion); }发布时用命令行工具生成增量包和Releases目录:
vpk pack -u MyApp -v 2.0.0 -o releases -p "bin/Release/net8.0/win-x64/publish"vpk会生成一个Releases文件夹,里面包含全量包和 delta 增量包,客户端更新时优先用增量,自动下载并替换。
4.3 自研、Velopack、ClickOnce/MSIX 怎么选
我自己的经验,把选择逻辑简化成一个表格:
| 维度 | 自研 HttpClient 更新器 | Velopack / Squirrel | ClickOnce / MSIX |
|---|---|---|---|
| 适用场景 | 内网工具、几十人小规模 | 对外发布的独立桌面软件 | 企业内网、Windows 平台强绑定 |
| 管理员权限 | 自己控制 | 默认 per-user,无需 UAC | 系统托管 |
| 增量更新 | 需要自己实现 | 支持 delta 增量 | MSIX 系统级,ClickOnce 基本全量 |
| 回滚机制 | 需要自己写 | 框架自带 | 系统自带 |
| 定制灵活性 | 最高 | 中等 | 最低 |
| 维护成本 | 高 | 低 | 低 |
如果你做商业软件,需要拿出去给大量外部用户用,我会优先推荐 Velopack。它的安装、更新、回滚、增量包生成都是一条龙,省下的时间足够你做好用的更新 UI 和埋点监控。
5. 官方通道路线:ClickOnce 与 MSIX 的适用边界
微软官方其实给过两套自动更新方案,ClickOnce 和 MSIX,但很多人理解不深,以为拿到就能用。实际用下来,这两条路都有很明显的边界。
5.1 ClickOnce 更新姿势
ClickOnce 最吸引人的一点是“不需要管理员权限就能安装和更新”。它把应用发布到 IIS 或共享目录,客户端每次启动时可以通过ApplicationDeployment.CheckForDetailedUpdate()检查更新。WinForms 和 WPF 项目里用得很早。
但 ClickOnce 的问题也很明显:更新包是全量或发布方式的,没有好的增量机制;对自定义安装动作支持很弱,比如要注册 Windows 服务、写系统注册表、安装驱动,ClickOnce 就基本做不了。对 .NET 6+ 项目,VS 支持 ClickOnce 发布,但老项目迁移过来时经常会遇到配置问题。
还有一个容易被忽略的坑:ClickOnce 的部署清单和应用程序清单都必须签名。证书过期以后,客户端会出现“无法验证发布者”的错误。很多内部系统换证书不及时,用户在那一天全部更新失败,排查一圈才发现是证书过期了。
5.2 MSIX 自动更新的边界
MSIX 是比 ClickOnce 更现代一点的打包格式,靠 Windows 系统托管理安装和更新。如果你发布到 Microsoft Store,系统会自动更新;如果走企业侧载,需要配合App Installer文件,在安装包服务器的*.appinstaller里指定更新 URL。
MSIX 的好处是干净、可增量更新、卸载不留垃圾,对 WPF/WinForms 项目也能用。但问题是系统要求比较高,Windows 10 1709 以上才算完整支持,而且对很多传统 Win32 软件来说,MSIX 的重打包和权限模型学习成本不低。
如果你的应用只是给某个企业内网用,IT 能统一控制系统版本,MSIX 是一条省心的路。但如果是面向用户的独立软件,我仍然建议用 Velopack 这类框架,而不是为了“官方”两个字硬套 MSIX。
6. 上线前必须自测的七个场景:文件占用、签名、回滚和杀软
更新系统最怕的不是功能不完整,而是没测过的边界情况。下面这七个场景,我建议每个项目上线前都当成必测项。
6.1 文件占用与权限不足
测试方式:启动应用,在应用正在运行时触发更新,看替换过程是否会失败。正常情况下,替换动作应该由独立更新进程或脚本延迟执行。还有一种情况是杀毒软件正在扫描新下载的 dll,导致复制文件被拒绝。解决方案是替换动作加重试逻辑,比如每隔 500ms 重试,最多重试 10 次。
6.2 证书和哈希校验
测试方式:故意把一个老版本的更新包放到服务器上,再故意篡改 zip 文件里的一个字节,确认客户端能识别并拒绝安装。哈希校验只校验文件完整性,但你还应该校验发布者签名。没有签名的更新系统,在局域网内还能忍,在公网环境就是裸奔。
6.3 回滚机制
测试方式:发布一个有意的“启动崩溃版本”,也就是新版本一运行就抛异常,看更新器能否自动恢复到上一版。我的建议是:更新前备份所有要覆盖的文件,启动时在某个全局标记里写入“当前版本号”,如果启动后 30 秒内没有收到“启动成功”的信号,自动把备份版本恢复回去。
6.4 更新器自身的更新
自研更新器很容易忽略这件事。如果你的Updater.exe也需要升级,但它不能覆盖自己,就很尴尬。常见解法有三种:更新器从服务器拉取新版本到临时目录,由带有自启动参数的当前版本启动临时版本完成替换;或者用两个更新器互相替换;再或者,直接用 Velopack,把更新器本身也交给框架托管。
6.5 多实例与并发更新
测试方式:连续打开多个应用实例,然后触发更新。如果主程序没有做单实例限制,更新器可能同时被启动多次,两个更新进程同时写同一个文件,轻则损坏,重则整套应用起不来。更新前必须检测是否已有实例在运行,最好用Mutex做单实例控制。
6.6 日志和监控
更新失败时用户只能看到“系统更新失败,请稍后重试”,但作为开发者,你需要知道具体是哪一步出的问题。应用目录下要留更新日志,至少包括:本地版本、远程版本、下载 URL、下载字节数、哈希校验结果、替换了哪些文件、异常堆栈。更理想的方案是日志上报到后端,这样即便用户不反馈,你也能在后台看到更新成功率。
6.7 杀毒软件误报
.NET 应用再加上一些自解压逻辑,很容易触发杀毒软件的启发式查杀,尤其是从临时目录释放并执行 exe 的动作。对策有几个:给 exe 做正规的数字签名;不要在临时目录下执行新文件,尽量在正式应用目录下操作;不要在更新包里塞加密混淆过度的代码。这些动作能减少一部分误报,但没法完全消除,所以在正式发布前把安装包提交给主流杀毒厂商验一下会稳妥很多。
更新系统这件事,做起来容易,做到可靠则需要耐心。如果让我现在从零开始做一个 .NET 桌面产品,我会先用 Velopack 跑通交付链路,再针对灰度、监控、回滚做二次改造;如果只是给公司内部写个工具,我会选 HttpClient 自更新器,保留最小可运行版本。能根据团队规模和用户量选择合适的路线,比纠结“哪个方案最好”重要得多。