Qt客户端bsdiff增量更新:QuickLZ替换bzip2与补丁校验

发布时间:2026/10/31 1:07:29
Qt客户端bsdiff增量更新:QuickLZ替换bzip2与补丁校验

简介:面向需要在Qt项目中实现差量升级的开发者,提供bsdiff补丁生成与qlz压缩算法结合的移植实现,重点处理跨平台集成、补丁生成效率以及压缩解压速度的平衡,适合具备C++与Qt基础、关注软件更新模块的读者参考。压缩包共705个文件,约6.22MB,包含cpp与h源码、pro及makefile工程配置、ui界面文件、bin/o/exe等编译产物,以及大量idx索引数据,可对照源码、构建配置与测试数据理解补丁流程。已有498人浏览学习。读者可从中获取Qt环境下bsdiff的接口封装思路、补丁生成与应用的数据流组织方式,并参考qlz压缩接入、并发处理和错误处理等实现细节,用于自己的差量升级方案设计与排错。

1. 200MB 资源包的发版困境:qt bsdiff 到底能省下多少流量

一个 Qt 桌面客户端,安装目录里躺着 200MB 左右的资源文件——图标、模型、音频、脚本。每次发版改动的实际内容往往不到 3MB,但用户拿到的仍是完整包。做过 qt bsdiff 之后,这个数字会变成 4~6MB 的 patch,下载时间从十几分钟压到一分钟以内。bsdiff 负责算出「新旧两个文件到底差在哪」,把差异写成一个极小的补丁;bspatch 负责在用户机器上把补丁和旧文件重新拼回新文件。原生 bsdiff 用 bzip2 压补丁,在嵌入式板子和低配办公机上解压慢、内存占用高,把它换成 QuickLZ 之后,解压耗时通常能降到原来的三分之一左右,代价是补丁体积略大。这套东西适合做增量更新的客户端、离线部署包、工业设备的现场升级,不适合一次性的小文件分发。

2. 差分与压缩的分工:后缀数组怎么算差异,QuickLZ 又省在哪

在动手改代码之前,得先弄清楚 bsdiff 输出的三个数据块各自是干什么的,以及为什么压缩层可以单独替换而不影响差分逻辑。这两件事分清楚了,后面在 Qt 工程里裁剪源码、重定义 patch 格式才有依据。很多人一上来就把 BSDPATCH 的头砍掉重写,结果合并端读出来的偏移全是错的,问题往往出在对三段结构和长度字段语义的理解上。

2.1 后缀数组如何找出新旧文件的可复用字节

bsdiff 的核心不是逐字节比对,而是给旧文件建立索引,然后用新文件去这个索引里查「最长能匹配多长」。它内部对 old 文件做一次后缀排序(blocksort),得到后缀数组,再用二分查找定位新文件每个位置在 old 中最长的匹配片段。匹配到的部分进入 diff 块,没匹配上的部分进入 extra 块。

理解这个机制有个很实际的推论:差分效果取决于新旧文件的相似度分布,而不是相似的总量。如果一次发版把资源文件整体重新打包、字节顺序全变了,哪怕内容语义上没改,bsdiff 也只能算出巨大的 extra 块,补丁接近全量。反过来,如果资源文件是追加式修改、只在尾部加内容,diff 块会非常小。我在一个图像处理 Demo 上验证过:同一批素材,按文件逐个差分,补丁合计 4.2MB;把素材整体打成一个 tar 再差分,补丁变成 11MB——因为 tar 的头部变化和文件顺序调整破坏了匹配连续性。

对应的工程结论是:不要对归档文件做差分,先拆包再逐文件差分,或者保持目录结构稳定、文件内追加写。这一条比换任何压缩算法都影响大。

2.2 patch 的三段结构与长度字段的真实含义

一个标准 bsdiff 产出的 patch,结构是「24 字节头 + 三段数据」。头部里依次是魔数BSDIFF40、ctrl 块压缩后长度、diff 块压缩后长度、新文件总长度。三段数据分别是:

数据块内容在合并端的作用
ctrl若干个 8 字节整数组成的三元组控制每一轮读多少 diff、拷多少 extra、old 指针移动多少
diff与 old 文件逐字节相加的差值与 old 对应区间做加法还原新文件
extra全新的、无法复用 old 的字节直接拷贝到新文件

