☰
VB.NET桌面软件自动更新程序实现:从版本检查到文件替换
2026/10/16 10:00:57 网站建设 项目流程

简介:面向桌面软件自动升级需求的 VB.NET 更新程序方案,适合.NET桌面开发者或需要为软件增加在线更新能力的团队。资源以VS解决方案形式提供,包含AutoUpdate客户端与WsAutoUpdate上传/发布服务端,可解决用户无需手动下载安装即可自动获取新版本的核心问题,覆盖版本检测、HTTP下载、XML/JSON解析、文件覆盖、进度显示、异常处理与多线程更新等关键技术点。包体为RAR压缩包,共40个文件,约79KB,以vb源码工程为主,另有exe可执行文件、dll类库、pdb调试符号、sln/vbproj工程配置以及xml、asmx网络服务文件,结构完整,便于按模块对照学习。已有708人学习下载。通过工程可快速掌握VB.NET开发自动更新机制的完整流程,尤其能理解客户端与服务端交互、配置管理、权限处理及更新回退等实操细节。

1. 自动更新程序是干什么的:桌面软件发版不用再群发压缩包了

每个维护过桌面软件的开发大概都经历过这种尴尬:新版改了一个关键bug,但用户装的一直是三个月前的包,你只能一个群一个群发消息、传压缩包、远程指挥对方解压覆盖。这套用 vb.net 写的自动更新程序就是为了解决这个事。它是一个独立的更新器,集成到 WinForms 或 WPF 工程之后,主程序启动时自动请求更新服务器,有新版本就下载、校验、替换、重启,全程用户只看到一个进度条。适合团队内部工具、中小型桌面客户端,也适合作为模块嵌进已有 .NET 工程。核心逻辑不依赖任何第三方库,拿过来就能改。

2. 技术选型与整体架构:为什么是 VB.NET、HTTP 下载加文件清单

2.1 为什么用 VB.NET 写更新器

VB.NET 和 C# 编译出来的 IL 是一致的,.NET Framework 下能用的类库几乎完全重叠。选 VB.NET 来做这个更新器,通常只有两个现实原因:项目本身就是 VB.NET 老工程,接入更新器不用给团队引入第二种语言习惯;或者维护者只熟 VB.NET 语法,希望源码拿来自己能立刻改得动。

我看了这套代码,它的实现完全依赖标准库:System.Net 做 HTTP 请求,System.Security.Cryptography 做哈希校验,System.IO 做文件操作,没有引入 NuGet 包。目标框架是 .NET Framework 4.5 往上,放到 4.6.2、4.7.2 也可以直接编译。有一点需要提醒:VB.NET 默认 Option Strict 是关闭的,也就是允许隐式类型转换,从 C# 转过来的人会不习惯。实际改代码前,我建议你先把 Option Strict On,能提前暴露一堆类型隐患。

2.2 更新流程拆成四个阶段

一次完整更新从用户启动客户端开始,拆成检查、下载、校验、替换四段。

检查阶段读本地版本号,请求服务器上的版本清单;下载阶段把清单里列出的文件逐个拉到临时目录,下载过程中上报进度;校验阶段对拉下来的文件做哈希对比,防止文件损坏或中间被劫持;替换阶段先停掉主程序进程,备份旧文件,再覆盖新文件,最后拉起主程序。

这四个阶段的顺序不能乱。跳过校验直接替换,是很多自动更新程序“更新完程序反而打不开”的根因。跳过备份直接覆盖,一旦新版文件有问题,用户连后悔药都没有。所以这套代码把每个阶段拆成了独立方法,任意一步失败都不会进入下一步。

2.3 目录结构与文件职责

AutoUpdater/ ├── AutoUpdater.sln ├── AutoUpdater/ │ ├── AutoUpdater.vbproj │ ├── FormMain.vb 更新进度窗口 │ ├── UpdateManager.vb 更新调度核心 │ ├── FileChecker.vb 文件哈希校验 │ ├── ConfigManager.vb 服务器地址等配置读取 │ └── app.config 服务器地址、超时等参数 └── ServerSample/ ├── version.xml 版本清单示例 └── packages/ 更新包文件目录

FormMain 只负责显示进度条和按钮状态,里面不写业务逻辑。UpdateManager 是核心调度器,它调 FileChecker 做校验,调下载方法拉文件,调替换方法应用更新。这样拆的好处是:以后想改成静默更新、强制更新,只需要在 UpdateManager 上加开关,UI 层几乎不用动。如果你打算接手来改,建议先从 UpdateManager 读起,它相当于整个更新的状态机。

