ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Delphi桌面程序自动在线升级方案:从版本比对到回滚兜底

Delphi桌面程序自动在线升级方案:从版本比对到回滚兜底

简介:这份Delphi自动在线升级程序源码面向使用Object Pascal进行桌面应用开发的程序员,尤其适合需要在自有项目中集成版本检测、文件下载与静默更新能力的开发者。源码围绕自动升级的完整链路展开,涵盖版本比对、多线程下载、哈希校验、文件替换与重启提示等环节,并涉及Indy或Synapse网络库、文件操作与多线程技术的实际运用,可作为理解软件更新机制的实践样本。压缩包共49个文件,约3.61MB,以fr3报表模板、exe可执行文件、ini配置、pas与dpr源码、dfm窗体、dcu编译单元及xml更新清单等为主,其中fr3文件对应采购、入库、对账等业务单据模板,源码与配置分离,便于按模块查阅与二次定制。目前已有330人学习下载。读者可据此掌握断点续传、增量更新、安全校验与性能优化等改造思路,并对照自身项目完善升级方案。

1. 桌面端交付的最后一公里:为什么手动补丁包迟早要出事

做过桌面软件交付的同行大概都有过这种经历:客户现场跑着一套 Delphi 写的业务系统,某天发现一个字段计算逻辑写错了,或者某个报表导出在特定分辨率下会崩。你改完代码、编译出新的 exe,然后开始一轮漫长的沟通——发邮件、传文件、让对方关掉程序、覆盖、再打开。一个客户还好,十个客户就是十倍的重复劳动,而且你永远不知道对方覆盖的是不是最新那一版。

Delphi 自动在线升级程序源码要解决的,就是这最后一公里的问题。它让客户端启动时自己去比对版本、拉取新文件、校验完整性、替换旧文件、重启生效,整个过程不需要人工介入。适合的人群很明确:手里有 Delphi 桌面项目、客户分散、发版频率不低、又不想上重型安装器方案的开发者。核心难点不在“下载文件”这一步,而在于版本比对策略、文件占用时的替换时机、增量与全量的取舍,以及升级失败后的回滚兜底。

2. 升级链路怎么拆:从版本号到落地文件的完整闭环

2.1 先想清楚升级的触发点和数据流

自动升级的本质是一条单向数据流:客户端上报当前版本 → 服务端返回差异清单 → 客户端下载 → 校验 → 替换 → 重启。这条链路里,任何一个环节的假设错了,后面都会翻车。

我一般把触发点放在主窗体创建之前,也就是Application.Initialize之后、主窗体Show之前。原因很简单:如果主程序已经加载了一堆 DLL 和资源,再去替换这些文件,Windows 的文件锁会直接让你失败。放在启动早期,被占用的文件最少,替换成功率最高。

数据流的具体形态取决于你选全量还是增量。全量升级就是服务端放一个完整的压缩包,客户端整个下载解压覆盖;增量升级是服务端维护一份文件清单,每个文件带 MD5 和版本号,客户端只下载 MD5 不一致的文件。前者实现简单、不容易出错,后者省带宽但服务端要维护清单的生成逻辑。

对于大多数中小规模的 Delphi 项目,我的建议是:首次上自动升级用全量,跑稳了再考虑增量。因为全量的调试成本低得多,出问题也好排查——大不了重新下一遍。

2.2 版本清单文件的设计与生成

服务端需要暴露一个版本描述文件,通常是一个 JSON 或 INI。JSON 更好解析,Delphi 里可以用System.JSON单元直接处理。清单里至少要包含:最新版本号、最低兼容版本号、更新包下载地址、更新包 MD5、是否强制更新。

{ "latest_version": "2.3.1", "min_compatible_version": "2.0.0", "package_url": "https://your-server/update/patch_231.zip", "package_md5": "a1b2c3d4e5f6...", "force_update": false, "release_notes": "修复报表导出在高分屏下的崩溃问题" }

min_compatible_version这个字段容易被忽略,但它很关键。如果客户端版本太老,直接跳到最新版可能会导致数据结构不兼容,这时候应该提示用户先升级到一个中间版本,或者干脆走完整安装包。force_update用于紧急修复场景,客户端收到true时不允许跳过。

生成清单的脚本可以放在构建流程里,每次编译完自动算 MD5、写 JSON、上传到服务器。这样避免手动维护清单时漏改版本号。

2.3 客户端下载与 MD5 校验的实现

