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")

这里有三个不同概念:

  1. pluginManagement.repositories 用于查找插件;
  2. dependencyResolutionManagement.repositories 用于查找普通依赖;
  3. 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-libraryjava 的重要区别是,它把依赖暴露分为:

  • 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 的关键,是区分三个阶段:

  1. Initialization(初始化)
  2. Configuration(配置)
  3. 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 是一个具有名称、输入、输出、动作和依赖关系的构建单元。

抽象地说,一个任务可以表示为:

T=(I,O,A,D,P)T = (I, O, A, D, P)

其中:

  • II:输入集合;
  • OO:输出集合;
  • AA:执行动作;
  • DD:依赖任务集合;
  • PP:任务属性,例如是否启用、是否总是执行。

理想情况下,任务动作可以看作函数:

O=A(I)O = A(I)

如果输入 II 没有变化,且输出 OO 仍然存在,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. dependsOnmustRunAftershouldRunAfter

三者语义不同。

dependsOn:声明执行依赖

tasks.register("verify") {
    dependsOn("test")
}

运行:

./gradlew verify

会先执行 test,再执行 verify。如果 test 失败,verify 通常不会执行。

mustRunAfter:声明顺序,不引入依赖

tasks.named("check") {
    mustRunAfter("assemble")
}

这并不保证执行 assemble。只有当 assemblecheck 都已经进入任务图时,才保证 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")
}

cleanupintegrationTest 的终结任务。它适合关闭测试服务器、删除临时资源等清理动作。

但终结任务不是事务回滚机制。清理代码本身失败时,构建仍可能失败;外部资源如果在 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 是一个延迟计算的值。它有两个重要特征:

  1. 值可以延迟到真正需要时再计算;
  2. 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
  • 在任务动作中访问 TaskConfiguration 等生命周期对象;
  • 读取未声明的环境变量;
  • 自定义插件保存了不可序列化状态;
  • 使用第三方插件,而该插件尚未适配配置缓存。

配置缓存不是“打开后必然加速”的开关。首次运行需要生成缓存;构建逻辑、Gradle 版本、参数、环境等发生变化时,缓存也可能失效。


六、依赖管理:从声明到解析

1. 依赖的三个层次

Gradle 依赖管理至少包含三个层次:

  1. 声明:项目表达需要什么;
  2. 解析:Gradle 从仓库和项目中选择具体组件;
  3. 消费:编译、测试、运行或打包使用解析后的文件和变体。

例如:

dependencies {
    implementation("org.apache.commons:commons-lang3:3.17.0")
}

坐标通常是:

group:name:version

对应:

org.apache.commons : commons-lang3 : 3.17.0

声明依赖不会立即等价于“把 JAR 下载下来”。Gradle 会根据当前配置,例如 compileClasspathruntimeClasspath,在需要时解析适用的依赖图。

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 不只是找到一个文件,还会读取被依赖项目的变体和元数据。例如 coreapi 依赖可以传递给 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. 自定义插件的生命周期

一个插件通常经历:

  1. Gradle 发现插件;
  2. 创建插件实例;
  3. 调用 apply(project)
  4. 插件创建扩展和任务;
  5. 用户在构建脚本中配置扩展;
  6. Gradle 计算任务图;
  7. 任务在执行阶段读取最终属性。

如果插件在 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. 多模块项目的三个关系

多模块构建同时存在三种关系:

  1. 项目包含关系:由 settings.gradle.ktsinclude 声明;
  2. 模块依赖关系:由 project(":core") 声明;
  3. 任务执行关系:由任务依赖和插件逻辑产生。

例如:

settings:
root
├── :app
└── :core

dependencies:
:app --implementation--> :core

task graph:
:app:compileJava
  depends on :core:jar 或 :core:classes

项目包含并不自动产生模块依赖。即使 settings.gradle.kts 同时包含 :app:coreapp 也不会自动看到 core 的类,必须显式声明:

implementation(project(":core"))

2. apiimplementation 在模块边界的影响

假设:

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. “修改文件后任务仍然跳过”

检查:

  1. 文件是否声明为 inputs.fileinputs.dir
  2. 输出是否声明为 outputs.fileoutputs.dir
  3. 是否修改了任务读取但未声明的环境变量;
  4. 是否启用了构建缓存并复用了旧结果;
  5. 是否有自定义任务把结果写到了声明输出之外。

使用:

./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>DirectoryPropertyRegularFileProperty
  • 在任务执行阶段只使用任务自身声明的输入;
  • 升级或替换不兼容配置缓存的第三方插件;
  • 将自定义逻辑移入可测试的约定插件。

不要把所有问题都用 --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 大致经历以下过程:

  1. Wrapper 确定 Gradle 版本;
  2. 初始化阶段读取 settings.gradle.kts
  3. 发现根项目、:app:core
  4. 解析并应用 Java、Java Library、Application 等插件;
  5. 配置 Java 25 Toolchain、依赖仓库和模块依赖;
  6. 根据 :app:build 找到 buildcheckassemble 等任务;
  7. 根据 app -> core 建立跨项目任务关系;
  8. 尝试复用配置缓存;
  9. 对每个任务检查输入、输出和任务实现;
  10. 若当前输出有效,则标记 UP-TO-DATE
  11. 若本地或远程构建缓存存在匹配结果,则加载缓存输出;
  12. 否则执行编译、测试和打包;
  13. 保存新的任务输出和可能的配置缓存;
  14. 返回任务失败或成功状态。

其中任一层都可能失败:

  • settings 语法错误:初始化失败;
  • 插件找不到:插件解析失败;
  • 仓库不可用:依赖解析失败;
  • Java 25 Toolchain 不可用:编译任务失败;
  • 任务输入声明错误:可能得到过期缓存结果;
  • 测试失败:checkbuild 失败;
  • 打包配置错误:assemble 失败。

掌握 Gradle,不是记住若干命令,而是能回答四个问题:

  1. 当前项目由哪些模块组成?
  2. 当前任务有哪些输入、输出和依赖?
  3. Gradle 为什么执行、跳过或缓存了这个任务?
  4. 某个插件或依赖在哪个生命周期阶段改变了构建模型?

当 Task、配置缓存、依赖、插件和多模块构建都被放回这条因果链中,Gradle 的行为就不再像一组分散的 DSL 配置,而是一个可以观察、诊断和演进的构建系统。


系列导航与关联阅读

官方资料

本文依据 Java、Spring 与相关项目官方文档重新梳理;正文、示例与生产清单由 WR BLOG 编写。