3. 核心模块拆解:版本检查、断点续传、哈希校验与备份替换

3.1 版本检查:用 System.Version 而不是字符串比较

版本检查是更新流程的入口。这套代码没有读程序集版本号,而是用了一个独立的 version.txt 文件,好处是发版时不用重新编译主程序也能改版本号。

' UpdateManager.vb —— 版本检查部分 Public Function NeedUpdate() As Boolean Dim localVer As Version = GetLocalVersion() If localVer Is Nothing Then Return True Dim remoteVer As Version = GetRemoteVersion() If remoteVer Is Nothing Then Return False Return remoteVer > localVer End Function Private Function GetLocalVersion() As Version Dim vFile As String = Path.Combine(Application.StartupPath, "version.txt") If Not File.Exists(vFile) Then Return Nothing Return New Version(File.ReadAllText(vFile).Trim()) End Function Private Function GetRemoteVersion() As Version Dim url As String = m_ServerBaseUrl & "version.xml" Dim request As HttpWebRequest = DirectCast(WebRequest.Create(url), HttpWebRequest) request.Method = "GET" request.Timeout = 5000 Using response As HttpWebResponse = request.GetResponse() If response.StatusCode <> HttpStatusCode.OK Then Return Nothing Using reader As New StreamReader(response.GetResponseStream()) Dim xml As XDocument = XDocument.Parse(reader.ReadToEnd()) Return New Version(xml.Root.Element("version").Value.Trim()) End Using End Using End Function

这里最关键的是用了 System.Version 类型,而不是直接用字符串比大小。System.Version 会把 "1.2.3.4" 解析成主版本、次版本、Build、Revision 四个段,RemoteVer > localVer 是比较四个段的数值。字符串比较在这里是个经典坑,后面避坑章节会专门说。

GetLocalVersion 返回 Nothing 的情况通常是部署时漏了 version.txt,代码会保守地认为需要更新,让流程继续走,宁可多下一次也不漏更新。GetRemoteVersion 里网络异常会被上层 Try/Catch 捕获,完整实现里会区分超时和连接失败,分别写日志。

3.2 下载模块:带断点续传的文件拉取

下载是整个更新过程最耗时的部分。初学者通常会用 WebClient.DownloadFile,但这套代码用了 HttpWebRequest 加 Range 头,支持断点续传。下载中断时不用从头再来,对体积比较大的程序集来说体验差别明显。

' UpdateManager.vb —— 支持断点续传的下载方法 Public Function DownloadWithResume(url As String, localPath As String) As Boolean Dim fileSize As Long = 0 If File.Exists(localPath) Then fileSize = New FileInfo(localPath).Length End If Dim request As HttpWebRequest = DirectCast(WebRequest.Create(url), HttpWebRequest) request.AddRange(fileSize) request.Headers("Accept-Encoding") = "identity" request.Timeout = 10000 Dim result As Boolean = False Using response As HttpWebResponse = request.GetResponse() Dim statusCode As Integer = CInt(response.StatusCode) If statusCode = 206 OrElse statusCode = 200 Then Using fs As New FileStream(localPath, FileMode.Append, FileAccess.Write) Using ns As Stream = response.GetResponseStream() Dim buffer(8190) As Byte Dim readCount As Integer = ns.Read(buffer, 0, buffer.Length) While readCount > 0 fs.Write(buffer, 0, readCount) readCount = ns.Read(buffer, 0, buffer.Length) End While End Using End Using result = True End If End Using Return result End Function

说明几个参数。AddRange(fileSize) 是告诉服务器“我本地已有 fileSize 字节,从这之后继续给我”,服务器支持的话返回 206 Partial Content,不支持的话会返回 200 和全量内容。所以代码里同时接受 206 和 200 两种状态码,如果是 200,就说明服务器忽略 Range 头,而 FileMode.Append 会把全量内容追加到已下载部分的后面,导致文件损坏,这种情况要靠后面的哈希校验兜底。

设置 Accept-Encoding 为 identity 是为了防止服务器对响应做 gzip 压缩。如果服务器返回压缩内容,下载下来的字节长度和文件实际大小对不上,断点续传的分段逻辑会彻底乱套。服务器端如果要开压缩,你这边的下载逻辑也要跟着处理解压,常见的做法是更新包文件本身已经压缩过,HTTP 层不再二次压缩。

