用VB.NET打造Windows软件自动更新器:从零实现完整更新链路 简介基于VB.NET编写的软件自动更新程序完整项目面向.NET桌面应用开发者、需要为自有软件实现升级功能的程序员解决手动下载安装繁琐、版本维护困难的问题。项目包含客户端与服务端两部分服务端使用WebService提供更新信息与文件分发客户端通过HTTP请求检查版本、下载更新包并完成解压覆盖覆盖了网络通信、XML解析、文件操作、多线程下载、进度反馈、异常处理及版本比较等关键技术点。资源包共40个文件以vb源文件、vbproj工程文件、dll程序集、pdb调试信息、resx资源文件、exe可执行程序及txt说明文档为主包体仅79KB结构清晰、便于查看代码与运行调试。目前已有708人学习下载。通过阅读源码可直接掌握用VB.NET实现自动更新的完整思路还可借鉴WebService接口设计、XML配置读取、后台任务与进度条联动等实用写法对开发自有更新器或学习桌面客户端与服务端联动有较高参考价值。1. 自动更新程序怎么落地一个 vb.net 更新器能干什么你的软件发给用户之后修了个崩溃然后就让用户去官网重新下载安装包——换我是用户十有八九会当没看见。自动更新程序就是解决这个痛点的它作为独立小程序跟着主程序一起发布启动时检查更新源发现新版本就自动下载、校验、替换、拉起新版本全程不用用户手动操作。这个 vb.net 写的更新器覆盖了完整链路版本列表解析、文件下载、哈希校验、备份替换、重启主程序所有代码都是 WinForms 工程拿到手改一下配置就能接进自己项目。适合单机桌面工具、内部业务系统这类跑在 Windows 上的软件也适合小团队没有专门运维时快速搭一条更新通道。2. 技术选型与架构设计为什么更新器必须是独立 exe2.1 独立进程比主程序内置模块更可靠我见过不少团队把更新逻辑直接写在主程序的启动流程里结果主程序一崩溃更新代码也跟着挂连回滚都做不了。后来改成独立 exe 就顺了。更新器跟主程序分离主程序只负责在启动时用Process.Start拉起 Updater.exe然后自己退出。这样更新过程在主进程之外即使主程序卡死或文件被占用更新器仍然有权限去替换文件。项目里的结构就是这样更新器是一个单独工程产出一个 Updater.exe跟主程序之间没有代码耦合。它俩的契约只有两个一个是版本号一个是更新源地址。主程序不需要关心下载逻辑也不需要关心解压、校验、备份这些脏活这些全在更新器这边做。这个设计还有一个额外好处以后主程序换语言重写更新器可以原封不动继续用。2.2 三种更新时机的对比与取舍做更新器第一个要定的决策是更新时机。我梳理过三种常见方案各有各的适用场景。方案用户体感磁盘占用复杂度适用场景同步替换弹窗提示后重启才生效低低内部工具、低频更新临时进程替换下载后主程序退出自动更新低中本项目采用的方案双目录切换下次启动直接进新版本翻倍高大型软件、游戏客户端本项目选的是第二种Updater.exe 被拉起后先完成下载与校验然后等待主进程退出替换文件最后重新拉起主程序。用户看到的是一个带进度条的更新窗口整个过程不需要手动点头。选这个方案我最看重的点是容错更新器在独立进程里万一替换失败至少主程序旧版本还在做了备份用户业务不受影响。双目录切换虽然体验更好但要在启动器里做目录选择逻辑对小项目来说太重了。2.3 解决方案目录拆解这个解决方案一共拆三个角色主程序、更新器、更新服务器目录。UpdateDemo.sln ├─ MyApp 主程序模拟真实业务软件 │ └─ MyApp.vb ├─ Updater 更新器主工程 │ ├─ Main.vb 入口解析命令行参数 │ ├─ UpdateService.vb 版本检查、下载、哈希校验 │ ├─ UpdateInstaller.vb 备份、替换、回滚 │ ├─ UpdateForm.vb 更新窗口界面 │ └─ app.config 更新源地址、超时、重试次数 └─ UpdateServer 更新包和 manifest.xml 存放目录UpdateService是核心逻辑负责从远程拿 manifest、比对版本、下载文件、算哈希。UpdateInstaller只管本地文件操作职责单一方便单独测试。app.config里放更新源地址这样换环境不用重新编译。2.4 更新主流程从 manifest 到重启的五步一次完整更新拆成五步拉取 manifest、比对版本、下载更新包、校验哈希、备份并替换。后面第三章会逐段拆代码这里先给整体认知。Updater.exe --manifest https://updates.example.com/manifest.xml --pid 1234启动命令里带了两个参数manifest 地址和主程序进程号。pid用来等主程序退出避免直接替换时命中文件占用。这个参数由主程序在退出前传给更新器更新器拿到它轮询等待确认进程结束才开始替换。3. 核心模块拆解一次更新从检查到重启的完整链路3.1 manifest 格式与解析更新源不需要一个服务端程序只需要一个静态目录里面放一个 manifest.xml 和若干个更新包。manifest 记录了当前远程版本号、是否强制更新、文件下载地址和哈希值。常见格式是 XML也有团队用 JSON原理一样。?xml version1.0 encodingutf-8? update version2.1.0.0/version minVersion1.0.0.0/minVersion forceUpdatefalse/forceUpdate releaseNotes修复导出功能崩溃新增批量导入/releaseNotes files file pathMyApp.exe urlhttps://updates.example.com/packages/2.1.0.0/MyApp.exe sha2568f9a... / /files /update解析用 XDocument几行代码就能读出来。Imports System.Xml.Linq Public Class UpdateInfo Public Property Version As String Public Property MinVersion As String Public Property ForceUpdate As Boolean Public Property ReleaseNotes As String Public Property DownloadUrl As String Public Property Sha256 As String End Class Public Function ParseManifest(xml As String) As UpdateInfo Dim doc As XDocument XDocument.Parse(xml) Dim root As XElement doc.Element(update) Dim info As New UpdateInfo() info.Version root.Element(version).Value info.MinVersion root.Element(minVersion).Value info.ForceUpdate Convert.ToBoolean(root.Element(forceUpdate).Value) info.ReleaseNotes root.Element(releaseNotes).Value Dim fileNode As XElement root.Element(files).Element(file) info.DownloadUrl fileNode.Attribute(url).Value info.Sha256 fileNode.Attribute(sha256).Value Return info End Function这里有几个字段必须理解到位。version是服务器上当前可用的最新版本minVersion是兼容性下限如果用户本地版本比它低说明太旧了应该强制升级sha256是更新包的哈希摘要下载完之后必须校验防止传输过程中文件损坏或被替换。解析失败时不要静默跳过我一般会记录日志并弹一个可读的错误提示因为 manifest 读不出字段说明更新源配置有问题属于发布层面的错误。3.2 版本比对与更新策略拿到远程版本信息后要和本地程序集版本做比较。VB.NET 里最省事的方式是AssemblyName.GetAssemblyName读取主程序 exe 的版本然后用Version.CompareTo比较。这里有个常见的坑不要直接做字符串比较10.9在字符串排序里会小于9.10必须用Version对象。Imports System.Reflection Imports System.Diagnostics Public Function NeedUpdate(currentExe As String, remoteVersion As String, minVersion As String) As String 返回值 表示不需要更新normal 表示推荐更新force 表示强制更新 Dim localVer As Version AssemblyName.GetAssemblyName(currentExe).Version Dim remoteVer As New Version(remoteVersion) Dim minVer As New Version(minVersion) If remoteVer.CompareTo(localVer) 0 Then Return End If If localVer.CompareTo(minVer) 0 Then Return force End If Return normal End Function逻辑说明先确认远程版本确实大于本地版本否则直接认为不需要更新。如果本地版本已经低于最低兼容版本说明用户基数版本太旧给用户一个不可跳过的更新提示走强制更新分支。中间的情况是普通推荐更新用户可以点“稍后”。版本号四段结构主版本.次版本.修订.构建在Version.CompareTo里按数值从高到低依次比较不会出现字符串排序的玄学问题。注意主程序 exe 的版本号要从“程序集版本”设置里指定而不是只改文件版本否则AssemblyName.GetAssemblyName读到的总是默认的 0.0.0.0。3.3 文件下载与进度上报下载用 HttpClient 写异步方法配合进度回调更新界面。下面这段代码是我在同类项目里常用的写法用GetStreamAsync拉流边读边写本地文件避免整个安装包一次性进内存。Imports System.Net.Http Imports System.IO Public Async Function DownloadFileAsync( url As String, localPath As String, Optional progressCallback As Action(Of Integer) Nothing) As Task Using http As New HttpClient() http.Timeout TimeSpan.FromMinutes(10) Using stream Await http.GetStreamAsync(url) Using fs New FileStream(localPath, FileMode.Create, FileAccess.Write, FileShare.None) Dim buffer(81920) As Byte Dim read As Integer Dim totalRead As Long 0 Dim totalBytes As Long stream.Length While (read Await stream.ReadAsync(buffer, 0, buffer.Length)) 0 Await fs.WriteAsync(buffer, 0, read) totalRead read If totalBytes 0 Then Dim percent As Integer CInt(totalRead * 100 / totalBytes) If progressCallback IsNot Nothing Then progressCallback(percent) End If End If End While End Using End Using End Using End Function这个实现有几个关键参数。buffer大小 81920 字节80KB这是对磁盘写入比较友好的块大小太大会增加内存压力太小会导致循环次数过多。totalBytes来自HttpResponseMessage.Content.Headers.ContentLength如果服务器返回了 chunked 编码stream.Length可能是 0这时进度会失真所以加了判断只有总长度已知时才算进度百分比。超时设置成 10 分钟是为了照顾内网共享目录下载慢的情况。如果要支持断点续传传统做法是在请求头里加Range: bytes已下载字节数-然后以追加模式打开文件。对大多数桌面软件更新包在几十 MB 以内的场景断点续传不是必选重试机制反而更实在。我一般会在外层包一个重试循环最多重试三次每次间隔 3 秒第二次重试前重新请求服务器比对下载文件大小。3.4 SHA256 校验与旧版本备份下载完成之后第一件事不是替换而是校验哈希。这一步能用最便宜的成本拦住一大批问题网线断了导致的半截文件、代理服务器缓存的脏数据、官方包上传错了。校验逻辑很简单把服务器 manifest 里记录的 sha256 和本地文件实际计算的摘要做一次大小写不敏感的比较。Imports System.Security.Cryptography Public Function ComputeSha256(path As String) As String Using sha As SHA256 SHA256.Create() Using fs As FileStream File.OpenRead(path) Dim hashBytes As Byte() sha.ComputeHash(fs) Dim builder As New Text.StringBuilder() For Each b As Byte In hashBytes builder.Append(b.ToString(x2)) Next Return builder.ToString() End Using End Using End Function Public Function VerifyFile(path As String, expectedHash As String) As Boolean Dim actualHash As String ComputeSha256(path) Return String.Equals(actualHash, expectedHash, StringComparison.OrdinalIgnoreCase) End Function传入的expectedHash就是 manifest 里的sha256字段。计算得到的摘要是小写十六进制字符串所以比较时用了OrdinalIgnoreCase避免格式大小写不一致。校验失败时不要直接删除文件留到临时目录里便于排查是网络问题还是服务器包本身不对。备份策略我习惯保留一个backup目录替换前把当前正在运行的 exe 复制或移动进去。这样新版本跑起来之后如果发生致命异常可以在主程序启动参数里加一个“检测到上次更新失败就回滚”的逻辑。备份只保留最近一份每次更新前先清空旧备份。3.5 等待退出、替换与拉起主程序更新器在替换文件前必须确认主程序真的退出了。通过启动参数里透传的 pid可以用Process.GetProcessById轮询判断。替换的大致步骤先尝试把目标目录里的 exe 改名成.old再把新文件移动进去最后删除.old。Imports System.Diagnostics Imports System.IO Public Function ReplaceExecutable(newFile As String, targetFile As String, backupFile As String) As Boolean 目标文件可能被锁先把备份改名再替换 If File.Exists(targetFile) Then If File.Exists(backupFile) Then File.Delete(backupFile) End If File.Move(targetFile, backupFile) End If Try File.Move(newFile, targetFile) Return True Catch ex As IOException 替换失败回滚备份 If File.Exists(targetFile) Then File.Delete(targetFile) End If If File.Exists(backupFile) Then File.Move(backupFile, targetFile) End If Return False End Try End Function这里有几个值得注意的地方。先移动旧文件而不是直接删除是因为万一新文件坏了还能原地把旧文件移回去。.old文件不需要保留替换成功后直接删。如果File.Move(targetFile, backupFile)这一步就抛了IOException说明主程序其实还没释放文件句柄外层应该等待 1 秒重试而不是立刻走回滚。拉起新版在前面几步成功之后执行直接Process.Start(targetFile)。如果更新器需要静默退出可以把主程序路径写在ProcessStartInfo.Arguments里附带一个“更新完成”标记主程序收到这个标记后清掉备份目录完成整个闭环。4. 界面与交互别在更新时把用户晾在进度条前4.1 启动时静默检查还是弹窗提示交互策略取决于场景。如果是内部工具用户在钉钉群里被催着用新版更新器应该在后台静默检查发现新版本后弹一个小气泡通知用户确认后再进入下载流程。如果是对外发布的软件直接弹窗告诉用户“有新版本”是比较老实的方式用户能感知到发生了什么不会觉得是被莫名其妙改了文件。一种务实做法用配置项控制提示时机。app.config里加一个PromptMode字段silent表示只在系统托盘区出现图标更新notify表示有更新时弹通知force表示强制更新。主程序启动时根据配置决定是否要弹出主窗口。Dim needUpdate As String UpdateService.NeedUpdate(currentExe, remoteVersion, minVersion) If needUpdate force Then 强制更新不做选择直接进更新流程 UpdateForm.Show() ElseIf needUpdate normal AndAlso promptMode notify Then Dim result MessageBox.Show( $发现新版本 {remoteVersion}当前版本 {localVersion}。{releaseNotes}, 软件更新, MessageBoxButtons.YesNo, MessageBoxIcon.Information) If result DialogResult.Yes Then UpdateForm.Show() End If End If这里UpdateForm.Show()是拨号盘模式的示意WinForms 里通常用模式对话框或者先创建窗体再ShowDialog。把版本号和 releaseNotes 拼进提示文案用户不用去官网查更新日志这是提升更新渗透率的小细节。4.2 进度显示与取消机制进度条和取消按钮是更新窗口的标配。下载过程里DownloadFileAsync的进度回调直接更新ProgressBar.Value取消按钮则用CancellationTokenSource中断下载。Private _cts As Threading.CancellationTokenSource Private Async Sub StartUpdate_Click(sender As Object, e As EventArgs) Handles StartUpdate.Click _cts New Threading.CancellationTokenSource() DownloadProgress.Value 0 Try Await UpdateService.DownloadFileAsync( info.DownloadUrl, tempFile, Sub(p) DownloadProgress.Value p, _cts.Token) Catch ex As OperationCanceledException MessageBox.Show(用户取消了更新) Return End Try End Sub进度回调Sub(p)是匿名方法WinForms 线程会帮忙封送到 UI 线程。取消只是取消了网络读取已经写入本地临时文件的部分会留在临时目录里下次更新时直接覆盖不影响正确性。4.3 强制更新、推荐更新、静默更新三种模式实现三种模式的做法是给 manifest 或 app.config 增加控制字段而不是写死交互逻辑。我在UpdateInfo里加过两个布尔字段ForceUpdate和SilentMode。强制更新时更新窗口隐藏关闭按钮进度完成后由更新器自己拉起主程序静默更新时不退出主程序只在后台把新版本下载好并存到 staging 目录等用户下次点开软件时再切换。静默更新省去了打断用户操作的顾虑代价是磁盘上会同时存在新旧两个版本。三种模式的优先级应该是强制更新 静默更新 推荐更新。强制更新不能被静默掩盖必须弹窗否则用户会一直停留在旧版本造成数据格式不同步的问题。4.4 更新完成后的参数透传与自启更新器完成替换后要把启动主程序的参数原样传回去。主程序在退出前可能正打开着某个文档更新器如果直接Process.Start(MyApp.exe)那个文档就丢了。我一般会让主程序在启动更新器前把当前工作状态写到一个session.json更新完成后读取这个文件恢复现场。主程序启动更新器和更新器拉起主程序的参数约定 主程序侧把当前 pid 和更新源写进参数 Process.Start(Updater.exe, $--manifest {manifestUrl} --pid {Process.GetCurrentProcess().Id}) 更新器侧替换完成后拉起主程序 Process.Start(targetFile, --updated-by-updater)读--updated-by-updater这个标记主程序可以在启动时清理更新器的临时备份文件也能避免用户手动双击打开主程序时走一遍同样的清理逻辑。5. 避坑实录文件占用、超时、校验失败的现场排查5.1 文件被占用替换 exe 时抛 IOException现象更新进度走到 100%紧接着弹一个未处理的IOException: 文件正在被另一个进程使用更新窗口直接崩溃。原因主程序虽然启动更新器后调了Application.Exit()但进程对象还会残留几秒尤其是主程序里有后台线程没退干净时exe 文件句柄释放不及时。更隐蔽的情况是杀毒软件正在扫描新下载的 exe勾着文件不放手。解决更新器替换前不要只等一次用一个循环WaitForExit加上Thread.Sleep(1000)最多等 10 秒。如果等待超时仍然占用不要硬删把当前状态写成“更新待生效”下次系统重启后再执行替换。我经常用临时目录存放新 exe等主程序下次启动时先检查 staging 目录再启动绕开杀毒软件锁文件的问题。5.2 大文件下载到一半超时现象30MB 的更新包下载到大约 80% 就卡死过一会儿抛TaskCanceledException或HttpRequestException。原因服务器上没设置合理的响应超时代理网关主动断开长时间空闲连接。更常见的是更新器代码里只设置了 HttpClient 的Timeout没处理服务器返回来的是分块编码即Transfer-Encoding: chunked的情况进度百分比一直停在一个数看起来像假死。解决下载要分重试策略第一次超时后等 3 秒重试第二次可以断点续传。断点续传用HttpRequestMessage带Range头然后以FileMode.Append打开本地文件。如果服务器支持还能用HashAlgorithm边下载边算哈希省掉下载完再遍历一遍文件的时间。5.3 更新包哈希校验永远对不上现象下载下来的文件不算大但每次VerifyFile都返回 False。把服务器目录里的包下载到浏览器算出来的摘要和 manifest 里的 sha256 又不一致。原因更新包上传后重新编译了一次忘了重新生成 manifest 里的 sha256或者上传工具默认做了 chunked 分段上传过程中部分文件没刷新。这类问题多发生在紧急修复版本发布流程没有强制先生成哈希再上传。解决把“算哈希”和“上传”绑定成一个发布脚本。每次编译完成先遍历输出目录生成 sha256把 manifest 写好最后再把更新包和 manifest 一起同步到服务器目录。校验失败时更新器不要回滚备份因为新版本本身可能没问题只是发布端的数据错了保留现场方便排查。5.4 Program Files 目录写入权限不足现象更新器提示替换失败但文件并没有被占用查看目标文件夹发现根本没有写入权限。原因主程序装在C:\Program Files下普通用户权限运行更新器时File.Move目标目录属于管理员权限范围非管理员账号无法写入。这在企业域环境里尤其常见用户在域策略下没有本机管理员权限。解决更新器 exe 要用requireAdministrator的 manifest 请求管理员权限让 UAC 弹窗授权后再执行替换。单文件更新器可以接受整个主程序都以管理员权限运行则不建议会造成普通用户白屏权限崩溃。更稳妥的方案是更新器平时不用管理员权限但通过 Windows 的RunAs或者计划任务方式在需要替换时临时提权。5.5 版本号比较出玄学现象把版本从 9.9 升级到 10.0更新器一直提示“检测到新版本”升级过一次之后又立刻检测到新版本陷入死循环。原因代码里用了字符串比较10.0.0.0和9.9.0.0排序时按第一个字符比较10...的1小于9所以每次启动都以为新版本没打上。这种问题用Strings.Compare或者直接比较两个字符串一定会踩。解决版本字段统一用System.Version类型New Version(10.0.0.0)解析后调用CompareTo做数值比较。同时主程序启动时要把本地版本打印到日志里方便看到实际运行时读到的版本是文件版本还是程序集版本这两个在发布时容易对不上。6. 二次开发把更新器接到你现有项目里6.1 改更新源与签名验证接入自己项目最先要改的是app.config里的 manifest 地址。如果放在对象存储上生成签名的临时 URL 会定期过期这时我建议加一层本地缓存更新器启动时先读本地缓存的 manifest如果签名 URL 过期了再重新向对象存储申请避免每次启动都触发一次完整下载。进一步做版本可靠性可以在 manifest 里增加signature字段用私钥对 version 和 sha256 拼接的字符串签名更新器用公钥验签。这样即使更新源被劫持没有私钥的人也伪造不出合法更新包。VB.NET 里用RSACryptoServiceProvider加载公钥VerifyData方法验签几十行代码就能接上。6.2 加增量更新单包整体替换在更新包超过 100MB 时体验很差增量更新的思路是 manifest 里列出每个文件的 sha256更新器扫描本地目录只下载那些哈希不一致的文件。文件级增量实现起来不复杂比二进制 diff 简单得多适合主程序由多个 dll、配置模板和资源文件组成的场景。注意不要用文件修改时间做增量判断时间戳在打包和 git checkout 时经常不一致哈希才是唯一可信依据。6.3 加后台静默更新如果产品是长期驻留托盘区的工具可以考虑静默更新模式用户正常使用时不打断更新器在后台下载新版到 staging 目录下载完成后短暂关闭主程序完成替换再在托盘区弹一个“已更新到新版”的通知。这个方案要注意主程序持有数据库连接或未保存文档时的退出策略更新器在替换前强制结束进程会导致用户数据丢失需要主程序侧实现一个干净的退出协议。项目做得多会发现更新器不是写一次就完事的东西它要跟着业务软件的版本策略一起演进。我自己的习惯是每接一个新项目都先在一台干净的虚拟机里走一遍“旧版本—带更新器—更新到新版本—校验版本号”的完整链路确认没有权限、哈希、文件占用这三类问题才发出去。这套流程帮我挡掉过不少发版事故从那以后我每次发版都强制走一遍。希望帮到你。本文还有配套的精品资源点击获取