简介:面向需要持续迭代的WPF桌面程序,自动更新是降低分发与维护成本的重要能力。这套代码资源围绕C#客户端与Java服务端,完整展示了版本检查、更新包下发、下载解压与替换重启等关键机制,适合正在为项目加入自动升级功能、或希望学习跨语言更新架构的开发者。包内共248个文件,约45.56MB;143个cs文件构成客户端核心逻辑,13个xaml提供WPF界面,21个java文件负责服务端接口,另有exe、jar、msi等可执行/安装产物以及json、xml配置文件与说明文档,结构清晰便于定位代码与配置。目前已有172人浏览学习,属于中小体量、可直接部署调试的工程示例。深入研读可掌握自动更新中的HTTP/HTTPS通信、版本信息解析、文件校验与压缩替换、进程重启等关键技术;借助随包代码和配置,能够快速复制到自有C#项目,或改造成通用更新组件,减少重复搭建成本。
1. 自动更新不只是下载一个文件
在 WPF 上位机和桌面工具开发中,自动更新是典型的“看着容易、做着坑多”。很多人最初打算用 WebClient 把新 exe 下载下来覆盖旧文件,运行时才发现:exe 被进程占用、版本号比较错误、下载断了一半、新版本启动崩溃却没有回滚。这些问题不是网络问题,而是协议和流程没设计好。该压缩包是一套 C# 自动更新客户端和 Java 服务端接口的实现,源码里包含 BZip2InputStream.cs、BZip2OutputStream.cs、FileManager.cs、BinaryHandle.cs,正好串起压缩、解压、文件覆盖、服务端发布。下面按“协议设计 → C# 客户端 → Java 服务端 → 上线收尾”的顺序拆讲,适合要在 WPF 项目里做自动更新的开发者。
2. 更新协议设计:C# 客户端与 Java 服务端先对齐版本状态
自动更新最忌讳两端各写各的。客户端用字符串比较版本号,服务端只丢一个文件名过来,结果上线前联调半天、上线后依然出问题。我接手这类项目时,第一步永远是把“清单”定下来:服务端返回结构化数据,包含最新版本号和下载文件信息;客户端拿到后先做逻辑判断,再决定是否下载。
2.1 用 JSON 清单替代散装的接口参数
在 C# 和 Java 之间传更新信息,JSON 是成本最低的方式。WPF 客户端用 System.Text.Json 解析,Java 服务端用 Spring Boot 的 Jackson 直接输出,几乎不引入额外依赖。相比 XML,JSON 字段更短,嵌套结构也更直观。下面是我在项目里常用的更新清单字段约定:
| 字段 | 类型 | 含义 |
|---|---|---|
| appName | string | 应用标识,防止多个产品共用一个服务器时串包 |
| version | string | 服务端认为的最新版本号 |
| minVersion | string | 允许运行的最低版本,低于它时必须强制更新 |
| downloadUrl | string | 更新包相对路径,客户端拼接服务器地址 |
| sha256 | string | 更新包 SHA256,下载后逐字节校验 |
| releaseNote | string | 更新说明,展示在 WPF 的更新弹窗里 |
协议设计的关键一点是 minVersion。如果客户端版本低于 minVersion,不能只做“建议更新”,必须阻塞旧版本继续运行;否则后续接口升级后,老客户端可能连服务器都连不上。对应清单 JSON 示例:
{ "appName": "HmiScada", "version": "1.4.2", "minVersion": "1.2.0", "downloadUrl": "/packages/hmiscada-1.4.2.bz2", "sha256": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08", "releaseNote": "修复 Modbus 重连偶发失败的问题" }下载地址如果是相对路径,客户端必须根据当前环境切换到 http 或 https。内网 WPF 上位机场景大多用 http,外网发布必须强制 https,否则更新包在链路上被篡改时,哈希校验形同虚设。
2.2 C# 端版本判断:用 Version 类型而不是字符串比较
很多 C# 入门项目会把版本号写成字符串,然后直接比较。这种做法在“1.9.2”与“1.10.0”之间会翻车,因为字符串按位比较时“1.10.0”反而小于“1.9.2”。正确做法是使用 System.Version。我把检查逻辑拆成一个独立方法,方便单元测试:
public CheckResult ResolveUpdate(UpdateManifest? manifest, Version localVersion) { if (manifest is null) return CheckResult.Failure("服务器没有返回更新清单"); var remote = Version.TryParse(manifest.Version, out var remoteVersion) ? remoteVersion : throw new FormatException($"非法版本号: {manifest.Version}"); if (remote <= localVersion) return CheckResult.NoUpdate; var min = Version.TryParse(manifest.MinVersion, out var minVersion) ? minVersion : null; bool forced = min is not null && localVersion < min; return CheckResult.Available(remote, forced); }这段代码里,先把服务端版本和本地版本都转成 Version,再做三次比较:无更新、可选更新、强制更新。CheckResult.Available 里带一个 forced 标志,UI 层拿到后弹不同的提示框。如果是强制更新,WPF 主窗口就不显示“下次再说”按钮,直接进入下载流程。
提示:如果本地程序有可能从“无版本号”状态升级,代码里要把本地版本默认成 0.0,避免空引用导致更新功能完全失效。
2.3 Java 服务端:清单接口和文件下载接口分开
服务端不需要做复杂业务,核心就是读更新清单返回给客户端,再把更新包文件以流的形式交给客户端。我通常写两个接口:GET /update/manifest 接收客户端上报的版本,返回清单;GET /update/packages/{fileName} 返回更新包二进制。分开的原因很实际:清单接口要求响应快、可以做灰度策略;文件下载需要支持大流量,方便单独加 nginx 缓存和带宽限制。
@RestController @RequestMapping("/update") public class UpdateController { private final UpdateManifest currentManifest; private final Path updateRoot; public UpdateController(@Value("${update.manifest-path}") String manifestPath, @Value("${update.root-path}") String root) throws IOException { this.updateRoot = Paths.get(root); this.currentManifest = readManifest(Paths.get(manifestPath)); } @GetMapping("/manifest") public ResponseEntity<?> manifest(@RequestHeader(value = "X-App-Version", defaultValue = "0.0") String clientVersion) { if (compareVersion(clientVersion, "1.0.0") < 0) { return ResponseEntity.status(426).body(currentManifest); } return ResponseEntity.ok(currentManifest); } @GetMapping("/packages/{fileName}") public ResponseEntity<Resource> package(@PathVariable String fileName) { if (!fileName.matches("[a-zA-Z0-9._-]+")) { return ResponseEntity.badRequest().build(); } Path target = updateRoot.resolve(fileName).normalize(); if (!target.startsWith(updateRoot) || !Files.exists(target)) { return ResponseEntity.notFound().build(); } return ResponseEntity.ok() .contentType(MediaType.APPLICATION_OCTET_STREAM) .header("Content-Disposition", "attachment; filename=\"" + fileName + "\"") .body(new FileSystemResource(target)); } }这里有两个容易在 Java 面试八股文里被问到的点。第一,客户端版本过低时返回 426 Upgrade Required,而不是硬编码 200,客户端可以专门捕获这个状态码做强制更新。第二,fileName 直接用 @PathVariable 拼路径存在目录穿越风险,matches 正则过滤加 normalize 后校验 startsWith,是 Java 服务端的标准防御姿势。自动更新接口虽然简单,安全边界不能省。整个更新协议到这里就闭环了:客户端上报版本,服务端回清单,客户端比较后决定是否下载,下载接口用路径参数定位文件。
3. 把 C# WPF 自动更新模块做成独立服务
协议定完,就轮到客户端落地。很多 WPF MVVM 项目会把自动更新直接写进 MainViewModel,然后发现界面等待、进度回调、退出重启全搅在一起。我自己的项目里,自动更新更像是一个“可独立启动的模块”:接收服务器地址和当前版本号,经过检查、下载、校验、准备四个阶段,最后通过事件告诉界面层该显示什么。
3.1 更新服务和 WPF 界面解耦
WPF 上位机里 MVVM 是标配,加不加 Prism 都有套路。自动更新代码放 ViewModel,字段一多就会和业务状态互相干扰。常见做法是定义 UpdateService,构造函数传入 HttpClient 和事件回调:
public class UpdateService { private readonly HttpClient _http; private readonly Version _localVersion; public UpdateService(HttpClient http, Version localVersion) { _http = http; _localVersion = localVersion; } public event EventHandler<UpdateProgress>? ProgressChanged; public event EventHandler<UpdateState>? StateChanged; public async Task RunAsync(CancellationToken ct) { var manifest = await GetManifestAsync(ct); var decision = ResolveUpdate(manifest, _localVersion); if (decision.Kind == UpdateKind.None) return; StateChanged?.Invoke(this, UpdateState.Downloading); var packageFile = Path.Combine(Path.GetTempPath(), $"{manifest.AppName}-{manifest.Version}.bz2"); await DownloadPackageAsync(manifest.DownloadUrl, packageFile, ct); if (!VerifySha256(packageFile, manifest.Sha256)) throw new InvalidDataException("更新包哈希校验失败"); PrepareUpdate(packageFile, manifest.Version, ct); StateChanged?.Invoke(this, UpdateState.ReadyToRestart); } }更新服务把流程固化成几个阶段:获取清单、版本判断、下载、校验、准备。UI 只需要订阅 StateChanged 事件,根据 ReadyToRestart 提示用户是否立即重启。WPF 的 Dispatcher 会在事件回调里自动回到 UI 线程,不需要额外写 Invoke,前提是事件在 UI 线程的同步上下文里触发。
3.2 下载更新包:流式下载与进度上报
下载最大的坑是直接把整个包 ReadAsByteArrayAsync,内存占用和 UI 卡死一起出现。我一般用 ResponseHeadersRead 配合 ReadAsStreamAsync,一边从网络读一边写磁盘,同时通过事件上报进度。这样能避免更新包过大导致内存暴涨,也方便在界面上画进度条。
private async Task DownloadPackageAsync(string url, string destFile, CancellationToken ct) { using var response = await _http.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, ct); response.EnsureSuccessStatusCode(); await using var source = await response.Content.ReadAsStreamAsync(ct); await using var target = new FileStream(destFile, FileMode.Create, FileAccess.Write, FileShare.None, 81920, true); var buffer = new byte[81920]; int read; long received = 0; long total = response.Content.Headers.ContentLength ?? 0; while ((read = await source.ReadAsync(buffer, ct)) > 0) { await target.WriteAsync(buffer.AsMemory(0, read), ct); received += read; if (total > 0) ProgressChanged?.Invoke(this, new UpdateProgress((int)(received * 100 / total))); } }代码里的 81920 字节缓冲区是 80KB,对齐 FileStream 默认缓冲区后能减少用户态和内核态的切换。如果更新包经常在 1MB 以下,缓冲区可以降到 4096,省内存但吞吐量略低。下载路径建议用 Path.GetTempPath() 组合文件名,不要放程序目录,避免杀毒软件对写入 exe 目录产生告警。
3.3 校验、解压和准备更新
更新包下载后要先做哈希校验。服务端生成的 SHA256 不能只在浏览器里看,要在 CI 或发布脚本里自动算。这里给出一个简单校验函数:
private static bool VerifySha256(string filePath, string expected) { using var sha = SHA256.Create(); using var stream = File.OpenRead(filePath); string hash = Convert.ToHexString(sha.ComputeHash(stream)).ToLowerInvariant(); return hash == expected.Trim().ToLowerInvariant(); }校验通过后,用压缩包里带的 BZip2InputStream 解压更新包。这个类继承自 Stream,可以直接 CopyTo 目标文件。因为 WPF 主程序的 exe 正被自己锁着,不能边运行边覆盖,我一般解压到update子目录,然后写一份pending.json,记录要替换哪些文件、旧文件备份到哪个目录。
3.3.1 替换文件的三个阶段
替换不能乱来,否则中途断电程序就废了。标准做法就是三个阶段:备份、替换、清理。
| 阶段 | 动作 | 失败处理 |
|---|---|---|
| 备份 | 把当前 exe/dll 复制到 backup/{version}/ | 失败则停止,保留原文件 |
| 替换 | 把 update 目录内的新文件复制到程序目录 | 失败则从 backup 恢复 |
| 清理 | 删除 backup 和 update 目录 | 删除失败不影响启动 |
这三个阶段最好放在独立的 Updater.exe 里执行,而不是在主进程里。主进程收到 ReadyToRestart 后,启动 Updater.exe,传入程序目录和 pending.json 路径,然后退出自己。Updater 完成替换后再拉起主程序。很多 WPF 项目打包成单文件后也推荐这个方式,能绕开“自己替换自己”的文件锁问题。
3.4 失败重试的基本参数
下载失败要重试,但不能是简单的死循环。我通常用指数退避,连续失败多次后提示用户检查网络。下面是一组常用默认值:
| 参数 | 默认值 | 说明 |
|---|---|---|
| MaxRetryCount | 3 | 超过后进入失败状态 |
| BaseRetryDelay | 2s | 第一次重试前等待 |
| MaxRetryDelay | 30s | 多次失败后最多等待 30s |
| DownloadTimeout | 30min | HttpClient.Timeout 要覆盖慢网场景 |
| ProgressReportInterval | 100ms | 避免进度事件刷爆 UI 线程 |
如果更新包超过 200MB,再考虑断点续传。小体积更新包直接用 GET 下载就好,Range 请求还要在服务端和 nginx 上同时支持,性价比不高。
4. Java 服务端生成更新包与发布配置
服务端除了提供接口,还要负责把发布产物打成更新包。压缩包里既然带了 BZip2OutputStream.cs,压缩格式自然就是 BZip2。我见过不少项目用 ZIP,但 WPF 端为了少引第三方包,直接用 BZip2 流解压单个文件更省事。如果整个软件有很多 dll、配置文件,就先打成 tar 再压缩成 tar.bz2。
4.1 用 commons-compress 生成 tar.bz2
Java 生成 tar.bz2 不需要自己写位级算法,Apache Commons Compress 里已经有 BZip2CompressorOutputStream,配合 TarArchiveOutputStream 就能工作。下面是 Maven 依赖和一个小工具方法:
<dependency> <groupId>org.apache.commons</groupId> <artifactId>commons-compress</artifactId> <version>1.26.2</version> </dependency>import org.apache.commons.compress.compressors.bzip2.BZip2CompressorOutputStream; import org.apache.commons.compress.archivers.tar.TarArchiveEntry; import org.apache.commons.compress.archivers.tar.TarArchiveOutputStream; public static void createTarBz2(Path output, List<Path> files) throws IOException { try (TarArchiveOutputStream tar = new TarArchiveOutputStream( new BZip2CompressorOutputStream(Files.newOutputStream(output)))) { tar.setLongFileMode(TarArchiveOutputStream.LONGFILE_POSIX); for (Path file : files) { TarArchiveEntry entry = new TarArchiveEntry(file.getFileName().toString()); entry.setSize(Files.size(file)); tar.putArchiveEntry(entry); Files.copy(file, tar); tar.closeArchiveEntry(); } } }代码逻辑就是往 tar 流里逐个塞业务文件,再由外层 BZip2 压缩整个归档。setLongFileMode 设置为 POSIX,是为了处理路径长度超过 100 字符的情况;WPF 发布目录如果比较深,这个参数踩过坑就会记得。
4.2 服务端发布目录与配置
更新包生成后放在服务端固定的发布目录里。我的习惯是保留三份历史版本,避免误发布后拿不回旧包。目录结构如下:
/opt/update-server/release/ ├── manifest.json ├── 1.2.0/ │ └── hmiscada-1.2.0.tar.bz2 ├── 1.3.1/ │ └── hmiscada-1.3.1.tar.bz2 └── 1.4.2/ └── hmiscada-1.4.2.tar.bz2manifest.json 放在发布目录根上,就是第 2 章那一段 JSON。发布脚本在 Java 服务启动前重新生成一次,核心逻辑是遍历目录找最新版本,然后调用 sha256sum 生成哈希。这个环节建议自动化:我见过手动把哈希填错的案例,客户端下载后校验失败,用户一直卡在“更新失败”。如果项目里有 mvnw.cmd,可以直接用它启动 Spring Boot 服务,不用预先装 Maven,前提是已把 Java 环境变量配置好。
Spring Boot 配置集中在 application.yml:
server: port: 8080 update: root-path: /opt/update-server/release manifest-path: /opt/update-server/release/manifest.json根路径和清单路径拆成两个配置,是为了后续支持“多个产品共用发布目录”。如果服务端放在 nginx 后面,还要同步调整 client_max_body_size,这个参数直接影响客户端下载大更新包时是否被中间层截断。
4.3 用 curl 模拟 C# 客户端的完整更新流程
服务端写好以后,先用命令验证一遍,再让 C# 端联调。Windows 和 Linux 都有 curl,直接模拟客户端行为最省事。
# 模拟客户端上报 1.2.0 版本,请求最新清单 curl -s -H "X-App-Version: 1.2.0" http://localhost:8080/update/manifest # 根据返回的 downloadUrl 下载更新包 curl -OJ http://localhost:8080/update/packages/hmiscada-1.4.2.tar.bz2 # 校验下载到的文件哈希 sha256sum hmiscada-1.4.2.tar.bz2如果清单里的 downloadUrl 返回 404,基本是 Spring Boot 的静态资源映射没配好。使用 FileSystemResource 时,重点检查构造函数里传入的 Path 是不是绝对路径。以上流程通过后,C# 端下载、校验、解压的调试压力会小很多。
5. 自动更新上线前必做的收尾:自检、回滚与日志
代码写完、接口连通,只完成一半。自动更新是典型的“发布时没提示、上线后用户骂”的功能,所以最后要落在收尾上。
5.1 更新后自检与自动回滚
新版本程序不一定能正常启动,尤其是配置项变化或依赖缺失的时候。我通常让 Updater.exe 在替换文件后先不直接拉起主程序,而是写一个first_run.flag,再启动主程序并等待退出码。如果主程序能在启动阶段自检通过并删除这个 flag,说明更新成功;如果超时或退出码非零,就把备份文件拷回来,并把日志写到 update.log。下面是一段简化后的批处理逻辑:
@echo off if not exist first_run.flag goto :normal app.exe --self-check if errorlevel 1 ( echo %date% %time% rollback >> update.log copy /Y backup\app.exe app.exe ) del first_run.flag :normal app.exe这段批处理的关键是 errorlevel 判断。app.exe 的 self-check 参数只做启动级检查,比如配置文件是否可加载、关键依赖是否存在。对于 WPF 程序,也可以在主程序里写逻辑,启动成功后主动删除 flag 文件,异常退出时不删,让下次启动自动回滚,这样更可控。
5.2 日志字段要能定位到具体包
更新日志不要只记“成功/失败”。每次更新至少应记录 check_time、local_version、remote_version、download_bytes、sha256_ok、rollback 这几个字段。比如一行日志:2025-03-14 10:00:12 | 1.2.0 -> 1.4.2 | bytes=1048576 | sha256=ok | rollback=false。这行日志放到客户端本地滚动日志里,远程排障时能直接看出是下载中断、校验失败还是回滚触发。内网 WPF 上位机场景还可以加一个 HTTP 上报接口,把关键字段汇总到服务端,不用一台台电脑去看日志。
5.3 发布检查清单和最后的技巧
如果团队只有一个人维护发布流程,最好把检查项写进发布脚本,而不是靠脑子记。我每次发完版都会过一遍清单:更新包是否用 tar+bzip2 重新压缩、sha256 是否与 manifest 一致、minVersion 是否比上一个版本小、Windows 防火墙和杀毒软件是否放行新路径、程序目录是否有写权限、发布包文件名是否只包含字母数字和 -。最后一条是我特别在意的:downloadUrl 里的文件名字符集尽量严格限定在[a-zA-Z0-9._-]+,这样能同时避开 Java 服务端路径穿越、C# 端 Content-Disposition 编码乱码、以及 HTTP 客户端在特殊字符上解析失败三个问题。文件命名干净了,自动更新这条链路会在上线后替你省掉不少半夜电话。
本文还有配套的精品资源,点击获取