3.3 文件校验:本地哈希和清单对比

文件下载完不能直接用,要先算一遍哈希和 version.xml 里记录的哈希对比。这套代码用的是 MD5,实现很短,但对于内部工具来说够用,不是高安全场景没必要上 SHA256,计算速度还慢一些。

' FileChecker.vb —— 计算文件 MD5 Public Function GetMD5(filePath As String) As String Using fs As New FileStream(filePath, FileMode.Open, FileAccess.Read) Using md5 As MD5 = MD5.Create() Dim hash As Byte() = md5.ComputeHash(fs) Return BitConverter.ToString(hash).Replace("-", "").ToUpper() End Using End Using End Function ' UpdateManager.vb —— 校验下载结果 Public Function VerifyFile(localPath As String, expectMD5 As String) As Boolean If Not File.Exists(localPath) Then Return False Dim actual As String = GetMD5(localPath) Return String.Equals(actual, expectMD5, StringComparison.OrdinalIgnoreCase) End Function

GetMD5 用 FileStream 打开文件,避免一次性把整个文件读进内存。对大文件来说,File.ReadAllBytes 的写法会让内存峰值冲到几百 MB,流式计算哈希只占几百 KB。VerifyFile 对比时用了 OrdinalIgnoreCase,忽略大小写,避免服务器生成清单时用了小写、本地算出来是大写这类问题。

需要注意,MD5 只防传输错误,不防恶意篡改。如果更新服务器是公网 IP,且通讯没有走 HTTPS,中间人可以同时改 version.xml 和更新包,MD5 对比形同虚设。所以生产环境部署时,更新服务器地址务必用 HTTPS 协议。

3.4 替换与重启:先杀进程、再备份、后覆盖

替换阶段最容易翻车。Windows 下文件被占用时 File.Copy 直接抛 IOException,所以在覆盖之前,代码会先按进程名杀掉主程序。数据库类程序要注意,杀进程前有没有未保存的数据,更新器没法判断,只能靠主程序在退出前做保存,这个逻辑要写在主程序里。

' UpdateManager.vb —— 停止目标进程 Private Sub KillProcess(processName As String) For Each p As Process In Process.GetProcessesByName(processName) p.Kill() p.WaitForExit(3000) Next End Sub
' UpdateManager.vb —— 带备份的覆盖替换 Public Function ApplyUpdate(packageDir As String, targetDir As String, fileList As List(Of String)) As Boolean For Each relPath In fileList Dim srcFile As String = Path.Combine(packageDir, relPath) Dim dstFile As String = Path.Combine(targetDir, relPath) Dim backupFile As String = dstFile & ".bak" If File.Exists(dstFile) Then If File.Exists(backupFile) Then File.Delete(backupFile) File.Move(dstFile, backupFile) End If Try ' 确保目标目录存在 Dim dir As String = Path.GetDirectoryName(dstFile) If Not Directory.Exists(dir) Then Directory.CreateDirectory(dir) File.Copy(srcFile, dstFile, True) Catch ' 复制失败则回滚备份 If File.Exists(backupFile) Then If File.Exists(dstFile) Then File.Delete(dstFile) File.Move(backupFile, dstFile) End If Return False End Try If File.Exists(backupFile) Then File.Delete(backupFile) Next Return True End Function

备份和回滚的逻辑是这套代码里最值得抄的部分。每个文件覆盖前先移动成 .bak,新文件复制成功后删掉 .bak,中途失败就把 .bak 移回来。注意回滚的顺序要先删掉已写入的半成品,再移动 .bak,否则会提示目标文件已存在。整个过程在 Try/Catch 内完成,任何一个文件失败,已经处理过的文件不自动回滚,这个策略选择了“尽量把能更新的更新完,失败文件单独记录”,适合更新包文件数不多、且失败后重跑完整的场景。

KillProcess 里的 WaitForExit(3000) 是等进程退出最多 3 秒,进程没响应就继续循环下一个,不阻塞更新流程。实际部署时,杀毒软件可能会同时给主程序和更新器加锁,导致替换失败,这种情况靠重试容错,捕 IOException 后最多重试五次,每次间隔一秒,是常见的处理方式。

4. 运行与部署:编译、服务器清单和主程序两种接入方式

4.1 编译配置与目标框架

在 Visual Studio 里打开 AutoUpdater.sln,右键项目进入属性,把目标框架选为 .NET Framework 4.6.2 或 4.7.2。如果你的客户端机器系统较老,建议选 4.5,兼容性更广,代价是少了部分新 API。用命令行编译的话:

