news 2026/9/10 3:03:36

QT实现软件自动更新:更新器设计与断点续传实践

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
QT实现软件自动更新:更新器设计与断点续传实践

简介:面向QT开发者的软件自动更新示例项目,完整演示了检测更新、下载更新、安装替换、验证与重启应用的实现路径。压缩包内共15个文件,约15KB,包含cpp源文件(HTTP下载、主窗口逻辑)、h头文件、ui界面文件、pro工程文件、qrc资源文件以及rc/ico/png图标资源,并附有使用说明txt,便于快速梳理工程结构并进行二次开发。已有830人学习下载。项目基于QT网络模块,通过QNetworkAccessManager构造请求、监听下载进度,利用文件操作类完成更新文件替换;代码量虽小但覆盖更新器核心流程,适合需要为QT应用嵌入自动更新功能的初中级开发者。通过阅读源码可掌握版本检测、下载交互、更新确认对话框的设计思路,也可直接将updater项目稍作修改后集成到自己的软件中。

1. 软件自动更新解决方案先想清楚一件事:更新器本身怎么被更新

接手软件自动更新需求时,最容易踩的坑不是下载、解压或覆盖的逻辑,而是边界问题:主程序能不能更新自己?在 Windows 上,正在运行的 exe 文件被写保护,rename 或覆盖都会抛出共享冲突。常见的解法是把更新逻辑从主程序里拆出来,做一个独立的更新器(updater.exe),由主程序通过 QProcess 启动它,更新器负责下载和替换,然后主程序退出、更新器替换文件、再拉起主程序。这个流程说起来只有四步,但实际落地会牵连版本命名、增量补丁、断点续传、插件目录路径、签名校验和崩溃回滚。用 QT 实现时,QNetworkAccessManager 负责网络请求,QProcess 负责进程间协作,QFile 负责覆盖与回滚,剩下的功夫都花在细节上。

这篇博文会从方案选型开始,讲版本清单怎么设计,再给出可复现的 QT 下载与断点续传代码,然后把更新器如何替换运行中程序、windeployqt 打包要注意哪些坑讲透。最后给你一个用 HTTP 条件请求做版本验证的小技巧,让每次启动检查都不会白白下载一遍。

2. 更新方案与版本检查:JSON 清单、增量补丁与回滚取舍

2.1 先定更新粒度:全量包、增量补丁和差分更新的取舍

很多团队第一个问题就是「更新包到底打多大」。常见做法是维护两种渠道:全量包(完整安装包或绿色版整包)和增量包。全量包实现简单、不容易出错,但每次几 MB 到几十 MB 的下载对老用户不友好;增量包只包含变更文件,代价是需要维护基线版本,每一个旧版本都要能计算出对应的增量。

差分更新是增量更新的一种极限形式,常见做法是 bsdiff/bspatch。QT 项目里可以通过 QProcess 调用外部的 bspatch.exe,也可以把 bsdiff 库编译进更新器。差分更新对二进制资源文件(比如 DLL、exe、资源包)很有效,能做到几百 KB 的补丁更新几十 MB 的原文件。但它的缺点是算法复杂、需要严格校验,一旦补丁应用失败,就得回退到全量包。我一般建议:主力互联网用户走全量包或文件级增量,内网或定向设备再上 bsdiff。

方案包体大小更新流程复杂度失败恢复适用场景
全量整包最大直接重试用户量小、网络好
文件级增量按文件回滚常规 PC 客户端
bsdiff 差分最小需要回退全量嵌入式、内网、带宽敏感

从维护成本来看,后两者都需要一份版本清单告诉客户端「从你的版本到最新版,应该下载哪些文件」。这个清单就是整个自适应更新系统的核心。

2.2 版本清单格式与检查逻辑

更新清单建议用 JSON,比 XML 简洁,QT 侧用 QJsonDocument 解析也方便。一个典型的清单结构如下:

{ "app_name": "MyApp", "current_version": "2.3.1", "updates": [ { "from_version": "2.3.0", "to_version": "2.3.1", "type": "incremental", "files": [ { "path": "bin/MyApp.exe", "url": "https://update.example.com/patch/2.3.0-2.3.1/bin/MyApp.exe.diff", "sha256": "7d2e8f3a1c...", "patch": true }, { "path": "resources/data.dat", "url": "https://update.example.com/patch/2.3.0-2.3.1/resources/data.dat", "sha256": "b1f2c3d4e5...", "patch": false } ] } ] }