合并端的循环逻辑是:读一个三元组(x, y, z),从 diff 块取 x 字节与 old 的当前区间逐字节相加写入新文件,从 extra 块取 y 字节直接写入,然后把 old 的读取位置加上 z。注意z 是相对增量,不是绝对偏移,这是最容易搞反的地方,写错了表现是文件前几 MB 正常、后面全是乱码。

三段数据本身都是经过压缩的,原版用 bzip2。替换压缩算法时,头部里的长度字段必须同时记录「压缩前长度」和「压缩后长度」——因为 QuickLZ 解压需要预先知道解压后的大小,只给压缩长度是解不出来的。原版 bsdiff 的头只存了压缩长度,靠 bzip2 流式解压自己判断结束,这一个差异是替换压缩层时第一个要解决的事。

2.3 换掉 bzip2 的账:内存、耗时与压缩率的三方权衡

bzip2 的压缩率好,但在低端设备上的解压表现不佳:解压一个 4MB 的补丁,峰值内存接近 4MB 的输出缓冲加内部状态,耗时在 ARM 平台上一秒以上。QuickLZ 是面向速度设计的压缩库,level 1 下解压吞吐通常是 bzip2 的数倍,内部状态固定,不需要动态分配大块内存。

三档压缩等级的实际差别大致如下:

等级压缩率相对 bzip2压缩耗时解压耗时适用场景
level 1约为 bzip2 的 70%~80% 体积最快最快补丁要在设备端解、设备性能弱
level 2约为 80%~88%中等快通用首选
level 3接近 bzip2慢较快补丁走流量计费网络、体积优先

我的选择通常固定 level 1 或 level 2,理由是补丁本身已经被差分压得很小了,多出来的那 15% 体积换不来什么,而解压速度直接影响用户点「升级」之后的等待感。这个取舍在嵌入式设备上尤其明显,一个跑在 ARM9 上的现场设备,level 1 解压 4MB 补丁耗时约 120ms,bzip2 是 500ms 以上。

提示:QuickLZ 的压缩等级和流缓冲大小是通过编译期宏QLZ_COMPRESSION_LEVEL、QLZ_STREAMING_BUFFER控制的,压缩端和解压端必须完全一致,否则解压直接返回 0。这两个宏千万不要散落在各个源文件里,要统一放到构建系统里定义。

2.4 Qt 环境下必须先处理的类型与 IO 边界

原生 bsdiff 是纯 C、POSIX 风格的代码,直接扔进 Qt 工程会遇到三类问题:

第一类是整数宽度。bsdiff 用off_t表示文件偏移,在 32 位 MSVC 上off_t是 4 字节,处理超过 2GB 的文件会截断;在 Linux 64 位上又是 8 字节。跨平台工程里我一般直接用qint64替换,读写文件走QFile,偏移量不再经过fseek。

第二类是内存映射。bspatch 合并时原版用mmap把 old 文件映射进地址空间,Windows 上没有这个接口。Qt 提供了QFile::map(),一行就能跨平台,但要注意映射失败(比如文件在网盘同步目录里被占用)时必须回退到普通读取,不能假设一定成功。

第三类是内存分配策略。bsdiff 内部大量使用malloc并按指针偏移做运算,如果换成QByteArray,任何一次扩容都会让原来的指针失效。我的做法是保留malloc/free,只在外层用QByteArray接管结果,边界清晰不打架。

这三点处理完,bsdiff 的 C 代码基本可以原样编译进 Qt 工程,不用重写算法。

3. 接进 Qt 工程:源码裁剪、双套构建与异步接口封装

把 C 源码拖进 Qt 项目只是第一步,真正决定这套东西能不能长期维护的,是目录约定、构建配置和对外接口的设计。我见过不少项目把 bsdiff.c 直接丢进 src 目录,QLZ_*宏写在某个 cpp 的开头,结果换一台机器编译就解压失败,排查半天才发现是宏定义没跟着走。

3.1 源码裁剪清单与目录约定