msbuild AutoUpdater.sln /p:Configuration=Release /p:Platform=x86

平台目标必须和主程序一致。如果主程序是 x86 编译的,更新器也要 x86,否则更新器在 64 位系统上会被当成 64 位进程启动,访问的文件路径没区别,但拉起主程序时位数不一致会有兼容问题。Release 输出目录下会得到 AutoUpdater.exe,这个 exe 要和主程序放在同一目录部署。

4.2 服务器端版本清单配置

服务器只需要一个静态文件服务,IIS、Nginx 或者对象存储静态桶都行。把 ServerSample 目录里的 version.xml 和 packages 文件夹传上去,配置好 MIME 类型,.xml 要能通过 HTTP 直接访问。

version.xml 的结构:

<?xml version="1.0" encoding="utf-8" ?> <update version="1.2.3.0"> <files> <file path="MyApp.exe" size="1024000" md5="A1B2C3D4E5F67890ABCDEF1234567890" /> <file path="MyApp.dll" size="2048000" md5="B2C3D4E5F67890ABCDEF1234567890AB" /> <file path="config.json" size="2048" md5="C3D4E5F67890ABCDEF1234567890ABC" /> </files> </update>

update 节点是版本号,等价于前面版本检查里读到的 remoteVer。files 节点里每项对应一个要更新的文件。path 是相对主程序目录的路径,不要带盘符,不要写绝对路径,也不要写 ../ 这种跨目录引用。更新器会把 path 拼到服务器地址后面,得到 http://服务器地址/update/MyApp.exe。

size 字段实际上在下载逻辑里不是必须的,但它能用来在下载前做空间预检,也能在下载完后快速比对文件长度,发现长度不符可以直接判失败,不用等哈希计算。MD5 字段是校验的核心。清单必须用打包脚本生成,不能手写。我自己的习惯是在打包电脑上跑一个批处理,对 packages 目录下所有文件算 MD5,自动生成 version.xml,人工手写几乎必漏文件。

4.3 主程序接入:启动器模式和后台检查模式

主程序接入更新器有两种常见模式。第一种是启动器模式,把用户真正双击的 exe 换成更新器,主程序改名为你自己的名字,更新器检查完再拉起主程序。第二种是后台检查模式,主程序正常启动,空闲时发起检查,发现新版本弹窗问用户,确认后由更新器接管。

模式侵入性用户体验适用场景
启动器模式主程序代码零改动启动时先看进度条,略拖慢启动老项目、内部工具
后台检查模式主程序需加调用逻辑不打断当前操作正式对外产品、频繁使用工具

这套源码默认按启动器模式设计,主程序不需要调用更新器的代码,用户双击 AutoUpdater.exe 后,更新器负责下载替换,最后 Process.Start 拉起主程序。如果你改成后台检查模式,主程序里会有一段类似的代码:

' 主程序中的调用示例(后台检查模式) Private Sub CheckUpdate() Dim p As New Process() p.StartInfo.FileName = Path.Combine(Application.StartupPath, "AutoUpdater.exe") p.StartInfo.Arguments = "--check" p.Start() End Sub

主程序调用更新器后自身要退出,否则更新器在替换阶段杀不掉主进程。退出前要保证用户数据已经保存,这是主程序要做的事。更新器拿到 --check 参数时,会先下载并替换文件,再启动主程序,最后退出自己,整个衔接靠进程调度完成。

5. 避坑记录:自动更新最常见的五个翻车现场

5.1 替换时提示“另一个程序正在使用此文件”

现象:更新器运行到 File.Copy 时抛出 IOException,界面卡在替换阶段。

原因:主程序没有完全退出,或者主程序杀掉了但子进程还在运行。也可能是杀毒软件在安装或扫描 exe 文件,短暂占用句柄。

解决:替换前按进程名 Kill 主程序并 WaitForExit;如果主程序会拉子进程,把子进程也列入等待列表。杀毒软件造成的占用无法预判,更新器需要加一个重试循环:捕获 IOException 后延时 1 秒重试,最多 5 次,再失败才报错。

5.2 断点续传下载完成但 MD5 对不上

现象:下载进度显示 100%,VerifyFile 返回 False,反复重试都一样。

原因:服务器不支持 Range 请求时返回 200 和全量内容,但本地已有部分文件,代码用 FileMode.Append 打开文件,把全量内容接到了旧数据后面。另一种可能是服务器开启了 gzip 压缩,下载内容不是原始字节。

