桌面应用自更新有一个绕不开的矛盾:运行中的 exe
文件被操作系统锁定,无法覆盖自己。你不可能在
SwitchData.exe 运行时直接把新版的
SwitchData.exe 复制上去,Windows 会直接拒绝写入。
常见的解法是”进程交接”:主程序只负责检查版本和下载决策,真正的”替换自己”交给一个独立的小程序(Updater.exe)完成——主程序先退出、释放文件锁,Updater 再解压覆盖、重启主程序。
本文基于一个真实 WinForms 项目的源码,拆解这套方案的完整实现:Mutex 单实例守卫、Version 版本比较、Timer 防重入、流式下载进度上报、文件锁检测与覆盖安装。整个链路不到 300 行代码,却覆盖了进程、线程、文件 IO、协议下载等多个工程细节,非常适合作为自更新方案的参考实现。
整体架构
先用一张时序图看清楚两个进程与更新服务器之间的协作关系:
sequenceDiagram
participant Main as 主程序 SwitchData.exe
participant Updater as 更新器 Updater.exe
participant Server as 更新服务器
Main->>Server: 启动时 GET UpdateJsonUrl(版本描述 JSON)
Server-->>Main: { version, url, notes }
Main->>Main: Version 比较,发现新版本
Main->>Updater: Process.Start(Updater.exe, [主exe路径, 包Url])
Main->>Main: Application.Exit() 退出并释放文件锁
Updater->>Server: 流式下载 Zip 更新包(16KB 分块 + 进度条)
Server-->>Updater: 更新包
Updater->>Updater: IsFileLocked 校验主程序已退出
Updater->>Updater: 解压 Zip 逐文件覆盖到主程序目录
Updater->>Main: Process.Start(主exe) 重启新版本
分工非常清晰:主程序负责”发现更新”,更新器负责”完成更新”。下面按执行顺序逐段实现。
一、命名 Mutex:单实例守卫与窗口唤醒
自更新方案的前提是同一时刻只有一个主程序实例在跑——否则 Updater 覆盖文件时,另一个实例还在占用 exe。项目里用命名 Mutex 做用户级单实例守卫:
// 用户级唯一名:可包含用户名或 SID
private static readonly string mutexName = $"SwitchData_Mutex_{Environment.UserName}";
using var mutex = new Mutex(true, mutexName, out bool isNewInstance);
if (!isNewInstance)
{
// 已有实例在运行:找到它的窗口并置前,然后退出当前进程
var currentProcess = Process.GetCurrentProcess();
foreach (var process in Process.GetProcessesByName(currentProcess.ProcessName))
{
if (process.SessionId == currentProcess.SessionId && process.Id != currentProcess.Id)
{
BringExistingWindowToFront(process.MainWindowHandle);
break;
}
}
Application.Exit();
return;
}
三个细节值得展开:
1. 为什么 Mutex 名要拼上
Environment.UserName? Windows
的命名内核对象按”会话命名空间”隔离,但多用户共机场景下(如服务器上多人远程登录),全局同名
Mutex 会让 A 用户误判 B
用户的实例为”已运行”。拼接用户名后,每个用户拥有独立的单实例守卫,互不干扰。
2. out bool isNewInstance 的语义。
构造函数的第一个参数 initiallyOwned: true
表示当前线程尝试持有该 Mutex;如果命名 Mutex
已被其他进程持有,isNewInstance 会返回
false。相比 CreateMutex +
GetLastError
的老式写法,这个重载一行就能判断,是推荐用法。
3. 重复启动时不要直接闪退,而是”唤醒”已有窗口。
用户双击图标的真实意图是”我要用这个软件”,所以代码遍历同名进程、比对
SessionId 排除自己,最后通过
SetForegroundWindow + ShowWindow(SW_RESTORE)
把最小化的旧实例拉到前台——这是单实例应用体验的标配动作。
二、启动即检查:用 Version 类型比较版本号
主程序启动后的第一件事是拉取更新描述。服务器端只需一个静态 JSON:
private class UpdateInfo
{
public string Version { get; set; } // 服务端最新版本,如 "2.3.1.0"
public string Url { get; set; } // 更新包下载地址
public string Notes { get; set; } // 更新说明
}
客户端检查逻辑的核心是版本比较。很多人用字符串比较或自己拆数字,其实
System.Version
自带四段式(Major.Minor.Build.Revision)比较语义,直接用即可:
private static async Task<bool> CheckForUpdateAsync()
{
var autoUpdate = ConfigurationManager.AppSettings["AutoUpdate"];
if (!Boolean.TryParse(autoUpdate, out bool isAutoUpdate) || !isAutoUpdate)
{
return false; // 未启用自动更新,直接返回
}
try
{
var updateJsonUrl = ConfigurationManager.AppSettings["UpdateJsonUrl"];
var jsonStr = await httpClient.GetStringAsync(updateJsonUrl);
var updateInfo = JsonSerializer.Deserialize<UpdateInfo>(jsonStr);
var localVersion = Assembly.GetExecutingAssembly().GetName().Version;
if (localVersion != null && Version.TryParse(updateInfo.Version, out var serverVersion)
&& serverVersion > localVersion)
{
var result = MessageBox.Show("发现新版本,是否更新?", "更新提示", MessageBoxButtons.YesNo);
if (result == DialogResult.Yes)
{
string mainExePath = Application.ExecutablePath;
string updaterPath = Path.Combine(
Path.GetDirectoryName(mainExePath), "update/Updater.exe");
// 启动独立更新器:参数依次为主程序 exe 路径、更新包下载地址
Process.Start(new ProcessStartInfo(updaterPath, [mainExePath, updateInfo.Url])
{
UseShellExecute = false
});
return true;
}
}
return false;
}
catch (Exception ex)
{
MessageBox.Show($"检查更新失败:{ex.Message}");
return false;
}
}
三个要点:
Version.TryParse而不是new Version(...):服务端 JSON 是外部输入,版本号可能写错格式,TryParse避免了抛异常中断主流程;>运算符重载:Version实现了IComparable,并重载了比较运算符,serverVersion > localVersion逐段比较四段版本号,语义正确;Process.Start传参用集合表达式:[mainExePath, updateInfo.Url]是 C# 12 的集合表达式,编译器自动展开为命令行参数,等价于老写法的字符串拼接,但更不容易漏空格转义。
三、运行期周期检查:Timer 的防重入模式
只做启动检查不够,长时间挂机的客户端也要感知新版本。项目用
System.Threading.Timer 每 30
分钟检查一次,且回调里有一个教科书级的防重入处理:
updateTimer = new System.Threading.Timer(async state =>
{
// 先停止定时器,避免检查期间被再次触发
updateTimer.Change(Timeout.InfiniteTimeSpan, Timeout.InfiniteTimeSpan);
if (await CheckForUpdateAsync())
{
updateTimer.Dispose();
Application.Exit();
return;
}
else
{
updateTimer.Change(TimeSpan.FromMinutes(30), TimeSpan.FromMinutes(30));
}
});
updateTimer.Change(TimeSpan.FromMinutes(30), TimeSpan.FromMinutes(30));
为什么要”先停再查”?System.Threading.Timer
的回调在线程池上执行,如果 CheckForUpdateAsync
因为网络卡顿耗了 40 秒,而定时器周期只有 30
秒,就会有多个回调并发执行——弹窗叠弹窗、重复启动更新器。Change(Timeout.InfiniteTimeSpan, ...)
相当于”暂停秒表”,检查完成后再重启周期,保证任意时刻最多只有一个检查在跑。
另外注意两点:
- 回调是
async void(Timer的回调签名是void TimerCallback(object? state),async lambda实为异步方法),内部必须全量 try-catch,否则异常会成为未观察异常直接丢失,这也是示例里CheckForUpdateAsync自己兜底 catch 的原因; - 主程序退出后要把窗口”交”给 Updater 前,先
updateTimer.Dispose(),防止退出途中定时器再触发一次检查。
四、独立更新器:流式下载与进度上报
Updater.exe 是一个极简 WinForms 程序,Main
里只做一件事:弹一个带进度条的 ProgressForm
下载更新包。下载实现值得细看:
private async Task DownloadFileAsync()
{
// ResponseHeadersRead:拿到响应头就返回,不等整个文件下载完
using var response = await httpClient.GetAsync(requestUrl, HttpCompletionOption.ResponseHeadersRead);
response.EnsureSuccessStatusCode();
var totalBytes = response.Content.Headers.ContentLength ?? -1L;
var canReportProgress = (totalBytes != -1);
txtFileSize.Text = $"文件大小:{FormatSize(totalBytes)}";
using var contentStream = await response.Content.ReadAsStreamAsync();
// 最后一个参数 true = useAsync,开启异步 IO
using var fileStream = new FileStream(savePath, FileMode.Create, FileAccess.Write,
FileShare.None, bufferSize, useAsync: true);
var buffer = new byte[bufferSize]; // 16KB
long totalRead = 0;
int bytesRead;
while ((bytesRead = await contentStream.ReadAsync(buffer)) > 0)
{
await fileStream.WriteAsync(buffer.AsMemory(0, bytesRead));
totalRead += bytesRead;
progressBar.Value = canReportProgress ? (int)((totalRead * 100d) / totalBytes) : 0;
}
}
两个容易写错的地方:
1. HttpCompletionOption.ResponseHeadersRead
是进度条的灵魂。 默认的 ResponseContentRead
会把整个响应体读进内存后才返回,几 MB 的更新包还能忍,几百 MB 的包会让
UI 卡死且进度条永远停在 0。改成 ResponseHeadersRead
后,GetAsync
在收到响应头时就返回,后续用流逐块读取,进度条才能实时推进。
2. 下载过程中把异常写回窗体结果,而不是弹窗。
窗体通过 DialogResult 向 Main 传递结果,用
async void 挂在 Shown 事件上(事件处理器是
async void 的合理场景):
private async void ProgressForm_Shown(object sender, EventArgs e)
{
try
{
await DownloadFileAsync();
this.DialogResult = DialogResult.OK; // 下载成功
}
catch (Exception ex)
{
ExitException = ex;
this.DialogResult = DialogResult.Abort; // 下载失败,异常交给 Main 统一弹窗
}
}
用异常对象 + DialogResult
的组合,把”网络失败”和”用户取消”统一成 Main
里一处分支处理,避免 UI 逻辑散落。
五、文件锁检测与覆盖安装
下载完成后进入最关键的替换环节。Main
的完整流程如下(简化后的关键路径):
if (args.Length != 2 || String.IsNullOrEmpty(args[0]) || !File.Exists(args[0]))
{
Application.Exit();
return;
}
System.Threading.Thread.Sleep(500); // 等待主程序完全退出,释放文件句柄
string mainExePath = args[0];
string mainExeDir = Path.GetDirectoryName(mainExePath);
string zipPath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, Path.GetFileName(requestUrl));
string extractPath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "new");
var form = new ProgressForm(requestUrl, zipPath);
var result = form.ShowDialog();
if (result == DialogResult.OK)
{
if (IsFileLocked(mainExePath))
{
MessageBox.Show("更新失败:主程序未退出", "更新提示",
MessageBoxButtons.OK, MessageBoxIcon.Error);
Application.Exit();
return;
}
ZipFile.ExtractToDirectory(zipPath, extractPath, true); // true = 覆盖已有文件
foreach (var file in Directory.EnumerateFiles(extractPath, "*", SearchOption.AllDirectories))
{
string dest = Path.Combine(mainExeDir, Path.GetRelativePath(extractPath, file));
Directory.CreateDirectory(Path.GetDirectoryName(dest));
File.Copy(file, dest, true); // 覆盖复制
}
Process.Start(mainExePath); // 重启主程序
Application.Exit();
return;
}
IsFileLocked
用”独占打开”的方式探测文件是否仍被占用:
private static bool IsFileLocked(string fileName)
{
try
{
using (FileStream fs = File.Open(fileName, FileMode.Open,
FileAccess.ReadWrite, FileShare.None))
{
fs.Close();
}
}
catch
{
return true; // 打不开 = 被占用
}
return false;
}
FileShare.None
表示不允许任何其他进程共享此文件——如果主程序还没退出,File.Open
会直接抛
IOException,我们就知道”锁还没放”。这比轮询进程列表更可靠,因为它检测的正是我们要的那把锁本身。
六、踩坑点与可改进方向
结合源码里防御性设计的落点,总结几个实战经验:
Thread.Sleep(500)不是万全之策,IsFileLocked才是兜底。 主程序Application.Exit()返回 ≠ 进程已退出、句柄已释放,所以更新器先睡 500ms 给系统留时间,再显式检测文件锁,双保险。若仍检测到锁,宁可失败重来,也不要带着锁强行覆盖——半更新的程序比旧版本更糟。finally清理临时产物。 无论成功失败,下载的 Zip 和解压目录都要删掉,否则每次更新在磁盘上留一份”尸体”,更新包大时非常占空间。把清理放进finally,异常路径也不漏。参数校验前置。 更新器对
args.Length != 2、主 exe 不存在等情况直接退出——独立进程最容易死在”参数不对”这种低级问题上,启动即校验比半路崩强。可改进点:生产级方案还可以增加更新包哈希/签名校验(防下载损坏与劫持)、失败回滚(覆盖前备份旧文件)、增量更新(只下载差异文件)等。这套骨架代码结构清晰,在这几处扩展都不需要动架构。
总结
回顾这套自动更新方案的四个关键设计:
- Mutex 命名互斥体做用户级单实例守卫,重复启动时唤醒已有窗口而非闪退;
Version类型 +TryParse完成安全的服务端版本比较,不用手写字符串拆分;Timer.Change(Infinite)防重入,避免网络抖动时周期回调堆积;- 独立 Updater 进程交接:主程序退出释放文件锁 →
ResponseHeadersRead流式下载带进度 →FileShare.None探测锁释放 → 解压覆盖 → 重启主程序。
“程序不能覆盖自己”这类看似无解的问题,往往一个独立的辅助进程就能优雅化解。这套模式同样适用于插件热更新、Agent 升级等场景,核心永远是那三步:让旧进程退场、在旁观者手里完成替换、把新进程扶上马。