拓冰建站拓冰建站
首页 / 资讯中心 / 正文

C#桌面客户端自动更新:从版本比对到增量更新的完整实现

简介C#自动更新程序源码是一套面向.NET开发者的客户端自动升级解决方案基于.NET 2.0并利用IIS等Web服务完成程序更新。压缩包共55个文件包括12个cs源码文件、8个exe可执行文件、10个ico图标文件配合resx资源、pdb调试文件、sln解决方案及xml配置等整体仅494KB麻雀虽小五脏俱全。已有734人学习/下载。资源分为XmlUpdate与AutoUpdateClient两大模块XmlUpdate用于为服务端所有文件与目录生成MD5值AutoUpdateClient则借助批处理实现客户端自我更新从中可以清晰看到MD5校验、版本比对、文件下载覆盖与重启更新的完整思路。适合需要在WinForms、控制台等.NET程序中快速集成自动升级功能的中初级开发者直接参考或二次开发。1. 先说项目背景与整体思路做桌面客户端开发的同学应该都有过这种经历软件发出去一版用户那装了几千台结果发现一个低级Bug只能挨个通知用户下载新版、手动覆盖安装。有些用户不会弄你还得远程教他先关掉程序再把压缩包解压覆盖到安装目录……一套流程下来技术支持的精力全耗在重复沟通上了。如果你有过这种经历那实现一个自动更新功能是值得投入时间去做的事。“C#自动更新程序源码”这个项目核心目标就一个让客户端程序具备自我升级的能力。用户打开软件时后台自动检查服务器上有没有新版本有就提示用户或者静默下载完成后自动替换下一次启动就是新版本了。整个过程不需要用户懂任何电脑操作也不需要技术支持远程指导。这篇文章我会把这套自动更新机制拆开讲清楚从版本比对原理到更新文件怎么组织再到核心代码怎么写最后是我自己在实际项目中踩过的一些坑。内容以C# WinForm/WPF为主但思路是通用的其他桌面技术栈也可以参考。适合谁来读刚做完一两个小项目、想让自己的软件看起来更完整的新手开发者被用户反馈“怎么更新啊”烦到不行的独立开发者以及公司内部工具链的维护人员。哪怕你还没写过一行C#只要会点编程基础跟着思路走也能落地。2. 自动更新的核心原理与方案选型2.1 更新的本质是什么说白了自动更新做了三件事知道“有没有新版”、拿到“新版文件的二进制数据”、把旧文件替换成新文件。这三件事听起来简单实际落地时每一个环节都有讲究。比如“有没有新版”这个问题最简单粗暴的做法是客户端拿本地版本号和服务器上的一个文本文件比对文本里写着最新版本号。但很多时候光比对版本号不够因为程序可能改动了很多文件只靠一个大版本号无法精确知道用户缺了哪些文件。更严谨的方案是服务器上维护一个版本清单文件里面记录每个文件的文件名、大小、版本号、校验值如MD5或SHA256客户端对比本地文件找出差异然后只下载有变动的文件。这个思路其实就是“增量更新”的基础。早期的更新程序很多是“全量更新”即不管三七二十一把整个安装包都下载下来重新安装。对于小软件来说全量更新也能用但当软件体积到几十上百兆甚至更大时全量更新就浪费带宽了。增量更新只下载变化的部分效率高但更新逻辑复杂度也上了一个台阶。2.2 几种常见更新方案对比我列一下实际项目中经常用的几种方案各有适用场景很好做选择方案优点缺点适用场景全量包手动下载实现简单操作繁琐用户体验差内部测试工具全量包启动时提示下载用户体验好一些下载时间长需要用户确认中小型工具软件增量更新文件级差异下载流量小体验好需精心设计版本清单逻辑文件多、更新频繁的软件差分更新字节级差异流量最小实现复杂工具链要求高大型商业软件如游戏客户端对于大多数C#桌面项目做到“文件级增量更新”基本够用了。字节级差分更新虽然更省流量但对算法的要求高一般小团队没必要上。我自己的项目用的是文件级增量更新简单可靠更新包最小可以到几十KB。2.3 为什么选择“独立更新程序 主程序”的架构新人做自动更新最容易犯的错是把更新逻辑写在主程序里主程序边跑边更新自己。这在Windows上会遇到一个大麻烦正在运行的可执行文件是没法被覆盖的。你在程序里调用File.Copy想去替换自己系统会报“文件正在被另一个进程使用”。解决思路有两个一是把更新代码写在一个独立的小程序里叫AutoUpdater.exe主程序检测到新版本后启动AutoUpdater.exe然后自己退出AutoUpdater负责下载、解压、替换文件完成后重新拉起主程序。另一个思路是用Windows的延迟替换机制MoveFileEx配合标志位让系统在重启时自动替换文件。后者听起来更优雅但实际体验不好毕竟很多用户不喜欢重启电脑。所以我选了独立更新程序的架构。这个方案改动清晰、逻辑简单更新过程哪个环节出问题也容易定位。AutoUpdater本身不常更新所以不存在“更新程序自己怎么更新自己”的死锁问题。3. 细说更新机制的关键环节与实现3.1 版本信息的组织方式要想让更新逻辑可维护第一步是把版本信息的格式定好。我推荐用JSON清晰、可读性好C#用System.Text.Json或Newtonsoft.Json解析都很方便。服务器上需要放两个关键文件version.json描述当前发布的版本信息大概长这样{ version: 1.2.3, releaseNotes: 修复了登录超时的问题增加了导出功能, files: [ { path: MyApp.exe, size: 1843200, md5: 8d3e2f1e9c2b6f6d9cbb1234567890ab, version: 1.2.3.0 }, { path: MyApp.Core.dll, size: 512000, md5: 4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c, version: 1.2.3.0 } ] }对应的更新文件包按目录结构放好比如一个 update/ 目录下面有 MyApp.exe 对应的新版本文件、MyApp.Core.dll 等。这里的md5字段很重要它是校验用的“指纹”。文件只要有1个字节的变动它的MD5值就会完全不同。下载完成后计算一下本地文件的MD5和清单里记录的比对一致就说明文件没有在传输过程中损坏或被篡改。3.2 主程序侧的更新检测逻辑主程序启动的时候在后台线程里做更新检查不要卡UI。核心逻辑是public class UpdateChecker { private readonly string _serverBaseUrl; private readonly string _localVersion; public UpdateChecker(string serverBaseUrl, string localVersion) { _serverBaseUrl serverBaseUrl.TrimEnd(/); _localVersion localVersion; } public async TaskUpdateInfo CheckAsync() { using var httpClient new HttpClient(); httpClient.Timeout TimeSpan.FromSeconds(10); var url ${_serverBaseUrl}/version.json; var json await httpClient.GetStringAsync(url); var remoteInfo JsonSerializer.DeserializeUpdateInfo(json); var localVer Version.Parse(_localVersion); var remoteVer Version.Parse(remoteInfo.Version); // 只有远程版本大于本地版本时才需要更新 if (remoteVer localVer) { return null; } return remoteInfo; } }这里用的Version类型是.NET自带的可以直接比较大小不用自己解析字符串省了不少事。注意服务端返回的速度如果超过10秒可能网络有问题直接按“无更新”处理就好避免用户每次启动都等半天。3.3 更新程序的工作流程AutoUpdater.exe是独立运行的它的执行流程我按顺序走把下载好的更新包放到本地临时目录与本地安装目录进行比较确定哪些文件需要替换。读取更新包内的文件清单计算本地对应文件的MD5。如果MD5相同说明文件没变过直接跳过如果MD5不同说明文件有变动列入“待替换”列表。把所有“待替换”列表中的文件先从安装目录复制到备份目录。从更新包中把新文件复制到安装目录。全部替换成功后删除备份目录然后重新拉起主程序。为什么要先备份再替换就是因为更新过程中可能出错——比如下载到一半网络断了或者某个文件被其他程序占用替换不成功。如果没备份那用户的主程序就被你搞坏了这是最糟糕的情况。而有了备份以后一旦替换失败AutoUpdater可以回滚把旧文件恢复回去至少保证用户的程序还能用下次再尝试更新。代码里AutoUpdater启动的第一件事是读取传入的参数参数里包含更新包的路径、主程序的路径等信息。实际参数可以通过命令行传也可以约定好一个固定的临时目录名。我之前做过一个版本是用命名管道传递参数但后来发现没必要命令行参数最简单直接也方便调试。3.4 文件替换时“正在占用”的处理细节前面说过Windows不允许直接覆盖正在运行的exe。AutoUpdater.exe自己是正在运行的进程但如果它要替换的是主程序MyApp.exe此时主程序已经关闭了所以是可以替换的。真正需要小心的是主程序可能派生了一些子进程比如它启动了Python或某个辅助工具这些子进程占用着DLL文件就会导致替换失败。我的处理方式是AutoUpdater里先尝试杀掉指定的主进程及其相关子进程用Process.CloseMainWindow()优雅退出等待几秒不行再Kill。然后做文件替换。如果替换时仍然报了占用错误可以根据错误信息提示用户手动关闭一些程序或者等待几秒重试。这里有个细节杀进程这个操作要非常谨慎。只杀自己程序相关的进程千万别做“见什么杀什么”这种操作。我见过有人的更新代码同时杀掉了所有同名的User进程结果用户开着多个软件全给你干掉了这种体验是灾难性的。3.5 下载更新文件时的断点续传与校验更新包体积不大的时候直接用HttpClient下载流保存文件就够了。但如果更新包有几十兆万一中途断网用户就得重新下载体验不好。可以加一个简单的断点续传逻辑public async Task DownloadFileAsync(string url, string localPath, CancellationToken ct) { var fileInfo new FileInfo(localPath); long existingLength fileInfo.Exists ? fileInfo.Length : 0; using var httpClient new HttpClient(); httpClient.Timeout TimeSpan.FromMinutes(30); // 大文件给足时间 var request new HttpRequestMessage(HttpMethod.Get, url); if (existingLength 0) { // 告诉服务器我已经有这么多字节了请从断点发给我 request.Headers.Range new RangeHeaderValue(existingLength, null); } using var response await httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, ct); response.EnsureSuccessStatusCode(); await using var fileStream new FileStream(localPath, FileMode.Append, FileAccess.Write); await using var networkStream await response.Content.ReadAsStreamAsync(ct); await networkStream.CopyToAsync(fileStream, ct); }注意这里用的是Append模式如果断点之后又重新下载Content-Range返回的内容会直接接在已有字节后面所以必须保证断点续传时服务器支持Range请求。IIS、Nginx、阿里云OSS这些都支持CDN也基本都支持。如果不支持Range头会被忽略服务器返回全部内容那Append后文件就坏了所以下载完成后一定要校验MD5发现不对就删掉重建。下载完成后的校验代码也很关键public static bool VerifyMd5(string filePath, string expectedMd5) { using var md5 MD5.Create(); using var stream File.OpenRead(filePath); var hash md5.ComputeHash(stream); var actual Convert.ToHexString(hash).ToLowerInvariant(); return string.Equals(actual, expectedMd5, StringComparison.OrdinalIgnoreCase); }如果MD5不一致直接删除文件重新下载最多重试三次。三次都不行说明可能网络环境有特殊问题提示用户检查网络后重试。3.6 多版本兼容问题这个坑很多新手想不到你的程序升级到1.3后版本清单里只包含相对于1.2的变更文件列表那如果用户用的是1.0呢比如这个用户超级懒从1.0之后一直没更新过然后某一天打开软件你的更新逻辑去服务器拉version.jsonversion是1.3于是开始下载1.3相对1.2的增量更新包。但是用户本地还是1.0的文件对不上更新完后一堆文件版本混乱程序直接崩了。解决方案有两种服务器上维护多个增量包为每个历史版本都准备一份“从该版本升级到最新版”的文件清单。比如update_1.0_to_1.3.zip、update_1.1_to_1.3.zip、update_1.2_to_1.3.zip。服务器判断用户的本地版本太旧比如跨了三个大版本就直接下载全量包不玩增量了。我之前做项目选择了第二种方案服务器上同时保留全量包和增量包。客户端上报本地版本服务器算出差异如果差异文件数量过多或者本地版本低于某个最低支持版本就让客户端下载全量安装包。这个配置可以写死在服务端JSON里灵活调整。4. 实操从零搭建一个可用的自动更新系统4.1 服务器端文件目录规划先做服务端静态文件的规划。假设我用Nginx托管更新文件根目录下大概是这样的/var/www/myapp-update/ ├── version.json ├── full/ │ └── myapp_1.2.3.0_full.zip └── patch/ └── 1.2.2.0_to_1.2.3.0/ ├── MyApp.exe ├── MyApp.Core.dll └── manifest.jsonversion.json是更新检查入口内容描述了当前版本和可用的更新包的信息。full目录放全量包patch目录按“源版本到目标版本”分目录存放增量包。这样结构清晰后续发布新版本时只需要上传对应的patch目录更新version.json即可。有些情况下版本服务器可能在公司内网或者没有公网IP。这种情况下可以退而求其次把更新文件放在共享网盘、对象存储上只要客户端能访问到就行。如果客户端和服务器在同一个局域网内甚至可以不更新version.json直接把URL写死在配置文件里用起来更简单。4.2 发布新版本的步骤做好以上准备后每次发版我按固定流程走一遍不容易出错在开发机生成新版本的构建产物比如MyApp.exe、MyApp.Core.dll等记录文件MD5。对上一个版本的客户端文件做比对找出差异文件放入patch目录。更新patch目录内的manifest.json也可以用同一份version.json写入文件名、大小、MD5。上传version.json和patch目录到服务器。自己机器上装一个旧版程序模拟用户启动验证更新流程是否通畅。这里有个温馨提示发版时一定先在测试环境完整走一遍升级流程再推正式服务器。我有一回着急上线新功能没有在测试环境验证直接传了正式服务器。结果用户A从1.0升到1.3更新完成后DLL版本错乱程序打不开。后来紧急修复时我就在想如果当时多花5分钟走一遍测试就不会发生这种生产事故了。4.3 主程序与更新程序的代码骨架下面给一个实用型的代码骨架大家可以直接参考改造。主程序侧的检测代码在Main()里启动// Program.cs 或者 App.xaml.cs 的启动逻辑里 static void Main() { // 启动时检查更新异步不阻塞正常启动 Task.Run(async () { try { var checker new UpdateChecker(https://update.example.com/myapp, 1.2.2.0); var updateInfo await checker.CheckAsync(); if (updateInfo ! null) { // 把更新信息传给AutoUpdater并退出主程序 var updaterPath Path.Combine(AppDomain.CurrentDomain.BaseDirectory, AutoUpdater.exe); if (File.Exists(updaterPath)) { var downloadUrl $https://update.example.com/myapp/patch/1.2.2.0_to_1.2.3.0/manifest.json; Process.Start(new ProcessStartInfo { FileName updaterPath, Arguments $\{downloadUrl}\ \{AppDomain.CurrentDomain.BaseDirectory}\, UseShellExecute false }); Environment.Exit(0); } } } catch (Exception ex) { // 更新失败不影响主程序正常使用 Debug.WriteLine($Update check failed: {ex.Message}); } }); // 正常启动逻辑…… }关键点是更新检查不能抛异常影响主程序网络问题、服务器宕机都不能让用户没法用软件。更新失败时连提示都可以不做有新版是惊喜没更新也不影响当前使用。AutoUpdater核心逻辑// AutoUpdater的简化实现 class AutoUpdater { static async Task Main(string[] args) { string manifestUrl args[0]; string appDir args[1]; string tempDir Path.Combine(Path.GetTempPath(), MyApp_AutoUpdate_ Guid.NewGuid().ToString(N)); try { Directory.CreateDirectory(tempDir); // 1. 下载版本清单 var manifest await DownloadJsonAsyncUpdateManifest(manifestUrl); // 2. 根据清单下载需要更新的文件到临时目录 foreach (var file in manifest.Files) { string remoteFile GetRemoteFileUrl(file.Path); string tempFile Path.Combine(tempDir, file.Path); Directory.CreateDirectory(Path.GetDirectoryName(tempFile)); await DownloadFileAsync(remoteFile, tempFile); if (!VerifyMd5(tempFile, file.Md5)) throw new Exception($文件校验失败: {file.Path}); } // 3. 关闭主程序 KillMainProcess(MyApp); // 4. 备份原文件 string backupDir appDir _backup; Directory.Delete(backupDir, true); CopyDirectory(appDir, backupDir); // 5. 替换文件 try { CopyDirectory(tempDir, appDir); } catch { // 替换失败回滚 RestoreFromBackup(appDir, backupDir); throw; } // 6. 重启主程序 Process.Start(Path.Combine(appDir, MyApp.exe)); } catch (Exception ex) { MessageBox.Show($更新失败{ex.Message}, 更新提示); } finally { Directory.Delete(tempDir, true); } } }这个骨架里有个backup逻辑替换任何文件前先把整个安装目录完整备份一份保证回滚的可能性。这个步骤在文件多、环境杂的情况下能救命。4.4 安全与异常兜底代码层面有几个安全点值得注意一是HTTPS。更新通道必须用HTTPS否则更新包在传输过程中可能被篡改。如果只是明文HTTP传输黑客可以改你的exe实现植入木马。在客户端里内置服务器公钥甚至可以做签名校验防止文件被替换。虽然对小项目来说加签可能有点重但最基础的HTTPS一定要上。二是数据完整性。下载完成后必须做MD5或SHA256校验防止文件在传输过程中被截断。我自己遇到过cdn缓存不刷新、部分区域拿到了旧文件的情况做了校验后就能及时识别并触发重新下载。三是更新过程要有日志。AutoUpdater的每一步操作都要写日志到本地文件比如“开始下载”“下载完成”“开始替换”“替换成功”。一旦用户反馈更新异常日志是最直接的问题定位手段。别觉得麻烦出一次问题你就知道日志有多值钱了。5. 常见问题与排查经验5.1 运行时提示“文件正在被另一个进程使用”出现这种问题99%是因为还有子进程没关干净。除了主程序MyApp.exe它可能还启动了后台驻留进程、托盘程序、或者某个用来做定时任务的进程。排查方法是在AutoUpdater替换文件前先通过Process.GetProcessesByName()获取包含项目前缀的所有进程逐一关闭再等待0.5~1秒让句柄释放。代码片段private static void KillProcessesByPrefix(string prefix) { foreach (var proc in Process.GetProcesses()) { try { if (proc.ProcessName.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) { proc.CloseMainWindow(); proc.WaitForExit(3000); if (!proc.HasExited) { proc.Kill(); proc.WaitForExit(1000); } } } catch { // 某些系统进程AccessDenied跳过即可 } } }注意这里的关闭顺序很重要。先关子进程再关主进程。不然你先杀掉了主程序子进程还在它的父进程不在了句柄可能不会被释放还是会报占用。5.2 下载更新包后校验MD5不一致可能原因有服务器/浏览器代理缓存了旧文件。尤其是国内一些CDN节点缓存策略不主动刷新导致拿到过期文件。下载被中途截断但客户端的断点续传逻辑没生效比如服务器不支持Range请求。遇到这种情况建议优先在服务器端为更新文件目录设置“不缓存”或“较短的缓存时间”。Nginx里可以这样配置location /update/ { add_header Cache-Control no-cache, no-store, must-revalidate; }CDN控制台里也把更新文件目录加入刷新或忽略缓存名单。客户端侧我建议更新文件统一使用带版本号的URL比如https://update.example.com/patches/1.2.2.0_to_1.2.3.0.zip版本号变了URL就变了天然绕开缓存。这也是为什么我一直推荐把patch按版本号分目录存放。5.3 更新后版本号不变这种问题通常出在发布流程上服务器上version.json更新了但patch包里的文件没有正确打包或者客户端读取的是本地某个固定的版本文件而这个文件没有随新版本更新。我的排查思路是先在AutoUpdater日志里看它实际下载了哪些文件、校验是否通过。如果一切正常而主程序显示的仍是旧版本号那多半是本地存储版本号的文件比如version.txt或注册表项没有在更新清单里。解决方法是把存储版本号的文件也作为更新内容的一部分并在更新清单里明确列出。5.4 杀毒软件误报C#程序如果用了某些第三方打包工具如ILMerge等加上AutoUpdater.exe这种名字偶尔会被杀软误判。尤其AutoUpdater同时有下载、执行文件、重启进程等行为非常容易被安全软件怀疑是“PUA软件”或“风险软件”。缓解方案申请代码签名证书给exe签名。这是最有效的方案不少国外杀软对签名文件更友好。微软SmartScreen也会对未签名的程序提示风险。尽量避免把更新逻辑写得像恶意行为比如频繁杀进程、写注册表自启动、隐藏窗口运行窗口如果必须隐藏至少在首次运行时向用户解释清楚更新程序的存在。更新包压缩时不要用RAR等冷门格式优先用zipWindows自带解压支持被杀软扫描的概率也低一些。5.5 更新之后再更新失败循环更新有一种情况比较诡异更新完成主程序启动显示的版本号已经是新的了但更新程序又弹出来说要更新。原因很多常见的是version.json里的版本号写错了比如服务器上version是1.2.3但客户端主程序里硬编码了Version号1.2.4程序启动后检查发现服务器版本号小于本地版本号自然不更新。但更新程序不知道该情况每次都拉服务器version.json发现服务器版本大于本地版本于是再次触发更新流程形成循环。解决办法客户端每次启动检查时读取本地程序集真正编译进去的版本号Assembly.GetExecutingAssembly().GetName().Version不要从外部配置文件读版本号。外部配置容易和代码不同步造成各种诡异问题。如果版本号必须写到配置文件那把这个配置文件本身作为更新内容的一部分保证一致性。6. 一些实用的经验与建议最后分享几个我用过多次、觉得特别有效的小技巧。更新包建议加上“强制更新”和“静默更新”两种模式的开关。一般小版本修复Bug可以静默后台下载下次用户启动时自动替换涉及数据结构变更、不兼容旧版本的大版本更新时必须强制用户更新否则旧客户端连接新版服务端解密、协议都可能出问题业务直接崩。用户界面文案要克制。第一次使用自动更新功能时用户如果看到一个下载进度条会觉得很新奇看第二次第三次就会嫌烦。我的经验是小更新不提示、直接后台做大更新弹一个小提示框写上“发现新版本1.3.0已为你下载完成重启生效”然后给一个“立即重启”按钮。别整“查看更新日志”这种多余交互多数用户根本不会点开看。更新服务器要有基本的流量监控和日志。不是技术的需求而是管理的需求。当你们公司两个产品线都在用一个更新服务器时你总得知道今天花了多少带宽、哪个版本的下载量最高。我用Nginx的时候会看access_log可以精确统计每个更新包的下载次数、来源IP这对调整发布策略、排查问题都有帮助。如果你是一个人在做独立开发不一定非要一开始就把更新功能做得特别完善。第一版先做最简单的全量更新主程序启动时检查version.json有新版就提示用户下载安装包用户自己双击安装。这个流程跑通了再逐步升级为自动替换、增量更新。一上来就搞分布式、断点续传、回滚机制工程量会大得吓人很容易半途而废。自动更新功能确实有些繁琐涉及的边界条件相当多。但只要架构稳定了后续维护成本是很低的它会把“发版”这个动作从一场通信灾难变成一个后台静默完成的小事。这也是值得做好的原因。本文还有配套的精品资源点击获取
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门