客户端拿到清单后,先比对from_version和本地版本。如果本地版本是 2.3.0,就找到对应的 entries;如果本地版本更老,比如 2.2.0,而服务端没有直接跳转的增量,通常做法是把新旧两个全量包的 diff 合并,或者干脆直接拉全量包。

版本比较不能直接用字符串比较,因为 "9" 会大于 "10"。写一个分数段比较函数是必须的。QT 里可以这样实现:

// 将版本号按 . 分割,逐段比较数值大小 static int compareVersion(const QString &v1, const QString &v2) { const QStringList p1 = v1.split('.'); const QStringList p2 = v2.split('.'); const int maxLen = qMax(p1.size(), p2.size()); for (int i = 0; i < maxLen; ++i) { const int n1 = (i < p1.size()) ? p1[i].toInt() : 0; const int n2 = (i < p2.size()) ? p2[i].toInt() : 0; if (n1 < n2) return -1; if (n1 > n2) return 1; } return 0; }

注意这里假设版本号全是数字,如果还有 rc、beta 后缀,就要额外设计排序规则。这个函数返回 -1、0、1,可以让compareVersion(local, remote) < 0直接判断需要更新。在更新逻辑里,必须是「小于」才触发,等于时什么都不做,避免每次启动都重复下载。

2.3 更新失败的回滚方案

更新器最难受的场景是文件替换到一半断电。所以不能在原目录直接覆盖。常见做法是:

  1. 把待更新文件先下载到updates/staging/目录。
  2. 在替换前,将当前文件复制到backup/目录。
  3. 重启更新器后,按顺序替换;每替换一个文件前先写.bak标记,记录旧文件的位置。
  4. 如果替换中途崩溃,下次启动时检查到.bak标记,优先恢复备份。

这个流程不需要数据库,用文件系统状态即可。QT 里用QFile::existsQFile::rename来完成状态判断。注意QFile::rename不能跨盘符,staging 目录要放在可执行文件所在磁盘,避免 D 盘更新 C 盘时的 rename 失败。如果必须跨盘,就改成 copy+remove。

回滚策略再补一点:启动更新器时先传一个--rollback参数,它只做恢复备份和重新拉起主程序两件事。这样即便主程序二进制被替换坏了,用户手动运行 updater 也能回到上一个可用版本。

3. 用 QNetworkAccessManager 实现下载与断点续传

3.1 下载器的最小结构

QT 的 QNetworkAccessManager 是异步的,不要在downloadFinished里做阻塞的文件复制。最小模型是:

// 创建下载器时,一个 manager 配一个 reply QNetworkAccessManager manager; QNetworkRequest request; request.setUrl(QUrl("https://update.example.com/patch/2.3.0-2.3.1/resources/data.dat")); request.setRawHeader("User-Agent", "MyAppUpdater/1.0"); QNetworkReply *reply = manager.get(request); QFile *file = new QFile("staging/data.dat"); file->open(QIODevice::WriteOnly); connect(reply, &QNetworkReply::readyRead, [reply, file]() { file->write(reply->readAll()); }); connect(reply, &QNetworkReply::finished, [reply, file]() { file->flush(); file->close(); reply->deleteLater(); // 这里触发校验与下一步流程 });

这个写法有一个隐患:readyRead里的readAll()会把当前 buffer 里所有数据读出来,但如果 buffer 太大会消耗内存。更稳妥的做法是把QFile挂到reply上,用setWriteFunction(Qt 5.15 或 6.x)或者用QMutex保护的字节数统计。要点是:不要在槽函数里做耗时操作,finished信号触发时数据可能还没完全写进磁盘,所以要有flush()close()的顺序。

3.2 断点续传与校验

断点续传的核心是 HTTP 的Range头。下载前先检查本地已存在文件的大小:

QFileInfo info("staging/data.dat"); qint64 resumeOffset = 0; if (info.exists()) { resumeOffset = info.size(); request.setRawHeader("Range", QString("bytes=%1-").arg(resumeOffset).toUtf8()); }

服务端支持 Range 时,返回 206 Partial Content,reply->header(QNetworkRequest::ContentLengthHeader)是剩余长度。注意此时文件打开模式要改为Append,但如果是新文件则用WriteOnly。还有一个细节:如果本地文件的长度恰好等于预期长度,说明已经下载完,不要发 Range 请求,直接跳过。判断预期长度可以通过版本清单里的size字段,如果清单里没有,就等收到 200 响应时拿一次ContentLengthHeader

