ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

基于Qt的BSDiff与QLZ增量更新补丁工具实践

基于Qt的BSDiff与QLZ增量更新补丁工具实践 简介面向需在Qt应用中集成增量更新机制的开发者这份实践项目将bsdiff的差异比较能力与qlz快速压缩结合完整展示生成补丁包并应用的流程。压缩包共705个文件、约6.22MB包括675个idx索引文件、多个cpp/h源码、o目标文件、bin二进制样例及pro工程文件等idx文件对应补丁过程中的索引数据源码与工程文件便于移植和二次开发bin样例可直接体验效果。代码覆盖接口封装、数据流处理、性能优化等关键环节配合示例二进制数据和生成界面相关源码可快速理解差异比较、压缩、补丁生成与应用的实现。已有495人学习下载适合具备一定C/Qt基础、关注差分升级和在线更新机制的中高级开发者参考也可作为工业设备、嵌入式及桌面软件轻量级差分升级方案的基础。 做客户端增量更新的同学一定绕不开BSDiff这个名字。它负责对比新旧文件生成patch包而这次项目里我们还要把默认的bzip2压缩换成QLZ再整体移植到Qt工程里做成一个跨平台可用的补丁生成工具。先说结论这套方案跑通之后客户端只需要下载几百KB到几MB的增量包就能完成几十MB甚至上百MB的版本升级对带宽、服务器压力、用户体验都是实打实的提升。QLZ压缩算法的特点是极快的压缩和解压速度代价是压缩率不如bzip2但在“本地生成patch、客户端在线升级”的场景下QLZ的集成成本和运行效率反而更舒服。这篇文章我按实际动手的顺序来写从移植思路、核心代码改造、Qt工程集成、到最后的发布部署和踩坑记录全程给出可以直接抄作业的方案。适合正在做Qt客户端、嵌入式设备升级、资源热更或离线包差异合并的开发者参考。1. 项目整体思路与移植前准备1.1 为什么选BSDiffQLZ这个组合BSDiff是目前应用最广泛的二进制差分算法原理是先做后缀排序suffix sort找出新旧文件里的公共子串把差异分成diff串和extra串再对这两部分分别压缩。原版BSDiff默认调用bzip2压缩率确实高但有几个实际痛点bzip2依赖库相对独立想编进Qt工程得单独处理头文件和库路径bzip2解压速度偏慢在低配机器或者嵌入式设备上一个几十MB的patch解压耗时会很明显原版BSDiff的版权和工程组织都比较陈旧直接拿过来编译多少要改点东西。QLZQuickLZ是另一个开源压缩库API极其简单整个库就一个.h和一个.c文件压缩和解压都不需要额外依赖非常适合嵌入式或集成到GUI程序里。实测下来QLZ的压缩速度比bzip2快很多解压速度更快特别适合“服务端生成一次patch、客户端反复解压”的场景。压缩率的差距没有想象中那么大因为BSDiff生成的diff串本身已经有很多重复结构QLZ在这个输入环境下表现够用。对比项bzip2QLZ压缩率高中等Diff场景够用压缩速度慢快解压速度慢极快源码体积较大多文件单文件集成简单移植成本需要处理外部库直接加入工程即可1.2 移植前需要确认的技术边界动手之前我先列了一份清单避免改到一半发现方向错了是否保留原版BSDiff的patch兼容性我们的结论是不保留。因为压缩算法都换了bspatch端也必须配套使用新格式兼容旧格式没有意义反而增加代码复杂度。如果你们有历史存量patch包那就得保留双模式或者做好格式版本标记。是否必须在Qt进程内调用BSDiff这里我选择直接把bsdiff和bspatch的main函数改造成库函数在Qt的Worker线程里调用而不是用QProcess去拉外部exe。原因很简单QProcess传参数容易遇到转义和编码问题而且补丁进度不好拿到UI里展示。文件大小上限。原版BSDiff内部用了不少int类型做偏移超过2GB的文件会溢出。这次项目里需要处理的资源包虽然没这么大但我还是把偏移全部换成了int64_t免得以后踩坑。QLZ的集成版本。我使用的是qlz 1.7.1版本API稳定官方源码解压即用。如果你们项目里已经引入了其他备份压缩库要注意符号冲突。2. 核心代码改造与qlz替换方案2.1 把bsdiff从命令行程序改成可调用库原版bsdiff的入口是main函数里面读文件、算diff、写patch。我们要做的是把它包成一个干净的API让Qt工程里能直接调用。我按下面的方式做了函数签名改造// patch_wrapper.h #ifndef PATCH_WRAPPER_H #define PATCH_WRAPPER_H #include string namespace PatchTool { // 生成补丁包 bool GeneratePatch(const std::string oldPath, const std::string newPath, const std::string patchPath, std::string errMsg); // 应用补丁包 bool ApplyPatch(const std::string oldPath, const std::string newPath, const std::string patchPath, std::string errMsg); } #endif这里有两个关键点。第一原版bsdiff在运行中会打印大量调试信息我在改造时把printf集中到一个回调函数里方便Qt端转成signal去更新进度条。第二原版bsdiff直接使用fopen读取文件在Windows下很容易因为文本模式把二进制数据搞坏我统一改成“rb”和“wb”二进制模式。2.2 统一压缩接口替换bzip2为qlz明确一下替换逻辑BSDiff算法本身不关心用哪个压缩库它只负责把diff块和extra块的原始数据准备好真正决定压缩格式的是bsdiff.c里的两个压缩函数。原版里分别调用bzip2的BZ2_bzBuffToBuffCompress和BZ2_bzBuffToBuffDecompress。我们只需要把这层改成QLZ即可。QLZ使用极其简单官方调用方式如下#include quicklz.h qlz_state_compress* state_compress (qlz_state_compress*)malloc(sizeof(qlz_state_compress)); qlz_state_decompress* state_decompress (qlz_state_decompress*)malloc(sizeof(qlz_state_decompress)); // 压缩 size_t compressedSize qlz_compress(inputData, outputData, inputSize, state_compress); // 解压 size_t decompressedSize qlz_decompress(inputData, outputData, state_decompress);需要注意的是QLZ要求每个压缩流对应独立的state结构且state结构内存必须对齐。我在bsdiff.cpp和bspatch.cpp里分别创建state不做跨线程共享避免并发问题。下面是我封装后的统一压缩接口// qlz_wrap.h #ifndef QLZ_WRAP_H #define QLZ_WRAP_H #include cstddef #include quicklz.h class QuickLZWrap { public: QuickLZWrap() { stateCompress_ new qlz_state_compress; stateDecompress_ new qlz_state_decompress; } ~QuickLZWrap() { delete stateCompress_; delete stateDecompress_; } // 压缩返回压缩后大小 size_t Compress(const char* in, size_t inLen, char* out, size_t outCap) { if (outCap inLen 400) return 0; return qlz_compress(in, out, inLen, stateCompress_); } // 解压 size_t Decompress(const char* in, char* out, size_t outCap) { return qlz_decompress(in, out, stateDecompress_); } // 从压缩数据流中读取原始长度和压缩长度 static size_t GetDecompressedSize(const char* in) { return qlz_size_decompressed(in); } static size_t GetCompressedSize(const char* in) { return qlz_size_compressed(in); } private: qlz_state_compress* stateCompress_; qlz_state_decompress* stateDecompress_; }; #endif这里有一个很多人容易忽略的细节qlz_compress的输出缓冲区必须比输入大。官方建议是输入长度加400字节因为压缩在极限情况下可能出现微膨胀。如果你直接拿一个恰好等于输入长度的缓冲区去存压缩结果极端数据下必崩。我一开始也图省事按inLen分配结果压一个全是随机字节的bin文件时直接越界后来老老实实改成inLen 400。2.3 patch包结构设计既然换了压缩算法原版的patch头就不再适用。我重新设计了patch包格式结构体定义如下// patch_format.h #pragma once #include cstdint namespace PatchFormat { constexpr char kMagic[4] {Q, D, P, 1}; struct CtrlItem { int64_t diffLen; // diff块解压后大小 int64_t extraLen; // extra块解压后大小 int64_t newPos; // 新文件中的起始偏移 int64_t diffCompLen; // diff块压缩后大小 int64_t extraCompLen; // extra块压缩后大小 }; struct PatchHeader { char magic[4]; // 固定为 kMagic int64_t oldFileSize; int64_t newFileSize; int64_t ctrlCount; int64_t ctrlBlockSize; // 实际就是 ctrlCount * sizeof(CtrlItem) int64_t diffBlockSize; // 所有diff块压缩数据总大小 int64_t extraBlockSize; // 所有extra块压缩数据总大小 }; }应用patch时bspatch先读PatchHeader校验magic和文件大小然后顺序读取ctrl块、diff块、extra块。之所以在每个CtrlItem里额外记录diffCompLen和extraCompLen是因为QLZ不像bzip2那样能给出一整块流的清晰边界也不像bzip2那样能自动知道下一个流从哪里开始。我们这样做之后bspatch就能精确地按长度切分每一块压缩数据。生成patch的写盘顺序是写入PatchHeader写入全部CtrlItem写入diff块压缩数据写入extra块压缩数据。原本bsdiff内部是用bzip2对整块diff串和extra串做一次压缩因为bzip2支持流式压缩、无需预先知道分块边界。QLZ更偏“一次性整块压缩”所以我在生成时把diff串按bsdiff划分的块边界拆开每个块单独压缩再写入diff区。这样的好处是bspatch解压时不需要等待整块数据读入内存占用更低。3. Qt工程集成与实操流程3.1 工程目录与依赖组织我用的是Qt 5.15.2配合MSVC2019 64位工程用CMake组织。第三次移植时我把目录固定成这样patcher/ CMakeLists.txt src/ main.cpp MainWindow.h MainWindow.cpp PatchWorker.h PatchWorker.cpp patch_wrapper.h patch_wrapper.cpp patch_format.h qlz_wrap.h third_party/ bsdiff.cpp bspatch.cpp quicklz.h quicklz.cCMake里不需要额外链接bzip2只需把qlz源文件加入编译列表set(PATCHER_SOURCES src/main.cpp src/MainWindow.cpp src/PatchWorker.cpp src/patch_wrapper.cpp src/third_party/bsdiff.cpp src/third_party/bspatch.cpp src/third_party/quicklz.c ) add_executable(patcher WIN32 ${PATCHER_SOURCES}) target_include_directories(patcher PRIVATE src src/third_party)注意bsdiff.cpp和bspatch.cpp里的main函数要删掉或改名否则和Qt入口冲突。如果你手头是原版源码直接把main函数体复制到patch_wrapper.cpp里的GeneratePatch和ApplyPatch即可记得把全局printf改成qDebug或者回调。3.2 Qt界面与后台线程衔接补丁生成是耗时操作几十MB的old和new文件对比可能要跑几十秒甚至几分钟绝对不能放在UI线程里。我用QtConcurrent::run来跑任务再把结果通过信号拿回主线程void MainWindow::onGenerateClicked() { QString oldPath ui-oldFileEdit-text(); QString newPath ui-newFileEdit-text(); QString patchPath ui-patchFileEdit-text(); setUiEnabled(false); QtConcurrent::run([]() { std::string err; bool ok PatchTool::GeneratePatch( oldPath.toStdString(), newPath.toStdString(), patchPath.toStdString(), err); emit patchFinished(ok, QString::fromStdString(err)); }); }在MainWindow构造函数里连接信号connect(this, MainWindow::patchFinished, this, MainWindow::onPatchFinished);为了让进度能实时反映到界面上我在patch_wrapper.cpp里加了一个回调函数指针在bsdiff的每个主要阶段触发一次void (*g_progressCallback)(int percent, const char* stage) nullptr; void PatchTool::SetProgressCallback(void (*cb)(int, const char*)) { g_progressCallback cb; }生成patch时按“构建suffix sort / 生成diff / 生成extra / 写入patch包”四个阶段回调百分比。这样界面就能显示一个简单的进度条而不是让用户干等。3.3 端到端验证流程这个环节我吃了不少亏提醒各位一定不要省。生成patch后光看文件大小变小了不算成功必须完整跑一遍“apply patch回放”再把得到的新文件和原始new文件做哈希比对。我建议的验证流程如下准备一份old文件和一个修改过的new文件记录new文件的SHA256。调用GeneratePatch生成patch包检查生成的patch包是否非空。用一份干净的old副本加patch包调用ApplyPatch得到还原文件。计算还原文件的SHA256与new文件对比一致才算通过。Qt端可以直接用QCryptographicHash非常简单QByteArray CalcSha256(const QString path) { QFile f(path); if (!f.open(QIODevice::ReadOnly)) return {}; QCryptographicHash hash(QCryptographicHash::Sha256); QByteArray buf; while (!f.atEnd()) { buf f.read(1024 * 1024); hash.addData(buf); } return hash.result().toHex(); }我建议在自动化测试脚本里专门保留这个校验步骤每次改完代码跑一遍回归防止哪天改了一行偏移代码导致patch生成错误却没被发现。4. 常见问题与排查技巧实录4.1 Qt环境与部署类问题很多人在Qt工程里接入第三方C库后运行时突然报“This application failed to start because no Qt platform plugin could be initialized. Reinstalling the application may fix this problem.”这个提示很误导人多半不是平台插件缺失而是程序没找到Qt的plugins目录。解决办法是把Qt的platforms目录手动放到exe同级目录下工程里如果用windeployqt做部署必须确保部署成功后platforms文件夹存在。我通常是直接在工具链里固定执行windeployqt patcher.exe执行完检查exe旁边是否有platforms/qwindows.dll。如果发布机器没装Qt还需要带上对应版本的Qt5Core.dll、Qt5Gui.dll、Qt5Widgets.dll。这个问题在命令行里跑bsdiff的时候不会暴露但一旦打开带界面的patcher工具就必现。4.2 算法与数据类问题实际开发中我更常遇到的是数据层面的坑。比如Windows下生成patch后bspatch里解压得到的字节数一直不对最后发现是fopen没指定二进制模式。原版bsdiff在Linux下跑没问题在Windows下打开文件默认是文本模式0x1A这种字符会被当EOF处理。移植到Qt工程时必须把所有fopen改成FILE* f fopen(path.c_str(), rb); // 读 FILE* f fopen(path.c_str(), wb); // 写还有个典型问题是QLZ解压崩溃。QLZ的state结构在创建时必须对齐到16字节malloc在大多数平台是足够对齐的但如果你用自定义内存池或者把state放到栈上要确认对齐属性。我在代码里加了静态断言static_assert(sizeof(qlz_state_compress) 4096, qlz state too large);同时保证state是new出来的而不是分配一个裸buffer强转指针。4.3 排查思路与调试建议如果回放完全对不上我建议用二分定位法缩小范围先生成一个0字节的old文件再拿任意小文件做new文件跑通整个流程如果小文件能跑通再换一个几KB的文件逐步增大直到复现问题在bspatch解压diff块和extra块之后先打印两块长度和解压后首字节和bsdiff生成时对比最后检查CtrlItem里的三个偏移值尤其是newPos确认在Bsdiff写ctrl时是不是按int64_t写入读取时也按int64_t读取。我实际遇到过一次诡异问题所有小文件都正常唯独打包一个300MB的固件镜像时输出文件只有几十KB。查了半天是bsdiff源码里有个局部变量还在用int存文件偏移超过2GB就溢出。后来我把bsdiff.h里所有涉及offset的字段全部改成int64_t问题才彻底消失。建议你们动手之前先把整个源码扫一遍把所有int改成int64_t。5. 发布部署与扩展方向到这里一个基于Qt的BSDiffQLZ补丁生成工具已经能端到端跑通了。如果你们的生产环境里有类似需求我还有几个建议升级包上传到服务端时建议在patch包里额外存一份新文件的SHA256客户端解压完成后自动校验避免中途下载损坏导致花屏或闪退。QLZ的压缩等级固定如果追求更高的压缩率可以在工程里保留bzip2作为可选后端做成策略模式。但我的经验是一旦差值算法已经压缩了重复串QLZ和省不省这几百KB相比速度优势更值得留。如果客户端运行在弱网环境可以考虑对同一个版本生成“全量patch”和“增量patch”两个文件服务端根据客户端上报的旧版本号选择下发。这次移植过程中我最大的体会是技术难点不在BSDiff算法本身而在于把旧C代码整洁地嵌进现代Qt工程并且让数据格式的每个细节都严格对齐。生成端和应用端是两兄弟任何一边改了格式另一边不跟着改立刻就是血泪现场。建议你们在最初就把patch头做成带magic和版本号的扩展结构哪怕第一版只有一种格式也要为以后留好余地。本文还有配套的精品资源点击获取
返回列表