原版 bsdiff 包里除了两个入口文件,还带着后缀排序、差分、扫描线切分、以及 bzip2 的完整实现。裁剪的第一步是把 bzip2 相关的东西全部拿掉,换成 QuickLZ 的两个文件(quicklz.c/quicklz.h)。

我习惯的目录结构是这样:

project/ ├── src/ │ ├── patch/ │ │ ├── PatchService.h # 对外的 QObject 接口 │ │ ├── PatchService.cpp │ │ ├── patch_format.h # 自定义 patch 头与读写函数 │ │ └── qlz_wrap.cpp # QuickLZ 的 C++ 薄封装 │ └── main.cpp └── third_party/ ├── bsdiff/ │ ├── bsdiff.c # 生成端入口 │ ├── bspatch.c # 合并端入口 │ ├── compress.c # 原来的 bzip2 调用点,改成调 qlz_wrap │ ├── diff.c │ ├── split.c │ └── blocksort.c └── quicklz/ ├── quicklz.c └── quicklz.h

关键改动点集中在compress.c。这个文件原本提供compress()和uncompress()两个函数,内部调 bzip2;现在把实现换成 QuickLZ,函数签名保持 byte 数组进、byte 数组出,其余代码一行不动。这样做的好处是 bsdiff.c 和 bspatch.c 里的业务逻辑完全不用碰,出问题也容易定位到是压缩层还是差分层。

需要额外加一件事:原版的compress()只返回压缩后的长度,不告诉你解压后多大。替换实现时要额外提供一个qlz_uncompressed_size()之类的接口,把「压缩前长度」暴露出去,供写 patch 头时使用。

3.2 CMake 与 qmake 两套构建写法

不同团队的 Qt 工程构建方式不一样,新项目多是 CMake,老项目还在 qmake。两套我都给一份,重点在于把 QuickLZ 的编译期宏放到构建系统层面统一定义。

cmake_minimum_required(VERSION 3.16) project(patchtool LANGUAGES C CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) find_package(Qt5 REQUIRED COMPONENTS Core) # Qt6 工程改成 find_package(Qt6 REQUIRED COMPONENTS Core) # 差分核心库:纯 C,按 C 编译,避免 C++ 名字修饰影响 C 接口 add_library(bsdiff_core STATIC third_party/bsdiff/bsdiff.c third_party/bsdiff/bspatch.c third_party/bsdiff/compress.c third_party/bsdiff/diff.c third_party/bsdiff/split.c third_party/bsdiff/blocksort.c third_party/quicklz/quicklz.c ) target_include_directories(bsdiff_core PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/third_party/bsdiff ${CMAKE_CURRENT_SOURCE_DIR}/third_party/quicklz ) # 最关键的两行:压缩等级和流缓冲必须全工程唯一,PUBLIC 保证下游一致 target_compile_definitions(bsdiff_core PUBLIC QLZ_COMPRESSION_LEVEL=2 QLZ_STREAMING_BUFFER=0 ) # 关掉 C 代码里的隐式函数声明告警,老代码里很常见 target_compile_options(bsdiff_core PRIVATE -Wno-implicit-function-declaration)

对应的 qmake 写法:

QT += core CONFIG += c++17 console INCLUDEPATH += $$PWD/third_party/bsdiff $$PWD/third_party/quicklz # 宏定义必须和解压端保持完全一致 DEFINES += QLZ_COMPRESSION_LEVEL=2 QLZ_STREAMING_BUFFER=0 SOURCES += \ third_party/bsdiff/bsdiff.c \ third_party/bsdiff/bspatch.c \ third_party/bsdiff/compress.c \ third_party/bsdiff/diff.c \ third_party/bsdiff/split.c \ third_party/bsdiff/blocksort.c \ third_party/quicklz/quicklz.c \ src/patch/PatchService.cpp HEADERS += src/patch/PatchService.h src/patch/patch_format.h

参数说明:QLZ_COMPRESSION_LEVEL取 1、2、3,决定压缩率和速度的权衡;QLZ_STREAMING_BUFFER设 0 表示一次性压缩整块数据,设非 0 值时可以用流式接口分片压缩,但会额外占用一块内部缓冲。补丁场景下三段数据都是先完全算出来再压,用 0 就够了。

