复合构建:复合构建概念、includeBuild、本地依赖替换
说到复合构建,我得先坦白一件事。我刚开始接触 Gradle 那会儿,一直觉得多项目构建就是全部了。直到有一次,我在一个大型微服务项目里,需要同时调试三个互相依赖的库……嗯,那场面简直是一场噩梦。每次改一个库的代码,都要先发布到本地 Maven 仓库,再让另一个项目引用。一来一回,半天就过去了。
后来我发现了复合构建,说白了就是「把多个独立的 Gradle 项目,临时组合到一起开发」。它不会改变你项目的发布方式,只是在本地开发时,让你能直接引用源码。这感觉就像……你本来要等快递,现在直接去仓库拿货了。
复合构建的核心概念
复合构建(Composite Build)允许你在一个构建中,包含另一个完整的 Gradle 构建。被包含的构建称为「参与构建」(Included Build)。
我个人习惯把复合构建理解为「开发时的临时合并」。它有几个关键特征:
- 独立生命周期:每个参与构建都有自己的 settings.gradle.kts、build.gradle.kts,独立配置
- 任务隔离:参与构建的任务不会自动暴露给主构建,除非你显式依赖
- 依赖替换:这是最实用的功能——自动用源码替换外部依赖
核心思想:复合构建不是「合并项目」,而是「在开发时临时建立源码级依赖关系」。
includeBuild:如何引入外部构建
在 settings.gradle.kts 里,用 includeBuild 方法引入另一个 Gradle 项目。路径可以是相对路径或绝对路径。
// 主项目的 settings.gradle.kts
rootProject.name = "my-app"
// 引入本地的工具库构建
includeBuild("../my-tools")
// 也可以引入多个
includeBuild("../shared-lib")
includeBuild("../config-module")
这里要注意:被引入的目录必须是一个完整的 Gradle 项目,也就是要有 settings.gradle.kts 文件。我曾经犯过一个错,把一个只有 build.gradle.kts 的目录 include 进来,结果 Gradle 直接报错说找不到 settings 文件。
本地依赖替换:自动生效的魔法
复合构建最强大的地方在于依赖替换。假设你的主项目依赖了 com.example:my-tools:1.0.0,而你又通过 includeBuild("../my-tools") 引入了 my-tools 构建。
Gradle 会自动做一件事:它会检查 my-tools 构建输出的所有模块(通过其 settings.gradle.kts 定义的子项目),如果某个模块的 group:name 和主项目的依赖匹配,就自动用源码替换掉外部 jar。
举个例子:
// 主项目的 build.gradle.kts
dependencies {
implementation("com.example:my-tools:1.0.0")
implementation("com.example:shared-lib:2.3.0")
}
// my-tools 项目的 settings.gradle.kts
rootProject.name = "my-tools"
// 这个项目会输出 com.example:my-tools:1.0.0
// shared-lib 项目的 settings.gradle.kts
rootProject.name = "shared-lib"
// 这个项目会输出 com.example:shared-lib:2.3.0
当主项目通过 includeBuild("../my-tools") 和 includeBuild("../shared-lib") 引入后,Gradle 会自动匹配 group:name,把这两个依赖替换成本地源码。你改完代码,直接运行主项目的任务,就能看到效果。
小技巧:依赖替换只匹配 group 和 name,不匹配 version。所以即使你依赖的是 1.0.0,而本地构建输出的是 2.0.0-SNAPSHOT,也能替换成功。这在实际开发中非常方便。
手动控制依赖替换
有时候自动替换不一定符合你的需求。比如你只想替换某个特定模块,或者想禁用自动替换。这时候可以用 dependencySubstitution 手动控制。
// 主项目的 settings.gradle.kts
includeBuild("../my-tools") {
dependencySubstitution {
// 只替换这个特定的依赖
substitute(module("com.example:my-tools-core"))
.using(project(":my-tools-core"))
// 明确不替换某个依赖
substitute(module("com.example:my-tools-legacy"))
.using(module("com.example:my-tools-legacy:1.0.0"))
}
}
我记得有一次,一个同事的本地构建里有个模块叫 :legacy,和线上发布的 jar 版本不兼容。自动替换导致主项目编译失败。我们用 dependencySubstitution 手动排除了那个模块,问题就解决了。
复合构建的任务执行
复合构建中,你可以从主构建直接执行参与构建的任务。比如:
// 执行 my-tools 构建中的 :clean 任务
./gradlew :my-tools:clean
// 执行 shared-lib 构建中的 :utils:test 任务
./gradlew :shared-lib:utils:test
任务路径的格式是 :<构建名称>:<任务路径>。构建名称默认就是被引入项目的根项目名称。如果你觉得名字太长,可以在 includeBuild 时指定别名:
includeBuild("../my-tools") {
name = "tools"
}
// 现在执行任务用 :tools:clean 即可
注意:复合构建的任务执行有性能开销。Gradle 需要先配置所有参与构建,再执行任务。如果参与构建很多,建议用 --parallel 并行执行。
复合构建的典型场景
我总结了几种最适合用复合构建的场景:
- 同时开发多个库:比如你维护一个工具库和一个业务库,业务库依赖工具库。用复合构建可以一边改工具库,一边在业务库中测试
- 调试第三方依赖:把第三方库的源码 clone 下来,用 includeBuild 引入,直接调试它的行为
- 跨团队协作:不同团队维护不同模块,用复合构建做集成测试,不用等发布
- 迁移改造:把旧项目拆分成多个独立构建,用复合构建逐步过渡
避坑指南
我曾经踩过几个坑,分享给你:
- 循环引用:两个构建互相 includeBuild,Gradle 会直接报错。复合构建不支持循环
- 版本冲突:参与构建和主构建用了不同版本的同一个依赖,可能导致运行时异常。建议统一版本管理
- 构建缓存失效:复合构建中,参与构建的缓存可能不会自动失效。如果改了代码但没生效,试试
--no-build-cache - IDE 支持:IntelliJ IDEA 对复合构建的支持还不错,但有时候需要手动刷新 Gradle 项目。如果遇到代码跳转不对,试试重新导入
知识体系图
实际项目中的使用建议
如果你刚开始用复合构建,我建议从一个小场景入手。比如你有一个工具库和一个应用项目,先在应用项目的 settings.gradle.kts 里 includeBuild 工具库,看看依赖替换是否生效。
我个人习惯在项目根目录放一个 composite 文件夹,里面放所有参与构建的项目的 git submodule 或 clone。这样路径管理起来比较清晰。
my-project/
├── settings.gradle.kts # 主构建配置
├── build.gradle.kts
├── composite/ # 复合构建目录
│ ├── my-tools/ # 工具库(git submodule)
│ ├── shared-lib/ # 共享库(git submodule)
│ └── config-module/ # 配置模块(git submodule)
└── app/
└── build.gradle.kts
对应的 settings.gradle.kts:
rootProject.name = "my-project"
includeBuild("composite/my-tools")
includeBuild("composite/shared-lib")
includeBuild("composite/config-module")
这样做的好处是,你可以用 git 管理所有参与构建的版本,团队成员 clone 后执行 git submodule update --init 就能拉取所有依赖的源码。
总结一下:复合构建是 Gradle 提供的一个非常实用的本地开发工具。它不会改变你的发布流程,只是在开发时让你能直接引用源码。核心就是 includeBuild 加上自动的依赖替换。用好它,能大幅提升多项目并行开发的效率。