下载完成后必须做 SHA-256 校验。QT 里用QCryptographicHash分块计算,避免一次性加载整个文件:

QCryptographicHash hash(QCryptographicHash::Sha256); QFile f("staging/data.dat"); if (!f.open(QIODevice::ReadOnly)) { // 打开失败,回滚 return false; } QByteArray buffer; while ((buffer = f.read(1024 * 1024)) > 0) { hash.addData(buffer); } const QByteArray result = hash.result().toHex(); // QByteArray::toHex 返回小写十六进制,与清单中的 sha256 对比 if (result != expectedSha256) { f.remove(); // 删除错误文件,下次重新下载 return false; }

校验失败时的处理逻辑建议:删除残留文件,退回全量下载,而不是只断点续传同一个坏文件。因为断点续传只能恢复中断,无法修复内容损坏。如果你用的是 HTTP 2,注意它不支持 Range 头,QT 默认可能启用 HTTP/2,如果服务端开的是 h2,Range 会被忽略。强制用 HTTP/1.1 可以设置request.setAttribute(QNetworkRequest::Http2AllowedAttribute, false)

3.3 并发与限速参数

更新器对网络的影响要可控。可以控制同时下载的文件数,常见做法是维护一个下载队列,一次最多并发 2 个文件。QNetworkAccessManager 本身没有并发限制,需要自己用信号量或队列管理。

一个简单方案:先关闭所有自动重定向,手动处理 301/302:

request.setAttribute(QNetworkRequest::FollowRedirectsAttribute, true);

对 TLS 证书校验,默认是开启的。版本清单的 URL 如果指向 IP,证书就很容易不过。处理方式是让清单里用域名,或者在代码里对特定域名关闭校验。关闭校验要慎重,属于安全风险,但内网设备常需如此。建议把「是否忽略证书错误」放到配置文件里,默认 false,只在调试或离线设备上打开。

限速可以在每次readyRead时控制读取量。比如每秒最多读取 512 KB,超过则暂停发送?不现实,QT 的底层 socket 不在用户控制内。更实际的做法是整体层面的流量控制:每次 write 到文件后,统计总字节数,如果超过设定的每秒配额,就thread()->msleep()。注意这么做会阻塞事件循环,需要把限速逻辑写到单独的工作线程。我一般建议更新器不要限速,而是把并发数按网络类型调整:Wi-Fi / 有线并发 4,移动网络并发 1。判断方式通过QNetworkInformation::transportMedium()(Qt 6)或QNetworkConfiguration(Qt 5)。

4. QT 更新器落地的进程替换与打包细节

4.1 为什么主程序不自更新,要单独做一个 updater

Windows 上,一个正在运行的 exe 文件是被系统独占的。即使你调用QFile::remove(),也会因为共享冲突返回 false。于是有一个常见误区:主程序先下载新包,然后 QProcess 启动cmd /c del old.exe再拷贝新文件。这个方案能跑,但一遇到「主程序还持有某个 DLL」或杀毒软件扫描占用时就会失败。正确做法是:主程序只负责下载 staging 文件,然后启动独立的 updater.exe,主程序立刻退出,updater 拿到权限后做复制、替换、重启主程序。

updater 进程和主程序之间的通信,最简单的不是 QT 的QLocalServer,而是命令行参数。主程序启动 updater 时传四个信息:版本清单路径、staging 目录、安装目录、pid(等待主程序退出的标识)。

Updater.exe --staging D:\app\updates\staging --install D:\app --current-version 2.3.0 --target-version 2.3.1 --wait-pid 4321

updater 启动后,轮询wait-pid对应的进程是否退出,超时 30 秒。如果主程序还在,拒绝替换主 exe,只替换次级 DLL。轮询代码:

// 等待目标 pid 退出,超时返回 false bool waitProcessExit(DWORD pid, int timeoutMs) { HANDLE h = OpenProcess(SYNCHRONIZE, FALSE, pid); if (!h) return true; // 进程不存在,视为已退出 const DWORD result = WaitForSingleObject(h, timeoutMs); CloseHandle(h); return result == WAIT_OBJECT_0 || result == WAIT_TIMEOUT && result == WAIT_ABANDONED; }

注意WaitForSingleObject返回WAIT_TIMEOUT表示还在运行,这段代码要更严谨一点:直接判断result == WAIT_OBJECT_0为退出,WAIT_TIMEOUT返回 false。上面示例是为了展示逻辑,实际应用中要区分超时继续等还是放弃。

替换时用MoveFileExQFile::rename更好,因为它支持MOVEFILE_REPLACE_EXISTINGMOVEFILE_WRITE_THROUGH

#include <windows.h> bool replaceFile(const QString &src, const QString &dst) { const wchar_t *srcW = reinterpret_cast<const wchar_t *>(src.utf16()); const wchar_t *dstW = reinterpret_cast<const wchar_t *>(dst.utf16()); BOOL ok = MoveFileExW(srcW, dstW, MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH); return ok != 0; }

MOVEFILE_WRITE_THROUGH要求文件复制过程不被中断,但它不保证磁盘物理写入,只保证写入操作完整。这里一个容易疏忽的地方是:MoveFileEx要求源和目标在同一卷内。所以 staging 目录必须放在可执行文件所在的盘符下,不能因为想避免写 C 盘就把文件放 D 盘。

4.2 用 QProcess 启动 updater 并等待退出

主程序侧启动 updater 的代码要处理重定向标准输出,避免子进程因管道缓存阻塞。QT 里 QProcess 默认会把子进程的输出重定向到父进程的控制台,但如果你没有窗口子进程,可能在重定向时阻塞。最简单的做法:

QProcess updater; QStringList args{ "--staging", stagingDirectory, "--install", installDirectory, "--current-version", currentVersion, "--target-version", targetVersion, "--wait-pid", QString::number(QCoreApplication::applicationPid()) }; updater.setProgram(updaterPath); updater.setArguments(args); updater.setWorkingDirectory(installDirectory); updater.start(); bool exited = updater.waitForFinished(30000);

setWorkingDirectory很重要。如果 updater 里用相对路径找资源,安装目录不对会导致找不到文件。另外不要直接QProcess::startDetached然后不管,要先等待 updater 完成初始化,确认它已经持有文件访问权,再让主程序退出。但通常场景是:主程序启动 updater 后 100ms 内退出,所以更可靠的模式是:主程序把自己的状态写到一个 flag 文件,updater 发现 flag 文件存在才替换主 exe。这样即使 wait-pid 不可用,也有文件状态兜底。

4.3 windeployqt 打包与路径问题

QT 程序打包时最常见的坑不是缺 DLL,而是qt_qpa_platform_plugin_path。你在热搜里看到的QT_QPA_PLATFORM_PLUGIN_PATH报错是典型的插件路径问题。windeployqt 默认会把 QPA 平台插件拷贝到platforms/目录,但如果你把 exe 放到别的目录,或者安装程序把插件放到了非标准位置,程序启动时找不到qwindows.dll就会退出。

推荐做法:不要依赖动态搜索路径,在主程序入口显式设置插件路径,但只对官方支持的部署方式做。注意不要直接 hardcode。

#include <QApplication> #include <QDir> int main(int argc, char *argv[]) { QApplication app(argc, argv); // 以可执行文件所在目录为基准,构造 plugins 目录 const QString appDir = QCoreApplication::applicationDirPath(); QCoreApplication::addLibraryPath(appDir + "/plugins"); // 如果使用共享库方式,还可以设置 platform plugin 路径 qputenv("QT_QPA_PLATFORM_PLUGIN_PATH", (appDir + "/plugins/platforms").toLocal8Bit()); // ... }

windeployqt 执行时注意--release--compiler-runtime参数。如果你用 MSVC 2019 构建,建议不要复制 vc_redist 安装包,而是直接跑 vc_redist.x64.exe 静默安装。因为你的更新器可能取代了系统旧版本,VC 运行库版本冲突会导致启动即崩溃。发布前用dumpbin /dependents检查 exe 引用的 DLL 列表,再用where命令确认 QTDIR 环境变量没有指向别的 QT 版本。过去在 CI 里最常见的翻车现场就是编译机装了多个 QT,windeployqt 用了 5.12,程序实际链接 5.15.2。

4.4 常见崩溃排查:插件路径、运行时库、路径权限

更新器本身也是 QT 程序,它同样需要 QPA 插件才能弹界面。如果你 updater 是纯控制台、没有 GUI,可以不加载 QPA,但就算纯控制台,QCoreApplication以外使用QApplication就要有插件。所以 updater 一般用QCoreApplication,只做网络和文件操作,不创建窗口。这样打包时不需要 platforms 目录,但如果你复用主程序的 QT DLL 目录,还是要把插件的路径指对。

崩溃排查走 Windows 事件查看器,看 Application 日志里faulting module。如果指向 Qt5Core.dll,通常是 QT 版本与编译器不匹配,比如 MinGW 编译的程序用了 MSVC 的 Qt DLL。如果是qwindows.dll,就是平台插件路径找不到。还有一种是你自己写的代码里QNetworkAccessManager有未处理信号,导致 lambda 捕获了外部局部变量后访问悬空指针。更新器比主程序更容易崩溃,因为它要运行在用户权限不稳定的环境下,建议在 updater 里给自己安装SetUnhandledExceptionFilter写崩溃日志,至少能记录到哪个文件替换失败。

5. 用 HTTP 条件请求验证更新器,避免每次启动都做无谓下载

更新器运行完第一次检查「有更新」并替换成功后,下一次启动主程序仍然会检查一次。如果服务端没有更新,客户端每次都要 GET 整个清单 JSON,虽然只有几 KB,但用户量大时仍会产生不必要的网络流量和日志噪音。这里可以用 HTTP 条件请求(ETag / Last-Modified)来优化。

服务端在返回版本清单时,加一个ETag: "v20240520-1000"Last-Modified头。客户端把这串值存在本地update_meta.ini或者 QSettings 里。下次请求时带上If-None-Match

QSettings settings("MyApp", "Updater"); const QString etag = settings.value("manifest_etag").toString(); QNetworkRequest request; request.setUrl(manifestUrl); if (!etag.isEmpty()) { request.setRawHeader("If-None-Match", etag.toUtf8()); } QNetworkReply *reply = manager.get(request);

当服务端内容没变时,返回 304 Not Modified,QT 里reply->attribute(QNetworkRequest::HttpStatusCodeAttribute)等于 304,此时什么都不用做,直接进入主程序启动流程。若返回 200,则解析新 JSON,同时把新的 ETag 写回设置。

用这个方法要注意 CDN:如果你的更新服务器走 CDN,ETag 可能需要自定义配置,普通 Nginx 默认会按内容生成 ETag。还有个细节:如果客户端使用代理或者 CDN 缓存列表,304 也可能被缓存,所以建议检查响应头里是否有ETag再做处理,没有就 old 逻辑全量 GET。

另一个验证更新器正确性的做法是加一个隐藏参数--check-only,它让更新器只做检查不下载,并输出UPDATE_AVAILABLENO_UPDATE到 stdout。主程序启动时可以带这个参数跑一次,对于安装包大小敏感的运维来说很有用。主程序不等待这个输出,只是快速拿一次判断结果,然后决定是否显示更新按钮。

更新器组件单独版本控制。updater.exe 的版本号与主程序不能绑死,否则修改更新逻辑时还得要求客户端跟主版本一起变。我在实际项目里会让 updater 检查自己的版本清单,如果 updater 自身有更新,则先用一个轻量的 bootstrapper 完成自更新。bootstrapper 可以简单到只做一件事:从固定 URL 下载新 updater.exe,替换旧文件。这样 katadarah 最终方案的可靠性来源于分层的责任:bootstrapper 只管应急,updater 只管业务,主程序只管触发。

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

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/10 3:03:07

匠艺·施工高标准——56大精工工艺,将匠心刻入毫厘之间

施工&#xff0c;是家装品质的基石&#xff0c;也是改善型客户最为关注的环节。名匠「高标整装」以施工高标准为核心&#xff0c;构建起行业领先的12大领跑者工艺系统、56大高标精工工艺-。“从精研1mm的进步&#xff0c;到较真1条缝的精度”——名匠的施工高标准&#xff0c;体…

作者头像 李华
网站建设 2026/9/10 3:01:36

Spring Boot 3.x中Caffeine缓存大小策略失效排查与解决

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/10 3:00:37

配电网可靠性指标的线性规划快速求解方法

简介&#xff1a;本资源是一份面向电力系统专业研究生、科研人员及配电网优化方向工程师的学术复现资料&#xff0c;聚焦于基于线性规划的非仿真类配电网可靠性评估方法。它完整复现了2018年发表于IEEE TRANSACTIONS ON SMART GRID的开创性论文《Reliability Assessment for Di…

作者头像 李华
网站建设 2026/9/10 2:59:16

低代码列表引擎字段样式配置:从数据展示到动态渲染的实践指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华