编译期还会遇到一个具体问题:老版本 bsdiff 里的blocksort.c用了register关键字和 K&R 风格声明,C++ 编译器会报错。解决办法是强制这几个文件以 C 语言编译——CMake 下LANGUAGES C CXX加.c后缀自动生效,qmake 下需要确认QMAKE_CFLAGS生效而不是QMAKE_CXXFLAGS。

3.3 封装成 QObject 异步服务:进度、取消与错误码

直接在主线程里调bsdiff(),几百 MB 的文件会让界面直接假死。标准做法是包装成 QObject,扔进QThread或者QtConcurrent::run。接口设计上我倾向于四个要素:模式、输入输出路径、进度信号、错误码。

// PatchService.h #pragma once #include <QObject> #include <QString> class PatchService : public QObject { Q_OBJECT public: enum class Mode { Diff, Patch }; // 错误码要能区分"文件打不开"和"补丁校验失败",否则现场没法排查 enum class Error { None = 0, OpenFailed, // 输入文件不存在或无权限 FormatMismatch, // patch 头魔数或版本不对 DecompressFailed, // QuickLZ 解压返回 0 CrcMismatch, // 合并结果与头部记录的 CRC 不一致 DiskFull, // 写入阶段失败 InternalError }; Q_ENUM(Error) explicit PatchService(QObject *parent = nullptr); ~PatchService() override; public slots: void run(Mode mode, const QString &oldPath, const QString &newPath, const QString &patchPath); void cancel(); // 置取消标志,下一轮循环退出 signals: void progress(int percent); // 0~100 void finished(bool ok, PatchService::Error code, const QString &detail); private: std::atomic_bool m_cancelled{false}; };

实现侧有个绕不开的细节:bsdiff 原版内部没有任何回调,进度只能靠插入钩子。最省事的做法是在bsdiff.c顶部加一对全局函数指针,在这个文件的数据分块循环里定期调用:

/* 加在 bsdiff.c 的头部区域 */ typedef void (*bsdiff_progress_cb)(int percent, void *user); static bsdiff_progress_cb g_cb = NULL; static void *g_cb_user = NULL; void bsdiff_set_progress_cb(bsdiff_progress_cb cb, void *user) { g_cb = cb; g_cb_user = user; } /* 在扫描新文件的主循环里,每处理一段就上报一次 */ if (g_cb && (newpos % (1024 * 1024) == 0)) { g_cb((int)(newpos * 100 / newsize), g_cb_user); }

全局变量在这里不优雅但实用,因为 bsdiff 本身是单次调用的命令行程序,改成库之后天然不适合并发。要支持并发就得给每次调用传上下文,改动量会大到失去移植的意义。我一般在外层加一把互斥锁,保证同一时刻只有一个差分任务在跑——反正这类操作本身就不该并发。

取消标志的处理同理:在回调里检查m_cancelled,如果是 true 就把回调返回值带出去,让 bsdiff 提前返回错误码,外层负责删掉写了一半的 patch 文件。这里有个坑,半成品 patch 文件不删掉的话,下次合并会读到残缺头,报的还是「魔数不对」,排查方向会被带偏。

4. QuickLZ 版补丁容器:头部重定义与两端完整实现

这一章是整套方案的核心。压缩层一换,patch 文件格式就必须自己定义,因为 bzip2 的流可以自描述结束,QuickLZ 不行。格式定义得好,后面所有校验、回滚、灰度发布都能挂上去;定义得糙,出问题时连是哪个环节坏的都定位不了。

4.1 自定义 patch 头:魔数、版本与三段双长度

头部我一般设计成固定 64 字节,字段紧凑但留出扩展位。核心思想是:每一段都同时记录压缩前和压缩后的长度,外加整个新文件的 CRC32。

