做桌面应用的兄弟应该都有过这种经历:客户报了一个 bug,你连夜修完,然后一个个在群里发安装包,让人家手动下载覆盖安装。你以为用户都装上了,过两天一查后台日志,还在绕着你昨天修复的崩溃点打转。非技术用户根本没有“我要更新”的意识,你让他删掉旧版本重装,他能问出三百个为什么。
这时候你需要的是一个能自己更新的桌面程序。这篇文章我会完整讲一遍我在 .NET 桌面应用里基于 JSON 配置做自动更新的思路,从 JSON 配置结构设计、版本号处理、文件校验,到客户端检查更新、下载、独立启动器替换主程序的完整流程,最后还附带我实际踩过的坑和排查方法。无论是 WinForms 还是 WPF,这套方案都能直接拿去改。
1. 整体设计思路:为什么用 JSON 配置来驱动更新
1.1 更新程序的本质:先告诉客户端“有什么”,再告诉它“怎么拿”
自动更新听起来很神秘,拆开其实就三件事:知道有没有新版本、把新文件拿下来、把旧文件换掉。第三个“换文件”最麻烦,因为程序正在运行的时候,exe 和 dll 文件通常是被锁定的,不能用 Process 直接覆盖,所以大部分成熟方案都会引入一个独立的 Updater 进程来干这件事。
前两件事的关键在于“知道有没有新版本”这一步。客户端必须从一个固定位置拿到一份描述“当前最新版本”的信息,而这份信息用什么格式、包含哪些字段,直接决定了后续所有代码怎么写。我用 JSON,没有用 XML,也没有用自定义的 ini。原因很简单:JSON 人类可读、调试方便、跨语言解析成本低,而且 .NET 从 Core 3.0 以后内置了System.Text.Json,序列化性能好、零额外依赖,不需要引第三方 NuGet 包。
更重要的是,JSON 配置可以由更新服务器、CI 流水线或者一个简单脚本自动生成。我见过有人用数据库接口返回更新信息,也可以,但桌面应用要的是“简单可靠”,一个部署在静态托管上的 JSON 文件,比维护一套后端接口省心得多。
1.2 一次完整的更新动作是怎样发生的
整个更新链路大致是这样走的:
- 主程序启动后,在一个后台线程里请求更新服务器上的
update.json。 - 客户端解析 JSON,取出
version字段,和当前程序集版本号比较。 - 没有新版本,直接结束更新流程;有新版本,则根据
force字段决定是弹窗提示用户,还是强制更新。 - 按需下载文件列表里的所有文件到本机临时目录。
- 对每个文件计算 SHA256,与配置里的哈希值比对,不一致就重新下载。
- 下载全部通过后,拉起一个独立的 Updater 进程,主程序退出。
- Updater 等待主进程完全退出,把临时目录里的文件移动到安装目录,替代旧文件。
- 启动主程序,更新完成。
第 6 到第 8 步是最容易出现问题的阶段。很多人第一次做更新,试图在主程序里直接覆盖自身 exe,结果在 Windows 上遇到“另一个程序正在使用此文件”的报错。所以我的方案里始终有一个独立 Updater,它是一切的兜底。
1.3 三个设计决策:全量包、更新策略、独立 Updater
第一个决策是全量更新还是增量更新。桌面应用更新最稳的方案是下载完整的新版包,因为增量更新要处理文件差异、补丁应用失败、跨版本跳跃等一系列头疼的问题。我早期做过一个按文件列表更新的方案,服务端只发布变更过的文件,客户端逐个下载,看起来省流量,但一不小心出现文件遗漏、版本混合,用户机器上的状态就变得不可预期。现在我的做法折中了一下:JSON 里写完整文件清单,实际发布时打包成一个 zip 文件,客户端下载 zip 后解压替换。这样既有整包一致的优点,传输上又比逐个小文件高效。
第二个决策是更新策略。我一般区分三种情况:普通更新,用户点“以后再说”就跳过;强烈建议更新,每次启动都提醒,但允许跳过;强制更新,弹窗不可关闭,必须更新才能继续用。这个策略完全由 JSON 里的force字段和本地“跳过次数”控制。强制更新主要用于后端接口协议不兼容、数据库结构变更、或安全漏洞修复这类场景。
第三个决策是独立 Updater 进程。Updater.exe 体积很小,在发布时就和主程序放在一起。它干的事非常单一:等待主程序退出、覆盖文件、重新启动主程序。因为职责单一,它自己几乎不需要更新,所以不容易陷入“更新器也要更新自己”的递归陷阱。
2. 核心细节解析:JSON 配置结构、版本号与文件校验
2.1 JSON 配置字段设计:一个能跑起来的清单
我实际使用的更新配置大概是这个样子的,给你一份可以直接抄走改的版本:
{ "version": "1.2.3.0", "minVersion": "1.0.0.0", "force": false, "updateType": "zip", "packageUrl": "https://update.example.com/MyApp/1.2.3.0/update.zip", "packageSize": 10485760, "packageHash": "e5c7a9c1f2c7b0d1be6b8baf05e6e4b2c8e0f1a3d4f5a6b7c8d9e0f1a2b3c4d5", "releaseNotes": "1. 修复启动崩溃问题\n2. 优化内存占用", "releaseDate": "2025-01-15T10:00:00Z", "channels": ["stable"] }字段设计时要考虑这些东西:
version:服务端最新版本号,四段整数,和System.Version直接对应。minVersion:最低可更新版本。如果客户端版本比minVersion还低,说明版本太老,可能需要强制更新,或者直接提示用户重新安装。force:是否强制更新。true 时客户端不显示“跳过”按钮。updateType:更新包类型。我用zip,也可以扩展成fileList,或者不同类型的 macOS/Linux 包。packageUrl:更新包下载地址。packageSize:压缩包大小,用于显示进度和校验。packageHash:压缩包的 SHA256,下载完先校验整个包,解压后再对关键文件做二次校验。releaseNotes:更新说明,可以带换行符,在弹窗里展示。channels:更新通道。这个字段可以根据需要扩展,比如stable、beta,客户端通过命令行参数或本地配置选择通道。
为什么把文件列表换成整包?首要原因是在多文件更新时,要保证所有文件来自同一个发行版本。如果做一个文件一个 URL 的清单配置,更新服务器上不同 CDN 节点缓存不一致,就可能出现一半新文件一半旧文件。整包下载天然规避了这个问题。
2.2 版本号比较:四段整数的坑与解法
版本号比较看起来简单,实际上有暗坑。如果你用string.Compare("1.10.0", "1.9.0"),结果会认为1.9.0更大,因为字符串比较是从左到右逐字符比的,1.1比1.9小。所以版本比较必须转成数值类型。
.NET 里直接有System.Version类型,它支持四个整数段:Major、Minor、Build、Revision。我要求配置里的version必须是四段整数,然后客户端这样比较:
Version serverVersion = Version.Parse(manifest.Version); Version localVersion = typeof(App).Assembly.GetName().Version; int cmp = serverVersion.CompareTo(localVersion); if (cmp > 0) { // 服务端版本比本地新,可以更新 }但有一个注意点:Version的CompareTo方法对缺失段位的版本号默认按 0 处理,也就是说1.2和1.2.0.0是相等的。这看起来没什么,但如果你的发布脚本从 git tag 生成版本号,偶尔少写了一两位,就会导致客户端永远认为“本地版本大于等于服务端版本”,从而不更新。我的建议是:在任何环节都强制生成四段版本号,少一段就直接拒绝构建。
另外,如果你以后计划做语义化版本号(比如1.2.3-beta.1),System.Version就帮不上忙了,需要自己解析预发布标识。桌面应用一般不建议在自动更新里引入预发布版本,除非你专门做 beta 通道,否则只会增加复杂度。
2.3 哈希校验:防止文件损坏和替换不完整
哈希校验我放在两层:下载完整压缩包后,先校验packageHash;解压安装时,对关键可执行文件再校验一次。前者保证压缩包在传输过程中没有损坏,后者防止压缩包内部文件缺失或构建机上传时出错。
SHA256 的计算代码很简单:
using System.Security.Cryptography; public static string ComputeSha256(string filePath) { using var stream = File.OpenRead(filePath); using var sha256 = SHA256.Create(); byte[] hash = sha256.ComputeHash(stream); return Convert.ToHexString(hash).ToLowerInvariant(); }校验逻辑我通常放在下载完成后:
string actualHash = ComputeSha256(updateZipPath); if (!string.Equals(actualHash, manifest.PackageHash, StringComparison.OrdinalIgnoreCase)) { File.Delete(updateZipPath); throw new Exception($"更新包校验失败,期望 {manifest.PackageHash},实际 {actualHash}"); }有一种外界干扰情况是杀毒软件拦截正在下载的 exe 或 zip,导致文件被截断或隔离,哈希自然对不上。遇到这种问题,不能轻描淡写地认为“重试就好”,要在日志里记录具体的失败文件名、路径和目标目录权限,方便后续排查。
3. 服务端与资源组织:更新包怎么放、配置怎么生成
3.1 更新文件怎么托管:静态文件服务就够了
自动更新的服务端不需要复杂的业务逻辑,它本质上是一个静态文件托管加一个 JSON 文件。你完全可以把update.json和update.zip丢到一个 Nginx 目录、阿里云 OSS、腾讯云 COS、GitHub Releases,甚至内网共享目录里,只要有 HTTP GET 能力就行。
我个人推荐优先用云对象存储加 CDN。原因有两个:一是带宽成本可控,二是有现成的刷新缓存能力。更新包不像网页要高频访问,它只在更新时被下载一次,流量不大,但是一旦发生强制更新,大量客户端会同时去拉同一个包,没有一个能扛住瞬时并发的静态服务会很尴尬。
更新目录结构我习惯按版本号隔离:
/MyApp/ update.json packages/ 1.2.3.0/ update.zip这样update.json永远指向最新版本,但历史版本的安装包仍然可以被手动下载。万一新版本发布后发现重大故障,可以直接把update.json回退指向旧版本,客户端理论上可以“回滚到旧版”(前提是旧版安装包没有被清理)。
3.2 配置文件的自动生成:脚本扫描构建产物
手动维护update.json是反人类设计,迟早会漏改版本号。我建议把这一步集成到构建发布流程里,用脚本扫描构建产物自动生成配置。
我写过一段简单的 PowerShell 脚本,流程是:读取构建产物目录里的主 exe 版本号,对 update.zip 计算 SHA256,然后把结果写进update.json。伪代码如下:
$exePath = ".\dist\MyApp.exe" $zipPath = ".\dist\update.zip" $version = (Get-Item $exePath).VersionInfo.FileVersion $hash = Get-FileHash $zipPath -Algorithm SHA256 $updateJson = @{ version = $version minVersion = "1.0.0.0" force = $false updateType = "zip" packageUrl = "https://update.example.com/MyApp/packages/$version/update.zip" packageHash = $hash.Hash.ToLower() releaseNotes = $env:RELEASE_NOTES } | ConvertTo-Json $updateJson | Set-Content ".\dist\update.json" -Encoding UTF8这个脚本跑完后,你只需要把dist目录里的update.zip和update.json上传到对象存储对应位置。版本号、哈希值全部由脚本生成,人工干预越少越好。
这里有个细节:JSON 文件编码尽量用 UTF-8 无 BOM。Windows PowerShell 5.1 的Set-Content -Encoding UTF8会写入 BOM,某些.NET下的HttpClient解析没问题,但一些早期的 XML 组件或纯字符串解析器可能会多出一个\uFEFF字符,导致反序列化报“missing field”之类的错误。如果遇到奇怪的 JSON 解析问题,先看文件有没有 BOM。
3.3 安装目录与临时目录的路径规划
路径规划是整个更新方案里容易被忽略但非常重要的一环。按用户安装方式不同,主程序可能装在C:\Program Files\MyApp、C:\Users\xxx\AppData\Local\MyApp或自定义目录。安装目录通常有写权限限制,尤其是Program Files,普通用户无权写入。所以更新操作不能由主进程直接执行,这也是独立 Updater 存在的另一个原因:它可以向用户请求 UAC 提权,或者,更简单的方式是让程序安装成 per-user 模式,安装在LocalAppData下,这样普通权限就能覆盖文件。
我建议桌面应用优先选择 per-user 安装目录,就是“安装到当前用户”而不是“安装到所有用户”。如果你用 MSIX 打包那另说,如果只是 NSIS/Inno Setup 做安装包,默认给当前用户安装,能省掉大量权限相关的坑。更新临时目录我放在Path.GetTempPath()下的一个独立子目录,比如:
var tempDir = Path.Combine( Path.GetTempPath(), "MyAppUpdater", Guid.NewGuid().ToString("N") ); Directory.CreateDirectory(tempDir);不要直接往安装目录写临时文件,否则杀毒软件和权限问题会放大十倍。用 GUID 子目录是为了避免多个实例同时下载时互相覆盖。更新成功后,这个临时目录由 Updater 清理;失败时也不要立即删除,保留现场方便排查,在下次成功更新后再清理过期临时目录。
4. 客户端实现:检查更新、下载、解压与启动器替换
4.1 检查更新模块:HttpClient 拉取配置并反序列化
更新配置文件的反序列化使用内置的System.Text.Json就够了,我一般定义一个和 JSON 字段对应的模型类:
public class UpdateManifest { public string Version { get; set; } public string MinVersion { get; set; } public bool Force { get; set; } public string UpdateType { get; set; } public string PackageUrl { get; set; } public long PackageSize { get; set; } public string PackageHash { get; set; } public string ReleaseNotes { get; set; } public string ReleaseDate { get; set; } public List<string> Channels { get; set; } }检查更新的代码核心是下面这一段,我加了超时控制和 HTTP 状态码判断:
using var httpClient = new HttpClient(); httpClient.Timeout = TimeSpan.FromSeconds(15); httpClient.DefaultRequestHeaders.UserAgent.ParseAdd("MyAppUpdater/1.0"); try { using var response = await httpClient.GetAsync( manifestUrl, HttpCompletionOption.ResponseContentRead, cancellationToken ); response.EnsureSuccessStatusCode(); string json = await response.Content.ReadAsStringAsync(cancellationToken); var manifest = JsonSerializer.Deserialize<UpdateManifest>( json, new JsonSerializerOptions { PropertyNameCaseInsensitive = true } ); return manifest; } catch (Exception ex) { // 网络异常不能影响主程序启动,必须吞掉并记录日志 Logger.LogWarning("检查更新失败: {0}", ex.Message); return null; }检查更新失败的策略要非常克制。我在很多方案里都强调:更新检查是辅助功能,不能因为更新服务器的网络故障连主程序都打不开。所有异常必须捕获,记日志,然后静默忽略,绝不能让用户感觉“这个软件一打开就报错”。如果你要展示更新失败提示,也应该是非阻塞的,不打断主流程。
4.2 下载更新包与进度显示
下载更新包我建议直接用HttpClient.GetAsync配合流写入文件,而不是DownloadFileTaskAsync。原因是可以自己做超时、支持取消,还能获取下载进度。
关键点是设置HttpCompletionOption.ResponseHeadersRead,这样能一边接收响应头一边开始写文件,而不是等整个响应都缓冲完才动手:
public async Task<bool> DownloadPackageAsync( string url, string destPath, IProgress<double> progress, CancellationToken ct) { using var response = await httpClient.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, ct); response.EnsureSuccessStatusCode(); long totalBytes = response.Content.Headers.ContentLength ?? -1; await using var sourceStream = await response.Content.ReadAsStreamAsync(ct); await using var targetStream = new FileStream(destPath, FileMode.Create, FileAccess.Write, FileShare.None); var buffer = new byte[81920]; long totalRead = 0; int read; while ((read = await sourceStream.ReadAsync(buffer, ct)) > 0) { await targetStream.WriteAsync(buffer.AsMemory(0, read), ct); totalRead += read; if (totalBytes > 0) { progress?.Report(Math.Min(1.0, (double)totalRead / totalBytes)); } } return totalBytes <= 0 || totalRead == totalBytes; }8KB 到 80KB 的缓冲区是性能与内存占用比较平衡的区间。缓冲区太小会导致频繁 IO 调用,太大则内存浪费。实测 80KB 是个稳妥值。
下载完成后,我把这个过程放到一个单独的DownloadAndVerifyAsync方法里,先校验哈希,再决定是继续安装还是删除重来。校验不通过时,最多重试三次,三次都不行就放弃本次更新并提示用户检查网络或安全软件。
4.3 独立 Updater 启动器:覆盖被占用的文件
现在讲整个方案里最核心的部分:怎么把“正在运行的主程序”替换成新版本。
由于主程序进程在运行,exe 和所有已加载的 dll 文件都被锁定,无法直接覆盖。我的方案是用一个小的命令行程序Updater.exe来完成替换。流程是这样的:
- 主程序把下载好的
update.zip复制到一个固定的 staging 目录,比如C:\Users\xxx\AppData\Local\MyAppUpdater\staging\update.zip。 - 主程序以参数形式启动
Updater.exe,例如:
Updater.exe --target "C:\Users\xxx\AppData\Local\MyApp" --source "C:\Users\xxx\AppData\Local\MyAppUpdater\staging\update.zip" --mainExe "MyApp.exe" --backup "C:\Users\xxx\AppData\Local\MyAppUpdater\backup"- 主程序收到启动命令后立即退出(
Environment.Exit(0)),不要等 Updater 的结果。 - Updater 启动后,先等待主程序进程完全结束。这一步轮询
Process.GetProcessesByName("MyApp")直到返回空数组,超时时间设为 30 秒。 - 确认主程序退出后,Updater 把整个安装目录复制一份到 backup 目录(如果 backup 存在就删了再复制),保证可以回滚。
- 解压
update.zip到 staging 解压目录,然后把安装目录里除Updater.exe和备份文件之外的所有文件删除,把新文件一一移动过去。 - 最后用
Process.Start启动新的MyApp.exe,然后 Updater 自身退出。
有一个很关键但容易忽略的点:Updater.exe必须放在一个不会被替换的位置。如果它也被更新流程覆盖,在替换途中文件被占用或不完整,整个机制立刻崩掉。所以我的发布脚本里会明确把Updater.exe排除在update.zip之外,它始终保持版本独立。
这段 Updater 的核心代码逻辑大致如下:
// 等待主进程退出 for (int i = 0; i < 60; i++) { if (Process.GetProcessesByName(mainProcessName).Length == 0) break; Thread.Sleep(500); } // 备份旧版本 if (Directory.Exists(backupDir)) Directory.Delete(backupDir, true); Directory.Move(targetDir, backupDir); Directory.CreateDirectory(targetDir); // 解压新版本 ZipFile.ExtractToDirectory(zipPath, stagingDir); // 移动文件 foreach (var file in Directory.GetFiles(stagingDir, "*", SearchOption.AllDirectories)) { string relativePath = Path.GetRelativePath(stagingDir, file); string dest = Path.Combine(targetDir, relativePath); Directory.CreateDirectory(Path.GetDirectoryName(dest)!); File.Move(file, dest, true); } Process.Start(Path.Combine(targetDir, mainExeName));实际要处理的东西肯定不止这些,比如目标目录里可能有用户生成的配置或数据文件,替换时不能全删掉;还有多个子目录需要单独排除或保留。我在做的时候会维护一个“排除清单”,比如logs/、userdata/、Updater.exe这些路径在删除旧文件时跳过。
4.4 强制更新、跳过更新与灰度发布
强制更新不能依赖客户端自觉,它必须发生在主程序的关键逻辑之前。做法是在主程序初始化阶段就调用更新检查,如果force == true并且版本号确实比本地新,就进入一个模态窗口,没有关闭按钮,只有“立即更新”按钮。用户更新完成才继续进入主界面。
普通更新则要允许用户跳过。我在本地AppSettings.json里记录用户点击跳过的次数和最后跳过版本:
{ "SkippedVersion": "1.2.3.0", "SkipCount": 3 }如果用户跳过次数超过三次,下次启动就不给“跳过”按钮了,直接引导更新。这个策略能在“不打扰用户”和“保证用户用新版本”之间折中。
灰度发布我通常通过channels字段和独立的 manifest URL 来做。比如update-stable.json和update-beta.json两个配置,客户端启动参数或全局设置里指定走哪个通道。稳定版通道只在确认 beta 版本运行稳定之后才把version提上去。这套机制不复杂,却能在发布后问题没暴露之前把影响面控制住。
5. 常见问题与排查技巧实录
5.1 文件被占用导致替换失败
这是自动更新里出现频率最高的错误。症状是 Updater 在删除旧文件时抛出UnauthorizedAccessException或IOException: The process cannot access the file because it is being used by another process。
常见原因有三个:主程序退出不彻底、有隐藏子进程没有结束、杀毒软件在扫描刚释放的文件。排查方法是在 Updater 里先枚举所有候选进程,再查看目标目录下还有哪些文件被占用。Windows 上最简单的定位工具是handle.exe,但生产环境不能指望用户装这个。我更推荐在代码里提前规避:主程序收到更新指令后,先停止后台线程、关闭文件句柄,再调用Environment.Exit(0);Updater 端等待进程退出时不要只查主进程名,还要检查有没有同名进程或子进程残留。
还有一个隐蔽问题:如果主程序是以当前用户权限运行的,而安装目录在C:\Program Files下,Updater 提权重启后可能因为权限不一致无法访问旧文件。所以我前面才强调最好 per-user 安装。如果必须装 Program Files,那么更新流程必须设计成“先降权或先拷贝到可写临时目录”,而不是直接提权覆盖。
5.2 HTTP 缓存导致永远检查不到新版本
发布了一个新版本,客户端却一直提示“已是最新版本”,日志里显示的 server version 还是老版本。这也算我见过的一个高频问题。原因通常是更新服务器或中间代理对update.json做了缓存。静态托管服务一般会自动带上ETag和Last-Modified响应头,但如果你用 CDN,默认缓存策略可能是缓存几分钟甚至更久。
解决办法有两个层面:一是发布时主动刷新 CDN 的缓存条目;二是在客户端请求update.json时加一个查询参数,比如?t=当前本地版本号或?t=Unix时间戳,强制绕过缓存:
var url = $"{manifestUrl}?t={DateTimeOffset.Now.ToUnixTimeSeconds()}";这个办法简单粗暴但有效。更新包本身也可以加版本参数,但没必要,因为哈希校验已经保证了内容正确性,缓存的主要矛盾在update.json上。
5.3 版本号比较错误导致永远不更新或反复更新
如果不更新问题排查到这里,多半是版本比较逻辑写错了。我说过最容易踩的坑是用字符串比较,此外还有一个低概率但很恼人的坑:程序集版本号里有AssemblyVersion和FileVersion两个属性,它们可能不一致。如果你用FileVersionInfo.FileVersion读版本号,而构建脚本从AssemblyVersion生成 JSON,两边口径不一样,就可能出现“配置里是 1.2.3.0,实际程序集版本是 1.2.3.1”的错位。我建议全链路统一使用FileVersion或统一使用AssemblyVersion,在发布脚本里强制两个字段来自同一个变量。
如果版本号本身没问题但客户端反复提示更新,说明客户端本地记录的已更新版本号没有被持久化,或主程序启动后没有把当前版本写到注册表/AppSettings。这种情况每次启动都会认为自己是旧版本,无限更新。更新完成后的主程序启动逻辑里,要执行一步“写入当前版本号到本地记录”,下次检查时以此为基准。
5.4 安全软件拦截和网络代理问题
桌面应用自动更新几乎绕不开杀毒软件的“特殊照顾”。下载一个 zip 再解压,里面出现 exe,某些杀毒软件会直接隔离或拦截。这个没有百分百绕过的方案,因为任何绕过杀毒的操作都等于在帮恶意软件干活。我的做法是:
- 从第一天就给所有 exe、dll、zip 做 Authenticode 签名,更新流程里校验签名。
- 下载完成、解压完成后,对关键 exe 验证
AuthenticodeSignature,确保文件确实由你的证书签名。 - 主动把安装目录加入杀毒软件白名单,这一步可以写在安装流程里,尊重用户选择。
- 更新包的哈希校验可以帮你识别“文件被病毒隔离或篡改”的情况,哈希不一致时不要硬上,提示用户检查。
网络代理问题则经常发生在企业内网或用户本机有代理的场景。HttpClient默认会走系统代理,如果你没有设置HttpClientHandler.Proxy,在一些特殊网络环境里请求会超时或返回 403。我的处理是:更新模块继承 IE 代理设置,同时增加一个超时值,不能影响主程序启动。配置项里还可以放一个可选的proxy字段,支持用户手动设置。
6. 一些掏心窝的实操建议
这套方案不是一蹴而就的,第一版我也做得特别粗糙:没有独立 Updater,直接在主进程里尝试替换 exe,结果不用说,各种“文件被占用”。做完整版之后我最大的体会是:自动更新的技术难点不在“下载”,而在“替换失败后怎么让程序还能用”。所以从一开始就保留 backup 目录和清晰日志,比任何花哨功能都重要。
我的建议是,第一次做自动更新,不要追求增量更新、断点续传、多通道并行这些高级能力。先把“检查 JSON 配置—下载整包—校验哈希—独立 Updater 替换—失败回滚”这条主链路跑通,发布一两个真实版本,把日志收集做好,再考虑优化。断点续传虽然体验好,但对服务端和客户端都有额外要求;增量更新更是需要充分考虑兼容性,属于进阶玩法。
另外,更新服务器的日志一定要记录。每次客户端请求update.json和下载更新包,都要能够看到有多少请求、多少成功、多少失败。如果你的用户量不大,哪怕只是 Nginx 的 access log 也够用。没有真实数据支撑,你永远不知道你的 200KB 更新包在用户网络里下载需要多久,也不知道有多少人的机器被杀毒软件拦在更新之外。
最后说一个小教训:我遇到过客户在弱网环境下更新到一半断网,主程序已经被 Updater 退出,重启后程序目录不完整,无法启动。最后靠 backup 目录还原才救回来的。所以回滚机制不是可选项,是必须项。你可以在 Updater 启动前判断 backup 目录是否可用,在主程序下一次启动时自动检查安装目录关键文件是否完整,不完整就提示用户恢复到上一个可用版本。别等到线上翻车再补这个功能。