资讯动态

Qt环境下bsdiff增量更新:从移植到qlz压缩的完整实践

发布时间:2026/10/14 1:57:53 来源:尧图企业网站定制
简介面向需要在 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 数据压缩率高但慢。qlzQuickLZ是压缩速度导向的算法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 -stdc99 -w注意如果工程启用了 C11 或更高标准不要把这个 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 FAILcmp 返回 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 都强制走一遍校验脚本不再靠“测了一次没问题”就上线。这套流程看起来繁琐但它省掉的是线上用户反馈“升级失败”之后漫长的排查时间。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