// patch_format.h #pragma once #include <QByteArray> #include <QtGlobal> // 全部字段按大端序序列化,避免结构体对齐和字节序问题 static const char kPatchMagic[4] = {'Q', 'B', 'S', 'D'}; static const quint8 kPatchVersion = 0x01; // 算法标识:区分压缩等级,合并端据此校验编译期宏是否一致 enum class PatchAlgo : quint8 { QlzLevel1 = 1, QlzLevel2 = 2, QlzLevel3 = 3 }; struct PatchHeader { quint8 version = kPatchVersion; quint8 algo = static_cast<quint8>(PatchAlgo::QlzLevel2); quint16 flags = 0; quint64 newSize = 0; // 合并后文件应达到的字节数 quint64 ctrlRaw = 0, ctrlComp = 0; quint64 diffRaw = 0, diffComp = 0; quint64 extraRaw = 0, extraComp = 0; quint32 newCrc32 = 0; // 合并结果的 CRC32,落盘前必须核对 }; inline void appendU64(QByteArray &buf, quint64 v) { for (int i = 7; i >= 0; --i) buf.append(static_cast<char>((v >> (i * 8)) & 0xFF)); } inline void appendU32(QByteArray &buf, quint32 v) { for (int i = 3; i >= 0; --i) buf.append(static_cast<char>((v >> (i * 8)) & 0xFF)); } inline quint64 readU64(const char *p) { quint64 v = 0; for (int i = 0; i < 8; ++i) v = (v << 8) | static_cast<quint8>(p[i]); return v; } inline quint32 readU32(const char *p) { quint32 v = 0; for (int i = 0; i < 4; ++i) v = (v << 8) | static_cast<quint8>(p[i]); return v; }

逻辑说明:不用#pragma pack直接memcpy结构体,是因为同一个结构体在 x86 和 ARM 上对齐方式可能不同,而且小端机器直接写盘会得到反序的整数字节。手工按大端序列化虽然多写几行,但跨平台永远是对的。algo字段看起来多余,实际上它是最后一道保险——如果生成端用 level 2 编译、解压端用 level 1 编译,解压在第一步就会失败,而报错信息通常只是「解压失败」,根本看不出是宏不一致;有了这个字段,合并端可以抢先报错并给出明确提示。

newCrc32是必须的。bsdiff 本身没有校验机制,patch 文件在传输过程中坏掉一个字节,合并出来的新文件可能前 90% 都正常,最后段乱码,用户装上之后才崩。加一个 CRC 在校验阶段挡住,比事后排查省太多事。

4.2 QuickLZ 的 C++ 薄封装与缓冲区余量

QuickLZ 的 C 接口不复杂,坑主要在缓冲区大小和状态对象的内存占用上。压缩后的数据可能比原始数据还大(小数据块时尤其明显),必须有足够的目标缓冲。

// qlz_wrap.cpp #include "quicklz.h" #include <QByteArray> #include <cstring> // QuickLZ 规定目标缓冲至少要比源数据大 400 字节,这是保守上限 static const int kQlzOverhead = 400; QByteArray qlzCompress(const QByteArray &raw) { if (raw.isEmpty()) return QByteArray(); QByteArray out(raw.size() + kQlzOverhead, Qt::Uninitialized); // 状态对象内含哈希表,体积不小,绝不能放栈上 auto *state = static_cast<qlz_state_compress *>( std::calloc(1, sizeof(qlz_state_compress))); if (!state) return QByteArray(); const size_t n = qlz_compress(raw.constData(), out.data(), static_cast<size_t>(raw.size()), state); std::free(state); if (n == 0) { // 缓冲不足会返回 0,必须判 return QByteArray(); } out.resize(static_cast<int>(n)); return out; } QByteArray qlzDecompress(const QByteArray &comp, quint64 expectRawSize) { if (comp.isEmpty() || expectRawSize == 0) return QByteArray(); QByteArray out(static_cast<int>(expectRawSize), Qt::Uninitialized); auto *state = static_cast<qlz_state_decompress *>( std::calloc(1, sizeof(qlz_state_decompress))); if (!state) return QByteArray(); const size_t n = qlz_decompress(comp.constData(), out.data(), state); std::free(state); // n 必须等于预期长度,否则说明头部记录的长度和实际数据对不上 if (n != expectRawSize) return QByteArray(); return out; }

参数说明:kQlzOverhead取 400 是 QuickLZ 官方给的保守值,压缩比最差的情况下也不会超过这个增量。qlz_state_compress内部带一张哈希表,用calloc放堆上是必须的,放栈上在嵌入式环境里直接爆栈。解压时传入的expectRawSize来自 patch 头里记录的原始长度,返回值和它不等就说明数据被截断或者头被写错了——这一步的检查比后面 CRC 检查更早发现问题,能省一轮 IO。

