WPF自动更新实战:C#客户端与Java服务端协议设计
2026/9/14 11:24:28 网站建设 项目流程

简介:面向需要持续迭代的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 字段更短,嵌套结构也更直观。下面是我在项目里常用的更新清单字段约定:

字段类型含义
appNamestring应用标识,防止多个产品共用一个服务器时串包
versionstring服务端认为的最新版本号
minVersionstring允许运行的最低版本,低于它时必须强制更新
downloadUrlstring更新包相对路径,客户端拼接服务器地址
sha256string更新包 SHA256,下载后逐字节校验
releaseNotestring更新说明,展示在 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 失败重试的基本参数

下载失败要重试,但不能是简单的死循环。我通常用指数退避,连续失败多次后提示用户检查网络。下面是一组常用默认值:

参数默认值说明
MaxRetryCount3超过后进入失败状态
BaseRetryDelay2s第一次重试前等待
MaxRetryDelay30s多次失败后最多等待 30s
DownloadTimeout30minHttpClient.Timeout 要覆盖慢网场景
ProgressReportInterval100ms避免进度事件刷爆 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.bz2

manifest.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 客户端在特殊字符上解析失败三个问题。文件命名干净了,自动更新这条链路会在上线后替你省掉不少半夜电话。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询