尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

xmake子工程拆分实战:add_subdirs与add_deps管理模块依赖

xmake子工程拆分实战:add_subdirs与add_deps管理模块依赖 先从一个很现实的场景说起吧。我刚接手一个比较大的C项目时根目录下的xmake.lua还是早期图省事写的样子一个target一个add_files(src/**.cpp)所有源码、工具函数、业务逻辑全都堆在一起编译。刚开始确实省心但随着模块变多问题接踵而至——改一行底层代码要等整个工程重新编译给底层模块写单元测试也只能硬着头皮塞进主程序里。后来我把项目拆成了几个子工程用xmake的add_subdirs和add_deps管理模块关系这期内容就把当时梳理出来的思路和踩过的坑完整记录下来。这篇文章适合谁如果你手上的工程已经不止一个产出物比如要同时编译一个动态库、一个可执行程序、一套测试用例或者想把通用模块抽出来单独维护那这篇就是给你准备的。读完你至少能搞定三件事用add_subdirs把子工程挂载到主工程、用add_deps理顺模块间的编译依赖、避免头文件和链接库传递时最常见的几个认知偏差。1. 为什么要把子工程“拆”出来1.1 单一target撑到什么时候会出问题先说个具体的判断标准。当你的xmake.lua里出现这种迹象时就该考虑拆分了target里add_files开始出现大范围通配比如add_files(src/**.cpp)且src下面已经有好几层目录。每次改一个基础头文件构建系统要重新编译几十上百个源文件。同一个target里既有库代码又有命令行入口还有测试代码但没法单独选择编译哪一个。想给某个模块单独设编译选项比如某个目录要用C17其余部分保持C14单一target内做这种差异配置非常别扭。这些问题不是xmake特有的任何构建系统在单target模式下都会碰到。但xmake的add_subdirs方案最大的好处是你不用把构建脚本拆得七零八落每个子工程只要维护好自己的target配置就行主工程只负责把子工程挂进来、指定依赖顺序。拆分子工程之后增量编译的收益是很直观的。我那个项目里core是一个被大量模块依赖的静态库以前改core里的一个函数连带编译整个项目要三四分钟拆完之后通常十几秒就能完成主程序的重新链接。1.2 拆分子工程的三个直接收益除了编译效率拆分还有一个容易被低估的好处职责边界。当你的团队里有人负责UI层、有人负责底层库、有人负责工具链时把各自的工作内容定义成独立的target每个人只需要在自己负责的子目录里修改构建配置交集自然就少了。第二个收益是可测试性。把core拆成独立子工程后我可以单独写一份测试target只依赖core和测试框架然后通过xmake build test_core只编译测试程序。这在单target模式下几乎做不到因为所有代码都被捆在一起。第三个收益是复用。子工程不一定要绑定在当前项目里它可以是独立仓库、独立目录通过add_subdirs挂到任意主工程里。只要你把target的公共头文件、链接依赖声明清楚这个子工程就能被多个项目复用。这也是很多团队选择xmake作为统一构建工具的原因之一。1.3 和CMake的add_subdirectory对比很多从CMake转过来的朋友第一次看到add_subdirs会觉得似曾相识。确实它和CMake的add_subdirectory做的事情本质上是一样的把另一个目录下的构建描述文件加载进来让其中的target成为当前工程的一部分。但两者的依赖传播模型不太一样。CMake里你需要明确区分INTERFACE、PUBLIC、PRIVATE三种链接和包含目录的传递方式在xmake里同样有public/private语义但它的接口更纯粹大多数时候你只需要面对add_deps和{public true}这两个概念就够了。后面的实操部分会详细讲这个这是很多新手困惑的重灾区。2. 子工程的设计与目录规划2.1 一颗“可扩展”的目录树怎么搭拆分子工程前先把目录规划好比直接动手改xmake.lua更重要。我习惯的组织方式是这样project_demo/ ├── xmake.lua ├── src/ │ ├── core/ │ │ ├── xmake.lua │ │ ├── core.h │ │ └── core.cpp │ ├── demo/ │ │ ├── xmake.lua │ │ ├── main.cpp │ │ └── app.h │ └── tools/ │ ├── xmake.lua │ └── main.cpp └── tests/ ├── xmake.lua └── test_core.cpp这个结构里src/core是一个纯静态库src/demo是主程序可执行文件src/tools是一个辅助工具程序tests是单独的测试程序。每个子目录都有一份自己的xmake.lua彼此之间通过target名称互相引用。这种布局的核心思路是一个目录对应一个构建单元构建单元之间尽量没有隐式耦合。如果你的目录层级更深可以继续嵌套add_subdirsxmake支持递归加载只要每层都有对应的xmake.lua即可。2.2 根xmake.lua里只做三件事根目录的xmake.lua不用堆太多东西我的习惯是只放三件事全局工程信息、通用规则、子工程的挂载列表。举个例子set_project(demo) set_version(1.0.0) add_rules(mode.debug, mode.release) set_languages(c17) add_subdirs(src/core) add_subdirs(src/demo) add_subdirs(src/tools) add_subdirs(tests)这里的add_rules(mode.debug, mode.release)用来启用xmake自带的调试/发布模式然后add_subdirs把各个子工程挂载进来。需要注意add_subdirs的顺序会影响加载顺序但不会决定编译顺序。编译顺序由target之间的add_deps依赖关系决定这一点和很多人的直觉不一样。我见过有人试图通过调整add_subdirs的顺序来“控制编译先后”结果加了新模块后又乱套了。正确做法是只关心依赖关系把顺序问题交给xmake去拓扑排序。2.3 子工程之间的“可见性”边界子工程拆分后target名称是全局唯一的。也就是说你不能在src/core里定义了一个叫core的target又在src/other里再定义一个同名targetxmake会直接报错。这算是一个设计约束但实名制反而让依赖关系更清晰你add_deps(core)的时候明确知道指的是哪个target。头文件目录和编译宏的“可见性”也是需要设计好的。如果你在core的target里写了add_includedirs(., {public true})那么所有依赖core的子工程在编译时都会自动带上这个头文件搜索路径如果你写成add_includedirs(.)不带public那么这个路径只在core自己内部有效其他依赖core的target根本看不到。这个语义我后面会展开说它是模块化构建脚本的一个核心知识点。3. 核心实操用add_subdirs添加子工程3.1 三个文件的完整示例现在直接看一份能跑的完整示例。整个工程还是按上面的目录结构组织先从最底层的core开始。src/core/xmake.luatarget(core) set_kind(static) add_files(*.cpp) add_headerfiles(*.h) add_includedirs(., {public true})这里set_kind(static)表示生成静态库add_files收集源码add_headerfiles声明公共头文件add_includedirs的public选项让头文件搜索路径对下游依赖者可见。src/demo/xmake.luatarget(demo) set_kind(binary) add_deps(core) add_files(*.cpp) set_targetdir($(projectdir)/bin)demo是最终的可执行程序set_kind(binary)表示生成二进制可执行文件。最关键的是add_deps(core)这行代码声明了demo依赖corexmake会保证先编译core再编译demo并且自动把core的公共头文件目录、链接库信息传递给demo。src/tools/xmake.luatarget(tools) set_kind(binary) add_deps(core) add_files(main.cpp) set_targetdir($(projectdir)/bin)tests/xmake.luatarget(test_core) set_kind(binary) add_deps(core) add_files(test_core.cpp) set_targetdir($(projectdir)/bin)依赖同一个core的子工程可以有很多个xmake不会重复编译core只会编译一次然后让这些子工程各自链接。3.2 add_deps让依赖关系自动带领编译add_deps设计得很聪明它的作用不只是保证编译顺序还会自动展开依赖信息。当demo依赖core时xmake会把core的add_includedirs里标记为public的路径传给demo的编译命令行。把core的add_defines里标记为public的宏传给demo的编译命令行。把core生成的静态库文件加入到demo的链接列表里。如果core本身还依赖其他target这个传递关系会继续往下算。这就意味着你不需要在demo里手动写add_links(core)也不需要手动指定core的头文件路径。你只要声明好依赖关系和相关接口的public属性剩下的link搜索路径、include路径、宏定义xmake会全部接管。我还测试过另一种情况core依赖另一个基础库base然后demo只声明add_deps(core)demo最终也会正确链接到base库。因为xmake在解析依赖关系时会形成一棵完整的依赖树所有链接信息会被汇总到最终的可执行目标上。这比手动用add_links去拼凑库顺序要省心太多。3.3 public细节头文件和宏的自动传递public这个修饰符值得单独拎出来讲因为大部分新手在拆分模块时踩的坑都跟它有关。先看一个反例。假设core的xmake.lua写成这样target(core) set_kind(static) add_files(*.cpp) add_headerfiles(*.h) add_includedirs(.) -- 少了 {public true}编译demo时demo里include了core.h但编译器会报“core.h: No such file or directory”。原因就是add_includedirs(.)默认只在core内部生效不会传递给依赖core的target。你必须在core里把它声明为publicadd_includedirs(., {public true})同理add_defines也支持public语义。比如core里定义了add_defines(CORE_USE_STL, {public true})那么所有依赖core的target在编译时都会自动获得这个宏定义。这个特性在控制模块对外表现时非常有用。比如你希望core以特定方式编译时下游代码也做相应调整就可以通过public宏来传递。4. 子工程携带资源、配置与自定义选项4.1 控制产物目录避免“bundle地狱”子工程多了以后如果不控制产物路径所有中间文件和最终二进制会混在一起时间一长根目录下全是out目录、bin目录、build目录很难分辨哪个产物属于哪个子工程。我的习惯是在每个子target里显式指定输出目录target(tools) set_kind(binary) set_targetdir($(buildir)/bin/tools) set_objectdir($(buildir)/objs/tools) add_deps(core) add_files(main.cpp)其中$(buildir)是xmake内置的构建根目录变量默认是项目的build目录。把每个子工程的最终产物和中间产物都分开后续做打包、清理、运行时定位二进制都方便很多。如果你希望多个target共享同一个输出目录比如demo和tools都想放到bin下可以统一在根xmake.lua里设置也可以在各自target里设置但显式设置的好处是每个target的行为一目了然不会因为全局变量被意外修改而影响其他target。4.2 用option把开关下发到子工程“添加子工程”不只是把目录挂进来有时候你还希望某些子工程是可选的比如工具程序只在调试或者需要生成文档时才编译。这时候可以借助xmake的option机制。根xmake.lua里定义一个optionoption(ENABLE_TOOLS) set_default(false) set_showmenu(true) set_description(Build the tools executable)然后在src/tools/xmake.lua里用has_config判断target(tools) set_default(has_config(ENABLE_TOOLS)) set_kind(binary) add_deps(core) add_files(main.cpp)这样默认情况下tools不会被编译当你执行xmake f --ENABLE_TOOLSy xmakexmake会重新配置工程然后tools才会进入构建列表。set_default(has_config(...))的意思是这个target是否默认参与构建由用户是否启用了对应option决定。这种做法的好处是同一个工程文件可以服务多种构建需求。不需要把某个模块的代码物理删掉或者注释掉只需要在配置时决定是否启用很干净。4.3 通过public宏做模块内外部差异化还有一类常见需求是同一个子工程在编译自身和编译下游时可能需要不同的宏。比如core内部想启用一些私有特性但对外暴露的接口希望保持稳定或者反过来core在构建时要针对不同平台定义不同的宏并把这些宏传递给下游。这里可以组合使用add_defines的public/private语义。比如target(core) add_defines(CORE_INTERNAL) -- 只对core自身生效 add_defines(CORE_PUBLIC_API, {public true}) -- 传递到下游实际项目里public宏最常见的用途是控制导出符号。比如你定义一个库希望下游看到的是CORE_API这个宏但宏的具体展开可能依赖平台core内部定义好并标记为public下游所有用到CORE_API的代码会自动获得正确的定义不需要在每个子工程里重复写。5. 常见问题与排查实录5.1 头文件找不到的三种典型场景子工程拆分后第一个高频报错就是找不到头文件。我把实际遇到的场景整理成了一张表现象原因解法demo编译报“core.h: No such file or directory”add_includedirs没有设置{public true}在core的target中改为add_includedirs(., {public true})demo能找到core.h但找不到core内部私有头文件core的私有头文件目录没有public但demo误以为能访问检查头文件目录层级把公共头文件放到public目录私有头文件留在内部目录头文件在子工程内可以找到在构建产物里没有只用了add_files收集源码忘了用add_headerfiles声明头文件用add_headerfiles声明需要导出的头文件并用set_installdir或install规则处理安装很多情况下头文件找不到并不是文件不存在而是“头文件搜索路径没有传递给当前需要它的target”。检查的时候先确认当前target里有没有add_deps到上游target再看上游target的相关路径有没有标public。5.2 链接时符号冲突或未定义拆分成多个库之后链接错误会变得比单target时代更多。最常见的两种一个是undefined reference。多数情况下是因为某个target使用了另一个target里的符号但忘了声明add_deps。记住add_links和add_deps的关系是add_deps是“项目内部target依赖”add_links是“链接外部库”。项目内部模块之间优先使用add_depsxmake会自动帮你处理链接顺序只有外部预编译库才需要add_links。另一个是符号重复定义。这种情况多半是同一个静态库被多个子工程分别add_links了或者多个target都把同一个源文件add_files进来。排查时先确认源文件没有被多个target同时引用再确认整个工程的链接列表里没有重复的库条目。用xmake -v看实际的链接命令行一眼就能看出来是不是某个库被拼了两遍。5.3 编译顺序“薛定谔”的原因很多人以为add_subdirs的顺序就是编译顺序实际不是。xmake的target排列顺序会影响一些全局配置的解析顺序但真正的编译顺序是靠依赖树推算出来的。如果你发现某个库经常比依赖它的可执行文件编译得晚检查一下是不是两个target之间缺少add_deps。还有一种情况是多个子工程之间形成了循环依赖。比如core引用了utils里的符号utils又引用了core里的符号。xmake在检测到循环依赖时会报警告但有时候不会立刻报错而是表现为链接阶段无法解析符号。遇到这种问题应该先回头审视模块划分把公共部分再抽出一层打破循环。5.4 子工程被意外编译的解决办法有时候你只是想构建某个子工程但xmake默认把所有默认target都编译了。如果某个target不想在默认情况下参与构建就用set_default(false)就像前面option示例里写的那样。反之如果希望某个target在依赖它的target被构建时即使默认关闭也能被拉起来xmake也会自动处理。set_default控制的是“是否进入默认构建集合”一旦有上游target依赖它它就会进入实际构建图。这个机制我用下来很顺手。比如单元测试默认不参与xmake但你跑xmake build test_core时它会被正常编译如果主程序依赖了某个默认关闭的库系统也会自动把它建出来。6. 一些值得尝试的扩展用法6.1 用set_group整理IDE里的目标树当工程里的target数量超过十几个之后在IDE里看目标列表会非常混乱。xmake提供了set_group接口可以给target分组target(core) set_group(library) target(demo) set_group(application)这样在支持xmake插件的小熊猫C、VS Code插件等编辑器里target列表会按照分组折叠显示看起来清爽很多。这个接口对命令行构建没有影响纯粹是工程可维护性的提升但实际体验差异挺大建议target多了之后就加上。6.2 子工程也能被外部工程复用如果某个子工程做得足够通用你可以把它从当前项目里独立出去放到一个单独的仓库。需要用到它的项目直接用add_subdirs指向那个目录即可。比如add_subdirs(../shared/utils)只要utils目录下有xmake.lua并且target定义完整主工程就能复用。这种跨项目的复用方式在团队内部很灵活比library dependency或者git submodule更轻量因为它直接基于文件系统路径不需要额外的依赖管理工具。如果你不想用相对路径也可以用xmake提供的包管理和add_requires机制把子工程发布成一个独立的xmake包再通过版本号引用。这属于进阶玩法适合要规范化分发模块的团队。6.3 继续向前把子工程拆成独立包对于需要多人协作、多项目共享的模块比起add_subdirsxmake的包管理方案会更专业。你可以在子工程目录里维护一个xmake.lua定义target和安装规则然后通过xmake create -t package初始化成独立包发布到私有或公共的仓库。这样下游项目只需要写add_requires(core, {version 1.0.0}) target(demo) add_packages(core)不需要知道core的源码在哪里构建时会自动下载或者从本地缓存里找到对应版本的core。这种方式的优点是可复用性和版本控制能力更强但复杂度也比add_subdirs高。我的建议是团队内部快速迭代用add_subdirs正式对外发布和稳定版本管理再切换成包管理方案。最后再分享一点个人的实操心得。我到现在还是喜欢在根xmake.lua里把add_subdirs一个个列得清清楚楚不喜欢用通配符一次性挂载所有子目录。虽然xmake支持add_subdirs(src/*)这种写法但通配符会掩盖目录之间的逻辑关系而且挂载顺序不稳定。每新增一个子工程都明确写一行add_subdirs虽然看起来啰嗦但别人接手你的工程时扫一眼根目录就能知道这个项目包含哪些模块比翻半天文件结构确认要高效得多。工具这东西用顺手了之后你会发现构建脚本也是代码也一样需要可读性。
返回列表