还有一个细节:out用Qt::Uninitialized构造,跳过清零。几百 MB 的缓冲全清零是纯粹浪费,QuickLZ 会完整写过每一个字节,不清零是安全的。

4.3 生成端:三段数据算完、压完、拼成文件

生成端的流程是「先让 bsdiff 算出三段未压缩数据 → 逐段用 QuickLZ 压 → 写头 → 追加三段压缩数据」。

// PatchService.cpp 中生成补丁的核心片段 bool PatchService::buildPatch(const QString &oldPath, const QString &newPath, const QString &patchPath, Error *err) { QByteArray ctrlRaw, diffRaw, extraRaw; // bsdiff 的原始入口产出三段未压缩数据,签名按你的裁剪结果调整 if (bsdiff_files(oldPath.toLocal8Bit().constData(), newPath.toLocal8Bit().constData(), &ctrlRaw, &diffRaw, &extraRaw) != 0) { *err = Error::InternalError; return false; } const QByteArray ctrlComp = qlzCompress(ctrlRaw); const QByteArray diffComp = qlzCompress(diffRaw); const QByteArray extraComp = qlzCompress(extraRaw); if (ctrlComp.isEmpty() || diffComp.isEmpty() || extraComp.isEmpty()) { *err = Error::DecompressFailed; // 压缩阶段失败,复用同一错误码 return false; } PatchHeader h; h.algo = static_cast<quint8>(PatchAlgo::QlzLevel2); h.newSize = QFileInfo(newPath).size(); h.ctrlRaw = ctrlRaw.size(); h.ctrlComp = ctrlComp.size(); h.diffRaw = diffRaw.size(); h.diffComp = diffComp.size(); h.extraRaw = extraRaw.size(); h.extraComp = extraComp.size(); h.newCrc32 = crc32OfFile(newPath); // 对新文件整体算 CRC32 QByteArray head; head.append(kPatchMagic, 4); head.append(static_cast<char>(h.version)); head.append(static_cast<char>(h.algo)); head.append(static_cast<char>(h.flags >> 8)); head.append(static_cast<char>(h.flags & 0xFF)); appendU64(head, h.newSize); appendU64(head, h.ctrlRaw); appendU64(head, h.ctrlComp); appendU64(head, h.diffRaw); appendU64(head, h.diffComp); appendU64(head, h.extraRaw); appendU64(head, h.extraComp); appendU32(head, h.newCrc32); QFile f(patchPath); if (!f.open(QIODevice::WriteOnly | QIODevice::Truncate)) { *err = Error::OpenFailed; return false; } // 顺序写:头 → ctrl → diff → extra,合并端按同样顺序读 f.write(head); f.write(ctrlComp); f.write(diffComp); f.write(extraComp); f.flush(); *err = Error::None; return f.error() == QFileDevice::NoError; }

这里有两点值得说明。一是bsdiff_files是裁剪后的封装,原版bsdiff.c的main里直接读写文件,改成库函数时要自己拆出「读入两个文件 → 调算法 → 输出三段」这三步,中间结果先放内存;如果文件超过可用内存,就得改成边算边写临时文件,代码量会翻倍。二是写文件的顺序必须和读的顺序严格一致,我在头里明确记录了每段长度,就是为了让读取端能按长度精确切分,而不依赖任何分隔符。

4.4 合并端:读头、解压、按三元组还原

合并端的核心循环和原版 bspatch 一致,差别只在于数据来源换成了 QuickLZ 解压后的缓冲。

