在C和Qt项目里泡了挺多年构建系统这块我先后折腾过不少qmake写多了觉得模板味太重CMake写多了又被它的语法和调试折腾得够呛。后来接触到Qbs一下子觉得思路对味了——用QML描述构建规则依赖关系实时解析改一处配置全工程自动重算这种“所见即所得”的构建体验确实让人上瘾。这篇文章我想把Qbs的几个高级玩法仔细拆一拆给那些已经用过一点Qbs、想在更复杂项目里把它用顺手的开发者也算是我自己踩坑记录的一次整理。先简单介绍一下Qbs是什么。它是一款开源构建系统面向C/C和Qt项目设计核心亮点是用类似QML的声明式语法来描述工程结构、编译选项和依赖关系。和传统构建系统相比Qbs不依赖单独的“配置步骤”而是直接读取项目文件并实时计算依赖因此增量和并行构建的表现非常出色。适合谁用呢如果你手上有大型Qt/C项目或者你厌倦了CMake里变量作用域、缓存和生成器之间的来回切换又或者你希望构建脚本本身就能像代码一样模块化复用那Qbs值得你认真研究。1. 内容整体设计与思路拆解1.1 Qbs的核心定位把构建规则当代码写我刚接触Qbs时最大的感受是它不像CMake那样把“配置”和“生成”拆成两个阶段而是把整个构建过程抽象成一个依赖图。这个设计哲学和现代语言的增量编译很像——工程里的每个文件、每条规则、每个属性都被视为一个节点属性一变依赖它的节点自动重算不需要“清理后重新配置”。举个例子在CMake里你改了一个编译宏经常需要重新运行cmake甚至要删掉build目录才能保证缓存正确。Qbs里只需要改一下项目文件里的属性重新执行构建它自己会识别出哪些目标受影响只重编相关的那部分。我实际用下来这种“实时重算”带来的效率提升非常明显尤其是大型工程里改一个公共头文件Qbs能精确追踪到头文件的依赖链而不是像某些系统那样把整个模块全量重编。Qbs的另一个定位特点是“声明式但可编程”。它的项目文件有完整的逻辑能力支持条件判断、循环、函数定义甚至能自定义Rule。这意味着你可以在构建脚本里做复杂的事情比如根据不同的操作系统选择不同的源文件、给不同的子工程设置不同的编译标准、甚至写插件式的自定义构建步骤。这种灵活度和表达能力让构建脚本本身也能像业务代码一样被review、被重构。我自己的经验是Qbs最适合那种“有点规模但不至于复杂到需要专门配置一套CI系统”的中大型项目。团队里不需要有专职的构建工程师只要一两个核心开发者能把Qbs的模块、Profile和条件规则梳理清楚整个团队就能获得非常顺滑的构建体验。1.2 Qbs、CMake、qmake的选型对比很多刚从qmake迁移过来的开发者会有个疑惑Qbs和qmake、CMake到底什么关系我觉得这三者解决的核心问题完全不同。qmake是Qt早期官方配套的构建工具它的设计思路是“为一个Qt项目生成Makefile再借助底层编译器完成构建”。qmake对Qt模块、moc、uic这些流程支持得最直接但工程一复杂就会露出疲态作用域规则冗长难以复用对第三方依赖的管理几乎为零。CMake是目前行业事实标准生态庞大几乎什么都能干。但CMake的学习曲线和调试体验是出了名的折磨人变量作用域跨越目录层级时很容易出现“改了没生效”“缓存没刷新”的问题。我在多平台项目里经常被CMake的生成器、toolchain文件、缓存的组合问题拖住。Qbs则是另一种思路它不生成Makefile而是自己直控编译进程通过依赖图做增量构建。对于纯Qt/C项目Qbs的体验相当顺滑但对于一些“必须用CMake生态”的场景比如要对接某些第三方库的Find模块Qbs虽然有相应的模块支持但生态确实不如CMake丰富。所以选型建议是团队愿意花一点时间学习QML语法并且项目以Qt/C为主的话Qbs的长期收益很高。下面用表格简单给三者做个对比对比项QbsCMakeqmake项目语言QML式CMake脚本qmake脚本配置阶段无实时解析configure generate每次执行增量构建精确到文件和依赖较快但缓存问题多较弱Qt模块支持原生且优秀需find_package原生自定义任务Rule/Job机制add_custom_command有限生态丰富度较小极大仅Qt学习门槛中高低1.3 Qbs与CI/CD的融合方式跟CI/CD结合这件事Qbs虽然不如CMake那样“默认就会”但适应速度很快。因为Qbs是命令行驱动的工具本质上就是在shell里执行qbs build所以和Jenkins、GitLab CI、GitHub Actions这类流水线工具的接入成本很低。我通常在CI里用一个单独的Profile配合环境变量控制构建配置。比如:qbs build profile:linux-gcc config:release jobs:4 qbs install --install-root:$INSTALL_DIR profile:linux-gcc关键在于把Qbs的Profile定义看成一等公民把编译器路径、目标架构、环境参数这些都拆出来而不是硬编码在项目文件里。这样CI的构建阶段只需要关心几个环境变量完全不用关心工程内部长什么样。2. 核心概念拆解与实践框架2.1 Product、Module、Profile三层结构理解Qbs的三个核心抽象Product目标产物、Module功能模块、Profile工具链配置就掌握了Qbs的骨架。Product是构建的单元相当于工程里的一个可执行文件、静态库或动态库。它定义了一组源文件、依赖模块、编译属性。比如你要构建一个命令行工具最基础的Product长这样CppApplication { name: myapp files: [main.cpp, utils.cpp] Depends { name: Qt; submodules: [core] } cpp.cppFlags: [-stdc17] }Module是能力的集合比如Qt库、第三方库或者你自己的功能包。模块可以定义头文件路径、库路径、编译宏等让依赖它的Product自动获得这些信息。Profile则是对构建环境的描述包含编译器、链接器、Sysroot、目标平台架构等信息。Profile跟你用什么系统、什么编译器绑定一般一个环境建一个Profileqbs setup-toolchain --detect qbs config profiles.linux-clang.baseProfile gcc-x86_64这套分层的设计带来一个很大的好处项目文件写的是“什么”Profile决定的是“在哪儿构建、用什么构建”两者完全解耦。同样的项目文件在Windows上使用MSVC Profile构建在Linux上使用GCC Profile构建几乎不需要改动工程本身。2.2 qbs文件语法要点与常见配置Qbs的.qbs文件是基于QML的它天然支持对象、数组、函数、条件表达式所以写起来比CMake的字符串拼拼凑凑舒服得多。我把自己常用的一套基础骨架记录在这里。最基本的工程结构Project { minimumQbsVersion: 2.0 name: demo-project qbsSearchPaths: [qbs-modules] references: [ apps/demo-app/demo-app.qbs, libs/network-core/network-core.qbs ] }references用来引用子项目比CMake的add_subdirectory更清晰因为它加载的是.qbs文件本身不会引入额外的构建目录层级。Product级别的常用配置QtGuiApplication { name: editor files: [ src/**.cpp, src/**.h, resources/**.qrc ] cpp.defines: [APP_VERSION\1.4.0\] cpp.cxxLanguageVersion: c17 install: true installDir: bin }这里有几个值得注意的细节。files支持通配符递归匹配但不要依赖它把整个目录一揽子包含进去我建议显式列出目录范围否则很容易误包含多余文件。cpp.defines的写法是数组多个宏就多写几个字符串。install: true表示构建后可以通过qbs install安装到指定目录。2.3 组Group与多目标配置Group是Qbs里一个特别好用的组织工具。一个Product下可以拆多个Group每个Group能单独指定文件集合、编译选项、安装路径。比如有这样一个场景同一个库既要编成静态库又要编成动态库或者同一个源文件在Windows和Linux上需要不同的宏定义。我常用的写法是DynamicLibrary { name: core files: [src/common/**] Group { name: platform-windows condition: qbs.targetOS.contains(windows) files: [src/win/win_impl.cpp] cpp.defines: [PLATFORM_WIN1] } Group { name: platform-linux condition: qbs.targetOS.contains(linux) files: [src/linux/linux_impl.cpp] cpp.defines: [PLATFORM_LINUX1] } }这里的condition是QML表达式True则这个Group生效。用这种方式我就再也不用像CMake那样写一大串if(WIN32) ... elseif(UNIX) ...嵌套了。每个平台的差异代码被清晰地隔离在自己的Group里可读性好了非常多。2.4 依赖管理与跨工程引用跨模块依赖是大型项目绕不开的话题。Qbs的依赖是通过Depends项声明的但它的能力远不止“能找到头文件”这么简单。比如我要让一个库依赖另一个库并且要求“库A编译时自动带上库B的头文件路径和符号链接”写法如下CppApplication { name: main-tool files: [main.cpp] Depends { name: network-core } Depends { name: Qt; submodules: [network] } }被依赖方则要申明它暴露给外界的接口StaticLibrary { name: network-core files: [src/**] Export { Depends { name: Qt; submodules: [core, network] } cpp.includePaths: [project.sourceDirectory /libs/network-core/include] } }注意这个Export块它是Qbs依赖管理的关键。被依赖库把外界需要的头文件路径、宏、依赖模块放进Export里依赖它的Product会自动把这些参数“吸收”到自己的编译环境里。这个机制相当于把“库的接口信息”和库本身打包在一起调用方不需要知道被依赖库内部怎么组织的省事很多。我在重构一个老旧的大型项目时就是靠Export把原来散落各处的全局包含路径一个个收编回各个库的内部最后工程根上的公共配置只留下很少的东西整个构建的清晰度提升了一大截。3. 高级实操技巧与实现细节3.1 多配置、多平台构建的工程配置Qbs里“多平台构建”一般用Profile区分工具链用config区分构建类型。我习惯建一个根级配置文件# 先配置两个profile qbs setup-toolchain --detect qbs config profiles.linux-gcc.qbs.architecture x86_64 qbs config profiles.windows-msvc.qbs.architecture x86_64 # 构建不同版本 qbs build profile:linux-gcc config:debug qbs build profile:linux-gcc config:release有个细节Profile里的属性可以继承。我为团队整理了一套层级基类Profile定义通用的优化开关和警告级别再派生出专门的Profile给不同平台qbs config profiles.linux-base.cpp.cxxLanguageVersion c17 qbs config profiles.linux-base.cpp.enableWarnings true qbs config profiles.linux-gcc.baseProfile linux-base qbs config profiles.linux-gcc.cpp.compilerName g这样调整某个平台的编译选项时不需要跑到每个Profile里改只改基类即可。对团队内部传递Profile配置我会写成一个shell脚本开会时大家跑一下就有统一的环境。当然更讲究的团队可以把它整理成qbs-config.qbs共享文件签入代码仓库新同事克隆后直接执行一条命令就能复现整套构建环境。3.2 条件属性与自定义规则的高级玩法条件属性是Qbs很强大的特性。你可以根据目标平台、架构、构建类型、甚至环境变量动态改变属性值。举几个我实际用过的例子。第一个不同构建类型使用不同的输出目录和符号。默认情况下debug构建的输出文件会和release混在一起但我们可以自定义CppApplication { name: demo destinationDirectory: build/ (qbs.buildVariant debug ? debug : release) files: [main.cpp] }第二个通过自定义属性实现“一键切换特性”。比如我想在构建时注入一个项目特有的开关直接用config:前缀的键值对传给项目文件qbs build config:customFeatureEnabled然后在.qbs里读它CppApplication { name: demo property bool customFeatureEnabled: false cpp.defines: customFeatureEnabled ? [ENABLE_FEATURE_X] : [] }property bool customFeatureEnabled这一句是关键它定义了一个可以在命令行通过config:customFeatureEnabled赋值的可配置属性。实测下来这种机制比CMake的-DFEATURE_X可读性更好因为属性的作用域清清楚楚不会出现“这个宏到底是给谁用的”这种疑问。第三个玩法是自定义Rule。规则可以定义Qbs识别哪类文件、执行什么命令、输出什么文件。比如自动把proto文件编译成C代码Rule { inputs: [proto] Artifact { filePath: input.filePath .pb.cc fileTags: [cpp] } prepare: { var args [--cpp_out output.filePath, input.filePath]; var cmd new Command(protoc, args); cmd.description compiling input.fileName; return cmd; } }自定义Rule是Qbs扩展性的王牌功能。普通构建系统遇到特殊文件类型往往得靠外部脚本绕一大圈而Qbs允许在项目内部定义一套完整的“输入-动作-输出”链路编译protox、生成代码、跑代码生成器、做资源打包这类任务都能顺理成章地收编进构建图里。我在一个项目里用它封装了内部代码生成器效果很理想改一个生成规则整个依赖链自动重算再也不会出现生成代码和手写代码版本对不上的问题。3.3 安装与部署配置qbs install的精细化控制qbs install看起来只是把构建产物复制到指定目录但实际用起来可以很精细。它的安装规则和分组机制能让部署步骤完全自动化。最基本的安装配置QtGuiApplication { name: editor install: true installDir: bin Group { name: icons files: [icons/editor.png] install: true installDir: share/editor/icons } Group { name: config-template files: [config/editor.conf] install: true installDir: etc/editor } }注意installDir的路径是相对于安装根的。安装根由命令行的--install-root指定也可以在项目里用一个路径参数动态设定。比如我给一个跨平台项目制作安装包时用脚本串起了整个流程qbs build profile:linux-gcc config:release qbs install --install-root:deploy/linux profile:linux-gcc然后dbghelp一下文件夹程序的所有依赖、资源、配置模板都被整整齐齐地归置到我们约定的目录结构里打包成tar.gz或者deb就只是下一步的体力活了。这套机制如果利用得好可以把“发版前的手工收集工作”彻底干掉。还有一个实用技巧install可以搭配qbs run使用。qbs run支持安装后开跑比如测试场景qbs run --install-root:deploy/test profile:linux-gcc这个组合能省掉开发时“先装一次再启动”的重复步骤特别是调试安装路径或相对资源路径的问题时很有用。3.4 使用Job并行加速大型工程的构建Qbs的并行构建能力很强。早期的qmake和Makefile方案在并行时经常碰到链接顺序问题Qbs基于依赖图调度天然支持并行执行独立的编译和链接任务。命令行里直接指定jobs参数即可qbs build profile:linux-gcc jobs:8在大型工程里我常用-j来观察瓶颈。如果并行度设太高导致内存不足可以把属性写入Profile让团队默认就有合理的并行配置qbs config profiles.linux-gcc.qbs.jobs 8 qbs config profiles.linux-gcc.qbs.jobsLowPriority 4还有一个容易被忽略的细节Qbs的增量构建比全量构建在依赖追踪上精细得多。它在磁盘上维护了一个很详细的构建图新增文件、删除文件、改动参数都能精确确定影响范围。所以在大项目上我习惯让CI每天都执行一次“干净构建”检查而本地开发则永远用增量构建两者互补。4. 命令行工具链与调试优化4.1 qbs命令行核心操作与常用参数Qbs的命令行工具设计得挺直接高频使用的命令并不多。整理一张速查表命令作用常用参数qbs setup-toolchain自动探测编译工具链--detectqbs config查看/设置配置属性profiles.name.*qbs build构建工程profile:,config:,jobs:qbs install安装产物--install-root:qbs run运行产物--install-root:qbs resolve重新解析工程依赖极少用qbs clean清理构建产物无qbs generate生成IDE/编辑器项目文件--generator:qbs log查看日志按需用平时最常用的就是qbs build加profile和config。有个技巧在项目根目录下放一个qbs-setup脚本把常用的Profile配置和构建命令固化下来团队成员跑脚本就行不用记一串命令。4.2 调试构建过程日志与依赖分析遇到构建失败第一件事是看日志。Qbs的--log-level参数很管用qbs build profile:linux-gcc --log-level debugdebug级别的日志会输出每个规则的执行细节包括哪条命令失败、哪个文件的依赖发生了变化、哪条任务被跳过。比盲改代码高效得多。还有一个查看依赖关系的好方法qbs resolve后可以用--show-progress观察构建计划的执行顺序。Qbs构建计划的输出就像一张执行图你能看到不同Product之间的依赖顺序这在排查链接顺序问题和循环依赖时很有帮助。我遇到过一次奇怪的问题某库明明改了头文件但依赖它的目标没有重编。查了半天发现是头文件路径大小写不一致——Linux上路径区分大小写Windows上不区分导致依赖记录出现了偏差。严格保持路径书写规范这在跨平台项目里是个隐性坑。4.3 与IDE和编辑器的集成技巧Qbs默认支持生成Visual Studio和Qt Creator可用的工程描述。Qt Creator对Qbs原生支持很好直接打开.qbs文件就能导入工程。命令行可用qbs generate生成其他格式的项目文件qbs generate --generator:vs2019 profile:windows-msvc这个能力在某些团队里很实用核心构建逻辑统一使用Qbs但个别同事习惯的IDE项目描述文件可以直接生成不用手工维护两份构建脚本。需要注意的是生成的IDE项目是快照形式Qbs工程改动后需要重新generate一次所以它适合作为辅助工具不适合作为唯一的构建入口。5. 常见问题与排查技巧实录5.1 问题Qbs构建挂在“resolving”状态一大批刚接触Qbs的开发者最先遇到的坑就是Resolving阶段很慢甚至看起来像卡死。这通常是因为用了references引用了大量子工程而每个子工程又要做完整的依赖分析。排查思路分三步检查是否出现循环依赖。Qbs对循环依赖的检测不够直观两个库互相Depends会让解析无法收敛。查看是否有远程模块依赖比如Depends { name: some-package }但没配置模块搜索路径解析器会反复尝试。项目文件里如果大量使用fileExists()之类的敏感文件操作也会拖慢解析。我遇到过一次卡住最后定位到是项目里有几十个Product都依赖同一个自定义模块而该模块的Rule在resolve阶段就执行了一次目录扫描效率极低。优化方式是改为显式列出文件不让规则去遍历大目录。5.2 问题增量构建不触发或过度触发增量构建的“不触发”和“过度触发”各有典型原因。不触发通常是你改了文件但Qbs认为没有变化常见原因是files通配符匹配了路径带外的文件或者文件时间戳精度问题。处理方式是删掉build目录重来一次如果重来后又正常了基本可以确定是依赖记录和实际文件状态不一致。过度触发则表现为“明明只改了一个文件结果半个工程重编”问题一般出在不恰当的Depends上。比如一个库的Export块里引入了全局的cpp.defines导致依赖它的所有Product都因为这个宏变化而失效。解决方法是缩小Export块的影响范围把宏定义放到具体的Group或条件分支里而不是直接放在Product的顶层。5.3 问题qbs install安装路径混乱qbs install出来的目录结构不对通常是installDir和install: true的组合问题。我总结了一套规范可执行程序的installDir统一用bin。库文件按目标平台惯例走Windows下放到binLinux下放到lib。资源文件放到share/app。配置文件放到etc/app。绝对不要在installDir里写带版本号的路径除非你有很强的理由。版本号应该作为目录末尾的一个层次而不是用来区分不同安装路径。5.4 实战排查案例自动化部署时的构建失败我在一次自动化部署时遇到了典型的“本机能编CI挂了”的问题。排查后发现原因本机开发环境里装了一整套开发工具包CI环境里没有而项目的Profile没有显式指定某些工具路径Qbs就自动走了PATH环境变量。这个问题的通用解法是把所有外部依赖都固化成Profile里的显式配置类型包括配置项示例编译器路径qbs config profiles.linux-gcc.cpp.compilerPath /opt/toolchain/gSysrootqbs config profiles.linux-gcc.cpp.sysroot /opt/sysrootQt路径qbs config profiles.linux-gcc.Qt.core.binPath /opt/Qt/bin模块路径qbs config profiles.linux-gcc.qbsSearchPaths /opt/modules这套固化动作虽然前期花点时间但换来的是“构建结果可复现”CI和本地行为完全一致。后来我把这套配置整理成脚本新同事入职五分钟之内就能把环境搭好再也不会出现“怎么他的电脑编得过我的电脑编不过”的诡异情况。5.5 踩坑记录Qbs与静态库依赖的链接顺序Qbs虽然自动处理依赖顺序但静态库链接顺序和依赖顺序是两回事。静态库libA依赖libB在链接命令里必须-lA -lB顺序反了就会报未定义引用。这个问题在Qbs里很隐蔽因为Qbs能正确解析头文件和符号依赖但最终交给链接器的参数顺序不一定符合预期。解决方法是显式指定链接顺序依赖StaticLibrary { name: lib-a Depends { name: lib-b } cpp.linkerFlags: [-Wl,--start-group, -lstatic-b, -lstatic-a, -Wl,--end-group] }或者更干净的做法使用Qbs的cpp.staticLibraries属性直接列表库顺序CppApplication { name: final-app Depends { name: lib-a } Depends { name: lib-b } cpp.staticLibraries: [a, b] }这个坑我踩过好几次分享出来的意思就是提醒大家Qbs可以在绝大多数情况下帮你自动处理依赖但链接器层面的顺序语义还是要自己心里有数。6. Qbs生态与扩展经验6.1 自研模块与共享模块的工程实践Qbs的模块化能力非常适合大团队复用。我们在团队里维护了一个私有模块库专门放一些公司内部公共的编译选项和第三方库封装。模块的目录结构一般是这样的qbs-modules/ ├── internal-utils/ │ ├── qbs-module.qbs │ └── ... ├── third-party-ssl/ │ ├── qbs-module.qbs │ └── ...模块文件里可以封装头文件路径、库路径、宏定义、依赖项甚至规则。比如封装一个第三方HTTP库Module { name: third-party-http Depends { name: cpp } cpp.includePaths: [moduleDir /include] cpp.libraryPaths: [moduleDir /lib] cpp.staticLibraries: [http_client] cpp.defines: [USE_HTTP_CLIENT1] }然后任何Product只要Depends { name: third-party-http }就自动获得这些配置。整个团队不需要知道这个库的安装路径在哪里也不需要知道它依赖什么配置信息完全内聚在模块内部。这不只是省事是真正的工程治理策略。6.2 从qmake/CMake迁移到Qbs的避坑指南从qmake迁移到Qbs相对平滑因为qmake和Qbs对Qt项目的支持都很直接但要注意几个差异qmake的CONFIG console对应Qbs的consoleApplication: true。qmake的DEFINES XXX对应Qbs的cpp.defines但Qbs里数组元素就是字符串宏带引号时需要写cpp.defines: [MY_MACROhello]引号的写法是很多迁移者会搞错的地方。qmake的INCLUDEPATH ...对应Qbs的cpp.includePaths库路径则是cpp.libraryPaths。从CMake迁移过来的人要特别注意一点CMake大量依赖target_link_libraries的传参习惯但Qbs里你只需要声明Depends头文件路径、库路径都是模块自动带过来的不用手写target_include_directories那一套。迁移过程中最大的感受是Qbs的工程文件会比CMakeLists短很多逻辑更直白但一开始会有点不习惯因为你要理解“模块即依赖注入”这套思想。6.3 保持构建脚本可维护性的设计模式构建脚本也是代码同样需要设计模式。我整理了三条在Qbs里长期维护的心得。第一尽量让项目文件只描述“产品是什么”把“怎么构建”留在Profile和模块里。比如不要在每个Product里重复写cpp.defines而是把它封装到基础模块里。第二合理拆分references。小项目无所谓但大型工程不要在一个Project块里堆几百个Product。按子系统拆成多个.qbs文件用references引用再按模块目录组织构建脚本的可读性会好很多。第三充分利用property定义自定义配置项。把那些经常变的开关、路径、版本号定义为项目属性而不是散落在各个文件里。这样命令行上通过config:xxx动态调整不会污染工程文件的主干逻辑。结尾做构建系统这件事本质上是在给整个团队铺路。路铺得好大家“走”起来顺畅铺得不好每天下班前都在跟莫名其妙的编译错误较劲。Qbs对我来说最大的价值不是某个单独的特性而是它把“构建流程的确定性”和“构建脚本的可维护性”同时做到了一个不错的平衡。一个人在项目里写.qbs文件实际上是在给整个团队定义工作流这份工作在传统项目里往往被低估。我个人现在的习惯是凡是新起的C/Qt项目第一件事就是先把Qbs的目录结构和Profile规划好再写下第一行业务代码。前期的半小时设计能给后面省下按天计算的排查时间。希望这篇整理能帮你在自己的工程里把Qbs用得顺一点少踩几个我踩过的坑。
阅读完成 · 觉得有帮助?