做前端 Web 的同事可能很难理解为什么桌面端一个“检查更新”的按钮做起来能折磨人这么久。但凡是发过几个 .NET 桌面应用的人基本都体会过这种状态修完 bug 兴冲冲打好包传给用户第二天用户还是用着老版本然后拿着一个早就修掉的问题来找你。自动更新放在功能列表里看起来只是“有则更好”真正上线后你就会发现它直接决定了你的用户到底在用哪个版本的代码、你的线上质量问题能不能快速收口。这篇内容我把 .NET 桌面应用程序自动更新的主流方案从头到尾捋一遍覆盖 ClickOnce、轻量组件类方案、Squirrel/Velopack 这类安装包框架、MSIX以及完全自研更新服务。每个方案会讲清楚原理、适用场景、接入要点和我在实际项目中踩过的坑最后给出一份可以直接照抄的选型建议。适合正在给 WinForms / WPF 应用做分发改造的开发者也适合想评估现有更新机制要不要换的团队。1. 先把“自动更新”这事的本质看清楚1.1 它到底难在哪儿很多人第一次做更新功能时会下意识觉得这有什么难的把新文件下载下来覆盖旧的就行。真这么干一次你就知道问题在哪了。最核心的矛盾是文件占用。应用主程序正在运行的时候exe 和 dll 文件是被进程锁住的Windows 不允许你直接删除或覆盖正在使用的文件一操作就是 sharing violation。所以更新动作必须绕开正在运行的进程要么等进程退出后再执行要么用一个独立的辅助进程来完成文件替换。其次是权限问题。如果你的应用装在 Program Files 目录下普通用户默认没有写权限更新时必然要弹 UAC 提权。对于企业内网环境还好说对于面向个人用户的工具软件每更新一次弹一次 UAC用户很快就会烦到直接不更新。再往下还有更新过程中的崩溃风险。如果更新到一半网络断了、磁盘满了、进程被强杀应用可能处于“老的被覆盖、新的没写完”的中间态直接没法启动。所以真正成熟的更新机制必须有备份、校验和回滚逻辑而不是简单粗暴地覆盖文件。除了这些技术层面的难点还有产品层面的问题更新的应用数据和程序目录怎么分离灰度发布怎么做强制更新和普通更新怎么区分更新失败的日志从哪里看这些问题在方案选型阶段就得想清楚。1.2 方案家族的整体格局.NET 生态里的自动更新方案我按实现深度大致把它们分成五类方案本质思路上手难度可控程度ClickOnce官方工具链自带的发布更新机制最低低AutoUpdater.NET 这类组件在应用内嵌入检查/下载/替换逻辑低中Squirrel / Velopack安装包框架完整管理安装/更新/卸载中偏高中高MSIX App Installer系统级打包格式靠安装器更新中低自研更新服务自己写清单、下载器、替换器和回滚逻辑最高最高这五类不是竞争关系更像是从低到高的阶梯。实际选型时主要看两件事你对更新流程有多特殊的控制需求以及你愿意为这个控制权付出多少维护成本。如果只是给公司内部工具做更新ClickOnce 完全够用。如果是做一个要正式对外分发、用户量不小的产品我建议至少用 Squirrel/Velopack 这个级别因为安装、更新、快捷方式、卸载这些事它已经帮你想好了。只有当你有灰度发布、多更新通道、自定义安装逻辑、离线局域网分发这类复杂诉求时才需要考虑自研。2. 方案一ClickOnce——求省事的首选2.1 ClickOnce 的工作机制ClickOnce 是官方工具链里集成度最高的发布方案。它的做法是把应用发布到一个静态文件服务器或共享目录上生成一个描述文件.application和一堆按文件记录哈希的清单数据。客户端通过 URL 访问这个文件自动比对版本、下载变更的 assembly然后安装到当前用户的 ClickOnce 缓存目录里。这里有个很多人没注意到的点ClickOnce 的应用并不是装在 Program Files 下而是装在用户级别的缓存里。这意味着安装和更新都不需要管理员权限也不会弹 UAC。卸载的时候从系统“程序和功能”里走一遍基本不会留垃圾。对内部工具软件来说这种“零权限、零依赖、卸载干净”的体验确实很省心。新版 SDK 虽然也支持通过命令行发布 ClickOnce但整体上它还是和旧框架绑定得比较深。如果你已经全面转向现代 .NET项目结构比较复杂ClickOnce 的适配成本会逐渐显现。2.2 接入步骤与关键配置用 ClickOnce 做更新的流程非常简单我一次次重复操作下来基本就是这几步在项目属性的发布面板里设置发布位置比如https://updates.example.com/app/。这个位置既可以是 HTTP 地址也可以是局域网共享目录。选择安装模式。如果希望应用离线也能启动选“可选更新”并勾选“该应用程序也可以从网络安装”实际上是把它做成联机脱机都可用的形式。配置更新检查。在“更新”设置里可以决定“每次启动前检查”“启动后检查”还是手动检查还能设置最低必需版本——这个就是强制更新的入口。发布后把生成的setup.exe和.application文件传到目标服务器用户在浏览器里点一下安装即可。如果不想用系统自带的更新提示弹窗也可以在代码里自己控制。需要留意的是ApplicationDeployment.IsNetworkDeployed这个判断本地调试时它不是网络部署状态直接调用更新 API 会抛异常if (ApplicationDeployment.IsNetworkDeployed) { var deployment ApplicationDeployment.CurrentDeployment; if (deployment.CheckForUpdate()) { deployment.Update(); Application.Restart(); } }实际项目里我一般会在后台线程做检查避免启动时卡界面。CheckForUpdate是同步的网络慢的时候可能把欢迎界面卡住好几秒换成CheckForUpdateAsync配合await会稳妥得多。2.3 实际使用中的限制ClickOnce 最让我难受的是每次发布都是“全量”的。它的更新比较粒度是文件级但如果某一个 dll 变了这次发布就会把整套程序重新发一遍。应用体量一旦上了几十上百兆更新流量和等待时间会很明显。另外它的更新机制对系统版本和网络环境依赖较强在极简环境或者特殊域环境里偶尔会出现“部署已损坏”之类的报错。数字签名问题也比较突出——不签名的 ClickOnce 部署在很多系统上会直接被 SmartScreen 拦截测试环境里看着没问题真实用户那里安装率掉得厉害。这类方案适合没有特别多定制需求的场景给公司同事做个内部小工具更新频率不高用户量不大。它不需要单独的更新服务端也不需要在应用里写太多更新代码属于“配置完就能跑”的类型。3. 方案二AutoUpdater.NET——轻量组件的典型代表3.1 组件方案的核心思路如果 ClickOnce 的控制粒度满足不了你又不想直接上完整的安装包框架那第三方组件是最常见的中间路线。这类组件里我实际用过、也愿意推荐的是 AutoUpdater.NET。它的更新思路非常直白应用启动时从一个固定 URL 读取 XML 清单清单里写明最新版本号和更新包地址。比对后如果发现新版本就弹窗提示用户下载更新包等主进程退出后由一个辅助进程负责解压、替换文件、重新拉起主程序。整个过程不需要创建 Windows 服务也不需要额外安装器一切都在应用的“自我更新”逻辑里完成。这个方案的优点首先是没有很强的框架约束——只要是个托管应用就能接。其次是更新包就是普通的 zip你可以用任何方式在服务器端生成甚至可以自己写脚本发布。第三是它的弹窗、下载进度、稍后提醒这些交互都内置了省掉自己造 UI 的功夫。3.2 服务端清单与客户端接入服务端需要准备的其实只有一个 XML 文件和一个 zip 包。XML 结构非常简单item version1.3.0.0/version urlhttps://updates.example.com/packages/app-1.3.0.zip/url changelog修复设置界面崩溃增加导出功能/changelog mandatoryfalse/mandatory /item客户端接入的核心就一行AutoUpdater.Start(https://updates.example.com/updates.xml);放在Main方法或MainWindow构造里都行。需要更细腻的控制时可以设置这些参数AutoUpdater.Synchronous true; // 是否阻塞等待检查结果 AutoUpdater.ShowRemindLaterButton true; // 是否显示“稍后提醒” AutoUpdater.RemindLaterTimeSpan TimeSpan.FromDays(1); AutoUpdater.LetUserSelectRemindLater true; // 允许用户选提醒时间 AutoUpdater.DownloadUpdate(); // 后台下载更新包如果希望启动时不弹窗、只做静默检查可以调用CheckForUpdateAsync等返回结果后再决定下一步动作。3.3 实测中必须注意的细节这类组件用起来顺畅但坑也比较典型。第一个坑是更新包必须保持目标机架构一致。如果应用本体是 32 位进程更新包里混进 64 位 native 依赖替换后轻则运行异常重则起不来。所以打好包之后务必跑一遍“从旧版本更新到新版本”的完整流程而不是只在全新环境里测。第二个坑是安装目录权限。应用如果装在 Program Files 下组件在替换文件时会触发 UAC 提权。这个属于预期行为但你要提前在更新说明里告诉用户别让用户以为是什么异常弹窗直接给取消了。第三个坑跟代码签名有关。更新包里的主 exe 必须签名否则 Windows 会把下载的文件打上“来自网络”的标记SmartScreen 很可能会拦一道。我自己经历过一次更新逻辑写得好好的用户测出来却永远升级不了最后一看就是拦截提示把用户吓退了。还有一个使用习惯层面的建议更新 URL 最好放到配置文件里而不是写死在代码中。这样将来迁移服务器、切换内网外网环境不需要为改一个地址重新发版。清单文件名和 zip 包命名也建议带上版本号方便排查问题。4. 方案三Squirrel 系与 Velopack——正式对外分发该考虑的档位4.1 它和“替换文件”式更新的本质区别ClickOnce 和 AutoUpdater.NET 本质上都在解决“把新文件放到旧位置”。Squirrel 系方案换了一套思路它把应用做成一个真正的安装包每个版本独立安装到各自的目录下更新时切换“当前版本”的指向而不是原地覆盖文件。这个思路带来几个实实在在的好处。第一每个版本都保留在磁盘上如果新版本启动失败可以退回上一个版本回滚不再是“把备份拖回来”那种脆弱操作。第二安装器可以帮你处理好快捷方式、卸载项、开始菜单目录这些零碎事对于正式产品这都是必须的体验。第三它支持增量更新只下载和上一版本有差异的内容对体积大的应用非常友好。早几年的 Squirrel.Windows 已经停止活跃维护现在社区更推荐 Velopack 这个继承者。它把 Squirrel 打包器重写了一遍支持新框架同时保留事件钩子机制应用可以在首次安装、更新后、卸载前执行自己的逻辑比如创建配置文件、清理用户数据。4.2 打包和接入的最小流程Velopack 的官方打包工具是命令行工具我用 dotnet tool 安装后基本流程是这样dotnet tool install -g velopack vpk pack -p publish/ \ -e MyApp.exe \ -v 1.3.0 \ -o releases/其中publish/是应用已经发布好的一整套文件目录-e指定主程序入口-v指定版本号-o是输出目录。打包完成后会生成 Setup.exe 安装程序和一个包含 RELEASES 索引的 releases 目录把后者整体扔到更新服务器上即可。客户端更新代码的大体轮廓using (var mgr new UpdateManager(https://updates.example.com/releases)) { var update await mgr.UpdateAppAsync(); if (update ! null) { RestartApplication(); } }需要提醒一句不同版本的客户端 API 名字略有差异接入时以你当前所用版本的官方文档为准不要直接粘贴网上旧帖子的代码。事件钩子通常在应用启动早期执行程序长这样VelopackApp.Build().Run();放在Main开头它会在合适的时机触发安装、更新、卸载回调你可以把创建桌面快捷方式、写默认配置这些动作塞进去。4.3 这套方案的实际成本选择 Velopack 意味着你要付出比之前方案多一些的学习成本。发布目录下会把历史版本的 nupkg 包都保留着你需要持续维护 releases 目录服务器磁盘占用必须有预期。增量更新虽然省带宽但对应的服务器端要按规范组织文件不是随便扔一个 zip 就能用的。另外这个方案同样离不开代码签名。安装器不签名SmartScreen 会在首次安装时就设置一道心理门槛。既然都做到正式分发阶段了签名证书这笔成本属于绕不过去的项目。我认为 Velopack 适合那些面向真实用户、需要口碑和安装体验的产品。它的更新过程完整可控又有增量更新和回滚能力属于“值得在这个档位上认真做”的方案。如果你想先用一个现成方案把基础打好Velopack 比自研来的性价比要高得多。5. 方案四MSIX——面向企业分发与打包治理5.1 MSIX 的更新原理MSIX 是 Windows 生态里的现代打包格式可以理解成把传统安装包、应用数据、权限声明统一成一个带签名容器的机制。配合 App Installer 文件应用可以从一个 HTTPS 地址完成安装和自动升级。它的更新逻辑是服务器上放一个.appinstaller文件里面声明当前版本、应用包下载地址和更新策略。客户端通过访问这个文件触发安装或更新。因为安装过程是系统级的文件替换、注册、卸载都由系统负责所以它对传统应用那种“覆盖文件”的脆弱操作做了很好的收敛。MSIX 还支持按文件粒度做增量更新只传递有变化的部分。这个能力依赖下载服务器对包文件的分块支持对于内网带宽有限的企业环境意义很大也是很多团队从传统更新切到 MSIX 的直接原因。5.2 接入步骤和限制条件主流的做法是通过打包工程或专用工具把应用生成.msix包然后配置.appinstaller文件里面关键内容就是指向应用包的 URL、版本号以及更新模式UpdateSettings OnLaunchHoursInterval24/OnLaunchHoursInterval UpdateModeOnLaunch/UpdateMode ShowPrompttrue/ShowPrompt /UpdateSettings这里UpdateMode可以设为启动时更新、后台静默更新或完全手动。企业场景最常用的还是OnLaunch用户每次启动时检查一次发现新版本自动装好。MSIX 的限制也很明确。第一它要求 Windows 10 1809 以上的系统还在维护 Windows 7 老应用的团队基本不用考虑。第二包必须签名并且签名证书要能被目标机器信任。企业内部可以用域下发证书信任对外分发就得走正规证书成本链路比较长。第三更新交互体验由系统统一接管你没法大幅定制安装界面产品级的品牌感会弱一些。如果你们是给政企客户做内网系统IT 部门统一管理终端MSIX 是个很稳的选择。它的更新机制不依赖应用内代码维护起来省心而且卸载干净度是所有方案里最好的。6. 方案五自研更新服务——需要完全掌控时的终极手段6.1 架构设计从哪里入手当前面几种方案都满足不了你的时候就需要拆开自己造了。我做过几个自研更新项目先说说核心架构结构其实可以概括成三部分更新清单、下载校验、独立替换器。更新清单负责描述“有什么新版本”和“新版本长什么样”。我一般用 JSON 而不是 XML因为服务端更好生成客户端解析也简单{ version: 1.4.0.0, url: https://updates.example.com/1.4.0.0/MyApp.zip, sha256: e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855, changelog: 优化启动速度修复设置页闪退, mandatory: false, releaseChannel: stable, rollout: 100 }这里的rollout是灰度比例0 到 100 之间的整数。客户端可以根据机器唯一标识比如网卡哈希或注册表随机 GUID计算出一个 0 到 99 的值落在灰度区间内才提示更新。这个机制在正式产品里价值很大先让 5% 的用户吃新版本确认没问题再逐步放量到全量出问题随时把清单里的rollout改回 0等于远程一键刹车。客户端检查更新的核心代码逻辑不复杂private async TaskUpdateManifest? CheckUpdateAsync() { using var http new HttpClient { Timeout TimeSpan.FromSeconds(10) }; var json await http.GetStringAsync(UpdateManifestUrl); var manifest JsonSerializer.DeserializeUpdateManifest(json); var latest new Version(manifest.Version); var current Assembly.GetExecutingAssembly().GetName().Version; return latest current ? manifest : null; } private async Taskstring DownloadAndVerifyAsync(UpdateManifest manifest) { var tempFile Path.Combine(Path.GetTempPath(), ${Guid.NewGuid():N}.zip); var bytes await new HttpClient().GetByteArrayAsync(manifest.Url); var hash Convert.ToHexString(SHA256.HashData(bytes)); if (!string.Equals(hash, manifest.Sha256, StringComparison.OrdinalIgnoreCase)) { throw new InvalidDataException(更新包哈希校验失败); } await File.WriteAllBytesAsync(tempFile, bytes); return tempFile; }哈希校验是自研方案里绝对不能省的一步。它防止的不是黑客篡改更多是网络传输中间的文件损坏和服务器配置错误。我见过不止一次因为忘了写校验逻辑结果用户在弱网环境下拿到半截文件更新到一半直接崩掉。6.2 替换器要设计成独立进程真正动手时会发现最麻烦的不是下载而是“怎么在应用运行时把文件换掉”。我的做法是主应用下载并校验完成后启动一个独立的 Updater.exe 辅助进程把目标路径、更新包路径、主进程 PID 作为参数传过去然后主应用立即退出。辅助进程的执行顺序可以固定成这几步// 1. 等待主进程完全退出 WaitForExit(pid); // 2. 备份旧版本 BackupDirectory(targetPath, backupPath); try { // 3. 解压更新包到暂存目录 ZipFile.ExtractToDirectory(packagePath, stagingDir, overwrite: true); // 4. 切换目录 SwapDirectory(stagingDir, targetPath); } catch { // 5. 失败则回滚 RestoreBackup(backupPath, targetPath); throw; } // 6. 拉起新版本 Process.Start(Path.Combine(targetPath, MyApp.exe));SwapDirectory的经典做法是先把旧目录重命名为app.old再把新目录重命名为正式目录。因为重命名发生在同一个卷内系统开销小、速度快窗口期比“先删后复制”短得多。这对大目录尤其重要——直接在旧目录里逐个覆盖文件中途断电就可能留下一个半新半旧的程序。自研方案必须连带解决的问题还有安装目录与应用数据目录分离。我强烈建议所有用户数据放到LocalApplicationData或ApplicationData下不要放在安装目录里。更新重装目录时旧数据一旦被清掉用户的配置、缓存、关键历史都没了这是不可接受的事故。6.3 自研要付出什么代价自研的代价是明摆着的。你要自己处理断点续传、网络异常重试、磁盘空间检查、杀毒软件误报、多实例互斥、版本比较策略、灰度匹配规则、日志上报和回滚判定。这些单拆出来都不难合在一起就是一个完整的发布管理系统。所以我的建议很明确除非你的业务确实需要“自定义更新频道 细粒度灰度 特殊网络环境 复杂文件差异更新”这类组合否则不要轻易走自研路线。能把 ClickOnce 或 Velopack 用得足够深度已经能覆盖绝大多数产品需求了。如果决定自研也建议把更新器和主应用分开维护更新器本身要足够小、足够稳别把更新逻辑和业务逻辑耦合在一起。7. 选型对照与跨方案避坑总结7.1 直接抄作业的选型对照做技术选型最忌讳“网上说哪个好就换哪个”我给出一份基于实战经验的对照表把关键差异摆出来方案适合场景增量更新更新UI可控度维护成本我的推荐指数ClickOnce内部工具、原型验证、小微企业产品无低低3/5AutoUpdater.NET中小独立应用、对UI有一定要求的场景无中低4/5Squirrel/Velopack正式对外分发、注重安装更新体验支持中高中4.5/5MSIX企业终端管控、Windows 10 1809支持低中3.5/5自研服务复杂灰度、多通道、离线分发等强定制自研高高视需求而定7.2 所有方案都绕不开的五个坑坑一数字签名。无论用哪个方案只要应用是从网络下载的没有代码签名证书SmartScreen 都会跳警告。企业内网可以通过组策略加白名单缓解对外产品必须尽快签掉。签名这件事越早做越好等用户开始抱怨安装率低再去补救损失已经造成了。坑二版本号比较逻辑。Assembly 版本、文件版本、信息版本三个概念经常被混用。我见过最典型的问题主程序的 Assembly 版本一直没变清单里写了新版本号结果更新逻辑永远判断“当前已经是最新”整个更新通道相当于失效。建议统一使用语义化版本号并且只以清单里的版本为准主程序自身的 Assembly 版本保持同步递增。坑三文件占用。更新失败的原因里文件被占用是最常见的。杀毒软件扫描正在占用 dll、用户开了多个实例、甚至有人的 Explorer 预览窗格卡住了 exe都会导致替换失败。成熟方案里要么重试几次要么提示用户关闭所有实例。我在自研方案里用命名 Mutex 做了单实例限制更新前先检查有没有其他实例存活。坑四更新过程不能阻塞主流程。更新检查是网络操作再快也要几十毫秒慢起来能卡十几秒。放在启动热路径上非常影响体验。正确做法是启动后异步检查有更新再弹提示用户点“更新”后才进入下载和替换流程。同时要提供“手动检查更新”的菜单入口很多用户不信任自动行为给个手动按钮能显著减少咨询量。坑五回滚策略。不管是框架方案还是自研都得回答一个问题新版本起不来怎么办Velopack 因为保留了历史版本目录回滚天然方便。自研方案至少要做一个“启动失败检测”新版本首次启动后上报心跳如果连续两次启动都没上报就把备份目录恢复回去。没有回滚策略的更新系统本质上是在赌每次更新都一定成功。7.3 我最常用的组合心得最后分享一点个人经验。我现在在项目里通常不会只用单一方案而是让它们配合着来。对外正式产品用 Velopack 打底加一个自研的灰度开关层清单用 JSON 维护通过参数控制是否支持预览版通道内部运维工具则回归 ClickOnce完全不值得为它们再铺一条复杂的更新链路。还有一个细节想提一下——更新日志。框架自带的功能里更新日志经常被忽略但用户是否愿意点“更新”按钮有时就差在你能不能让他知道这次更新解决了他遇到的问题。每次发布前花十分钟把 changelog 写清楚更新确认率会有肉眼可见的提升。别只写“修复若干问题”要写“修复设置界面闪退”这种用户能直接感知的描述。这事没有银弹。找准自己的场景边界把方案的特性吃透比追逐新的更新框架重要得多。