bool PatchService::applyPatch(const QString &oldPath, const QString &newPath, const QString &patchPath, Error *err) { QFile pf(patchPath); if (!pf.open(QIODevice::ReadOnly)) { *err = Error::OpenFailed; return false; } const QByteArray head = pf.read(64); if (head.size() != 64 || memcmp(head.constData(), kPatchMagic, 4) != 0) { *err = Error::FormatMismatch; return false; } if (static_cast<quint8>(head[4]) != kPatchVersion) { *err = Error::FormatMismatch; return false; } // 算法标识必须和本端编译期宏一致,否则解压必然失败 if (static_cast<quint8>(head[5]) != QLZ_COMPRESSION_LEVEL) { *err = Error::FormatMismatch; return false; } const quint64 newSize = readU64(head.constData() + 8); const quint64 ctrlRaw = readU64(head.constData() + 16); const quint64 ctrlComp = readU64(head.constData() + 24); const quint64 diffRaw = readU64(head.constData() + 32); const quint64 diffComp = readU64(head.constData() + 40); const quint64 extraRaw = readU64(head.constData() + 48); const quint64 extraComp= readU64(head.constData() + 56); const QByteArray ctrlC = pf.read(static_cast<qint64>(ctrlComp)); const QByteArray diffC = pf.read(static_cast<qint64>(diffComp)); const QByteArray extraC = pf.read(static_cast<qint64>(extraComp)); const QByteArray ctrl = qlzDecompress(ctrlC, ctrlRaw); const QByteArray diff = qlzDecompress(diffC, diffRaw); const QByteArray extra = qlzDecompress(extraC, extraRaw); if (ctrl.isEmpty() || diff.isEmpty() || extra.isEmpty()) { *err = Error::DecompressFailed; return false; } // old 文件整块读入;超过 512MB 时必须改成分段映射,见最后一章 QFile of(oldPath); if (!of.open(QIODevice::ReadOnly)) { *err = Error::OpenFailed; return false; } const QByteArray oldBuf = of.readAll(); QByteArray out(static_cast<int>(newSize), Qt::Uninitialized); qint64 oldpos = 0, newpos = 0; const char *cp = ctrl.constData(), *dp = diff.constData(), *ep = extra.constData(); while (newpos < static_cast<qint64>(newSize)) { // 三元组:diff 段长度、extra 段长度、old 指针相对增量 const qint64 dlen = static_cast<qint64>(readU64(cp)); const qint64 elen = static_cast<qint64>(readU64(cp + 8)); const qint64 seek = static_cast<qint64>(readU64(cp + 16)); cp += 24; for (qint64 i = 0; i < dlen && newpos + i < static_cast<qint64>(newSize); ++i) { const qint64 src = oldpos + i; out[newpos + i] = (src >= 0 && src < oldBuf.size()) ? static_cast<char>(oldBuf[src] + dp[i]) : static_cast<char>(dp[i]); } newpos += dlen; oldpos += dlen; dp += dlen; memcpy(out.data() + newpos, ep, static_cast<size_t>(elen)); newpos += elen; ep += elen; oldpos += seek; } if (crc32OfBuffer(out) != readU32(head.constData() + 64 - 4)) { *err = Error::CrcMismatch; return false; } QFile nf(newPath); if (!nf.open(QIODevice::WriteOnly | QIODevice::Truncate)) { *err = Error::DiskFull; return false; } nf.write(out); *err = Error::None; return true; }

逻辑说明:oldpos初始为 0,每轮先加dlen再加seek,注意seek是有符号增量,读的时候要按有符号整数解释(上面的readU64返回无符号,转成qint64就自然还原了补码)。写入前对越界做了保护——理论上合法的 patch 不会越界,但坏的 patch 会,不做保护就是一次崩溃。

参数说明:newSize直接从头部取,用它预分配输出缓冲,避免反复扩容。CRC 校验放在写盘之前,坏数据不落盘,用户目录里不会出现一个「看着存在但打不开」的新文件。

5. 五个让 patch 打不上的现场:从现象倒推原因

这套东西上线前我踩过的坑,基本都集中在这几处。每一条我都按「看到什么 → 为什么 → 怎么改」记下来,遇到类似症状可以直接对照。

5.1 现象:合并到 60% 左右开始乱码,前面全对

原因:oldpos的增量方向搞反了。原版 bsdiff 的ctrl[2]是「在读完 diff 段之后,old 指针要移动多少」,可正可负;如果按无符号读,负数会被解释成一个巨大的正数,指针立刻飞出文件末尾,之后所有加法都作用在越界地址上。

解决:写头和读头时都按有符号 64 位处理,读取后立刻static_cast<qint64>。同时把越界保护加上,宁可返回错误也不要静默算出垃圾数据。验证方法是拿一个「只删除了中间一段」的旧文件做测试,这种用例必然产生负增量。

5.2 现象:本机生成本机合并一切正常,换一台机器解压失败

原因:QLZ_COMPRESSION_LEVEL只在生成端的工程里定义了,合并端用的是头文件里的默认值。QuickLZ 一旦压缩等级不匹配,qlz_decompress直接返回 0,没有任何提示。

解决:把两个宏提到构建系统的PUBLIC定义里,任何链接这个库的目标都自动继承。同时在 patch 头里加algo字段,合并端读到不匹配立刻报「格式不匹配」而不是「解压失败」。上线前用两台不同架构的机器互相生成、互相合并各跑一遍,作为回归用例固定下来。

5.3 现象:Windows 上合并时直接崩溃,Linux 正常

原因:原版 bspatch 用mmap映射 old 文件,移植时有人写了个#ifdef _WIN32分支直接return NULL,后续代码没判空就解引用。

解决:统一改用QFile::map(),它在各平台都有实现。映射失败(文件被占用、路径在同步盘里)时回退到readAll()整块读入。回退路径一定要测——我当时只测了正常路径,结果在用户的网盘目录里翻车,映射失败后走到了未初始化分支。

5.4 现象:小于 4KB 的补丁比原文件还大

原因:QuickLZ 有固定的头部开销,数据量小时压缩后反而膨胀。项目里改了几个配置文件,新旧文件差几百字节,压完反而多出几十字节。

解决:在生成端加一个判断,压缩后长度大于等于原始长度时,把该段标记为「未压缩存储」。头部flags字段里用两个 bit 记录三段各自是否压缩,合并端据此决定走不走qlzDecompress。这个改动不大,但能避免「增量更新比全量还大」这种很尴尬的场面。

5.5 现象:old 文件为空时生成补丁直接段错误

原因:bsdiff 对旧文件做后缀排序,空文件没有后缀可排,内部数组长度为 0,后续的索引访问越界。首次安装场景下 old 文件确实不存在或为空。

解决:入口处判断 old 文件大小,为 0 时不做差分,直接生成一个「extra 段等于整个新文件、diff 段为空」的补丁,合并端逻辑完全不用改。这种补丁的体积约等于新文件压缩后的大小,语义上仍然是合法的 patch,安装流程不需要分支。

6. 校验与进阶:把补丁交付做成能回滚的流水线

补丁发出去之后最怕的不是体积大,是「用户装完打不开」。所以每次生成补丁,我都会跑一遍固定流程:生成端算出新文件 CRC 写进头里,合并端写完盘再算一遍对比,同时对合并结果做一次完整文件读取(不是只看大小),确认没有空洞。这套三方校验跑下来大概多花两百毫秒,但能挡住绝大多数「传输出错」和「写入被截断」的问题。

补丁文件本身建议再套一层整体校验,比如把 SHA-256 放在下载页的清单里,客户端下载完先校验补丁再应用。理由很简单:patch 损坏的表现和 old 文件版本不对的表现几乎一样,没有校验就只能靠猜。

6.1 大文件要改成分段处理

上面给的代码是整块读入内存的写法,文件超过 512MB 就要改成流式。做法是把 old 文件用QFile::map()分段映射(比如每段 64MB),diff 段和 extra 段边解压边写临时文件,最后rename成目标文件名。改动集中在两处:oldBuf[src]换成按段查询,out换成QFile直接写入。三元组循环的结构不用动。

6.2 版本兼容要留退路

patch 头里那个version字段不是摆设。我一般约定:主版本不同直接拒绝合并,次版本不同尝试兼容。同时客户端保留上一版的完整安装包,patch 连续失败两次就自动回退到全量下载。这个兜底逻辑比任何算法优化都重要——增量更新失败一次的用户,第二次就不会再点升级按钮了。

从那以后我每次改压缩层或者 patch 格式,都强制先用一台低配设备跑一遍「生成 → 传输 → 合并 → 启动」的完整链路,哪怕只是改了一个字段宽度。差分算法本身很稳,出问题的永远是边界、类型和宏定义这些不起眼的地方。希望帮到你。

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