客户端拿到清单后,第一步是比对本地版本。本地版本可以存在注册表、INI 文件或者 exe 的版本资源里。我倾向于用 exe 的版本资源,因为编译时自动写入,不会出现文件和记录不一致的情况。

function GetLocalVersion: string; var FileName: string; InfoSize, Wnd: DWORD; VerInfo: Pointer; VerValue: PVSFixedFileInfo; Dummy: DWORD; begin FileName := ParamStr(0); InfoSize := GetFileVersionInfoSize(PChar(FileName), Dummy); if InfoSize = 0 then Exit('0.0.0'); GetMem(VerInfo, InfoSize); try GetFileVersionInfo(PChar(FileName), 0, InfoSize, VerInfo); VerQueryValue(VerInfo, '\', Pointer(VerValue), Dummy); Result := Format('%d.%d.%d', [VerValue^.dwFileVersionMS shr 16, VerValue^.dwFileVersionMS and $FFFF, VerValue^.dwFileVersionLS shr 16]); finally FreeMem(VerInfo); end; end;

这段代码从当前 exe 的版本资源里读出主版本号。dwFileVersionMS的高 16 位是主版本,低 16 位是次版本,dwFileVersionLS的高 16 位是构建号。拿到本地版本后和清单里的latest_version做字符串比较——注意,版本号比较不能直接按字符串比,因为 "2.10.0" 按字符串会小于 "2.9.0"。正确做法是拆成整数数组逐段比较。

下载环节用TIdHTTP或者THTTPClient都可以。下载完成后必须校验 MD5,校验不通过就删掉重下。这一步是防止下载过程中网络中断导致文件损坏,损坏的包解压后覆盖上去,程序直接起不来。

function DownloadFile(const Url, SavePath: string): Boolean; var HTTP: THTTPClient; FS: TFileStream; begin Result := False; HTTP := THTTPClient.Create; FS := TFileStream.Create(SavePath, fmCreate); try try HTTP.Get(Url, FS); Result := True; except on E: Exception do // 记录日志,返回 False end; finally FS.Free; HTTP.Free; end; end;

THTTPClient是 Delphi 较新版本自带的,跨平台支持好。如果项目还在用老版本 Delphi,TIdHTTP也能用,但要注意 Indy 的 SSL 配置在某些 Windows 版本上需要额外处理。

2.4 文件替换的时机与批处理方案

文件替换是整个升级里最容易翻车的一步。主程序 exe 自己正在运行,没法自己覆盖自己。常见的做法是:下载完更新包后,释放一个独立的 updater.exe,由它来关闭主程序、解压覆盖、再启动主程序。

updater.exe 的逻辑要足够简单,因为它是在一个“主程序已经退出”的环境里跑的,没有主程序的运行时支持。它只做几件事:等待主进程退出、解压 zip、复制文件、启动主程序、自己退出。

// updater.exe 的核心逻辑 begin // 1. 等待主程序退出,最多等 30 秒 WaitForProcessExit('MainApp.exe', 30000); // 2. 解压更新包到临时目录 UnzipPackage('patch.zip', 'C:\Temp\update\'); // 3. 复制文件到安装目录 CopyFiles('C:\Temp\update\', ExtractFilePath(ParamStr(0))); // 4. 启动主程序 ShellExecute(0, 'open', 'MainApp.exe', nil, nil, SW_SHOWNORMAL); end.

WaitForProcessExit可以用OpenProcess+WaitForSingleObject实现,也可以用简单的循环检查进程是否存在。解压可以用 Delphi 自带的System.Zip单元,不需要第三方库。

这里有个细节:复制文件时如果目标文件被占用,CopyFile会失败。所以 updater 必须在主程序完全退出后才开始复制。等待超时后如果主程序还没退出,应该放弃本次升级并记录日志,而不是强行结束进程——强行结束可能导致用户数据丢失。

3. 避坑指南:升级失败的五种典型现场

3.1 现象:升级后程序启动报“找不到 DLL”

原因:更新包里漏掉了某个依赖的 DLL,或者 DLL 的版本和 exe 不匹配。全量升级时如果打包脚本只复制了 exe,没复制依赖项,就会出现这个问题。

解决:打包脚本里维护一份完整的文件清单,每次构建时按清单复制。清单本身也要纳入版本管理,新增依赖时同步更新。另外,升级前先备份旧版本的所有文件到backup目录,启动失败时可以回滚。

3.2 现象:MD5 校验通过但解压报错

原因:下载的 zip 文件本身损坏,但 MD5 是在服务端算的,客户端下载后没重新算就通过了。或者服务端上传时文件被截断,MD5 清单没同步更新。

解决:客户端下载完成后必须重新计算 MD5 并与清单比对,不能信任服务端的值。解压时捕获异常,解压失败就删除临时文件并提示用户重试。

3.3 现象:升级过程中断电,程序再也起不来

原因:文件替换到一半时断电,exe 被覆盖了一半,或者关键配置文件丢失。

解决:采用“先写临时文件再重命名”的策略。所有文件先复制到目标目录下的.tmp文件,全部复制成功后再批量重命名为正式文件。重命名是原子操作,断电最多导致.tmp文件残留,不会破坏原文件。同时保留一份完整的备份,启动时检测到异常可以自动恢复。

3.4 现象:部分客户升级后版本号没变

原因:客户端读版本号读的是注册表里的记录,而不是 exe 的实际版本。升级后 exe 换了,但注册表没更新,程序以为还是旧版本,反复提示升级。

解决:版本号只从 exe 版本资源读取,不维护额外的版本记录。如果确实需要缓存,升级成功后立即更新缓存,并且以 exe 版本资源为准做校验。

3.5 现象:升级包下载速度极慢或超时

原因:服务端带宽不足,或者客户端网络环境差。没有断点续传,每次失败都从头下载。

解决:对于大包,实现断点续传。THTTPClient支持Range头,可以在下载中断后从已下载的字节位置继续。同时设置合理的超时时间,避免用户等太久。如果包不大(几十 MB 以内),全量下载通常可以接受,不必过度设计。

4. 进阶技巧:让升级方案更稳的几个细节

4.1 用配置文件控制升级行为

不要把升级参数硬编码在代码里。服务端地址、检查间隔、是否允许用户跳过,这些都应该放在一个独立的配置文件中。这样不同客户可以有不同的升级策略,不用重新编译。

[Update] ServerUrl=https://your-server/update/ CheckInterval=3600 AllowSkip=1 BackupCount=3

CheckInterval控制检查频率,单位秒。AllowSkip决定用户能否点“稍后再说”。BackupCount保留最近几个版本的备份,方便回滚。

4.2 升级日志要写到用户能拿到的地方

升级失败时,用户描述的现象往往很模糊——“就是打不开”。所以日志必须写到固定位置,比如%APPDATA%\YourApp\update.log,记录每次检查的时间、版本比对结果、下载进度、校验结果、替换结果。出问题时让用户把这个文件发过来,比反复沟通高效得多。

日志要滚动,不能无限增长。可以按天切分,保留最近 7 天。

4.3 灰度发布:先让一部分客户端升级

服务端可以维护多个版本的清单,按客户端标识(比如机器码或客户编号)返回不同的latest_version。先让内部测试机升级,观察一天没问题,再逐步放量。这样即使新版本有严重 bug,影响面也可控。

实现方式很简单:清单接口接收一个client_id参数,服务端根据这个参数决定返回哪个版本的清单。客户端不需要做任何特殊处理。

4.4 回滚机制:给用户一颗后悔药

再完善的测试也挡不住现场环境的多样性。回滚机制是最后的兜底。具体做法是:每次升级前把当前版本的所有文件复制到backup\版本号\目录,升级后如果主程序启动失败(可以通过 updater 检测启动后进程是否存活来判断),自动从备份目录恢复。

function TryRollback(const BackupDir: string): Boolean; begin Result := False; if not DirectoryExists(BackupDir) then Exit; // 复制备份文件回安装目录 CopyFiles(BackupDir, ExtractFilePath(ParamStr(0))); Result := True; end;

回滚的触发条件要谨慎设置。如果主程序启动后 10 秒内退出,可以认为是启动失败,触发回滚。但如果用户是正常关闭程序,也会被误判。所以更稳妥的做法是让主程序在成功启动后写一个标记文件,updater 检测这个标记文件是否存在来判断启动是否成功。

这套方案我在几个中小型 Delphi 项目上跑过,最深的体会是:升级逻辑本身不复杂,复杂的是各种边界情况的处理。文件占用、网络中断、权限不足、杀毒软件拦截,每一个都可能让升级失败。所以不要追求一次写完就完美,先把主流程跑通,然后根据日志逐步补边界处理。备份和回滚一定要在第一版就做进去,这是唯一能在出问题时让你睡个安稳觉的东西。希望帮到你。

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

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进