简介:这份C#自动更新程序源码面向.NET 2.0环境下的桌面应用开发者,解决通过IIS等Web服务分发版本、实现客户端自动检测下载与升级的核心需求。资源围绕两个完整模块展开:XmlUpdate用于生成服务端所有文件及目录的MD5值清单,为客户端校验提供依据;AutoUpdateClient负责执行更新流程,并借助批处理机制解决程序自身被占用时的自我替换问题。包内共55个文件,以cs源代码、exe可执行程序、ico图标资源为主,辅以pdb调试符号、txt说明文档、XML配置等,压缩包总大小仅494KB,轻量紧凑。解决方案中包含AutoUpdateSystem等多个工程,目录结构清晰,可直接编译运行或按需改造。目前已有735人学习下载,适合需要为现有C#应用快速搭建自动更新机制的中级.NET开发者参考,有助于理解文件校验清单生成、更新状态判断及程序自更新等典型实现思路。
1. C#自动更新程序源码:先搞懂这套代码在解决什么问题
桌面上跑着一个 C# 写的客户端工具,发版时让用户手动下载安装包、覆盖安装、再重新配置路径,一次发布就是一场客服事故。所谓“C#自动更新程序源码”,就是解决这个问题的代码集合:客户端启动时检查服务器上的新版本,发现更新就下载安装包,校验完整性,替换正在运行的程序文件,最后把新版本拉起来。整个过程不需要用户碰安装程序,也不需要知道服务器地址。
这个标题看起来是个下载即用的代码包,但真正落地时你会发现难点根本不在“下载文件”,而在“如何替换一个正在运行的自己”——Windows 下 exe 和 dll 被进程占用后不能直接覆盖,版本号比较、更新包校验、失败回滚这些环节才是翻车重灾区。这篇文章就按一套最小可跑的更新链路来讲:架构怎么设计、服务器清单怎么写、客户端代码怎么落、替换和回滚怎么处理、哪些坑我替你踩过了。适合用 WinForms / WPF / 控制台程序做桌面工具、想把更新能力握在自己手里的开发者,不依赖第三方更新 SDK,也不用读一堆商用文档。读完你能照着把更新模块接进自己项目里,并能回答“更新失败怎么办”。
2. 更新流程与启动器设计:为什么自动更新必须有个“中间人”
2.1 自更新与引导器更新:两种架构选型对比
先看两个方案的区别。方案 A 是“主程序自己更新自己”:主程序下载新的 exe,然后尝试覆盖当前正在运行的 exe。几乎必失败,因为 Windows 下运行中的 exe 文件会被锁定,File.Copy 或 File.Replace 会抛 UnauthorizedAccessException。方案 B 是“引导器更新”模式:把更新逻辑拆到一个独立的 Launcher 进程里,主程序发现新版本后,先启动 Launcher,然后自己退出;Launcher 等待主进程结束,执行文件替换,再拉起新版本主程序。这套模式里,Launcher 是那个“中间人”,也是整个更新链路里最不能被省略的部分。
| 对比项 | 自更新(方案 A) | 引导器更新(方案 B) |
|---|---|---|
| 替换正在运行的程序 | 不支持,文件被锁 | 支持,主程序退出后替换 |
| 实现复杂度 | 低 | 中高,多一个进程 |
| 失败回滚 | 难,文件已覆盖 | 容易,替换前先备份 |
| 可靠性与线上表现 | 较差,容易更新失败 | 较稳定,主流桌面应用做法 |
| 适用场景 | 只更新数据文件/配置文件 | 更新 exe/dll 核心程序 |
我一般直接选方案 B,并且 Launcher 独立成一个很小的 exe 放在主程序目录里,平时不启动,只有更新时才被带起来。它本身不参与业务逻辑,做得越薄越好。
2.2 最小目录结构与更新状态机:从检查到替换的完整链路
一个最小可用的目录结构是这样的:
C:\App\ ├── MyApp.exe # 主程序 ├── MyApp.dll # 业务程序集 ├── Launcher.exe # 更新引导器,负责替换文件 ├── appsettings.json # 主程序配置 ├── update\ │ ├── staging\ # 更新包解压后的临时目录 │ ├── backup\ # 旧版本备份目录 │ └── *.part # 下载中的临时文件 └── manifest.json # 服务器更新清单(也可只在服务器上)主程序检查更新的完整状态机是:检查服务器清单 → 比对版本号 → 下载更新包 → 校验 SHA256 → 解压到 staging → 启动 Launcher 并传入主程序路径 → 主程序退出 → Launcher 等待主进程结束 → 备份旧文件 → 用 staging 覆盖主目录 → 拉起新主程序 → Launcher 退出。每一步失败都要有明确处理:下载失败重试,校验失败删除临时文件,覆盖失败回滚备份。
这个状态机里最容易写错的是顺序:先备份再覆盖,而不是先覆盖再备份。很多人图省事,直接覆盖,更新包有问题时旧版也回不去了,用户只能干瞪眼等重装。
2.3 更新包格式与打包约定:zip 里的路径决定了解压后能不能跑
更新包格式我固定用 zip,.NET 自带的 System.IO.Compression 就能解压,不需要引第三方压缩库。格式坑不多,但有一个必须提前约定:zip 内部不能多包一层顶层目录。比如解压后如果是“MyApp\MyApp.exe”这种结构,你直接把整个目录覆盖到主程序目录,就会出现“主目录/MyApp/MyApp.exe”两层嵌套,启动命令全错。我见过不止一次因为这个翻车的。
打包时约定为:zip 解开后,里面的文件路径必须直接对应主程序目录的相对路径,即 MyApp.exe 在 zip 根目录下。用命令行打标准包时,先 cd 进发布目录再压缩:
cd ./publish Compress-Archive -Path * -DestinationPath app_2.3.1.zip“*”表示把当前目录的全部内容压缩到 zip 根下,而不是把 publish 目录本身作为一个顶层文件夹打进去。PowerShell 的 Compress-Archive 没有 -NoRootDirectory 这种参数,所以关键是先切进目录再压缩,这个顺序错了,客户端解压后结构就错了。解压代码用的是 ZipFile.ExtractToDirectory,后面会写,但这里先记住一个原则:解压的目标目录必须是 staging,不能直接解压到主程序目录,否则文件还在被占用时就会写入失败。
3. 服务器端更新清单与版本号策略:升级能不能成,一半在服务器
3.1 manifest 字段设计:version、min_version、hash、force 各管什么
服务器要有一个固定 URL 放更新清单,我用的格式是 JSON,字段如下:
{ "version": "2.3.1", "min_version": "2.0.0", "url": "https://update.example.com/app/app_2.3.1.zip", "hash": "a3f2b8c1d9e4f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1", "size": 15728640, "force": false, "release_notes": "修复了配置保存失败的问题", "publish_time": "2025-01-15T10:00:00Z" }各字段的作用如下表:
| 字段 | 作用 | 客户端校验位置 | 缺失时的行为 |
|---|---|---|---|
| version | 服务器最新版本号 | 检查更新时比对 | 无法判断是否有更新,建议直接报错 |
| min_version | 低于此版本必须强制更新 | 版本比较后二次判断 | 按普通更新处理,不强制 |
| url | 更新包下载地址 | 下载前校验域名白名单 | 提示更新失败 |
| hash | 更新包 SHA256 值 | 下载完成后校验 | 不校验则半包风险极高 |
| size | 更新包字节大小 | 下载完成后比对 | 与 hash 配合使用 |
| force | 是否强制更新 | 更新弹窗逻辑 | 默认 false |
| release_notes | 更新说明 | 展示在更新弹窗 | 不展示说明 |
注意 min_version 和 force 是两回事。min_version 表示“低于这个版本号的客户端必须更新,没有取消按钮”;force 表示“这个版本强制要求用户马上升级,即使版本号不是最新的”。两者可以叠加使用,我一般用 min_version 做版本门槛,用 force 做单个版本的硬性要求。
3.2 版本号比较:用 Version 类,别用字符串比
版本号比较是自动更新里看着简单、实际最容易翻车的环节。字符串按字符逐位比较,“1.0.0.10”会被判定小于“1.0.0.9”,因为字符 ‘1’ 比字符 ‘9’ 小。如果你用 string.Compare 去比版本号,用户会永远“更新失败”或者永远“已是最新版本”,而且很难排查。
C# 里直接用 System.Version 类:
string serverVersionStr = "2.3.1"; string localVersionStr = "2.3.0"; if (Version.TryParse(serverVersionStr, out Version serverVersion) && Version.TryParse(localVersionStr, out Version localVersion)) { int result = serverVersion.CompareTo(localVersion); // result > 0 表示服务器版本更新 } else { // 解析失败,记录日志,按无更新处理,不要抛异常打断主程序 }Version.CompareTo 会比较 Main、Major、Build、Revision 四段。这里有个细节:Version.Parse("2.3") 会被解析成 2.3.0.0,而 Version.Parse("2.3.1") 是 2.3.1.0,第四段没写时默认补 0,所以不会出现“2.3 比 2.3.0.1 大”这种误判。但反过来,如果你在服务器端写了“2.3.0.1”,客户端本地是“2.3”,两者比较结果是服务器大于本地,逻辑上没问题,只是提示文案要显示全版本号,不然用户看不懂。
还有一点要注意:Build 号是否参与比较需要提前定。如果你的发布流程里 Build 号是每次编译自动递增的,那它参与比较没问题;如果 Build 号是随意填的,建议只比较前三段,降低误判概率。做法是构造一个只保留前两段的 Version 对象再比较,或者直接比较 Major 和 Minor 两个字段。
3.3 全量包还是差分包:先想清楚再定更新粒度
更新包粒度直接决定服务器带宽和客户端成功率。全量包就是把整个发布目录打成 zip,可能几十 MB;差分包只包含变化的文件,可能只有几百 KB。对中小工具来说,我建议前期无脑选全量包,理由有三:打包脚本简单、客户端逻辑少、不会出现基线版本对不上的问题。
差分包的痛点在于“基线”。你只知道客户端当前版本号,但不知道它本地的文件是否和发布时一致——用户可能手动改过配置,可能之前更新失败残留过旧 dll。差分包是按“从版本 A 升到版本 B”生成的,如果客户端实际文件状态不是干净的版本 A,应用差分包后会出现各种奇怪问题。所以实际项目里我见过很多团队的做法是:核心 exe/dll 走全量包,只有资源文件(图片、配置模板、UI 素材)走增量,而且增量文件列表放在服务器清单里,客户端逐个下载。这个后面第 6 章再展开,这里先记住一个原则:全量包解决“能不能跑”的问题,差分包解决“流量省不省”的问题,先把前者做好再做后者。
服务器端还需要注意一个现实问题:如果更新包放在普通服务器而不是 CDN,要确认服务器支持 Range 请求,否则客户端实现断点续传时,服务端不认 Range 头,断了就得从头下。检查方式是请求时带 Range: bytes=0-1023,看响应码是不是 206。
4. 客户端更新核心实现:下载、校验、替换、回滚的可抄代码
4.1 检查更新与版本比较:UpdateService 的入口代码
客户端代码我按职责拆成两个类:一个 UpdateService 负责“检查 + 下载 + 校验”,一个 Launcher 负责“等进程退出 + 覆盖 + 回滚”。先看 UpdateService 里最核心的检查方法:
public class UpdateService { private readonly string manifestUrl; private readonly string localVersion; private readonly string stagingDir; private readonly string backupDir; public UpdateService(string manifestUrl, string localVersion) { this.manifestUrl = manifestUrl; this.localVersion = localVersion; this.stagingDir = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "update", "staging"); this.backupDir = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "update", "backup"); } public async Task<UpdateInfo> CheckForUpdateAsync() { using var httpClient = new HttpClient(); httpClient.Timeout = TimeSpan.FromSeconds(15); string json; try { json = await httpClient.GetStringAsync(manifestUrl); } catch (Exception ex) { // 网络失败不阻断主程序启动 return UpdateInfo.NotAvailable("网络请求失败: " + ex.Message); } // 解析清单 var manifest = JsonSerializer.Deserialize<Manifest>(json); if (!Version.TryParse(manifest.Version, out Version serverVer) || !Version.TryParse(localVersion, out Version localVer)) { return UpdateInfo.NotAvailable("版本号解析失败"); } bool hasUpdate = serverVer.CompareTo(localVer) > 0; // 返回更新信息,由 UI 层决定是否弹窗 return new UpdateInfo { HasUpdate = hasUpdate, ServerVersion = manifest.Version, DownloadUrl = manifest.Url, Hash = manifest.Hash, Size = manifest.Size, IsForce = manifest.Force, ReleaseNotes = manifest.ReleaseNotes }; } }HttpClient 的超时时间设 15 秒,只用于拉取小体积清单文件,下载大文件时不能复用这个配置,要单独放开超时。这里每调用一次就 new 一个 HttpClient 其实不算最优实践,但更新检查的频率很低,不会造成连接耗尽。Version.TryParse 失败时按“无更新”处理,是因为更新模块不能影响主程序的正常启动——宁可这次不检查,也不能让主程序闪退。
4.2 下载更新包并做完整性校验:HttpClient + SHA256
检查发现新版本后,进入下载流程。下载用 HttpClient,边下边写入临时文件,下载完成后用 SHA256 做整体校验,通过再解压:
public async Task<bool> DownloadAndVerifyAsync(UpdateInfo updateInfo) { // 先建 staging 目录,下载临时文件命名加 .part 后缀 Directory.CreateDirectory(stagingDir); string tempFile = Path.Combine(stagingDir, "update.zip.part"); string targetFile = Path.Combine(stagingDir, "update.zip"); using var httpClient = new HttpClient(); httpClient.Timeout = TimeSpan.FromMinutes(30); httpClient.DefaultRequestHeaders.UserAgent.ParseAdd("AppUpdater/1.0"); // 支持断点续传:已存在的 .part 文件大小作为 Range 起点 long existingLength = File.Exists(tempFile) ? new FileInfo(tempFile).Length : 0; httpClient.DefaultRequestHeaders.Range = new RangeHeaderValue(existingLength, null); using (var response = await httpClient.GetAsync(updateInfo.DownloadUrl, HttpCompletionOption.ResponseHeadersRead)) { response.EnsureSuccessStatusCode(); await using var sourceStream = await response.Content.ReadAsStreamAsync(); await using var fileStream = new FileStream(tempFile, FileMode.Append, FileAccess.Write); byte[] buffer = new byte[81920]; int bytesRead; while ((bytesRead = await sourceStream.ReadAsync(buffer)) > 0) { await fileStream.WriteAsync(buffer.AsMemory(0, bytesRead)); } } // 整体校验 hash,不一致则删除 .part,提示用户重新下载 string actualHash = await ComputeSha256Async(tempFile); if (!string.Equals(actualHash, updateInfo.Hash, StringComparison.OrdinalIgnoreCase)) { File.Delete(tempFile); return false; } // 校验通过后改名为正式文件名 File.Move(tempFile, targetFile, true); return true; }缓冲区 81920 字节是 Stream.CopyToAsync 默认值的两倍,对多数网络环境已经够用;太快反而容易触发服务器限流。断点续传的实现方式很简单:先看 .part 临时文件有多大,把这个大小塞进 Range 头,服务器支持 206 就从断点继续写,不支持时服务器会返回 200 并从头传,FileMode.Append 会把这部分数据追加到之前已损坏数据的后面,导致文件变长且损坏。所以依赖断点续传时,必须确认服务器支持 Range,否则续传功能要关掉,不然反而制造脏文件。最安全的做法是下载完先比 size 再比 hash,任何一步不过都整包重来。
4.3 Launcher 进程外替换:等待退出、备份、覆盖、拉起主程序
这是整条更新链路最关键的一段。主程序调用下面的方法,把更新参数传给 Launcher,然后主程序自己退出:
public static void LaunchUpdaterAndExit(string mainExePath, string stagingDir, string backupDir) { // 主程序所在目录,Launcher.exe 必须放在这里 string launcherPath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Launcher.exe"); // 通过命令行参数传路径,避免写中间配置文件,换行符和斜杠要转义 string args = $"\"{mainExePath}\" \"{stagingDir}\" \"{backupDir}\" \"{AppDomain.CurrentDomain.BaseDirectory}\""; Process.Start(new ProcessStartInfo { FileName = launcherPath, Arguments = args, WorkingDirectory = AppDomain.CurrentDomain.BaseDirectory }); // 主程序主动退出,把进程控制权交给 Launcher Environment.Exit(0); }主程序调完 LaunchUpdaterAndExit 后必须退出,如果不退出,Launcher 要等到天荒地老。这里有一个时序细节:Process.Start 是先启动 Launcher 再退出主进程,Launcher 启动后不能立刻执行覆盖,要先轮询主进程是否真正退出,否则文件照样被锁。我一般写 10 秒超时,每 500ms 查一次进程列表:
static void WaitForMainProcessExit(string mainProcessName, int timeoutSeconds = 10) { int waited = 0; while (waited < timeoutSeconds * 1000) { // 按进程名查找,不包含 .exe 后缀 var processes = Process.GetProcessesByName(Path.GetFileNameWithoutExtension(mainProcessName)); if (processes.Length == 0) { return; } foreach (var p in processes) { p.Dispose(); } Thread.Sleep(500); waited += 500; } // 超时则按“覆盖失败”处理,走回滚,不要强行覆盖 throw new TimeoutException("等待主程序退出超时"); }等主进程退出后,Launcher 执行标准的“备份-覆盖-回滚”操作:
static void ApplyUpdate(string mainDir, string stagingDir, string backupDir) { // 1. 备份当前旧版本到 backup 目录 if (Directory.Exists(backupDir)) { Directory.Delete(backupDir, true); } Directory.CreateDirectory(backupDir); foreach (string file in Directory.GetFiles(mainDir, "*", SearchOption.TopDirectoryOnly)) { string fileName = Path.GetFileName(file); // 跳过 Launcher 自身和 update 临时目录,其他文件全部备份 if (fileName.Equals("Launcher.exe", StringComparison.OrdinalIgnoreCase)) { continue; } File.Copy(file, Path.Combine(backupDir, fileName), true); } // 2. 用 staging 里的新文件覆盖主目录 try { foreach (string file in Directory.GetFiles(stagingDir, "*", SearchOption.TopDirectoryOnly)) { string target = Path.Combine(mainDir, Path.GetFileName(file)); File.Copy(file, target, true); } } catch (Exception ex) { // 3. 覆盖失败,回滚旧版本 Rollback(mainDir, backupDir); throw new IOException("覆盖失败,已回滚。原因: " + ex.Message); } // 4. 清理 staging Directory.Delete(stagingDir, true); }这个实现用的是 TopDirectoryOnly 浅层覆盖,实际项目里主程序目录往往有子目录,需要改成递归遍历。这里省略了递归代码,但逻辑不变:先备份所有旧文件,再逐个覆盖。文件在覆盖过程中出现 IOException 时,data 目录和配置目录不能被备份也不能被覆盖——用户数据不能动,这是底线。Rollback 就是从 backupDir 把文件复制回 mainDir,读者可以自己补成递归版本。
Launcher 最后拉起主程序并退出:
Process.Start(new ProcessStartInfo { FileName = mainExePath, WorkingDirectory = mainDir });整套替换过程,耗时就取决于文件数量和磁盘速度,通常 1 到 3 秒。用户视角里就是“点击重启更新,等两秒,新版本就打开了”,不会看到控制台窗口。
5. 自动更新高频踩坑记录:文件占用、版本误判、覆盖失败
5.1 替换时提示文件被占用,UnauthorizedAccessException
现象:Launcher 执行 File.Copy 覆盖主程序 exe 时抛出 UnauthorizedAccessException,更新失败,用户看到报错弹窗。原因:主程序进程没有真正退出,或者主程序退出了但还有子进程/守护进程持有 dll。最常见的场景是主程序退出前没有释放某个后台线程,线程还在跑,exe 文件句柄就没释放。解决:先检查主进程是否在等待超时后仍然存在,如果存在,不能用强杀进程的方式,而是记录日志并放弃本次更新,等下次启动再试;如果主程序已退出,但某个 dll 仍被占用,重点检查是否有其他进程加载了该 dll。我自己的习惯是在主程序退出前,把所有非前台线程标记为后台线程,确保环境退出时线程不阻塞进程结束。另外 Launcher 覆盖文件前先尝试以独占方式打开目标文件,能打开再覆盖,打不开就进入回滚,这个预检能大幅度减少替换中途失败的次数。
5.2 版本号字符串比较翻车,1.0.0.10 比 1.0.0.9 还小
现象:服务器发布 1.0.0.10 后,客户端一直提示“已是最新版本”,用户永远收不到更新。原因:代码里用了 string.Compare 或 >= 直接比较字符串,字符串排序按字符逐位比较,1.0.0.10 的前缀“1.0.0.1”比“1.0.0.9”小,所以被判成旧版本。这个坑非常隐蔽,因为前几个版本号都是个位数时看不出问题,一旦版本号到两位数就翻车。解决:统一用 System.Version 类,且版本号字段必须前后端一起规范,服务器生成 manifest 时也做一次 Version.TryParse 校验,解析不了直接不让发布。另外,客户端“已是最新版本”的提示文案要在版本号字段异常时一起打日志,不然用户反馈“看不到更新”,你很难从表面判断是网络问题还是版本比较逻辑问题。
5.3 更新包下载一半导致 hash 对不上,反复更新失败
现象:客户端下载更新包时网络抖动,下载完成度 70%,文件残缺。hash 校验失败后客户端重试,每次都从头下载,如果网络一直不稳定,就一直失败。原因:没有把“下载中”和“下载完成”分成两个文件状态,也没做断点续传,失败后直接删除整个临时文件。解决:下载先写到 .part 临时文件,只有 hash 校验通过才改名为正式 update.zip;断点续传用 Range 头实现,但前提是服务器支持 206 响应,否则关闭续传,保证整包下载的可靠性。下载失败重试时建议加指数退避,第一次等 2 秒,第二次等 4 秒,最多重试 3 次,避免用户网络差时客户端反复打断正常使用。还有一个经验:hash 计算要边下边算,不要等整个文件落盘后再从头读一遍,大文件下两次 IO 耗时差距很明显。实现方式是下载循环里同步调用 IncrementalHash 更新,下载完成后取 Hash 值比对。
5.4 没做回滚,更新失败后用户连旧版本都打不开
现象:新版本覆盖主程序后启动就闪退,用户想退回旧版,发现旧版文件已经被覆盖了,只能手动重新安装。原因:更新流程里只有“覆盖”没有“备份”,也没有失败回滚。这属于设计层缺失,不是偶发 bug。解决:Launcher 覆盖前必须把当前主目录里的旧文件备份到 backup 目录,覆盖过程任意一步失败,就把 backup 里的文件复制回来。备份和替换都采用先复制到临时文件名再 Move 的方式,避免复制到一半断电造成文件残缺。我见过一个真实案例:某项目发布的新版依赖一个第三方 dll,但打包时漏了这个 dll,覆盖完成后主程序因为找不到依赖直接闪退。因为提前做了备份,回滚后用户无感,问题只影响了测试环境的几个内部用户。这个案例里备份目录是最后的后悔药,必须要默认存在,而不是等出事了再补。
5.5 新 exe 被安全软件拦截,更新后主程序直接消失
现象:更新完成后,用户的杀毒软件把新主程序 exe 隔离了,界面提示“文件不存在”或者启动无反应。原因:新 exe 是刚落盘的,没有足够的信誉积累,部分安全软件对“运行目录下新增可执行文件”的行为比较敏感。这种问题在开发环境基本复现不出来,因为开发机的杀毒软件早就信任了构建产物,但用户机器上不一定。解决:发布前给 exe 做代码签名,签名后的文件在 Windows 上的信任等级会高很多;Launcher 替换文件时尽量在用户无感知的后台完成,避免弹 UAC 提示;更新包内不要包含奇怪的附加数据,文件保持干净的数字签名。另外我建议部署一个“更新后自检”逻辑:新版本启动后上报一次版本号和启动结果,如果新版本启动失败,Launcher 在下一次启动时自动回滚到 backup 目录的旧版本,这个方案的优先级比签名还高,因为它不依赖外部安全生态的配合。
6. 进阶玩法:增量更新、强制更新、静默更新与一个保命习惯
6.1 增量更新的最小可行设计
增量更新的核心问题是基线管理。一个稳妥的起步方案是:服务器上同时保留全量包和增量包,增量包只覆盖资源文件,不覆盖 exe/dll。客户端先判断当前版本号和目标版本号之间是否只差一个版本,如果是,就下载增量清单,按清单拉取变化文件;如果跳了多个版本,直接退回全量包。增量清单可以放在 manifest.json 里新增一个字段:
{ "version": "2.4.0", "incremental": [ { "path": "assets/skin_v2.bin", "hash": "abc123" } ], "incremental_base": "2.3.1" }客户端本地版本为 2.3.1 且服务器 incremental_base 为 2.3.1 时,只下载 assets/skin_v2.bin,其他不做任何替换,这样流量消耗可以降到原来的十分之一。注意 exe/dll 永远不进增量包,因为运行中的程序集替换风险仍然不可控,出了问题连回滚都复杂。
6.2 强制更新与静默更新的触发逻辑
强制更新利用 manifest 里的 min_version 和 force 字段。客户端拿到清单后,按顺序做三层判断:先比较版本号决定是否提醒,再比较 min_version 决定是否不可跳过,最后看 force 决定是否拦截启动。弹窗设计的差异在于:普通更新可以点“稍后”,强制更新没有取消按钮,只有“立即更新”。静默更新则是把下载和替换放在后台:主程序启动后先静默下载更新包到 .part,下载完只提示“重启应用以生效”,不打断当前操作。这个方案的体验最好,但实现前提是替换逻辑完全由 Launcher 接管,主进程退出和拉起时机由用户的重启动作触发,而不是由更新模块主动踢人。
6.3 一个保命习惯:先做可回滚,再做自动更新
我最初写自动更新模块时,上来就写下载和替换,根本没考虑回滚,结果一次线上发布漏了依赖文件,更新后的旧用户全部卡在启动闪退。那之后我把回滚当成了默认项而不是异常分支:更新包替换前必须备份,备份必须验证完整,覆盖失败自动还原并拉起旧版本。这套动作写完,自动更新才是真正可以撒手不管的功能,不然就是个定时炸弹。现在每次给项目加自动更新功能,我都先问三个问题:更新失败后用户能回到可用状态吗;更新包校验失败会不会污染现有文件;服务器清单挂了客户端会不会卡启动。三个都回答“能”,才敢上线。希望帮到你。
本文还有配套的精品资源,点击获取