简介:面向需要在 Qt 应用中实现增量更新与差分包生成的开发者,这份项目围绕 bsdiff 的 Qt 移植展开,并把默认的 bzip2 压缩替换为 qlz,以兼顾补丁体积与压缩、解压速度。包内共 705 个文件,以 idx 索引与测试数据为主,辅以 cpp、h 源码及 o、bin、exe 等构建产物,另有 makefile、pro、ui 与 json 配置,压缩包约 6.22MB。已有 498 人学习,说明该方向具有一定参考需求。读者可了解 bsdiff 差异计算、qlz 接口封装、数据流处理与补丁应用的组织方式,也能结合示例数据观察 patch 生成流程;其中索引文件占比较高,可用于对照补丁分块与版本数据。源码、工程配置和构建产物的组合便于排查编译、链接与运行问题,适合作为 Qt 桌面端增量升级模块的入门与改造参考。
1. 从一台内网老机器说起:Qt 移植 bsdiff 到底解决什么问题
上线前最后一天,测试同学跑来说:客户内网那台老机器只能走离线升级包,200MB 的整包拷进去要四十分钟,而这次只改了两个页面文案和一张启动图。我第一次遇到这类需求时想当然地上了全量替换,结果被传输时间按在地上摩擦。后来改用二进制差分:拿旧版本和新版本跑一次 diff,生成 patch 包,客户端本地合并。同样是这次改动,产出的 patch 包不到 2MB,合成耗时不到一秒。这份资源做的事,就是把 bsdiff 从原生 C 工具链搬进 Qt 工程,把默认的 bz2 压缩换成 qlz,并补齐 patch 生成、校验、应用这一整条链路。它适合正在做离线升级、增量更新的 Qt/C++ 客户端开发者,尤其是桌面和嵌入式方向的同学。
2. 工程结构:qmake 里塞进 bsdiff 与 QuickLZ 的目录长什么样
这份资源不是单个 demo 文件,而是一个可以独立编译的 Qt 工程。理解它的第一件事是看目录分层——bsdiff 的原生实现和 qlz 的原生实现都属于“外部 C 代码”,业务侧才是 Qt 的 C++ 封装,这两层必须物理隔开,否则后面改编译选项、换压缩库会互相牵连。
2.1 目录分层与文件清单
我把它拆成三层:3rdparty放原始 C 源码,patcher放差分业务封装,app放命令行工具和 Qt 界面。这种划分的好处是,3rdparty下的文件可以做到“除了必要的可移植性补丁之外一行不改”,将来 QuickLZ 升级或者换回 bz2,只需要动patcher里的封装层。
bsdiff-qt/ ├── 3rdparty/ │ ├── bsdiff/ │ │ ├── bsdiff.c # 生成 patch:后缀数组 + 匹配 │ │ ├── bspatch.c # 应用 patch:ctrl/diff/extra 三段合并 │ │ └── bsdiff.h # 对外声明,包在 extern "C" 里 │ └── qlz/ │ ├── qlz.c # QuickLZ 压缩/解压实现(单文件) │ └── qlz.h # qlz_compress / qlz_decompress 等接口 ├── patcher/ │ ├── patchformat.h # patch 文件头结构、魔数、版本号 │ ├── diffengine.cpp/.h # 封装 bsdiff,产出 patch 文件 │ ├── patchengine.cpp/.h # 封装 bspatch,消费 patch 文件 │ └── streamcompressor.cpp/.h # qlz 的 C++ 封装,带缓冲区大小计算 ├── app/ │ ├── main.cpp # 命令行入口:diff / patch 两个子命令 │ └── mainwindow.cpp/.h # 可选的图形界面,拖文件进去就能跑 └── bsdiff-qt.propatcher这一层是整个工程的价值所在,它把两个“裸 C 函数”包装成有错误码、有进度回调、有内存所有权语义的 C++ 类。app层只是薄薄一层壳,命令行照样能用,不想带界面就把mainwindow从.pro里摘掉。
2.2 .pro 文件怎么写,C 源码如何被 Qt 正确编译
这里是最容易翻车的地方。qmake 对.c和.cpp的处理方式不同:.c走 C 编译器,.cpp走 C++ 编译器。如果你把bsdiff.c改名成bsdiff.cpp图省事,里面大量void*隐式转换和旧式 C 语法会直接报错。正确做法是保留.c后缀,让 qmake 用 C 规则编译,然后在 C++ 侧用extern "C"声明。
QT += core gui CONFIG += c++11 TARGET = bsdiff-qt TEMPLATE = app # 关键:区分 C 源码与 C++ 源码,qmake 会自动选用对应编译器 SOURCES += \ 3rdparty/bsdiff/bsdiff.c \ 3rdparty/bsdiff/bspatch.c \ 3rdparty/qlz/qlz.c \ patcher/diffengine.cpp \ patcher/patchengine.cpp \ patcher/streamcompressor.cpp \ app/main.cpp HEADERS += \ 3rdparty/bsdiff/bsdiff.h \ 3rdparty/qlz/qlz.h \ patcher/patchformat.h \ patcher/diffengine.h \ patcher/patchengine.h \ patcher/streamcompressor.h # 头文件搜索路径,业务层直接 #include "qlz.h" 即可 INCLUDEPATH += 3rdparty/bsdiff 3rdparty/qlz patcher # 压缩算法开关,不定义则回退到 bz2 分支 DEFINES += PATCH_USE_QLZ # Windows 下需要显式链接,避免 _fseeki64 相关告警 win32: DEFINES += _CRT_SECURE_NO_WARNINGS这份.pro里真正起作用的是三行:SOURCES把 C 和 C++ 混编、INCLUDEPATH让业务层不必写长路径、DEFINES提供编译期开关。CONFIG += c++11是为std::unique_ptr和 lambda 回调准备的,Qt 5 之后默认就够用,但写上去更保险。
extern "C"那一段单独说一下,因为新手最容易漏:
// patcher/diffengine.cpp extern "C" { #include "bsdiff.h" // 原生 C 实现,必须包在 extern "C" 里 #include "qlz.h" } #include <QFile> #include <QDebug> // 统一入口:oldPath 旧版文件,newPath 新版文件,patchPath 输出 bool DiffEngine::generate(const QString &oldPath, const QString &newPath, const QString &patchPath, QString *err) { QByteArray oldBa = oldPath.toLocal8Bit(); QByteArray newBa = newPath.toLocal8Bit(); QByteArray patchBa = patchPath.toLocal8Bit(); // bsdiff 返回 0 表示成功,非 0 时按 errno 处理 int ret = bsdiff(oldBa.constData(), newBa.constData(), patchBa.constData()); if (ret != 0) { if (err) *err = QStringLiteral("bsdiff failed, code=%1").arg(ret); return false; } return true; }toLocal8Bit()这一步在 Windows 上不能省。QString 内部是 UTF-16,直接调toUtf8()传给 C 的fopen,中文路径在 GBK 环境下会变成乱码,fopen返回空指针,最后表现为“生成 patch 时崩溃但没有任何提示”。这个坑我后面第 5 章还会展开。
2.3 为什么把 bz2 换成 qlz
bsdiff 原版用 bzip2 压三段数据,压缩率不错,但代价是:依赖 libbz2、压缩慢、解压也需要几百毫秒级。放在桌面端可能感知不强,放在嵌入式设备或者启动时的静默升级流程里,就是实打实的卡顿。
换成 QuickLZ 的理由很直接——它是单文件实现,没有外部依赖,解压速度能到几百 MB/s 量级,压缩率比 bz2 差一截但 patch 包本身就不大。对“客户端本地应用 patch”这个场景,解压耗时比压缩率重要得多。
| 维度 | bz2(原版) | qlz(本工程) |
|---|---|---|
| 依赖 | 需要 libbz2 | 单文件 qlz.c |
| 压缩率 | 高 | 中等,约高 10%~20% 体积 |
| 压缩速度 | 慢 | 快 |
| 解压速度 | 中等 | 很快,适合客户端本地合并 |
| 缓冲区要求 | 需自行估算 | 源码 + 目标 + 约 400 字节余量 |
| 适合场景 | 服务端一次性打包 | 端上高频、低延迟应用 |
需要说清楚的是,qlz 的压缩率损失是真实存在的。如果你的 patch 包要通过极窄的带宽下发,体积敏感,那 bz2 仍有价值。所以工程里用PATCH_USE_QLZ做了一个编译期开关,同时在文件头里写了算法标识,运行时按标识分派,两种格式的 patch 包可以共存。
3. 核心算法拆解:一个 patch 包从生到用的完整链路
搞清楚目录之后,得看 bsdiff 到底吐出什么。很多人以为 patch 是一份“差异文件”,实际上 bsdiff 产出的是三段数据:ctrl(控制指令)、diff(字节差值)、extra(新增原文)。理解这三段,才能理解后面为什么压缩要分开做、为什么 patch 包大小会失控。
3.1 后缀数组与最长匹配
bsdiff 的匹配逻辑是:对旧文件建立后缀数组,然后顺序扫描新文件,在每个位置上二分查找“旧文件里以该位置开头、和新文件当前位置匹配最长的后缀”。找到之后记录匹配长度,再继续往后走。
主函数里维护三个游标:scan是新文件里的当前位置,pos是当前猜测的旧文件对应位置,lastscan/lastpos是上一次“确定要落一个 ctrl 指令”的位置。核心循环骨架长这样:
/* 简化后的匹配主循环,变量含义见下方说明 */ while (scan < newsize) { oldscore = 0; /* 1. 用上一个偏移量做增量试探,命中率高且几乎不耗时 */ for (scsc = scan; scan - pos + scsc < newsize; scsc++) { if (pos + scsc < oldsize && old[pos + scsc] != new[scan + scsc]) break; } /* 2. 在旧文件后缀数组里二分搜索,得到向前的匹配长度 lenf */ lenf = search(I, old, oldsize, new, newsize, pos, pos + oldscore, &pos); /* 3. 同时算一次向后的匹配长度 lenb,用于决定上一段该怎么切 */ lenb = 0; if (scan + lastoffset < oldsize && old[scan + lastoffset] == new[scan]) { lenb = search(I, old, oldsize, new, newsize, scan + lastoffset, scan, &pos); } /* 4. 只有当前匹配明显优于上一段时才落新指令,否则继续累积 */ if (lastscan != -1 && ((lenf == 0 && lastlen == 0) || (lenf == 0 && lastlen > 0) || (lenf > 0 && lastlen > 0 && lenf < lastlen))) { /* 把 lastscan ~ scan 这段写成一条 ctrl 记录 */ } }search()内部是对后缀数组I做二分,每次比较都用memcmp比一段后缀,复杂度接近 O(log n) 次比较。这里不展开完整的qsufsort后缀排序实现,你只要知道:建后缀数组本身是 bsdiff 最吃内存的一步,处理 200MB 文件时峰值内存能到文件大小的十几倍,这也是第 5 章第一条坑的来源。
参数上要注意lenf和lenb:lenf表示从当前位置向后能匹配多长(决定 diff 段长度),lenb表示从当前位置向前能匹配多长(决定 extra 段该怎么切)。两者都参与最终 ctrl 三元组的计算,改错一个就会产出一个“能生成但应用后文件损坏”的 patch——这种 bug 最难查,因为生成阶段不报错。
3.2 ctrl、diff、extra 三段的编码
匹配结束后,bsdiff 会写出三段数据。每一段都是一个(x, y, z)三元组序列,含义是:从旧文件当前位置读x字节,和 diff 段里x字节逐位相加,得到新文件的第一段;然后从 extra 段里拷贝y字节原样追加;最后旧文件指针偏移z字节。
三元组用变长整数编码,每个数最多 8 字节:
/* 变长编码:低 7 位一组,最高字节的符号位表示正负 */ static off_t offtin(u_char *buf) { off_t y = buf[7] & 0x7F; y = y * 256; y += buf[6]; y = y * 256; y += buf[5]; y = y * 256; y += buf[4]; y = y * 256; y += buf[3]; y = y * 256; y += buf[2]; y = y * 256; y += buf[1]; y = y * 256; y += buf[0]; if (buf[7] & 0x80) y = -y; /* 符号位 */ return y; }这个编码设计的好处是:小数值只占 1~2 字节,z(偏移量)为负数时也能表达。移植到 Qt 时,off_t在 Windows 上默认是 32 位有符号,一旦文件超过 2GB,offtin就会溢出成负数,解出来的三元组完全错乱——表现出来就是“patch 应用后文件长度对了但内容全是垃圾”。
三段数据的长度会写进 patch 文件头(原版紧跟在"BSDIFF40"魔数之后),应用侧第一件事就是读这三个长度,据此分配内存。这里就引出了压缩层的位置:三段数据是分别独立压缩的,ctrl 和 diff 用小缓冲区、extra 用大缓冲区,分开压能避免互相拖累。
3.3 bspatch 的解压与内存写入
应用侧的逻辑比生成侧简单得多,本质就是一个循环加两次内存拷贝:
/* 应用阶段核心循环:old + diff -> new,再追加 extra */ for (newpos = 0; newpos < newsize; ) { /* 一次读三个变长整数,分别是 diff 长度 x、extra 长度 y、旧文件偏移 z */ oldpos = offtin(ctrl + ctlpos); ctlpos += 8; x = offtin(ctrl + ctlpos); ctlpos += 8; y = offtin(ctrl + ctlpos); ctlpos += 8; /* 边界校验必须有,否则损坏的 patch 会直接把堆写穿 */ if (oldpos < 0 || x < 0 || y < 0 || oldpos + x > oldsize || newpos + x + y > newsize) { return -1; } /* diff 段是字节差值,逆序存放,逐位相加还原 */ for (i = 0; i < x; i++) { new[newpos + i] = (u_char)(db[dblen - i - 1] + old[oldpos + i]); } newpos += x; oldpos += x; /* extra 段是新增内容,原样拷贝 */ if (y > 0) { memcpy(new + newpos, extra + epos, y); newpos += y; epos += y; } oldpos += z; /* 旧文件指针跳跃,z 可为负 */ }注意db[dblen - i - 1]这里的逆序访问:原版 bsdiff 在写 diff 段时是倒着写的,所以读的时候也要倒着读。移植时如果把这一句改成正序,生成的 patch 在生成端不会报任何错,只有应用出来的文件校验不过。我第一次移植时就栽在这,查了两个小时才定位到一行下标。
oldpos += z这一步是整个算法的精髓——它允许“旧文件里的内容被重排后复用”,所以即使新版本里某个模块整体挪了位置,也只需要极小的一段 diff 加一个偏移量,不用全量重传。
4. 压缩层替换:把 QuickLZ 接到三段数据上
算法链路通了之后,压缩层是这份资源真正的改造点。原版 bsdiff 依赖 libbz2 的流式 API(BZ2_bzWrite/BZ2_bzRead),而 QuickLZ 是纯内存式的:给一块源缓冲区,返回压缩后的长度。这两种模型的差异决定了你必须在中间加一层“攒够再压”的封装。
4.1 qlz_compress / qlz_decompress 的封装
QuickLZ 的 API 只有四个函数需要关心:
| 函数 | 作用 | 注意点 |
|---|---|---|
qlz_compress(src, dst, size, state) | 压缩,返回压缩后字节数 | dst必须预留size + 400余量 |
qlz_decompress(src, dst, state) | 解压,返回解压后字节数 | 目标缓冲区大小从源数据头里读 |
qlz_size_compressed(src) | 从压缩块头读原始源长度 | 用于分配解压目标缓冲区 |
qlz_size_decompressed(src) | 从压缩块头读解压后长度 | 必须校验,防止恶意 patch 撑爆内存 |
封装时最大的问题是状态对象的大小。qlz_state_compress是一个不透明结构,不同编译选项下大小不同,所以不能new它,必须用qlz_state_*_size()查大小再动态分配:
// patcher/streamcompressor.cpp QByteArray StreamCompressor::compress(const QByteArray &raw, QString *err) { // QuickLZ 要求目标缓冲区至少比源大 400 字节,否则会越界写 const int capacity = raw.size() + 400; QByteArray out(capacity, Qt::Uninitialized); // 状态对象大小由库决定,必须动态分配,不能栈上 new const size_t stateSize = qlz_state_compress_size(); QScopedArrayPointer<char> state(new char[stateSize]); memset(state.data(), 0, stateSize); size_t written = qlz_compress(raw.constData(), out.data(), static_cast<size_t>(raw.size()), reinterpret_cast<qlz_state_compress *>(state.data())); if (written == 0) { if (err) *err = QStringLiteral("qlz compress failed"); return QByteArray(); } out.resize(static_cast<int>(written)); return out; } QByteArray StreamCompressor::decompress(const QByteArray &packed, int expectSize, QString *err) { // 先从块头读出解压长度,和调用方给的期望值做交叉校验 const size_t declared = qlz_size_decompressed(packed.constData()); if (expectSize > 0 && static_cast<int>(declared) != expectSize) { if (err) *err = QStringLiteral("size mismatch: %1 vs %2") .arg(declared).arg(expectSize); return QByteArray(); } QByteArray out(static_cast<int>(declared), Qt::Uninitialized); QScopedArrayPointer<char> state(new char[qlz_state_decompress_size()]); memset(state.data(), 0, qlz_state_decompress_size()); size_t got = qlz_decompress(packed.constData(), out.data(), reinterpret_cast<qlz_state_decompress *>(state.data())); if (got != declared) { if (err) *err = QStringLiteral("qlz decompress incomplete"); return QByteArray(); } return out; }两处细节值得说。第一,QByteArray(capacity, Qt::Uninitialized)这个构造在 Qt 5.10 之后才有,老版本用resize()后手动取data(),性能差别只在大块内存上体现,但能省一次全量清零。第二,expectSize的交叉校验不是可选项——patch 文件是从网络下载的,一个被截断或篡改的块头会让qlz_size_decompressed返回一个巨大值,然后QByteArray直接分配失败或触发 OOM。这道校验就是防御这一点的。
4.2 编译期开关与双格式兼容
工程里定义了PATCH_USE_QLZ,但运行时不能只看编译选项,因为用户手上的旧 patch 包可能是 bz2 格式的。做法是在 patch 文件头里写算法标识,应用侧按标识分派:
// patcher/patchformat.h #pragma once #include <QtGlobal> #pragma pack(push, 1) struct PatchHeader { char magic[8]; // "BSDIFFQ\0" quint8 algorithm; // 0 = bz2, 1 = qlz quint8 reserved[3]; // 对齐填充 quint32 version; // patch 格式版本 qint64 newFileSize; // 目标文件总大小 qint64 ctrlCompSize; // ctrl 块压缩后长度 qint64 diffCompSize; // diff 块压缩后长度 qint64 extraCompSize; // extra 块压缩后长度 quint32 crc32; // newFileSize 之后的头部校验 }; #pragma pack(pop)#pragma pack(1)不能省。结构体里有qint64和quint8混排,默认对齐下编译器会插入填充字节,导致“写入端和读取端结构体大小不一致”,读出来的长度字段全是错位的。这类问题在单平台测试时经常发现不了,一到 32 位 ARM 上就炸。
crc32只覆盖头部本身,用于快速判断文件是否被截断;文件内容的完整性校验放在应用完成之后,对new缓冲区整体算一次哈希再和预期比对。
4.3 三段数据分开压缩的顺序与缓冲区
生成侧的写入顺序是:先写头部占位,再依次写 ctrl、diff、extra 三块压缩数据,最后 seek 回头部回填三个压缩长度和 crc。这个“先占位后回填”的模式很常见,但要注意QFile::seek在写入模式下必须保证文件已经打开且没被缓冲层吃掉。
bool DiffEngine::writePatch(const QString &path, const QByteArray &ctrl, const QByteArray &diff, const QByteArray &extra, qint64 newSize, QString *err) { QFile f(path); if (!f.open(QIODevice::WriteOnly)) { /* 错误处理 */ } PatchHeader hdr; memset(&hdr, 0, sizeof(hdr)); memcpy(hdr.magic, "BSDIFFQ", 8); hdr.algorithm = 1; // 1 = qlz hdr.version = 1; hdr.newFileSize = newSize; f.write(reinterpret_cast<const char *>(&hdr), sizeof(hdr)); // 先占位 // 三块独立压缩,顺序必须和头里字段顺序一致 QByteArray c = StreamCompressor::compress(ctrl, err); QByteArray d = StreamCompressor::compress(diff, err); QByteArray e = StreamCompressor::compress(extra, err); f.write(c); f.write(d); f.write(e); // 回填真实长度,注意必须在 close 之前 seek hdr.ctrlCompSize = c.size(); hdr.diffCompSize = d.size(); hdr.extraCompSize = e.size(); hdr.crc32 = qChecksum(reinterpret_cast<const char *>(&hdr), sizeof(hdr) - sizeof(quint32)); f.seek(0); f.write(reinterpret_cast<const char *>(&hdr), sizeof(hdr)); f.close(); return true; }qChecksum的偏移量和长度要格外小心:算 crc 时不能把crc32字段自己算进去,所以长度是sizeof(hdr) - sizeof(quint32);但因为crc32是最后一个字段,这个写法恰好成立——如果哪天调整了结构体字段顺序,这一行必须同步改,否则头部校验永远失败。
5. 踩坑与排查:Qt 移植 bsdiff 的五个翻车现场
这一章是血泪经验。下面五条都是在真实工程里反复出现过的,每条按“现象 → 原因 → 解决”写,你可以对照自己的日志定位。
5.1 大于 2GB 的文件生成 patch 后应用失败
现象:小文件一切正常,换成几 GB 的数据包后,bspatch返回 0(成功),但产出的文件大小对了、内容全错,哈希校验必然失败。
原因:bsdiff 源码里用的是off_t和fseeko。在 Linux 64 位上off_t是 64 位,在 Windows(MSVC/MinGW32)上默认是 32 位有符号。超过 2GB 后offtin解出的偏移量溢出成负数,oldpos + x > oldsize的边界校验又恰好绕过去了。
解决:在bsdiff.h里加一层类型重定义,用qint64替换off_t,并把所有fseeko/ftello换成_fseeki64/_ftelli64(Windows)或保持fseeko(Linux/macOS)。我在3rdparty/bsdiff/bsdiff.h顶部统一#define off_t qint64,配合-D_FILE_OFFSET_BITS=64,两个平台一次过。
5.2 生成的 patch 包比新文件还大
现象:某次发版后 patch 包 210MB,而新版安装包才 195MB,被运营同学质疑“这还叫增量吗”。
原因:新旧版本差异过大(换了资源编码、重排了整个资源目录),bsdiff 的匹配率极低,extra段几乎等于整个新文件,再加上 diff 段和控制段的冗余,最终比原包还大。
解决:不能无脑信任差分。生成侧要加一道阈值判断:patchSize >= newSize * 0.6就退回全量包,并在发布系统里标记这次走整包。这个阈值是我踩了两次之后固定下来的经验值,不同项目可以调,但一定要有。
5.3 qlz 解压时崩溃在 memcpy
现象:应用 patch 时进程直接挂掉,调用栈顶在memcpy,Debug 模式下能看到目标缓冲区大小明显不对。
原因:qlz_decompress的目标缓冲区按“压缩后大小”分配,而不是“解压后大小”。QuickLZ 的压缩率乐观估计是 2~4 倍,按压缩后长度分配必然写越界。
解决:永远先用qlz_size_decompressed(packed)读出真实解压长度再分配,并且和 patch 头里记录的长度做交叉校验;额外加一次qBound上限保护(比如 4GB),防止恶意构造的块头直接申请巨量内存。
5.4 中文路径下生成 patch 直接失败
现象:英文路径一切正常,路径里带中文就返回非 0,日志里也没有明确信息。
原因:QString::toUtf8()传给 C 的fopen。Windows 下fopen走的是 ANSI 代码页(GBK),UTF-8 字节被当成乱码,文件打不开。
解决:统一用toLocal8Bit();或者更彻底一点,C 层不改,在 C++ 层用QFile打开文件、readAll()读进内存,再把缓冲区交给 bsdiff 的内存版本函数。后者在大文件上内存压力大,我一般选前者。
5.5 应用 patch 后没校验,静默损坏
现象:用户升级后程序能启动,但某个功能偶发崩溃,重装后恢复正常,日志里什么都没有。
原因:patch 应用成功返回,但产出文件和预期版本不一致(下载不完整、磁盘写入错误、老版本文件被用户手工改过),没有做完整性校验就直接替换了原文件。
解决:三段固定流程——应用前校验旧文件哈希、应用后校验新文件哈希、替换时先写临时文件再原子rename。任何一步失败都保留原文件不动,把错误码上报。这套流程加完之后,同类问题的定位时间从两天缩短到十分钟。
6. 二次开发:把差分能力封装成可复用的 Qt 组件
如果只是拿这个工程跑一次 demo,改改main.cpp就够;但真要在自己的客户端里长期用,我建议先做一层接口抽象。工程里的DiffEngine/PatchEngine现在是直接函数调用,改成接口类之后,压缩算法、进度回调、失败重试都能独立演进,业务侧不感知。
接口可以这样定:IPatchEngine暴露generate()、apply()、setProgressCallback()三个方法,apply()内部再拆成“读头 → 解压三段 → 合并 → 校验 → 原子替换”五步,每步单独返回状态码。这样上层 UI 能显示细粒度进度,出问题也能定位到具体阶段。
// patcher/ipatchengine.h class IPatchEngine { public: virtual ~IPatchEngine() = default; // 生成:old + new -> patch,进度回调参数为 0~100 virtual bool generate(const QString &oldPath, const QString &newPath, const QString &patchPath, std::function<void(int)> onProgress, QString *err) = 0; // 应用:old + patch -> newPath,内部完成校验与原子替换 virtual bool apply(const QString &oldPath, const QString &patchPath, const QString &outPath, std::function<void(int)> onProgress, QString *err) = 0; };apply()里的原子替换有个细节值得写出来:不要直接覆盖oldPath,先写到oldPath + ".tmp",fsync之后再QFile::rename。这样即使用户在升级过程中断电,老文件也还在,下次启动可以重试。我在一个嵌入式设备项目上就是因为少了这一步,被一次意外断电搞出过一批“既没有旧版也没有新版”的砖机。
| 策略 | patch 体积 | 应用耗时 | 失败风险 | 适用场景 |
|---|---|---|---|---|
| 全量替换 | 100% | 低 | 低 | 首次安装、跨大版本 |
| bsdiff + bz2 | 小 | 中 | 中 | 带宽敏感、服务端下发 |
| bsdiff + qlz | 略大 | 低 | 中 | 端上本地合成、离线升级 |
| 差分 + 全量兜底 | 最小 | 低 | 最低 | 生产环境的推荐组合 |
验证方法也要提前想好,不能靠手工点。我一般写一个循环脚本:拿两个版本的文件,按 1%、5%、20%、100% 的比例随机制造差异,每个比例跑十次“生成 → 应用 → 哈希比对”,全绿才算这条链路稳定。这个测试跑一遍大概几分钟,但能提前暴露 90% 的边界问题——比如空文件、单字节文件、全零文件、两个完全相同的文件,这四种极端情况下bsdiff的行为各不相同,不测一定翻车。
从那以后我每次发版前都强制走一遍 patch 回归:用上一版安装包和新包跑一次 diff,在干净机器上跑一次 patch,再比一次校验和,三步全过才允许推。这套习惯帮我拦下过好几次“能生成但应用后损坏”的静默 bug,也让我对ctrl三元组那几行代码保持了足够的敬畏。希望帮到你。
本文还有配套的精品资源,点击获取