Java 基础体系 · 第 79/100 篇。示例统一以 Java 25 LTS 为语言和 JVM 基线;框架示例使用与其兼容的现代稳定版本。
Gradle 完整基础:Task、配置缓存、依赖、插件和多模块构建
Gradle 是一个以“构建模型 + 任务图 + 可扩展插件”为核心的构建自动化工具。它不只负责执行 javac、测试和打包,还负责:
- 描述项目结构;
- 解析和选择依赖;
- 根据输入判断任务是否需要重新执行;
- 组装任务之间的依赖关系;
- 加载插件并扩展构建模型;
- 在多模块项目中协调模块之间的构建;
- 缓存配置结果和任务输出。
本文使用 Kotlin DSL(*.gradle.kts)说明 Gradle 机制。示例面向 Java 25,并假设使用的 Gradle 版本已经支持 Java 25 运行时和 Java 25 Toolchain。Gradle 对 Java 运行时的支持与对 Java Toolchain 的支持是两个独立问题,实际项目应根据所选 Gradle 版本的兼容性矩阵确认,不能仅因为本机安装了 Java 25 就认为任意 Gradle 版本都支持它。
一、先建立一个可运行的 Gradle 项目
一个最小的 Java 多模块项目可以是:
gradle-demo/
├── settings.gradle.kts
├── build.gradle.kts
├── gradle.properties
├── gradlew
├── gradlew.bat
├── gradle/
│ └── wrapper/
├── app/
│ ├── build.gradle.kts
│ └── src/
│ ├── main/java/example/app/Main.java
│ └── test/java/example/app/MainTest.java
└── core/
├── build.gradle.kts
└── src/
└── main/java/example/core/Greeting.java
使用 Wrapper 初始化或升级 Gradle:
gradle wrapper --gradle-version 9.1.0
实际版本应替换为项目验证过的版本。之后构建必须优先使用:
./gradlew build
Windows 使用:
gradlew.bat build
gradlew 的作用不是“封装 Gradle 命令”,而是固定项目所需的 Gradle 发行版,使开发机、CI 和生产构建使用同一版本。Wrapper 自身也应提交到版本控制系统。
1. settings.gradle.kts
pluginManagement {
repositories {
gradlePluginPortal()
mavenCentral()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
mavenCentral()
}
}
rootProject.name = "gradle-demo"
include(":app", ":core")
这里有三个不同概念:
pluginManagement.repositories用于查找插件;dependencyResolutionManagement.repositories用于查找普通依赖;include(":app", ":core")声明两个子项目。
插件仓库和普通依赖仓库不是同一个解析过程。插件通常先通过插件标识符映射成模块坐标,再从插件仓库解析;普通依赖则直接按照模块坐标解析。
RepositoriesMode.FAIL_ON_PROJECT_REPOS 表示不允许子项目自行声明仓库。这样可以避免某个模块偷偷增加仓库,导致依赖来源不一致。
2. 根项目 build.gradle.kts
plugins {
id("java") apply false
}
allprojects {
group = "example"
version = "1.0.0"
}
subprojects {
apply(plugin = "java")
java {
toolchain {
languageVersion.set(JavaLanguageVersion.of(25))
}
}
tasks.withType<JavaCompile>().configureEach {
options.encoding = "UTF-8"
}
tasks.withType<Test>().configureEach {
useJUnitPlatform()
}
repositories {
mavenCentral()
}
}
apply false 的含义是:解析插件,但不在根项目应用插件。此处随后通过 subprojects { apply(plugin = "java") } 应用 Java 插件。
这是一种能工作的集中配置方式,但大型项目通常会把公共构建逻辑提取为约定插件,而不是在根脚本中大量使用 subprojects。后文会解释原因。
3. core/build.gradle.kts
plugins {
`java-library`
}
dependencies {
api("org.slf4j:slf4j-api:2.0.17")
}
java-library 与 java 的重要区别是,它把依赖暴露分为:
api:依赖会进入本模块的公开编译 API;implementation:依赖只在本模块内部使用,不进入消费者的编译类路径。
4. app/build.gradle.kts
plugins {
application
}
application {
mainClass.set("example.app.Main")
}
dependencies {
implementation(project(":core"))
testImplementation("org.junit.jupiter:junit-jupiter:5.12.2")
}
implementation(project(":core")) 表示 app 依赖 core 的产物。Gradle 会先构建 core,再把它的输出接入 app 的相应配置。
5. Java 源码
core/src/main/java/example/core/Greeting.java:
package example.core;
public final class Greeting {
public static String message(String name) {
return "Hello, " + name;
}
}
app/src/main/java/example/app/Main.java:
package example.app;
import example.core.Greeting;
public final class Main {
public static void main(String[] args) {
String name = args.length == 0 ? "Gradle" : args[0];
System.out.println(Greeting.message(name));
}
}
运行:
./gradlew :app:run --args="Java 25"
预期输出:
Hello, Java 25
构建和测试:
./gradlew clean build
查看任务:
./gradlew tasks
查看完整依赖图:
./gradlew :app:dependencies
查看某个配置下的依赖原因:
./gradlew :app:dependencyInsight \
--dependency junit-jupiter \
--configuration testRuntimeClasspath
二、Gradle 的执行模型:项目、配置和任务
理解 Gradle 的关键,是区分三个阶段:
- Initialization(初始化)
- Configuration(配置)
- Execution(执行)
其基本流程如下:
flowchart TD
A[启动 Gradle Wrapper] --> B[初始化阶段]
B --> C[读取 settings.gradle.kts]
C --> D[发现根项目和子项目]
D --> E[配置阶段]
E --> F[加载插件与构建脚本]
F --> G[建立任务及其关系]
G --> H{配置缓存可复用?}
H -- 是 --> J[复用配置结果]
H -- 否 --> I[完成配置并保存配置缓存]
J --> K[执行阶段]
I --> K
K --> L[选择任务图]
L --> M[按依赖和顺序执行任务]
M --> N[检查增量和构建缓存]
N --> O[执行或跳过任务]
1. 初始化阶段
初始化阶段决定参与本次构建的项目以及插件管理方式。settings.gradle.kts 属于这个阶段的核心输入。
例如:
./gradlew :app:test
Gradle 并不是只读取 app/build.gradle.kts。它必须先读取 settings 文件,确认 :app 存在,然后才配置相关项目。
2. 配置阶段
配置阶段执行构建脚本,创建任务、设置属性、解析部分模型并建立任务关系。
下面的代码在配置阶段会立即执行:
println("configuration phase")
tasks.register("hello") {
println("task configuration phase")
doLast {
println("task execution phase")
}
}
执行:
./gradlew hello
通常会看到:
configuration phase
task configuration phase
task execution phase
即使运行另一个任务,前两个 println 也可能出现,因为它们属于配置阶段;只有 doLast 中的代码属于任务执行动作。
这也是为什么不应在构建脚本顶层执行耗时操作:
// 不推荐:每次配置项目都会读取并解析文件
val text = file("large-input.txt").readText()
更合理的方式是把读取操作放进任务动作,并声明为输入:
val analyzeFile = tasks.register("analyzeFile") {
val inputFile = layout.projectDirectory.file("large-input.txt")
inputs.file(inputFile)
doLast {
val lineCount = inputFile.asFile.useLines { lines -> lines.count() }
println("lines: $lineCount")
}
}
这里的 inputs.file(inputFile) 告诉 Gradle:任务结果依赖这个文件。否则 Gradle 无法可靠地判断文件变化是否应使任务失效。
3. 执行阶段
执行阶段只处理任务图中被选中的任务。执行:
./gradlew :app:test
并不意味着所有项目的所有任务都会执行。Gradle 会根据目标任务及其依赖关系构造一个有向无环图(DAG)。
如果关系为:
:app:test -> :app:classes -> :app:compileJava
那么执行顺序必须满足:
:app:compileJava
:app:classes
:app:test
任务图中的箭头通常表示“当前任务依赖另一个任务”,而不是简单的文本先后关系。
三、Task:Gradle 的基本执行单元
1. Task 是什么
Task 是一个具有名称、输入、输出、动作和依赖关系的构建单元。
抽象地说,一个任务可以表示为:
其中:
- :输入集合;
- :输出集合;
- :执行动作;
- :依赖任务集合;
- :任务属性,例如是否启用、是否总是执行。
理想情况下,任务动作可以看作函数:
如果输入 没有变化,且输出 仍然存在,Gradle 就有机会跳过任务。这就是任务的增量执行和 UP-TO-DATE 判断的基础。
2. 注册任务,而不是立即创建任务
推荐使用:
tasks.register("hello") {
doLast {
println("Hello Gradle")
}
}
不推荐在大型项目中随意使用:
tasks.create("hello") {
doLast {
println("Hello Gradle")
}
}
register 返回 TaskProvider<T>,任务对象可以延迟创建;create 会立即创建任务。延迟创建减少了不必要的配置工作,也是配置缓存和配置避免(configuration avoidance)的重要基础。
读取任务时也应尽量保持惰性:
val hello = tasks.register("hello") {
doLast {
println("Hello")
}
}
tasks.register("runHello") {
dependsOn(hello)
}
3. dependsOn、mustRunAfter 和 shouldRunAfter
三者语义不同。
dependsOn:声明执行依赖
tasks.register("verify") {
dependsOn("test")
}
运行:
./gradlew verify
会先执行 test,再执行 verify。如果 test 失败,verify 通常不会执行。
mustRunAfter:声明顺序,不引入依赖
tasks.named("check") {
mustRunAfter("assemble")
}
这并不保证执行 assemble。只有当 assemble 和 check 都已经进入任务图时,才保证 assemble 排在前面。
shouldRunAfter:尽量排序
tasks.named("check") {
shouldRunAfter("assemble")
}
这是弱顺序约束。遇到并行执行或复杂依赖关系时,Gradle 可能忽略它,以避免产生不合理的任务图。
错误示例:
tasks.register("publishReport") {
mustRunAfter("test")
}
很多人误以为执行 publishReport 会自动执行 test,实际不会。若需要自动执行,应使用:
tasks.register("publishReport") {
dependsOn("test")
}
4. finalizedBy:收尾任务
val integrationTest = tasks.register("integrationTest") {
doLast {
println("run integration tests")
}
}
tasks.register("cleanup") {
doLast {
println("cleanup temporary resources")
}
}
integrationTest.configure {
finalizedBy("cleanup")
}
cleanup 是 integrationTest 的终结任务。它适合关闭测试服务器、删除临时资源等清理动作。
但终结任务不是事务回滚机制。清理代码本身失败时,构建仍可能失败;外部资源如果在 Gradle 进程被强制终止时没有释放,也不能仅靠 finalizedBy 保证恢复。
5. 任务动作的追加
tasks.register("sample") {
doFirst {
println("first")
}
doLast {
println("last")
}
}
同一个任务可以有多个动作。doFirst 会把动作放到已有动作之前,doLast 会追加到末尾。通常应优先使用类型化任务和明确的输入输出,而不是通过大量 doFirst 修改其他插件创建的任务行为。
四、任务输入、输出、增量执行和构建缓存
1. UP-TO-DATE 的基本判断
考虑任务:
tasks.register("generateFile") {
val outputFile = layout.buildDirectory.file("generated/value.txt")
outputs.file(outputFile)
doLast {
outputFile.get().asFile.apply {
parentFile.mkdirs()
writeText("value\n")
}
}
}
第一次执行:
./gradlew generateFile
任务会运行,因为输出不存在。
第二次执行:
./gradlew generateFile
如果任务动作、输入和输出状态没有变化,Gradle 可能输出:
> Task :generateFile UP-TO-DATE
这里的“可能”很重要:任务是否可跳过还取决于任务类型、输出状态、实现类、属性和其他 Gradle 判断条件。
如果只写文件却不声明输出:
tasks.register("badGenerateFile") {
doLast {
layout.buildDirectory.file("generated/value.txt").get().asFile.writeText("value")
}
}
Gradle 不知道这个文件是任务输出,无法基于它进行可靠的任务状态判断。
2. 输入变化如何导致任务重新执行
假设任务输出由输入文本决定:
tasks.register("copyGreeting") {
val source = layout.projectDirectory.file("greeting.txt")
val target = layout.buildDirectory.file("generated/greeting.txt")
inputs.file(source)
outputs.file(target)
doLast {
target.get().asFile.apply {
parentFile.mkdirs()
writeText(source.asFile.readText())
}
}
}
状态变化如下:
| 情况 | 输入 | 输出 | 结果 |
|---|---|---|---|
| 第一次执行 | 存在 | 不存在 | 执行 |
| 第二次执行 | 未变 | 存在 | UP-TO-DATE |
修改 greeting.txt |
变化 | 存在 | 执行 |
| 删除输出文件 | 未变 | 不存在 | 执行 |
这不是文件复制工具的特殊规则,而是任务输入输出模型的直接结果。
3. 增量任务与 InputChanges
对于大量输入文件,任务可以只处理新增、修改和删除的文件。实现增量任务时,必须正确声明输入,并处理 Gradle 提供的变化信息。否则“只处理变化文件”可能导致删除、重命名或全量重建场景产生错误结果。
工程上,能使用 Gradle 已有的增量任务类型时,应优先使用已有类型;自定义增量任务需要同时验证:
- 首次执行;
- 输入文件新增;
- 输入文件修改;
- 输入文件删除;
- 输出目录被清空;
- 任务实现或参数发生变化。
4. UP-TO-DATE 与 Build Cache 不是一回事
这两个概念经常混淆。
UP-TO-DATE
表示当前工作区中的任务输出已经符合当前任务状态,因此不必再次执行。
Build Cache
构建缓存保存任务输出,允许另一个工作区甚至另一台机器复用已经计算出的结果。缓存键通常取决于任务实现、输入、输出相关属性和环境因素。
启用本地构建缓存:
./gradlew build --build-cache
配置中也可以启用:
// settings.gradle.kts
buildCache {
local {
isEnabled = true
}
}
共享远程缓存还涉及认证、可写权限、缓存污染和密钥管理,不能把任意远程目录直接当成可信缓存。
如果任务读取了未声明的外部文件、环境变量或系统时间,缓存就可能复用错误结果。例如:
tasks.register("badVersionFile") {
outputs.file(layout.buildDirectory.file("version.txt"))
doLast {
val version = System.getenv("RELEASE_VERSION") ?: "dev"
layout.buildDirectory.file("version.txt").get().asFile.writeText(version)
}
}
这里 RELEASE_VERSION 没有声明为输入。一次构建写入 1.0.0 后,下一次即使环境变量变为 1.0.1,Gradle 仍可能认为任务状态没有变化。
正确方式:
tasks.register("versionFile") {
val releaseVersion = providers.environmentVariable("RELEASE_VERSION")
.orElse("dev")
inputs.property("releaseVersion", releaseVersion)
outputs.file(layout.buildDirectory.file("version.txt"))
doLast {
val value = releaseVersion.get()
layout.buildDirectory.file("version.txt").get().asFile.apply {
parentFile.mkdirs()
writeText(value)
}
}
}
五、配置缓存:缓存的是“配置结果”,不是任务输出
1. 配置缓存解决什么问题
每次运行 Gradle,配置阶段都可能重新执行所有相关构建脚本。大型项目中,真正耗时的可能不是编译,而是:
- 加载大量插件;
- 创建大量任务;
- 读取项目模型;
- 解析构建脚本;
- 计算任务关系。
配置缓存会保存一次构建的配置阶段结果。后续执行满足条件时,Gradle 可以跳过大部分配置逻辑,直接进入任务执行阶段。
启用方式:
./gradlew build --configuration-cache
也可以在 gradle.properties 中配置:
org.gradle.configuration-cache=true
配置缓存与构建缓存的区别是:
| 缓存 | 缓存对象 | 解决的问题 |
|---|---|---|
| 配置缓存 | 配置阶段生成的构建状态 | 减少重新配置项目 |
| Build Cache | 任务输出 | 减少任务实际执行 |
UP-TO-DATE |
当前工作区任务状态判断 | 跳过已有有效输出 |
三者可以同时生效,但互相不能替代。
2. 配置缓存的核心约束
配置缓存要求:任务执行阶段不能依赖配置阶段捕获的可变项目对象。
有风险的写法:
tasks.register("printProjectDir") {
doLast {
println(project.projectDir)
}
}
project 是配置模型对象。任务动作在执行阶段捕获它,会使配置缓存难以安全复用,具体行为取决于 Gradle 版本和使用方式,可能产生配置缓存问题报告。
更好的写法是把所需值转换成可序列化的 Provider 或文件属性:
tasks.register("printProjectDir") {
val projectDirectory = layout.projectDirectory.asFile
inputs.dir(projectDirectory)
doLast {
println(projectDirectory)
}
}
更常见的任务是读取参数:
tasks.register("printMessage") {
val message = providers.gradleProperty("message")
.orElse("default")
inputs.property("message", message)
doLast {
println(message.get())
}
}
执行:
./gradlew printMessage --configuration-cache -Pmessage=hello
Provider 是一个延迟计算的值。它有两个重要特征:
- 值可以延迟到真正需要时再计算;
- Gradle 可以追踪它与任务输入、配置缓存之间的关系。
因此,以下代码比直接读取系统属性更适合构建逻辑:
val release = providers.systemProperty("release")
.orElse("false")
3. 配置缓存中的外部输入
构建逻辑可能依赖:
- Gradle 属性;
- 系统属性;
- 环境变量;
- 文件内容;
- 当前目录;
- 网络请求;
- Git 状态;
- 当前时间。
这些输入必须在正确的生命周期中读取并声明。
例如:
val gitBranch = providers.environmentVariable("GIT_BRANCH")
.orElse("unknown")
tasks.register("showBranch") {
inputs.property("gitBranch", gitBranch)
doLast {
println("branch=${gitBranch.get()}")
}
}
不应在配置阶段直接执行网络访问:
// 不推荐
val remoteVersion = URL("https://example.com/version").readText()
这会导致配置阶段不稳定、离线构建失败、缓存结果难以复现,并可能让所有 Gradle 命令都被网络拖慢。若确实需要网络输入,应将其设计为明确的任务输入,并处理超时、失败和缓存策略。
4. 检查配置缓存问题
执行:
./gradlew build --configuration-cache
如果存在不兼容用法,Gradle 会在构建结果或报告中说明问题。应使用报告定位具体对象访问,而不是简单地加参数屏蔽问题。
常见问题包括:
- 在任务动作中直接访问
Project; - 在任务动作中访问
Task、Configuration等生命周期对象; - 读取未声明的环境变量;
- 自定义插件保存了不可序列化状态;
- 使用第三方插件,而该插件尚未适配配置缓存。
配置缓存不是“打开后必然加速”的开关。首次运行需要生成缓存;构建逻辑、Gradle 版本、参数、环境等发生变化时,缓存也可能失效。
六、依赖管理:从声明到解析
1. 依赖的三个层次
Gradle 依赖管理至少包含三个层次:
- 声明:项目表达需要什么;
- 解析:Gradle 从仓库和项目中选择具体组件;
- 消费:编译、测试、运行或打包使用解析后的文件和变体。
例如:
dependencies {
implementation("org.apache.commons:commons-lang3:3.17.0")
}
坐标通常是:
group:name:version
对应:
org.apache.commons : commons-lang3 : 3.17.0
声明依赖不会立即等价于“把 JAR 下载下来”。Gradle 会根据当前配置,例如 compileClasspath 或 runtimeClasspath,在需要时解析适用的依赖图。
2. Configuration 是什么
Gradle 中的 Configuration 是一组具有用途和属性的依赖集合。常见 Java 配置包括:
implementation:生产代码编译和运行使用;api:由java-library提供,表示公开 API 依赖;compileOnly:只在编译时存在,运行时不打包;runtimeOnly:运行时存在,编译时不需要;testImplementation:测试编译和运行使用;testRuntimeOnly:只在测试运行时使用。
示例:
dependencies {
api("org.slf4j:slf4j-api:2.0.17")
implementation("com.fasterxml.jackson.core:jackson-databind:2.19.1")
compileOnly("org.jetbrains:annotations:26.0.2")
runtimeOnly("ch.qos.logback:logback-classic:1.5.18")
testImplementation("org.junit.jupiter:junit-jupiter:5.12.2")
}
若生产代码的公开方法签名出现某个类型,该库通常属于 api:
public final class UserService {
public org.slf4j.Logger logger() {
return ...;
}
}
如果某个库只在方法内部使用:
public final class UserService {
public String normalize(String value) {
return InternalLibrary.normalize(value);
}
}
它通常应使用 implementation,避免把内部实现暴露给消费者。
3. 传递依赖和冲突选择
假设依赖图为:
app
├── library-a -> common:1.0
└── library-b -> common:2.0
Gradle 需要为同一模块 common 选择一个版本。默认冲突解决通常选择版本较高者:
common:2.0
但这只是版本选择,不代表 library-a 一定兼容 common:2.0。因此,依赖解析成功不等于运行时一定正确。
查看选择原因:
./gradlew :app:dependencyInsight \
--dependency common \
--configuration runtimeClasspath
输出会说明某个版本是直接声明、传递引入还是因为冲突解决被选择。
4. 依赖约束和强制版本
依赖约束表达“建议或要求的版本”,而不是简单增加一个直接依赖:
dependencies {
constraints {
implementation("org.example:common:2.3.0") {
because("修复已知兼容性问题")
}
}
}
若需要严格限制版本,可以使用版本约束:
dependencies {
implementation("org.example:common") {
version {
strictly("2.3.0")
}
}
}
strictly 可能使某些传递依赖解析失败。它适合确实需要强约束的场景,不应把所有依赖都无差别锁死,否则升级和兼容性诊断会变得困难。
5. 版本目录
在 gradle/libs.versions.toml 中集中管理版本:
[versions]
junit = "5.12.2"
slf4j = "2.0.17"
[libraries]
junit-jupiter = { module = "org.junit.jupiter:junit-jupiter", version.ref = "junit" }
slf4j-api = { module = "org.slf4j:slf4j-api", version.ref = "slf4j" }
构建脚本中使用:
dependencies {
testImplementation(libs.junit.jupiter)
}
版本目录解决的是声明重复和命名统一问题,不等同于依赖锁定。要获得可复现解析,还需要依赖锁定、固定仓库和适当的依赖验证策略。
6. 依赖锁定
启用锁定:
./gradlew dependencies --write-locks
之后 Gradle 会生成锁文件,使动态版本或传递依赖版本在构建中保持稳定。升级依赖时应显式更新锁文件并检查变更。
动态版本示例:
implementation("org.example:library:1.+")
它可能导致今天和下周解析到不同版本。生产构建通常应避免动态版本,除非项目明确接受非确定性。
7. 项目依赖和外部依赖
模块依赖:
implementation(project(":core"))
外部依赖:
implementation("org.slf4j:slf4j-api:2.0.17")
模块依赖的特殊之处在于,Gradle 不只是找到一个文件,还会读取被依赖项目的变体和元数据。例如 core 的 api 依赖可以传递给 app,而 implementation 依赖通常不会进入 app 的编译 API。
七、Java 插件、应用插件和插件解析
1. 插件是什么
插件是向项目应用构建能力的代码单元。插件可以:
- 创建任务;
- 创建 Configuration;
- 添加扩展对象;
- 设置默认目录;
- 注册编译、测试和打包逻辑;
- 发布组件和元数据。
应用 Java 插件:
plugins {
java
}
应用 Java Library 插件:
plugins {
`java-library`
}
应用 Application 插件:
plugins {
application
}
插件应用之后,项目才会拥有相应的模型。例如 Application 插件提供:
application {
mainClass.set("example.app.Main")
}
2. 插件标识符和版本
根项目中常见写法:
plugins {
id("org.example.my-plugin") version "1.2.3"
}
插件标识符不是普通 Maven 坐标。Gradle 会按照插件解析规则,将插件 ID 映射到实现模块,并从 pluginManagement 声明的仓库中解析。
根项目统一声明版本、子项目延迟应用:
// root build.gradle.kts
plugins {
id("com.example.convention") version "1.0.0" apply false
}
// app/build.gradle.kts
plugins {
id("com.example.convention")
}
apply false 不代表插件代码完全不被解析,而是表示不把插件应用到当前项目。子项目可以复用已经声明的插件版本。
3. 扩展对象与任务类型
插件不应要求用户修改大量低级任务;更好的方式是提供扩展对象:
myFeature {
enabled.set(true)
outputDirectory.set(layout.buildDirectory.dir("feature"))
}
插件内部通过类型安全的属性读取这些配置,再注册任务。
Gradle API 中常见的惰性类型包括:
Property<T>:单个值;ListProperty<T>:列表;MapProperty<K, V>:映射;DirectoryProperty:目录;RegularFileProperty:文件;Provider<T>:延迟值。
示例:
abstract class GreetingExtension {
abstract val name: Property<String>
}
插件实现:
class GreetingPlugin : Plugin<Project> {
override fun apply(project: Project) {
val extension = project.extensions.create<GreetingExtension>("greeting")
project.tasks.register("printGreeting") {
val name = extension.name.convention("Gradle")
inputs.property("name", name)
doLast {
println("Hello, ${name.get()}")
}
}
}
}
这里的关键是:任务注册时只建立属性关系,执行时才读取 name.get()。这比在配置阶段把值拷贝成普通字符串更适合增量构建和配置缓存。
4. 自定义插件的生命周期
一个插件通常经历:
- Gradle 发现插件;
- 创建插件实例;
- 调用
apply(project); - 插件创建扩展和任务;
- 用户在构建脚本中配置扩展;
- Gradle 计算任务图;
- 任务在执行阶段读取最终属性。
如果插件在 apply 阶段就执行编译、网络访问或读取大量文件,就会把任务工作错误地提前到配置阶段。
5. buildSrc 与 included build
小型项目可以使用 buildSrc 存放约定插件,但 buildSrc 的变化通常会使整个构建逻辑重新编译和配置。更具扩展性的做法是使用 included build:
gradle-demo/
├── build-logic/
│ ├── settings.gradle.kts
│ └── convention/
│ └── build.gradle.kts
根 settings.gradle.kts:
pluginManagement {
includeBuild("build-logic")
repositories {
gradlePluginPortal()
mavenCentral()
}
}
build-logic/convention/build.gradle.kts:
plugins {
`kotlin-dsl`
}
repositories {
gradlePluginPortal()
mavenCentral()
}
gradlePlugin {
plugins {
register("javaLibraryConvention") {
id = "example.java-library-convention"
implementationClass = "example.JavaLibraryConventionPlugin"
}
}
}
子项目使用:
plugins {
id("example.java-library-convention")
}
约定插件适合集中表达“所有 Java Library 都使用 Java 25、UTF-8、JUnit、统一检查规则”等构建规则。它比根脚本对所有子项目进行无条件修改更容易测试、复用和演进。
八、多模块构建:项目关系、类路径和任务图
1. 多模块项目的三个关系
多模块构建同时存在三种关系:
- 项目包含关系:由
settings.gradle.kts的include声明; - 模块依赖关系:由
project(":core")声明; - 任务执行关系:由任务依赖和插件逻辑产生。
例如:
settings:
root
├── :app
└── :core
dependencies:
:app --implementation--> :core
task graph:
:app:compileJava
depends on :core:jar 或 :core:classes
项目包含并不自动产生模块依赖。即使 settings.gradle.kts 同时包含 :app 和 :core,app 也不会自动看到 core 的类,必须显式声明:
implementation(project(":core"))
2. api 和 implementation 在模块边界的影响
假设:
app -> core -> logging-api
如果 core 使用:
api("org.slf4j:slf4j-api:2.0.17")
那么 app 编译 core 的公开 API 时可以看到 slf4j-api。
如果改成:
implementation("org.slf4j:slf4j-api:2.0.17")
那么该库被视为 core 的内部实现依赖,app 不应依赖它来编译自己的源代码。
错误做法是:app 直接使用 core 的内部依赖类型,却只在 core 中声明 implementation。这可能在某些运行时路径中“碰巧能编译”,但模块边界已经失去清晰语义;正确做法是让 app 声明自己真正需要的依赖,或者由 core 将公开 API 需要的依赖声明为 api。
3. 只构建一个模块
./gradlew :core:build
只请求 core 的任务。
./gradlew :app:build
会包含 app 构建所需的 core 任务,因为存在项目依赖。
./gradlew build
会执行根项目 build 及其相关子项目的 build,具体任务集合由插件提供的生命周期任务决定。
4. 跨模块任务配置
推荐使用类型和 configureEach:
tasks.withType<Test>().configureEach {
useJUnitPlatform()
}
而不是强行假设每个模块都有某个具体任务:
tasks.named("test") {
// 如果某个子项目没有 test 任务,这里可能失败
}
如果必须配置可能存在的任务,应先判断插件或任务是否存在,或者把行为放入明确的约定插件中,让插件只应用于符合条件的项目。
5. 循环依赖
项目依赖应形成有向无环图:
app -> core -> model
以下关系是非法的:
app -> core
core -> app
Gradle 会检测到循环依赖并失败。更深层的循环可能来自测试工具模块、代码生成模块或插件自动添加的依赖。
解决循环的原则不是随意增加 mustRunAfter,因为顺序约束不能消除项目依赖循环。应重新划分模块边界,例如提取公共接口模块:
app -> api
core -> api
九、测试、打包和 Java 25 Toolchain
1. Toolchain 的意义
java {
toolchain {
languageVersion.set(JavaLanguageVersion.of(25))
}
}
Toolchain 表示构建应使用 Java 25 工具链进行编译、测试或运行,而不是简单地设置:
sourceCompatibility = JavaVersion.VERSION_25
后者主要描述源代码兼容性,不能保证机器上存在对应的 JDK,也不能完整表达编译器、测试运行时和运行任务应使用哪个 JDK。
Toolchain 的前置条件是 Gradle 能找到或安装符合要求的 JDK。企业环境通常应明确配置 JDK 来源和网络策略,避免 CI 在无网络环境中临时下载失败。
2. JUnit 5 测试
app/src/test/java/example/app/MainTest.java:
package example.app;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
final class MainTest {
@Test
void createsGreeting() {
assertEquals("Hello, Test", MainGreeting.message("Test"));
}
private static final class MainGreeting {
static String message(String name) {
return "Hello, " + name;
}
}
}
更合理的测试通常应直接测试生产代码,而不是在测试中复制实现。这里的测试只是展示 testImplementation 和 JUnit 平台配置。
运行:
./gradlew :app:test
查看测试报告:
app/build/reports/tests/test/index.html
test 失败时,build 通常也会失败,因为 Java 插件把测试接入了 check,而 build 又依赖 check。
3. Application 插件
./gradlew :app:run
Application 插件会使用 mainClass 和运行时类路径启动 Java 应用。
打包发行版:
./gradlew :app:installDist
生成目录:
app/build/install/app/
├── bin/
└── lib/
构建可分发压缩包:
./gradlew :app:distTar :app:distZip
它们与普通 jar 的区别是:发行版包含启动脚本和运行时依赖,而 jar 默认只表示当前项目的类和资源。
十、常见错误及其诊断路径
1. “任务没有按我想的顺序执行”
先区分需求:
- 需要自动执行另一个任务:
dependsOn; - 只在两个任务都执行时排序:
mustRunAfter; - 允许 Gradle 在必要时忽略排序:
shouldRunAfter; - 任务失败后仍做清理:
finalizedBy。
查看任务实际关系:
./gradlew :app:test --dry-run
--dry-run 会展示计划执行的任务,但不会真正执行动作。它适合确认任务图,不代表输入输出判断后的最终实际工作量。
2. “修改文件后任务仍然跳过”
检查:
- 文件是否声明为
inputs.file或inputs.dir; - 输出是否声明为
outputs.file或outputs.dir; - 是否修改了任务读取但未声明的环境变量;
- 是否启用了构建缓存并复用了旧结果;
- 是否有自定义任务把结果写到了声明输出之外。
使用:
./gradlew generateFile --info
--info 通常会说明任务为什么执行、跳过或从缓存加载。不要一看到缓存就使用 clean;先确定是输入声明错误还是缓存预期行为。
3. “依赖已经声明,为什么类仍找不到”
常见原因是配置不对:
- 生产代码误放在
testImplementation; - 编译时需要的库放在
runtimeOnly; - 子项目没有声明
implementation(project(":core")); - 使用了
compileOnly,但运行时没有提供对应库; - 依赖被放在错误的模块中;
- 解析到的变体不包含预期能力。
检查:
./gradlew :app:dependencies --configuration compileClasspath
./gradlew :app:dependencies --configuration runtimeClasspath
编译类路径和运行时类路径不同。一个依赖能在测试运行时出现,不代表生产编译类路径一定可见。
4. “依赖解析成功,但运行时报 NoSuchMethodError”
这通常表示编译时和运行时使用了不兼容的版本,或多个版本冲突后选择了错误版本。
诊断步骤:
./gradlew :app:dependencyInsight \
--dependency suspicious-library \
--configuration runtimeClasspath
然后比较:
./gradlew :app:dependencies --configuration compileClasspath
./gradlew :app:dependencies --configuration runtimeClasspath
如果存在多个版本,优先理解冲突来源和库的兼容范围,而不是盲目使用 force。强制版本可能让解析通过,却把不兼容问题推迟到运行时。
5. “打开配置缓存后构建失败”
先单独运行:
./gradlew help --configuration-cache
help 本身不需要编译,适合快速暴露配置阶段问题。
之后逐步执行:
./gradlew :core:compileJava --configuration-cache
./gradlew :app:test --configuration-cache
常见修复方式:
- 使用
Provider替代配置阶段直接读取外部值; - 使用
Property<T>、DirectoryProperty、RegularFileProperty; - 在任务执行阶段只使用任务自身声明的输入;
- 升级或替换不兼容配置缓存的第三方插件;
- 将自定义逻辑移入可测试的约定插件。
不要把所有问题都用 --no-configuration-cache 解决。该参数适合临时绕过问题,不是修复方案。
6. “Java 25 编译失败”
确认三个版本:
java -version
./gradlew --version
然后确认:
- Gradle 运行时是否支持 Java 25;
- Java Toolchain 是否能找到 Java 25;
- 编译器是否确实使用了目标 Toolchain;
- 第三方插件是否支持当前 Gradle 和 Java 版本;
- 代码使用的 Java 25 语言特性是否被当前编译配置允许。
Gradle 版本升级可能同时影响插件 API、依赖解析、默认行为和配置缓存兼容性,因此应通过 Wrapper 和 CI 固定验证,而不是只在个人机器上替换 JAVA_HOME。
十一、推荐的项目结构与构建边界
一个持续演进的多模块项目可以按职责划分:
root
├── app # 可运行应用
├── core # 核心领域或业务逻辑
├── api # 对外接口和 DTO
├── persistence # 数据访问实现
├── integration-test # 集成测试
└── build-logic # 约定插件
依赖方向应尽量单向:
app -> core -> api
persistence -> core
integration-test -> app
这里的箭头表示“依赖”。若 core 反过来依赖 app,通常意味着应用装配逻辑泄漏进核心模块。
公共配置应进入约定插件,例如:
plugins {
id("example.java-library-convention")
}
约定插件可以统一:
- Java 25 Toolchain;
- 编码;
- 测试平台;
- 编译器警告;
- 检查任务;
- 依赖仓库策略;
- 发布元数据。
模块构建脚本则只描述模块特有的内容:
plugins {
id("example.java-library-convention")
}
dependencies {
api(project(":api"))
implementation("org.slf4j:slf4j-api:2.0.17")
}
这样做的实际收益不是“脚本更短”,而是构建规则有了明确的拥有者:公共规则由插件负责,模块依赖由模块负责,项目组成由 settings 负责。
十二、从一次命令到最终产物的完整因果链
执行:
./gradlew :app:build --configuration-cache --build-cache
Gradle 大致经历以下过程:
- Wrapper 确定 Gradle 版本;
- 初始化阶段读取
settings.gradle.kts; - 发现根项目、
:app和:core; - 解析并应用 Java、Java Library、Application 等插件;
- 配置 Java 25 Toolchain、依赖仓库和模块依赖;
- 根据
:app:build找到build、check、assemble等任务; - 根据
app -> core建立跨项目任务关系; - 尝试复用配置缓存;
- 对每个任务检查输入、输出和任务实现;
- 若当前输出有效,则标记
UP-TO-DATE; - 若本地或远程构建缓存存在匹配结果,则加载缓存输出;
- 否则执行编译、测试和打包;
- 保存新的任务输出和可能的配置缓存;
- 返回任务失败或成功状态。
其中任一层都可能失败:
- settings 语法错误:初始化失败;
- 插件找不到:插件解析失败;
- 仓库不可用:依赖解析失败;
- Java 25 Toolchain 不可用:编译任务失败;
- 任务输入声明错误:可能得到过期缓存结果;
- 测试失败:
check和build失败; - 打包配置错误:
assemble失败。
掌握 Gradle,不是记住若干命令,而是能回答四个问题:
- 当前项目由哪些模块组成?
- 当前任务有哪些输入、输出和依赖?
- Gradle 为什么执行、跳过或缓存了这个任务?
- 某个插件或依赖在哪个生命周期阶段改变了构建模型?
当 Task、配置缓存、依赖、插件和多模块构建都被放回这条因果链中,Gradle 的行为就不再像一组分散的 DSL 配置,而是一个可以观察、诊断和演进的构建系统。
系列导航与关联阅读
- 系列入口:Java 完整学习路线:从 Java 25 语言与 JVM 到 Spring、微服务和生产交付
- 上一篇:Maven 完整基础:生命周期、依赖、插件、BOM、仓库和可重复构建
- 下一篇:Java 25 模块系统 JPMS:requires、exports、opens、服务和迁移
官方资料
本文依据 Java、Spring 与相关项目官方文档重新梳理;正文、示例与生产清单由 WR BLOG 编写。

评论
0 条讨论