简介:一套Delphi自动升级源码,面向C/S架构桌面应用开发者,用于解决客户端程序版本迭代时的自动检测与升级问题,免去人工分发安装包、版本不统一的烦恼。完整实现了前端自动升级模块,涵盖轮询式版本检测、升级包下载、程序重启替换等核心流程,检测间隔可按需配置,并采用主程序与升级程序分离设计,便于开发者快速集成到现有系统中。资源包共56个文件,以pas源码、dfm窗体、dcu编译单元为主,同时包含dproj/groupproj工程文件、res资源文件、skincfg皮肤配置以及可直接运行的exe示例,结构清晰,压缩包仅3.52MB。借助内置的update与TestUpdate两套示例工程,开发者可直观对照升级程序与主程序的交互细节,快速完成二次开发或功能裁剪。目前已有668人学习下载,适合需要为Delphi项目增加自动升级能力的初中级开发人员参考使用。
1. 自动升级源码里那条轮询链路的工程价值
在C/S系统里,业务代码写得再仔细,也逃不过一个现实:线上总有用户跑旧版本,新功能用不上、旧Bug修不到。这套Delphi自动升级源码处理的正是发布链路的最后一公里——客户端按设定间隔轮询升级服务器,发现版本落后就下载升级包,再启动独立升级程序替换文件。和"启动时检查一次"不同,它用可配置间隔的持续轮询,适合接口协议变更后强制旧客户端升级的场景。源码中update工程是独立升级器,TestUpdate工程模拟主程序,配合一个版本清单文件就能跑通全流程,适合做二次改造。
2. 升级服务器端的清单文件与版本号管理协议
升级服务器端在这套源码里不是主角,但整个升级链路能不能可靠,七成取决于服务器端约定。以下方案是按C/S项目里最常见的做法补全的:BS后台负责上传升级包、维护版本号,客户端只认一个固定的清单文件。清单文件把"当前最高版本、最低可升级版本、文件列表、校验值"一次性暴露给客户端,轮询逻辑才有得比对。
2.1 版本清单的JSON字段约定
清单文件放在升级服务器的固定路径下,我一般命名为version.json。客户端升级程序启动后会先拉取这个文件,再和自身携带的版本号做比对。字段设计不能只放一个大版本号,因为增量升级时还要知道"哪些旧版本可以直接升、哪些必须强制升"。一个能用的最小结构是这样:
{ "version": "2.3.1", "minVersion": "2.0.0", "releaseDate": "2026-01-15", "force": true, "files": [ { "path": "app/main.dll", "md5": "9c2db10e6c4f31a8b7a6c5d4e3f2a1b0", "size": 154624 }, { "path": "app/menu.xml", "md5": "5f1e2d3c4b5a69788796a5b4c3d2e1f0", "size": 2048 } ] }version字段是升级后的目标版本,客户端拿到它和本地常量做比较。minVersion界定升级门槛,低于这个值的旧客户端不允许继续使用,检查到后直接弹强制更新提示。force字段决定非强制版本是"提示后跳过"还是"弹窗必须升"。files列表按相对路径列出升级包包含的文件,md5用于下载完成后的完整性校验,size用于进度条计算和磁盘空间预检。
这里有一个容易踩的坑:如果客户端用的是Delphi 2007之前的AnsiString,JSON里出现中文描述时编码不对会导致解析错乱。建议清单文件一律以UTF-8无BOM形式发布,并在客户端解析前统一转换。服务器端写文件时不要用记事本默认的ANSI编码,IIS或Nginx返回时也要带上charset=utf-8。在Delphi里用TJSONObject解析时,中文路径会以Unicode字符串保存,和服务器返回的UTF-8字节流比对时容易踩坑,可以在解析后统一规范化路径分隔符。
提示:版本号字段建议统一为三段式(主版本.次版本.修订号),避免"2.2.10"和"2.2.9"这类字符串比较产生假阳性。用TStringList按'.'切分成整数后再逐段比对,比直接CompareStr可靠。
2.2 升级包文件哈希与目录结构
服务器端文件目录和清单里的files.path必须一一对应,不能依赖中文路径或带空格目录。我一般把升级服务器目录组织成:
upgrade-server/ version.json packages/ app/main.dll app/menu.xmlpackages目录和JSON中path字段拼接后就是下载地址。计算MD5在Delphi里用Indy的IdHashMessageDigest单元,一段可复用的函数如下:
uses System.Classes, System.SysUtils, IdHashMessageDigest; function TFileHash.MD5OfFile(const AFileName: string): string; var LFileStream: TFileStream; LMD5: TIdHashMessageDigest5; begin LFileStream := TFileStream.Create(AFileName, fmOpenRead or fmShareDenyWrite); try LMD5 := TIdHashMessageDigest5.Create; try Result := LowerCase(LMD5.HashStreamAsHex(LFileStream)); finally LMD5.Free; end; finally LFileStream.Free; end; end;VCL和FMX项目都能直接用这段代码,唯一要求是Indy组件版本一致。函数里LowerCase是为了统一十六进制串大小写,Windows和Linux上生成的结果都是小写,和服务器端对MD5时不用再考虑大小写差异。服务器端生成清单时,遍历packages目录算出每个文件MD5,再把结果写进version.json。如果团队里有其他语言生成清单,也可以用命令行快速验证:
md5sum packages/app/main.dll哈希的作用是验证文件没被截断或者写坏,而不是做安全防篡改。MD5在网络链路上可以被构造碰撞,要防恶意替换必须靠签名机制,第5章会补上这部分。服务器和客户端如果只是内网直连,哈希校验已经够用。
2.3 BS后台维护版本号与灰度窗口
摘要里提到的BS页面,在项目里落地后只需要三个接口:上传升级包、发布版本、查询当前版本。客户端只依赖查询接口,前两个接口仅管理员可见。一张表把接口契约固定下来:
| 接口 | 方法 | 行为 | 客户端是否调用 |
|---|---|---|---|
| /api/version/current | GET | 返回当前version.json内容 | 是 |
| /api/archive/upload | POST | 上传文件到packages目录并计算MD5 | 否 |
| /api/release/publish | POST | 更新version.json并覆盖旧版本号 | 否 |
灰度窗口不是每个项目都要求,但如果服务器和客户端在同一个内网段,建议至少在发布接口里增加一个releaseTime字段。客户端轮询到新版本时,如果releaseTime晚于当前时间,就继续等待而不是立刻升级。这样能把"升级时刻"从服务器端控制住,防止刚上传完文件还没验证就大面积推给用户。这个字段在2.1的JSON里遗漏了的话,可以在files同级补上,客户端比对时多做一次时间判断。
服务器端只要把这三个接口固定下来,客户端的轮询逻辑就变得很简单:拉JSON、比版本、决定是否下载,三个动作顺序执行。接口响应里不要带多余的业务字段,客户端解析失败时宁可跳过本轮升级,也不要在主线程里弹错误框。
3. uMain.pas与uDown.pas的轮询检测与下载逻辑拆解
接下来是客户端核心。update工程里uMain.pas负责界面和轮询状态机,uDown.pas负责具体下载。这两个单元的分工很明确:uMain不问"怎么下载",uDown不管"什么时候下载"。
3.1 uMain.pas的TTimer轮询触发机制
uMain.pas的主窗体上放了一个TTimer,它决定了检测频率。项目里默认间隔是60000毫秒,也就是摘要里说的1分钟检测一次。这个值不是拍脑袋定的,升级服务器带宽和客户端数量决定它的下限,用户对弹窗的容忍度决定它的上限。
procedure TfrmUpdate.FormCreate(Sender: TObject); begin FCurrentVersion := '2.2.9'; FTimerCheck.Interval := 60000; // 60秒轮询一次 FTimerCheck.Enabled := True; end; procedure TfrmUpdate.TimerCheckTimer(Sender: TObject); begin FTimerCheck.Enabled := False; // 先停掉Timer,防止重入 try if TUpdater.CheckNewVersion(FServerUrl) then TUpdater.DownloadAndInstall(FUpgradePackageUrl); finally FTimerCheck.Enabled := True; // 本轮逻辑跑完再恢复 end; end;这里有个关键动作:每轮定时器触发后,第一件事是把Enabled置为False。如果不这样做,当CheckNewVersion内部执行时间超过1分钟,TTimer的下一次触发就会排队,造成并发检测。虽然TTimer本身不是多线程,但重入会让状态机错乱,比如下载过程中又触发一次检查,两个下载流写同一个文件,文件必然损坏。
FCurrentVersion这个常量在实际项目里不应该手写,而应该调用FileInfo.pas里的GetFileVersion函数,从当前exe的文件版本信息区读取。这样每次用Delphi编译时带上版本资源,主程序报出的版本号就和构建产物的版本保持一致,避免忘了改常量导致升不上去。CheckNewVersion内部做的事可以归纳成三步:拉version.json、解析JSON取version字段、和FCurrentVersion做整数化比较。返回True表示服务器版本更新。DownloadAndInstall建议同步执行,因为下载期间用户看到的界面是"正在升级,请勿关闭",不需要同时做别的事情。拉起独立升级进程的操作必须在下载完成之后进行。
3.2 uDown.pas的文件下载实现
uDown.pas里是下载逻辑,使用Indy的TIdHTTP完成文件拉取。TIdHTTP比较适合这种"下载完就走"的短连接场景,不需要引入额外的网络库。关键实现如下:
uses IdComponent, IdHTTP; function TUpdater.DownloadFile(const AUrl, AFileName: string; AOnProgress: TWorkEvent): Boolean; var LHttp: TIdHTTP; LFileStream: TFileStream; begin Result := False; LHttp := TIdHTTP.Create(nil); try LHttp.OnWork := AOnProgress; // 进度回调,驱动进度条 LHttp.Request.UserAgent := 'AutoUpdater/2.0'; LHttp.Request.Accept := '*/*'; LHttp.ConnectTimeout := 5000; // 连接超时5秒 LHttp.ReadTimeout := 30000; // 读取超时30秒 LFileStream := TFileStream.Create(AFileName, fmCreate or fmShareDenyWrite); try LHttp.Get(AUrl, LFileStream); Result := True; finally LFileStream.Free; end; finally LHttp.Free; end; end;回调类型直接用Indy的TWorkEvent,定义在IdComponent单元,避免自定义回调签名和OnWork不匹配。ConnectTimeout和ReadTimeout必须显式设置,尤其在内网带宽抖动时,默认的无限超时会让升级程序挂在界面上,用户只能强杀进程。另外fmShareDenyWrite这个打开模式很关键:它在文件创建期间禁止其他进程写同一路径,避免升级程序把自己刚下载的文件再写坏。如果发现下载后的文件和version.json里的size对不上,优先怀疑是不是这里没有做共享限制。
下载顺序也有讲究。多文件升级包在下载阶段就按顺序下载到临时目录,全部下载成功后统一校验MD5,任何一个文件校验失败就清空临时目录重来。这样可以避免"主程序换了一半、本地文件新旧混杂"的情况。临时文件建议存放到与主程序同卷的目录下,比如{主程序目录}\UpdateTemp,因为不同卷之间的文件移动是复制加删除,跨盘移动失败率明显高于同卷重命名。
3.3 升级器与主进程的握手协议
下载校验完成后,uMain.pas会把控制权交给独立的升级进程update.exe。这里有一个进程间的握手协议:主程序把当前进程ID和更新参数传给升级器,升级器在替换文件前先确认主程序已经退出。
uses Winapi.ShellAPI, Winapi.Windows, System.SysUtils; procedure TfrmUpdate.LaunchUpdater; var LParams: string; LSei: TShellExecuteInfo; begin LParams := Format('/pid=%d /src=%s /dst=%s', [GetCurrentProcessId, QuoteFileName(FTempPath), QuoteFileName(FAppPath)]); FillChar(LSei, SizeOf(LSei), 0); LSei.cbSize := SizeOf(LSei); LSei.fMask := SEE_MASK_NOCLOSEPROCESS; LSei.lpFile := PChar(ExtractFilePath(Application.ExeName) + 'update.exe'); LSei.lpParameters := PChar(LParams); if ShellExecuteEx(@LSei) then WaitForSingleObject(LSei.hProcess, 60000); end;升级器拿到/pid参数后,用OpenProcess轮询目标进程是否退出,确认退出后再执行文件覆盖。这里用ShellExecuteEx而不是简单的ShellExecute,是因为需要拿到进程句柄做超时等待,主程序在60秒内没有退出就认为升级失败并唤醒用户处理。参数里的/src指向临时目录,/dst指向主程序所在目录,升级器按JSON里的files列表逐一移动文件。
更稳的做法是升级器先把旧文件改名为.bak,再从临时目录移入新文件。第二步失败时能把.bak恢复回去,做到原地回滚。这块回滚逻辑在TestUpdate项目里体现得最明显,下一章就围绕TestUpdate把它串起来。
4. TestUpdate模拟客户端驱动的完整升级验证
TestUpdate工程是一个最小可运行的主程序,Main.pas里放着版本信息和模拟的业务界面。它的存在价值是让update工程在开发期就有对手戏可演:主程序报出"我是2.2.9版",升级器检测到服务器是2.3.1版后完成替换。这个工程跑通了,再往真实项目迁就只剩替换文件列表和版本常量。
4.1 搭建本地升级验证环境
TestUpdate和update两个工程用Delphi 10.4或11都可以直接打开编译,Community Edition也足够跑这套demo。验证不需要内网服务器,一台开发机就能完成。把TestUpdate.exe放到一个目录,比如C:\AutoUpdateDemo\App,把update.exe和version.json放到上一级目录,然后启动一个静默的HTTP服务托管整个目录。Windows 10以上系统可以用PowerShell的简易服务器,也可以直接用Python:
cd C:\AutoUpdateDemo python -m http.server 8080如果开发机没有Python,用IIS或者HFS这类工具都行,只要保证下面两个URL能访问到:
- http://127.0.0.1:8080/version.json
- http://127.0.0.1:8080/packages/app/main.dll
TestUpdate的Main.pas里把FServerUrl指向上面的地址,FCurrentVersion保持2.2.9,服务器端version.json的version写成2.3.1。此时触发轮询,理想情况是:TestUpdate界面上出现"发现新版本2.3.1",确认后下载临时文件,关闭自身进程,update.exe替换文件并重新拉起TestUpdate.exe。整个链路能走完,自动化升级部分就算验证通过。
4.2 版本比对分支与边界条件
升级逻辑会遇到的版本关系不止"服务器版本更高"这一种。下面这张表列出需要覆盖的测试用例,TestUpdate里每个case都有对应断言:
| 场景 | 本地版本 | 服务器version | minVersion | 预期行为 |
|---|---|---|---|---|
| 正常升级 | 2.2.9 | 2.3.1 | 2.0.0 | 提示并升级 |
| 版本一致 | 2.3.1 | 2.3.1 | 2.0.0 | 静默跳过 |
| 服务器回滚 | 2.3.1 | 2.2.9 | 2.0.0 | 不处理,等待下个周期 |
| 强制升级 | 1.9.8 | 2.3.1 | 2.0.0 | 拦截,不允许关闭窗口 |
| 灰度未开始 | 2.2.9 | 2.3.1(未来releaseTime) | 2.0.0 | 不提示,继续轮询 |
版本回滚这个case容易写错。本地版本高于服务器版本时不降级是一个默认前提,因为客户端卸载降级往往伴随数据兼容问题,自动降级的风险大于收益。这意味着整个升级逻辑都是单向的,如果服务器端误发布了一个低版本号,客户端不会自动修正,运维层面要留一个手动回滚手段,而不是依赖客户端的自动行为。
4.3 升级日志与失败回滚验证
升级过程的每一步都要落日志,否则没法排查"用户说弹窗了但没升级成功"这类问题。TestUpdate和update共用一个日志目录,写文件采用追加方式,保留最近5个文件。日志里至少要记录:检测时间、服务器返回版本、下载文件数、校验结果、替换开始和结束时间。
[2026-01-15 10:23:01] fetch version.json: version=2.3.1, force=true [2026-01-15 10:23:02] compare local=2.2.9 < remote=2.3.1, need upgrade [2026-01-15 10:23:05] download packages/app/main.dll, 154624 bytes [2026-01-15 10:23:06] md5 check pass, move to temp ok [2026-01-15 10:23:07] launch updater, pid=8821 [2026-01-15 10:23:09] replace file ok, old backup kept as main.dll.bak回滚的验证方式是把待替换的main.dll故意写坏,比如在文件尾部追加一个字节。升级器替换后启动TestUpdate,如果启动闪退或版本号没变化,检查是否存在同名的.bak文件:存在就把.bak恢复回来,并追加一条rollback日志。实际上这笔逻辑应该由升级器自己完成而不是靠人工,所以这里要做的是确认升级器在文件替换失败时自动恢复了旧版本,TestUpdate的下一次启动才会是旧版本号而不用重装。
开发期遇到一个常见迷思:升级程序本身不能升级自己。update.exe如果被业务文件更新覆盖,下次升级时就没法拉起升级器。解决方法就是把update.exe独立于主程序目录,或者至少保证任何升级包的files列表都不包含update.exe自身。TestUpdate这套结构里update.exe放在App上一级,自然就避开了这个坑。
5. 升级包签名校验与无感升级的落地细节
5.1 在下载校验层加入签名验证
MD5只能验证文件完整,不能验证文件来源。升级服务器如果走公网,最直接的办法是把version.json和升级包一起做签名。Delphi项目里加RS256签名需要引入第三方库,对于内网工具类系统,用共享密钥的HMAC就能防住大部分篡改场景。服务器端生成清单时,额外发布一个sign字段,内容是version.json主体文本按约定密钥计算的HMAC-SHA256值,客户端先校验签名再解析字段。System.Hash单元提供了现成的HMAC实现:
uses System.Hash; function TUpdater.VerifySignature(const AJson, ASign, AKey: string): Boolean; begin Result := THashSHA2.GetHMAC(AJson, AKey) = ASign; end;计算范围必须和服务器端完全一致,要么对整个version.json文本,要么对去除sign字段后的规范化文本,两边约定好后用测试用例锁定。凡是签名校验失败的版本一律不进入下载流程,并记录一条安全告警日志。更新频率低、带宽敏感的小团队可以跳过签名,但最低限度要把下载URL限定为HTTPS。HTTPS证书链校验在Indy里默认开启,不要因为内网证书过期就把TIdHTTP的证书校验选项设为绕过,这个习惯带到外网项目会出问题。
提示:签名密钥不要写死在代码里。编译期常量会泄漏在exe的字符串中,Delphi字符串裸眼可见。建议把密钥散列后分段放进注册表或配置文件中,代码里只保存拼接规则。
5.2 升级完成后由主程序接管启动
升级器替换完文件后,不直接启动新版本主程序,而是把启动动作交还给主程序自己。实现上是在替换完成后创建一个标记文件(如.updated),主程序启动时发现这个标记,就清理临时目录、删除.bak备份、输出一条"升级完成"日志。这样的好处是主程序可以决定在自身的数据迁移逻辑跑完后再清理旧文件,避免升级器替主程序做决策。
另一个实现细节是升级器的窗口隐藏。既然升级过程发生在主程序退出之后,升级器的窗口就没有必要占用用户屏幕。设置Application.ShowMainForm := False,同时定时检查主进程句柄是否退出。整个升级过程控制在几十秒内,用户看到的是主程序短暂消失后带着新版本重新出现,感知会舒服很多。需要强提醒时,再通过TrayIcon在系统托盘给提示。往真实项目迁移时,先跑通TestUpdate的case,再把这几个常量替换成自己服务器的地址和版本清单路径,链路就能复用。
本文还有配套的精品资源,点击获取