FEATURED · 精选文章

.NET桌面应用自动更新方案详解:从手写到Velopack对比与避坑指南

发布时间 / 2026/9/9 3:31:05
来源 / 创域科博编辑部
栏目 / 资讯中心
.NET桌面应用自动更新方案详解:从手写到Velopack对比与避坑指南 你遇到过这种情况吗凌晨两点修完一个线上崩溃把新包传到服务器第二天一看后台统计还有七成用户跑在旧版本上。用户群里还在刷屏说那个 bug 还在。这就是桌面应用和 Web 最大的区别——代码改完推送上去不算完事你得让用户机器上的程序自己愿意更新、能更新、更新失败还能退回来。.NET 桌面应用想做好自动更新不是丢一个安装包到服务器就结束的文件占用、权限、签名校验、断点续传、回滚这些环节一个都绕不开。这篇文章我按 .NET 生态里实际用过的几条路线把手写更新器、ClickOnce、MSI引导器、AutoUpdater.NET、Velopack 这几种方案全部过一遍。不吹不黑每个方案的适用场景、核心实现、坑点都会讲到最后再补一份通用避坑清单。如果你正在给 WPF 或 WinForms 应用做更新功能这篇文章应该能帮你少走不少弯。1. 先把问题看透桌面更新为什么难做很多人第一次做自动更新时想的方案特别简单程序启动时查一下服务器版本号如果新直接下载 zip 覆盖本地文件。结果真正做了才发现Windows 对运行中的 exe 和 dll 有严格的占用保护你在资源管理器里删一个正在运行的程序都会提示操作无法完成程序自己更不可能把自己替换掉。1.1 文件占用、权限和信任三道坎第一道坎是文件占用。主程序 MyApp.exe 正在运行的时候它对应的 exe 和一堆 dll 都被系统锁定不能删除、不能覆盖、甚至连重命名都不行。解决办法通常不是让主程序自己更新自己而是引入一个独立的小更新器进程由它来等待主程序退出、执行文件替换、再负责把主程序拉起来。第二道坎是权限。如果你把应用安装在 C:\Program Files 下普通用户对那个目录只有读权限没有写权限。更新器去替换文件时要么触发 UAC 提权弹窗要么干脆失败。很多办公电脑的用户根本没有管理员权限UAC 弹窗对他来说就是看到是否允许此应用对你的设备进行更改第一反应就是点否。第三道坎是信任。更新器这种自己下载文件、自己替换程序的行为本质上和木马没有任何区别所以杀毒软件和 SmartScreen 对没有代码签名的更新器非常敏感。你在自己机器上跑得好好的发出去到客户那边直接被杀软拦掉这种事我见过太多次。1.2 一张表看懂各方案的取舍在讲具体方案前我先用一张表把这些路线的主要差异摆出来。这样你带着对比去看后面的细节会更容易判断自己的项目适合走哪条路。方案是否需要管理员权限增量/差量更新自动回滚学习成本适用场景手写 Http 独立更新器可选看安装目录可自行实现需自己写中想完全掌控更新逻辑、有后端配合ClickOnce不需要部分支持不支持低内部工具、快速分发、原型验证MSI 引导器需要不支持不支持高系统级安装、需要注册表/服务的产品AutoUpdater.NET可选整包更新有限低中小型工具、想省事但不想被 ClickOnce 限制Velopack不需要原生支持原生支持中高正式商业产品、需要良好更新体验先说结论没有银弹。如果你的应用要装到 Program Files 且要写注册表MSI 路线回避不了如果你只是给公司内部十几个人用的小工具上 Velopack 反而是过度设计如果你想要长期面向大量用户交付我最后会推荐你在 Velopack 和手写方案之间做选择。下面逐个展开。2. 最轻量的写法HttpClient JSON 清单 独立更新器先讲最基础的方案。这个方案虽然原始但它把所有自动更新的关键环节都暴露出来了检查、下载、校验、替换、回滚。把这套逻辑吃透后面用任何现成框架你都能看懂它到底在做什么。2.1 服务端清单与检查逻辑服务端只需要一个静态目录里面放两个东西一个 update.json 清单文件若干版本的 zip 安装包。清单文件长这样{ version: 1.2.3.0, mandatory: false, url: https://download.example.com/releases/myapp-1.2.3.0.zip, sha256: 7E5FDC3B7A3C8E9D21FA66B4C52A6D8E4F0B1C2D3E4F5A6B7C8D9E0F1A2B3C4, changelog: [ 修复了启动崩溃, 新增了导出功能 ] }客户端启动时或定时去请求这个文件和当前程序集版本比较public async TaskReleaseInfo? CheckForUpdateAsync(CancellationToken ct default) { using var http new HttpClient(); http.Timeout TimeSpan.FromSeconds(10); var json await http.GetStringAsync(UpdateManifestUrl, ct); var release JsonSerializer.DeserializeReleaseInfo(json); var current Assembly.GetExecutingAssembly().GetName().Version; return release.Version current ? release : null; }注意这里我比较的是Assembly.GetName().Version它对应的是 AssemblyVersion不是 FileVersion。AssemblyVersion 是你程序集的标识FileVersion 是文件属性里显示的那个版本号很多团队会把 FileVersion 改成每次编译都递增但 AssemblyVersion 保持不变所以判断更新一定要用 AssemblyVersion 或清单里单独声明的版本号不要用 FileVersion。2.2 核心更新器的实现片段拿到 release 信息后主程序不自己做替换而是把下载、替换的工作交给一个独立进程。下载可以直接用 HttpClient 流式写入临时文件var tempZip Path.Combine(Path.GetTempPath(), $myapp-{release.Version}.zip); using (var http new HttpClient()) { var response await http.GetAsync(release.Url, HttpCompletionOption.ResponseHeadersRead, ct); response.EnsureSuccessStatusCode(); await using var fs File.Create(tempZip); await response.Content.CopyToAsync(fs, ct); }下载完成后启动更新器var psi new ProcessStartInfo { FileName Path.Combine(AppContext.BaseDirectory, Updater.exe), UseShellExecute true, Verb runas, // 当安装目录需要管理员权限时 Arguments $--target \{AppContext.BaseDirectory}\ $--zip \{tempZip}\ $--backup \{backupDir}\ $--restart \{mainExe}\ }; Process.Start(psi); Environment.Exit(0);更新器里做三件事等主进程退出、把旧文件移动到备份目录、把新包解压到目标目录// 1. 等待主进程退出 var main Process.GetProcessById(mainPid); main?.WaitForExit(); // 2. 将旧文件挪到备份目录 Directory.CreateDirectory(backupDir); foreach (var file in Directory.EnumerateFiles(targetDir, *, SearchOption.AllDirectories)) { var relative Path.GetRelativePath(targetDir, file); var dest Path.Combine(backupDir, relative); Directory.CreateDirectory(Path.GetDirectoryName(dest)!); File.Move(file, dest, overwrite: true); } // 3. 解压新包 ZipFile.ExtractToDirectory(zipPath, targetDir, overwriteFiles: true); // 4. 重启主程序 Process.Start(Path.Combine(targetDir, mainExe));用先移动到备份目录而不是直接删除是回滚的关键。万一解压失败、用户机器磁盘突然满了、或者杀软把新 exe 拦了你还有机会把 backup 目录里的旧文件恢复回去。直接删旧文件再解压失败后应用就彻底瘫了。2.3 断点下载、哈希校验和回滚备份上面这个基础版本能跑但离可用还差三步。第一步是断点续传。桌面应用更新遇到弱网是常态几十 MB 的包下载到一半断了如果每次都从头开始用户会崩溃。可以先看本地临时文件已经下载了多少字节然后用 HTTP Range 头请求剩余部分var request new HttpRequestMessage(HttpMethod.Get, release.Url); if (File.Exists(tempZip)) { var existing new FileInfo(tempZip).Length; request.Headers.Range new RangeHeaderValue(existing, null); }服务器返回 206 Partial Content 时追加写入返回 200 时从头写。Nginx、IIS、各大对象存储服务都默认支持 Range所以这个方案基本不需要后端额外配合。第二步是哈希校验。下载完成的 zip 必须校验 SHA256防止文件在传输过程中损坏或者服务器文件本身出了问题using var sha SHA256.Create(); await using var fs File.OpenRead(tempZip); var hash Convert.ToHexString(await sha.ComputeHashAsync(fs, ct)); if (!string.Equals(hash, release.Sha256, StringComparison.OrdinalIgnoreCase)) { File.Delete(tempZip); throw new InvalidDataException(更新包哈希校验失败); }如果在公司内网 HTTP 环境下分发哈希校验是你唯一能依赖的防篡改手段。没有哈希校验一个坏包发出去可能让所有客户端全部挂掉。第三步是恢复机制。更新器执行替换时用 try-catch 包住整个流程任何环节抛异常就把 backup 目录里的文件全部移回 target 目录。这一步在真实场景里非常有用——我遇到过目标目录被第三方软件临时占用、解压时磁盘满的情况没有回滚逻辑的话用户就只能重装软件了。2.4 更新器自身怎么更新这个方案里最容易被忽略的问题是Updater.exe 本身也是程序它如果也放在安装目录里替换目标目录时它自己也跑在那里Windows 会拒绝删除运行中的 exe。我的做法是主程序启动更新器之前先把 Updater.exe 复制到%TEMP%\MyAppUpdater\Updater.exe再从临时目录启动它。更新器在临时目录里运行不占用安装目录的任何文件这样它就能放心地替换安装目录里的一切包括那个旧的 Updater.exe。这个把更新器复制到临时目录再运行的小技巧是手写方案里比较关键的一步。另外更新器向用户展示下载进度时别用同步阻塞的写法。下载放在 Task 里跑进度用 IProgress 或事件推送到 UI避免界面卡死。用户看到界面假死第一反应就是去任务管理器把程序结束紧接着就是文件处于不一致状态。3. 托管路线ClickOnce 和 MSI引导器各管什么场景不想自己写更新器的人通常会在 ClickOnce 和 MSI 两条托管路线里挑。这两条路线都能把更新能力托管给系统或平台省掉大量自研代码但它们的边界非常不一样。3.1 ClickOnce 快速接入但有边界ClickOnce 是 Visual Studio 直接支持的一项技术。发布时把应用发布到一个网络路径或 URL客户端首次点击安装后后续更新通过部署清单自动完成。接入代码非常少if (ApplicationDeployment.IsNetworkDeployed) { var deployment ApplicationDeployment.CurrentDeployment; var info await deployment.CheckForDetailedUpdateAsync(); if (info.UpdateAvailable) { await deployment.UpdateAsync(); Application.Restart(); } }传统 ClickOnce 应用是自己更新自己的因为 ClickOnce 在更新时把新版本下载到用户 AppData 下的隔离目录然后切换当前激活版本应用自身文件其实没有被占用的问题。这是它最大的省心之处。但 ClickOnce 有几个硬边界需要提前知道。第一应用被安装在一个随机生成的 Apps\2.0 目录下代码里不能假设自己安装在 Program Files 或某个固定路径写注册表、装服务、创建全局文件系统对象都受限。如果你只是纯 WinForms/WPF 工具这通常没问题如果产品要配置 Windows 服务ClickOnce 直接出局。第二启动时更新检查可能会拖慢启动而且服务器不可达时会弹错误提示。内网环境偶尔卡一下可以忍面向公网用户时体验很难看。第三ClickOnce 没有回滚机制。新版本发布后如果发现严重问题你很难让所有用户迅速退回旧版。它可以设置 minimumRequiredVersion 强制用户升级到某个版本但自动回滚这个能力它没有。3.2 MSI 引导器面向系统级安装如果你的应用必须装到 Program Files、写注册表、安装 Windows 服务那 MSI 几乎绕不开。但 MSI 本身不提供自动更新能力它只是一套安装和卸载机制你需要额外写一个引导器定期去服务器检查新 MSI 版本然后静默执行msiexec /i setup-1.2.3.msi /qn /norestart引导器获取退出码来判断结果0表示安装成功3010表示成功但需要重启。这里有一个关键的坑MSI 版本升级不是直接把新 MSI 装上去就行的。如果你用 WiX 这类工具打包需要正确管理 ProductCode、UpgradeCode 和 ProductVersion。UpgradeCode 用来标识这是同一个产品ProductCode 每次大版本应该变化ProductVersion 要递增。这批配置没理清升级时会出现已安装更高版本、新旧文件交错、或者无法卸载旧版的问题。MSI 引导器的方案里更新包的下载其实也是整包下载MSI 体积如果很大弱网用户会非常痛苦。又因为安装需要管理员权限每次自动更新都会触发 UAC。所以这个方案更适合企业级产品配合软件资产管理、组策略分发来用而不是面向普通 C 端消费者频繁发布更新的场景。3.3 两条路线怎么选我的判断标准很简单应用完全没有系统级需求且用户群体可控ClickOnce 是最快的。它不需要服务器做复杂逻辑不需要自己写更新器Visual Studio 里点几下就能发布。缺点是你放弃了对更新流程的掌控。反过来应用一定要做系统级安装或者甲方明确要求用 MSI 交付那就用 MSI同时接受它的重和笨。MSI 本身的设计目标是可靠安装不是高频自动更新指望它做 C 端产品的更新体验基本等于拿卡车跑 F1。4. 接入 AutoUpdater.NET小团队省事但别忽略底层逻辑AutoUpdater.NET 是个老牌开源库NuGet 上直接搜 Autoupdater.NET.Official 就能装。它解决的是 ClickOnce 管不住、自研更新器又太费时间的中间问题你的应用可以是普通安装目录部署更新逻辑由这个库托管。4.1 服务端 appcast.xml 准备AutoUpdater.NET 的标准做法是服务器放一个 appcast.xml 文件?xml version1.0 encodingutf-8? item version1.2.3.0/version urlhttps://download.example.com/releases/myapp-1.2.3.0.zip/url changelog修复崩溃问题新增导出功能/changelog mandatoryfalse/mandatory /item需要注意编码建议强制 UTF-8。默认的 appcast.xml 如果带中文字符且没用 UTF-8 保存很容易出现乱码。另外文件里的版本号是四段式客户端拿它和当前 AssemblyVersion 比较四段版本号里任意一段更大就会被判定有新版本。4.2 客户端接入与事件钩子客户端核心代码就一行AutoUpdater.Start(https://example.com/update/appcast.xml);如果你不想每次启动都同步检查可以把AutoUpdater.Synchronous设为 false让它在后台线程做检查。默认它会弹一个发现新版本的对话框用户可以选择立即更新或者稍后提醒。想免打扰的话可以绑定几个事件做自定义 UICheckForUpdateEvent检查完成回调能拿到是否有更新DownloadProgressChangedEvent下载进度UpdateDownloadedEvent下载完成ParseUpdateInfoEvent自定义解析 appcast.xml 的逻辑AutoUpdater.NET 在处理替换时会在应用目录启动一个 AutoUpdater.exe 更新器进程由它关闭主程序、替换文件、再重启应用。所以你的发布目录里会多一个 AutoUpdater.exe发布时不要漏掉它。这个设计思路和我前面讲的手写方案类似只是框架替你实现了。4.3 经验教训这些配置细节不能省使用 AutoUpdater.NET 最常见的坑有三个。第一AutoUpdater.ReportErrors false一定要设。默认情况下如果检查更新时服务器不可达它会在客户端弹错误框。面向非技术用户的产品你不想让他们看到无法连接到远程服务器这种字眼。第二更新包必须放对文件名或确保 url 正确。AutoUpdater.NET 在 appcast.xml 里指定了 url 就按 url 下载如果你偷懒不配 url它会按一定的默认规则拼接文件名那种情况很容易 404。我的建议永远是显式写清楚 url。第三对公网产品尽量设置强制更新和签名校验。AutoUpdater.Mandatory true可以强制用户必须更新。更新包本身建议走 HTTPS不要用裸 HTTP否则更新包在传输过程中被替换的风险是真实存在的。整体评价AutoUpdater.NET 胜在省事几行代码就能拥有检查、下载、提示、替换的完整链路。缺点是你的控制力有限复杂更新策略灰度、差量、自动回滚它给不了你。它适合中小型工具和团队产品一旦进入规模化运营阶段还是得上更现代的方案。5. Squirrel.Windows 的继任者 Velopack差量更新与自动回滚如果你做的是一个要长期面向大量用户交付的正式桌面产品我的首选是 Velopack。它是 Squirrel.Windows 的继任者跨平台WPF、WinForms、Avalonia 都支持而且把很多以前要自己纠结的问题直接解决了。5.1 为什么说 Velopack 解决了历史难题先说差量更新。传统整包更新对用户最大的伤害是流量和等待时间一个 50MB 的应用每个月更新两次用户每次都要下载完整包。Velopack 做更新时会对比新旧版本文件差异只下载变化的部分差量包经常只有整包的十分之一到三分之一。对于带宽有限、网络不稳定的用户这个体验差距是质变级的。再说自动回滚。Velopack 在应用更新后启动新版本时会在后台记录启动状态。如果新版本启动失败、或者启动后崩溃导致没有正常确认启动成功下一次运行会自动回滚到上一个可用版本。这个机制对你来说就是发了一个坏包至少不会把用户困在一个打不开的版本里。还有一个很多人没注意到的点Velopack 安装默认不需要管理员权限。它把应用装到用户目录下更新自然也不需要提权。这直接绕开了第 1 节说的权限难题对没有管理员权限的企业用户太友好了。5.2 接入、打包和发布全流程首先安装命令行工具dotnet tool install -g vpk在项目里安装 Velopack NuGet 包然后在 Program.cs 最顶部调用VelopackApp.Build().Run();这段代码必须在 Main 方法里最早执行因为它要处理命令行参数、启动钩子、回滚标记等逻辑。如果你放在中间某些更新和回滚场景会失效。更新检查逻辑大概长这样using var mgr new UpdateManager(https://download.example.com/update/); var index await mgr.CheckForUpdatesAsync(); if (index ! null index.TargetFullRelease ! null) { await mgr.DownloadUpdatesAsync(index); mgr.ApplyUpdatesAndRestart(); }发布时先正常 publish 出一份应用文件然后vpk pack -p ./publish -v 1.2.3 -e MyApp.exevpk 会根据 publish 目录的内容生成安装包、更新包和版本描述文件并把它们组织成 Velopack 要求的发布目录结构。你只需要把生成的内容传到服务器静态目录即可。具体参数我建议每次打包前vpk pack --help确认一下不同版本可能略有变化。5.3 使用体验与团队适配前提Velopack 的上手成本比 AutoUpdater.NET 高一些主要在于打包流程需要额外理解。它的发布目录、版本描述文件、delta 生成机制都有自己的一套约定团队里得有一个人把这些东西吃透不然打包出了偏差不容易排查。但要特别注意Velopack 对应用的安装目录有约束。它管理自己的安装目录和版本文件你不要在安装目录里随意写运行时文件。需要持久化的数据应该写到环境变量或用户目录。这条约束对很多从直接在 exe 旁边写文件走过来的老项目来说是一个不小的改造点。我的实际体感是一旦把 Velopack 的发布流水线跑顺更新对用户是完全静默的体验非常接近现代商业软件。如果团队有这个改造精力它值得投入。6. 不管选哪套方案这几个坑都得提前埋线最后这部分和具体框架无关是 .NET 桌面应用做自动更新时绕不开的通用事项。这些坑我踩过不止一次列出来给你当检查清单。6.1 签名、证书与杀软误报更新器被杀软拦截是自动更新最隐蔽的杀手。你测试环境一切正常用户机器上报检测到威胁而且不是你重新打包能解决的。代码签名证书能显著降低 SmartScreen 和杀软的误报率因为它提供了这个程序来自可信发布者的信任链条。没有代码签名的情况下至少要做到更新包走 HTTPS、带哈希校验、更新器逻辑尽量简单无敏感行为。不要把更新器和远程执行代码等功能混在一个包里越像木马越容易被杀。6.2 版本号、强制更新与灰度放量版本号策略我会在项目初始化时定死。AssemblyVersion 作为程序集标识不要每次编译自动递增FileVersion 可以随便变让 QA 能区分构建版本。更新判断永远基于前者否则每次编译都会触发用户更新。强制更新需要放在服务端配置里而不是写死在客户端。举个例子你的清单里加一个 minVersion 字段客户端检测到当前版本低于 minVersion 就强制更新不允许跳过。产品出紧急事故时你只需要改服务器上的 minVersion客户端下一轮检查就会进入强制更新流程而不是发新包。灰度发布可以做得很简单服务端按用户 ID 或机器特征哈希取模比如只允许哈希值在 0-9 区间的客户端看到新版本。这样即使新版本有重大问题影响面也可控比全量发布稳得多。6.3 配置和数据文件的迁移策略更新不只是换 exe。配置文件在旧版本里可能加了新字段数据库 schema 可能变了。如果更新后直接覆盖 config 文件用户的自定义设置会丢如果直接跑数据库迁移脚本老版本用户升级时可能因为连接串变更连不上。我的做法是配置文件从不覆盖用默认配置 用户配置合并策略新版本启动时发现缺少字段就补默认值。数据库迁移脚本则要求幂等也就是重复执行不会报错因为更新器可能因为网络问题重试多次。6.4 失败回滚与可观测性自动更新是一个不可控的分布式操作所以可观测性一定要做。我习惯在任何更新方案里都加一个 updater.log检查时间、下载文件大小、校验结果、替换了哪些文件、耗时多久全记下来。用户反馈更新失败时你让他把这个日志发过来五分钟就能定位问题。日志里要记录客户端当前版本、目标版本、错误异常堆栈、机器平台信息。没有这些你很难判断是网络问题、权限问题还是文件占用问题。一个看起来一样的更新失败弹窗背后的原因可能千差万别。最后继续聊一个我现在的习惯。我做一个新桌面项目时会把更新链路作为基础设施提前定下来而不是功能写完再补。如果团队没有成熟方案我会先按第 2 节手写一个最小可用的更新器把服务端清单格式定死把签名和日志带上。等产品真正需要灰度、差量、自动回滚时再切换到 Velopack服务端目录结构调整不大客户端的更新体验也会顺滑地过渡。先让子弹飞一会儿再上重型武器这个节奏很少出错。
RELATED — 相关阅读

相关资讯

LATEST — 最新资讯

最新发布

TODAY — 本日精选

新闻

WEEKLY — 本周精选

新闻

MONTHLY — 本月精选

新闻