简介:面向需要在 Qt 应用中实现差分升级的开发者,这份资源将开源工具 bsdiff 移植到 Qt 环境,并使用快速压缩算法 qlz 生成 patch 包。其基本思路是比较新旧版本文件,只提取差异部分并压缩输出,特别适合嵌入式、网络带宽受限或需要轻量级增量更新的场景。资源包共 705 个文件,压缩后约 6.22MB;除 675 个 idx 索引文件外,还包含 cpp/h 源码、ui 界面、pro 工程与 makefile 等构建配置,以及 bin 示例文件和可执行程序,覆盖算法封装、界面调用、编译构建、补丁生成与验证的完整链路。已有 496 人学习下载,参考价值明确。借助其中的 diffUpgradeWidget 界面模块和 old.bin、new.bin 等示例数据,可以清晰理解 bsdiff 差异比较与 qlz 压缩在补丁生成、合并流程中的具体配合方式,为自有 Qt 工程引入增量升级能力提供可直接参照的移植范本。
1. Qt 里跑通 bsdiff 增量更新:不只是把 C 代码编进工程
做客户端增量更新的工程师,大概率都听过 bsdiff 这个名字。算法老、差分效果好、生成的 patch 比整包小一个数量级,这是它这么多年没被淘汰的原因。但真把 bsdiff 塞进 Qt 工程,事情就没那么顺了——原版代码是纯 C 风格,依赖 bzip2 做二次压缩,内存分配策略又糙,直接编进 Qt 项目会踩出一串编译和运行时的问题。
这份资源的核心,是把 bsdiff 移植到 Qt 环境下,生成 patch 包,并把二次压缩算法从 bzip2 换成 qlz。qlz 的压缩率略低于 bzip2,但压缩速度快一个量级,解压速度更是快得明显,非常适合客户端做增量包的场景。适合谁?手里有 Qt 跨平台客户端、想自己做差分升级、又不想引入一堆第三方运行时的人。下文从移植过程、接口设计、qlz 接入到 patch 校验,完整过一遍,最后给你看几个掉过坑的排查记录。
2. 移植前先摸清 bsdiff 的底:核心流程与 qlz 选型理由
2.1 bsdiff 为什么快:suffix sorting 与三元组编码
bsdiff 的思路其实不复杂:把旧文件拆成一个个 block,用 suffix sorting 算法建立索引,然后在新文件里找最长的匹配片段,匹配不上的部分记录为插入数据。patch 文件里最终保存的是三类指令——额外数据、差分数据和长度信息,应用 patch 时按这些指令把旧文件重组成新文件。
这一步是整个差分算法的重头戏,原版用的是 divsufsort 做后缀排序,复杂度接近 O(n)。移植到 Qt 时要注意,bsdiff 内部有自己的内存分配宏,默认走 malloc/free,而 Qt 工程通常启用了异常和特定的 new/delete 行为,混着用没问题,但如果全局重载过 new,就要保证 bsdiff 的分配不走 Qt 的内存池,否则后续排查内存问题时非常难受。
另外,bsdiff 的输出是二进制格式,没有做任何容错设计。patch 文件头里写的是 Ctrl 块长度、Diff 块长度、New 文件大小三个 64 位整数。移植时最好保留这个格式不动,因为应用端要严格按这个格式读,改动格式会导致旧版本客户端无法升级。
2.2 qlz 替换 bzip2 的三个理由
原版 bsdiff 用 bzip2 压缩 diff 数据,压缩率高但慢。qlz(QuickLZ)是压缩速度导向的算法,level 1 压缩速度能到几百 MB/s,解压速度更快,而且解压不需要额外分配大块内存。对于客户端升级场景来说,压缩在服务端做,慢一点无所谓,但解压在用户机器上做,快就是实打实的体验提升。
还有一层考虑是依赖体积。bzip2 库本身不大,但要为每个平台交叉编译一遍,而 qlz 是单文件实现,核心就一个 .c 和一个 .h,往 Qt 工程里加非常省事。对于纯 Qt 项目,少一个外部依赖就少一组配置问题,这是选 qlz 很现实的理由。
2.3 移植时改了什么:从 C 到 C++/Qt 的适配点
bsdiff 原版的 main 函数是面向命令行的,参数解析、文件读写全是标准 C 库。移植到 Qt 后,需要拆成可复用的库接口,IO 部分换成 QFile 或自定义回调,这样 patch 的生成逻辑可以嵌入到服务端工具里,也可以做成 GUI 程序的一个按钮动作。
我一般会把 bsdiff 拆成两个层次:核心 diff 引擎保持纯 C,不依赖 Qt;外层包一层 C++ 类,处理文件路径、进度回调、错误码映射。这样核心算法可以单独单元测试,外层逻辑方便 Qt 的信号槽集成。
核心适配点如下:
- 替换 malloc/free 为可配置的分配回调(可选,默认保持原样)
- 把文件读写改为回调函数,方便接入 QFile 或网络流
- 移除全局状态,避免多次调用 bsdiff 时静态变量互相干扰
- 增加错误码,替代原版的直接 exit
// bspatch_core.h 核心接口设计(节选) typedef struct { void* (*malloc_fn)(size_t size); void (*free_fn)(void* ptr); } bsdiff_allocator_t; typedef struct { int (*read_old)(void* userdata, const unsigned char* buf, int64_t len); int (*write_patch)(void* userdata, const unsigned char* buf, int64_t len); } bsdiff_io_t; int bsdiff_core(const char* old_file, const char* new_file, const char* patch_file);上面这段接口定义了内存分配器和 IO 回调。bsdiff_core 是对外的主入口,内部会创建 suffix sort 索引、做匹配、写 patch 文件。注意 read_old 的返回值为 0 表示成功,非 0 表示读取失败,这个约定要和 Qt 的 QFile 错误处理对齐,避免错误码语义混淆。
2.4 Qt 工程内的目录结构与 .pro 配置
把 bsdiff 源码放进 Qt 工程时,推荐单独建一个 ThirdParty 目录,不跟业务代码混在一起。目录结构大致是这样:
ThirdParty/ bsdiff/ include/ bsdiff.h bspatch.h qlz.h src/ bsdiff.c bspatch.c qlz.c bsdiff.pri.pri 文件里要处理的事情有几件:头文件路径、源文件列表、还有针对 C 文件的编译选项。bsdiff.c 和 bspatch.c 是 C 文件,如果工程用 C++ 编译,要么改文件后缀为 .cpp,要么在 .pro 里用 QMAKE_CFLAGS 单独指定。我建议保留 .c 后缀,在 .pri 里单独加配置,这样以后升级源码版本时可以直接替换文件,不用动代码。
# bsdiff.pri INCLUDEPATH += $$PWD/include SOURCES += \ $$PWD/src/bsdiff.c \ $$PWD/src/bspatch.c \ $$PWD/src/qlz.c # C 文件按 C99 标准编译,防止旧语法告警 QMAKE_CFLAGS += -std=c99 -w注意,如果工程启用了 C++11 或更高标准,不要把这个 CFLAGS 加到全局,否则会影响其他 C++ 文件的编译。写在 .pri 里只对当前模块生效,这是最稳妥的做法。另外,-w 参数是关闭所有告警,如果你的团队要求零告警,可以去掉 -w 然后逐个处理告警,但 bsdiff 的源码比较老,可能会有几百条风格类告警,酌情处理。
3. 把 bsdiff 封装成 Qt 接口:生成 patch 与进度回报
3.1 PatchGenerator 类设计:信号槽、取消机制、错误码
封装 bsdiff 成 Qt 类,主要是为了三件事:把耗时的差分计算放到后台线程、把进度反馈给界面、允许用户在计算过程中取消。这三件事在纯 C 的 bsdiff 里都没有,需要自己补。
// patchgenerator.h class PatchGenerator : public QObject { Q_OBJECT public: explicit PatchGenerator(QObject* parent = nullptr); void setFilePair(const QString& oldFile, const QString& newFile); void setOutputPath(const QString& patchFile); void setCompressLevel(int level); // qlz level: 1~3 public slots: void start(); void cancel(); signals: void progressUpdated(int percent); void finished(const QString& patchPath, qint64 patchSize); void failed(int errCode, const QString& errMsg); private: QString m_oldFile; QString m_newFile; QString m_patchFile; int m_compressLevel; bool m_cancelled; };这个类把差分计算封装成一个任务。start() 里会 new 一个线程跑 bsdiff_core,跑完通过信号把结果传回主线程。cancel() 是给用户取消用的,实际实现里不是立即中断计算,而是设置一个 flag,让核心代码在每处理完一块数据后检查一次,这样能保证退出时不会留下一半的 patch 文件。
信号设计上,finished 带 patch 路径和大小,方便上层直接展示给用户;failed 带错误码和描述,错误码从 bsdiff 内部的错误枚举映射过来。这样界面层不用关心 bsdiff 内部逻辑,拿到错误码直接查表显示即可。
3.2 多线程执行与进度计算:避免 UI 卡死的实现细节
bsdiff 的耗时集中在 suffix sorting 阶段,文件越大耗时越长,几百 MB 的文件跑几十秒很正常。如果不放后台线程,界面直接假死。进度计算也不是简单的按字节算,因为大部分时间花在排序和匹配上,这两阶段的时间占比在文件内容不同时差异很大。
void PatchGenerator::start() { QtConcurrent::run([this]() { int ret = bsdiff_core(m_oldFile.toUtf8().constData(), m_newFile.toUtf8().constData(), m_patchFile.toUtf8().constData()); if (ret == 0) { QFile f(m_patchFile); emit finished(m_patchFile, f.size()); } else { emit failed(ret, QStringLiteral("bsdiff failed with code %1").arg(ret)); } }); }QtConcurrent::run 是这里最省事的方案,不用手动管理 QThread 生命周期。要注意的是,m_oldFile 这些成员变量在这个 lambda 里被读取,如果用户在任务执行期间再次调用 setFilePair,会引发数据竞争。一个简单的做法是在 start() 内部做一次拷贝,后续逻辑只操作局部变量,这样即使主线程改了成员变量,也不会影响正在执行的任务。
进度计算方面,bsdiff 原版没有回调接口,我把 bsdiff_core 内部加了一个可选的 progress_callback,在排序阶段每处理完 10% 的数据回调一次,匹配阶段按已扫描的新文件字节数回调。两层加起来做个加权平均,exhibit 出来大约是个平滑增长的进度条,不会出现卡在 99% 的情况。
3.3 通过测试用例验证封装正确性
封装完成后,第一件事不是接入业务,而是写几个测试用例验证正确性。测试方案很直接:用随机文件、文本文件、二进制文件各做一组,生成 patch 后用 bspatch 还原,比对还原文件和原文件是否逐字节一致。
void TestPatchGenerator::testRoundTrip() { PatchGenerator gen; gen.setFilePair("/tmp/old.bin", "/tmp/new.bin"); gen.setOutputPath("/tmp/patch.bin"); QSignalSpy spy(&gen, &PatchGenerator::finished); gen.start(); QVERIFY(spy.wait(30000)); Patcher patcher; patcher.setPatchFile("/tmp/patch.bin"); patcher.apply("/tmp/old.bin", "/tmp/restored.bin"); QByteArray original = readFile("/tmp/new.bin"); QByteArray restored = readFile("/tmp/restored.bin"); QCOMPARE(original, restored); }这是一个 Qt Test 的典型用例。QSignalSpy 用来等待 finished 信号触发,避免用 QThread::sleep 这种不靠谱的同步方式。还原出来后直接 QCOMPARE 两个 QByteArray,能过就说明生成和应用两端都正常。这个用例跑通之后,才建议继续接 UI 层。
4. 接入 qlz 压缩层:patch 体积再降 40% 的关键
4.1 qlz 的 API 与内存要求
qlz 的 API 极简,核心就两个函数:qlz_compress 和 qlz_decompress。压缩前需要准备一个 state 缓冲区,大小由 qlz_state_size 函数返回,level 越高 state 越大。注意一点,qlz 的压缩和解压需要各自的 state,不能共用。
// qlz 基本用法 void* state = malloc(qlz_state_size(QLZ_COMPRESSION_LEVEL)); size_t compressed_size = qlz_compress(state, src, dst, src_len); free(state); void* state_d = malloc(qlz_state_size(QLZ_COMPRESSION_LEVEL)); size_t restored_len = qlz_decompress(state_d, src, dst, src_len); free(state_d);qlz_compress 的返回值是压缩后的字节数,这个值必须原样写入 patch 文件,解压时才能知道读多少字节。qlz_decompress 的返回值是解压后的字节数,正常情况下应该等于原始长度。移植时把这个逻辑封装一下,保证压缩和解压使用相同的 level,否则会解压失败。
4.2 压缩与差分编码的配合方式
bsdiff 生成 patch 的过程中,diff 数据和 extra 数据是分别压进 patch 文件的。这两个数据块的压缩是独立的,qlz 可以分别处理。在 patch 文件里,Ctrl 块记录了每个 diff 段的长度、extra 段的长度和在新文件中的偏移,压缩后这些长度仍然有效,跟压缩无关。
// bsdiff.c 中压缩段落的替换逻辑(示意) unsigned char* diff_buf = NULL; size_t diff_len = ...; // 差分数据 size_t diff_comp_len = 0; void* cstate = malloc(qlz_state_size(level)); if (cstate) { unsigned char* comp_buf = malloc(diff_len + 1024); diff_comp_len = qlz_compress(cstate, diff_buf, comp_buf, diff_len); // 写入 patch 的 diff 段 write_patch(comp_buf, diff_comp_len); free(comp_buf); free(cstate); }注意这里给压缩输出多分配了 1024 字节。这是 qlz 的一个特性——最坏情况下,压缩输出可能比输入略大,因为要写入头部信息和状态标记。如果你的 patch 比较小,比如只有几 KB,压缩后可能反而变大了,这时可以做一个判断,压缩后如果比原始数据还大,就直接写原始数据,不压。
4.3 patch 文件格式调整与兼容性处理
接入 qlz 后,patch 文件的格式从 bsdiff 标准格式变成了自定义格式。主要改动有两处:头部增加压缩算法标识字段,diff 和 extra 数据段增加压缩前原始长度字段。应用端读 patch 时,先读标识,再决定走 bzip2 还是 qlz 路径。
patch 文件布局(接入 qlz 后): Header: magic "BSDIFF40" + 压缩算法标识(4字节) + ctrl_len(8字节) + diff_len(8字节) + new_size(8字节) Ctrl 数据区: 压缩后存储 Diff 数据区: 压缩后存储 Extra 数据区: 压缩后存储这里有一个不太容易发现的坑:bsdiff 标准格式里,Ctrl 段是一组 3×8 字节的结构体数组,应用端需要流式读取。如果你改动格式,要保证 Ctrl 段内部的结构不破坏,否则应用端无法解析。我实际做的时候,只加了算法标识和额外的长度字段,Ctrl 段内部保持原样,这样应用端的解析逻辑改动最小。
4.4 兼容旧客户端:双格式 patch 还是强制升级
一旦接入 qlz,生成的 patch 包只有新版本客户端能解。如果你的用户群里有还没升级到新客户端的存量用户,服务端必须能区分下发哪种 patch。
两种常见做法:一是服务端同时存两套 patch——一套旧格式(bzip2),一套新格式(qlz),按客户端版本下发;二是只生成新格式 patch,并在服务端做版本控制,新 patch 只推送给已升级到兼容版本的客户端。前者需要服务器多存一倍数据,后者需要客户端升级过程不能断档。
从工程实践来看,我一般建议用第一种。理由很简单:客户端升级是不可控的,你没法强制用户第二天就全部升级。保留旧格式 patch 的成本就是磁盘多占一点,但这能保证新老版本都能正常升级,尤其是做强制升级版本的时候,新的安装包本身就必须能被旧版本下载。
5. 避坑与排查:移植 bsdiff 过程中常见的五个坑
5.1 坑一:大文件差分时内存飙升到无法接受
现象:对 500MB 的旧文件跑 bsdiff,程序在排序阶段占用了超过 2GB 内存,直接 OOM。
原因:bsdiff 的 suffix sorting 需要为每个字节建立一个后缀数组索引,这个数组本身就是 N×4 字节(int 型),加上辅助数组,内存占用大约是文件大小的 6~8 倍。500MB 文件对应 3~4GB 内存,这在 32 位环境下直接崩,64 位环境下也不好看。
解决:一是尽量用 64 位构建;二是把内存分配器换成可监控的版本,实时观察内存占用;三是调整业务策略,对超过 200MB 的大文件改用分块差分,把文件切成多个段,分别生成 patch。分块方案会降低压缩率,但对于超大文件,可用性是第一位的。
5.2 坑二:Qt 的 QString 到 C 字符串转换导致路径错误
现象:在 Windows 环境下,生成的 patch 文件路径总是不对,或者根本找不到旧文件。
原因:QString::toLocal8Bit 和 toUtf8 在处理含中文或空格路径时,转换结果在 C 函数里可能被截断或乱码。更隐蔽的是,bsdiff_core 内部用 fopen 打开文件,而 Windows 的 fopen 默认按 ANSI 编码解释路径,中文路径必挂。
解决:b sdiff 的 IO 层改用宽字符接口,或者在调用前把路径转为 8.3 短路径。Qt 里更省事的方式是,不传路径给 bsdiff,而是通过 QFile 打开文件句柄,再把句柄传进 bsdiff 的 IO 回调。这样绕过了路径编码问题,最可靠。
5.3 坑三:qlz 压缩级别不一致导致解压失败
现象:服务端生成 patch 时 compression level 设为 2,客户端解压时写成 1,解压出来的数据全是乱码。
原因:qlz 的压缩数据头里带有 level 信息,但 qlz_decompress 的 state 必须用相同 level 初始化。如果用 level 1 的 state 去解 level 2 的数据,内部表的大小不对,解压结果就是错的。
解决:在 patch 文件头的算法标识字段里写入 level 值,解压前读取并校验。如果不匹配,直接报错而不是继续解。同时建议在代码里做一层断言,压制这类问题的发生。
5.4 坑四:生成 patch 过程中取消操作留下半截文件
现象:用户点击取消后,输出目录里留了一个只有几十 KB 的 patch 文件,下次生成时工具提示文件已存在或直接覆盖,导致旧文件被误删。
原因:bsdiff 原版的输出流程是边计算边写入,取消时没有清理逻辑。
解决:生成时先写到一个临时文件,全部完成后再 rename 成最终文件名。取消或异常退出时删掉临时文件。这个改动很小,但能避免很多后续的认知混乱。
5.5 坑五:Qt 4 与 Qt 5 的 QString::toUtf8 行为差异
现象:同一段代码,在 Qt 4 下生成的 patch 正常,在 Qt 5 下生成的 patch 应用后客户端崩溃。
原因:Qt 5 的 QString 底层从 UTF-16 改为 UTF-8 存储后,toUtf8 在含有非法 UTF-16 字符时行为变化,导致路径或文件内容读取时字节不一致。
解决:统一强制用 QFile::encodeName 做路径转换,不要手写 toUtf8。对于文件名中的特殊字符,QFile::encodeName 在 Windows 下会自动处理为 8.3 格式,这是最省心的方案。
6. patch 校验与回滚预案:上线前必须做的一套验证流程
patch 生成和接入 qlz 都完成之后,不要急着上线。增量更新最怕的不是 patch 生成慢,而是 patch 在部分用户机器上应用失败,又没有回滚机制,用户就卡在旧版本上了。我从项目初期开始做增量更新,血的教训告诉我,上生产前至少要做三轮验证。
6.1 校验一:字节级一致性验证
用真实的旧版本安装包和新版本安装包生成 patch,应用后逐字节比对还原文件与原始新文件。这个校验用脚本自动化,不能靠人眼比对。
# verify_patch.sh 核心逻辑 ./bspatch_tool old.bin new.bin patch.bin ./bspatch_tool apply old.bin restored.bin patch.bin cmp new.bin restored.bin && echo "PASS" || echo "FAIL"cmp 返回 0 才输出 PASS。这里要注意,cmp 对大文件的性能较差,但校验阶段文件通常只有几十 MB,可以接受。如果文件特别大,可以用 sha256sum 替代 cmp,速度更快。
6.2 校验二:版本目录的增量历史链验证
客户端升级不是只有一版,用户可能从任意旧版本升上来。所以服务端要维护的 patch 不只是“最新版对上一版”,而是最新版对所有仍受支持的历史版本。校验时随机抽几个历史版本做升级链测试。
常见做法是:每个历史版本都生成对应的 patch,然后模拟升级路径——旧版本 → patch 应用到中间版本 → 再应用下一个 patch → 直到最新。这条链上任何一个环节失败,都要排查是差分问题还是 patch 格式问题。这里有一个经验:不要把多个 patch 链式应用,因为每个 patch 应用后文件的校验和会不同,出错时很难定位是哪一个 patch 造成的。最好是每个版本直接生成对最新版的 patch,不做中间过渡。
6.3 校验三:异常输入与容错测试
patch 文件损坏、旧文件被篡改、磁盘空间不足、版本号不匹配,这些都属于异常输入。客户端应用 patch 时要把这些情况都考虑进去。
int Patcher::apply(const QString& oldFile, const QString& patchFile, const QString& newFile) { // 前置检查:patch 文件头部魔数与版本号 QFile patch(patchFile); if (!patch.open(QIODevice::ReadOnly)) return ERR_PATCH_OPEN_FAILED; QDataStream in(&patch); quint32 magic; in >> magic; if (magic != 0x42534449) return ERR_BAD_MAGIC; // "BSDI" // 检查补丁版本是否与当前客户端兼容 quint16 version; in >> version; if (version != PATCH_FORMAT_VERSION) return ERR_VERSION_MISMATCH; // 解析并应用 return applyInternal(oldFile, patchFile, newFile); }这一段代码展示了 patch 应用前的两个关键检查:魔数校验和版本号校验。魔数不匹配说明文件根本不是 patch,报错即可;版本号不匹配说明 patch 是旧格式或未来格式,需要提示用户升级客户端而不是盲目解析。这个逻辑看起来简单,但很多团队为了省事直接跳过,等到线上出问题才补回来。
从那以后,我每次生成 patch 都强制走一遍校验脚本,不再靠“测了一次没问题”就上线。这套流程看起来繁琐,但它省掉的是线上用户反馈“升级失败”之后漫长的排查时间。希望帮到你。
本文还有配套的精品资源,点击获取