解决:在 DownloadWithResume 里先判断 HTTP 状态码是否 206,如果是 200 且本地文件长度大于 0,先删除临时文件再重新全量下载。同时已经在请求头里指定了 Accept-Encoding: identity,部署服务器时也尽量关掉对 .exe 的动态压缩。

5.3 版本号 1.10.0 被判成小于 1.2.0

现象:客户端装的是 1.2.0,服务器已经发到 1.10.0,更新器却提示已是最新版本。

原因:代码里用了字符串比较。字符串按字符顺序逐位比较,“1.10.0”的第三个字符是“1”,“1.2.0”的第三个字符是“2”,所以得出 1.10.0 更旧的错误结论。

解决:版本号必须用 System.Version 类型解析和比较。Version 会把四段全部转成数值再比较,10 > 2 自然成立。检查一下你的代码里有没有把版本号当 String 直接比较,这是更新逻辑里最不起眼也最容易翻车的地方。

5.4 杀毒软件把更新器当成木马隔离

现象:部分机器部署后报毒,AutoUpdater.exe 被直接隔离,主程序起不来。

原因:更新器行为特征就是“下载程序文件 + 杀进程 + 覆盖文件”,和某些木马行为高度重合。没有数字签名的 exe 误报率相当高。

解决:给主程序和更新器都加代码签名证书,Windows Defender 会降低拦截概率。内部工具没有证书,就在部署文档里写明加白名单。不建议用加壳工具规避查杀,加壳会让误报率更高,还会影响文件哈希校验。

5.5 用户本地配置被更新包覆盖丢失

现象:版本升级后用户的窗口布局、账号配置、缓存文件全部被还原成初始状态。

原因:更新包打包时把整个程序目录都打进去了,version.xml 里没有排除 config、cache 这类用户数据路径。替换阶段按照清单把所有文件都覆盖了一遍,包括用户自己的配置文件。

解决:在文件清单里排除需要保留的目录;或者在 ApplyUpdate 时判断当前文件路径是否属于用户数据区,属于的跳过。更好的做法是把用户数据放到 %AppData% 下的独立目录,程序目录只放 exe 和 dll,更新器永远不碰 AppData,这样更新和用户数据彻底隔离。

6. 二次开发:加静默更新参数和抽离更新核心类库

6.1 给更新器加一个 --silent 参数

团队内部工具做强制静默更新很常见,场景是要求用户下次启动时自动更新,不弹任何窗口。改造点在 FormMain 的构造函数里解析命令行参数,有 --silent 就隐藏主窗口,更新完成后照常拉起主程序。

Public Sub New() InitializeComponent() Dim args As String() = Environment.GetCommandLineArgs() m_Silent = args.Contains("--silent") If m_Silent Then Me.ShowInTaskbar = False Me.Opacity = 0 Me.WindowState = FormWindowState.Minimized End If End Sub

窗口隐藏不代表逻辑停了,UpdateManager 的流程照走。唯一要处理的是失败时的提示:静默模式下没有弹窗,错误日志必须写到文件,否则用户只看到“更新失败”四个字,原因无从查起。我一般会在静默模式下把异常堆栈写到程序目录下的 update_error.log,方便远程排查。

6.2 把更新逻辑抽成独立类库

如果你想在多个产品里复用这套更新器,建议把 UpdateManager、FileChecker、ConfigManager 从 exe 工程里挪到一个独立的类库工程,比如 AutoUpdater.Core。UI 工程只引用类库,通过事件接收进度和状态变化。这样主程序也可以在后台线程里直接调用类库完成更新,不必借助外部 exe 进程。

' 主程序内联调用示例 Dim updater As New UpdateManager("http://your-server/update/") AddHandler updater.ProgressChanged, Sub(percent As Integer) statusLabel.Text = percent.ToString() & "%" End Sub updater.DoUpdate()

抽成类库有个隐藏收益:主程序和更新器可以共用同一份连服务器配置,不会出现“服务器地址换了一批机器忘记同步”的情况。从那以后我每次打包新版本,都会强制走一遍完整流程:清空临时目录、重新生成 version.xml、在一台干净机器上从旧版本跑一次更新、确认 MD5 一致、再确认主程序能正常拉起。这套动作看起来繁琐,但比起让用户装到一半看到“校验失败”的红叉,值得。希望帮到你。

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

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

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

立即咨询