构建脚本基础:build.gradle结构、Project与Task、Groovy/Kotlin DSL入门

说起build.gradle,我猜你第一眼看到它时,心里可能会想:“这不就是个配置文件吗?” 其实它的内核远比配置文件要深。我做了几年Gradle工程化后,才真正理解它背后那张“声明式构建”的网。

build.gradle 的骨架

一个典型的build.gradle文件,由三部分组成:插件声明、依赖管理、自定义配置。举个例子:

plugins {
    id 'java'
    id 'org.springframework.boot' version '2.7.0'
}

repositories {
    mavenCentral()
}

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-web'
    testImplementation 'org.junit.jupiter:junit-jupiter-api:5.8.0'
}

tasks.named('test') {
    useJUnitPlatform()
}

jar {
    manifest {
        attributes 'Main-Class': 'com.example.App'
    }
}

这里的repositories、dependencies、tasks都是Gradle内置的配置块。每个配置块背后都对应一个Project对象的方法调用。Gradle在执行脚本时,会把这些块“委托”给一个叫Project的实例来解析。

我个人习惯把plugins块放在最上面,因为它决定了后面能用的所有扩展。项目依赖的仓库和外部插件顺序其实无所谓,但插件块必须优先——这是Gradle的硬性要求。

Project:构建的“总指挥”

每个build.gradle文件都对应一个Project对象。它负责管理整个构建的生命周期,包括:

  • 加载并解析脚本
  • 创建和配置Task
  • 管理依赖关系
  • 触发构建流程

你可以把Project想象成一个容器,里面装了插件、依赖、配置属性,以及最重要的——Task。Gradle的构建执行,本质上就是按照Task依赖图(DAG)的顺序,依次运行这些Task。

我曾经在一个多模块项目里,不小心在子项目的build.gradle里重复声明了全局插件,导致构建速度翻倍。记住:allprojects { }和subprojects { }区块会影响父Project的子Project集合,用之前一定要想清楚作用域。

Task:构建的“最小原子”

Task是Gradle里最基础的可执行单元。每个Task都有动作(action)、输入(inputs)和输出(outputs)。靠输入输出,Gradle能实现增量构建——只有发生变化的部分才重新执行。

定义一个Task很简单:

tasks.register('hello') {
    doLast {
        println 'Hello, Gradle!'
    }
}

执行./gradlew hello就会打印这句问候。你可能会觉得这不就是一个脚本函数吗?但它背后隐藏着Gradle的配置阶段和执行阶段的分离逻辑:

  1. 配置阶段:Gradle解析所有Project和Task定义,构建依赖图
  2. 执行阶段:Gradle按照图顺序依次执行Task动作

嗯,这里要注意:如果你将代码写在doLast外面,它会在配置阶段就执行掉,而不是在执行阶段。我曾经因为这个bug排查了一下午——Task一直在“提前跑”。

Groovy DSL vs Kotlin DSL:你的选择

Gradle从5.0开始就支持两种DSL:Groovy DSL(.gradle)和Kotlin DSL(.gradle.kts)。说白了,一个用Groovy,一个用Kotlin。你想想看,如果你之前是Android开发者,大概早就用上Kotlin DSL了。我个人推荐新项目优先选用Kotlin DSL,理由很简单:类型安全、IDE提示友好、重构更安心。

但如果你接手的是老项目,Groovy DSL也不差,只是调试时容易因为动态类型踩坑。

下面是同一个配置在两种DSL中的写法对比:

功能 Groovy DSL Kotlin DSL
插件声明
plugins { id 'java' }
plugins { java }
依赖声明
implementation 'com.google.guava:guava:30.1-jre'
implementation("com.google.guava:guava:30.1-jre")
Task注册
task myTask { doLast { ... } }
tasks.register("myTask") { doLast { ... } }
属性赋值
sourceCompatibility = 11
sourceCompatibility = JavaVersion.VERSION_11
字符串拼接
"lib-$version"
"lib-\$version"

Kotlin DSL最大的好处是——你在IDE里Ctrl+点击一个扩展方法,能直接跳到定义处。这对于排查奇怪报错特别有用。我建议至少把build.gradle.kts的代码格式化加上,不然括号层级稍微一多就晕了。

核心知识结构

下面这张图总结了本章的核心概念及其关系。你看一眼就能明白build.gradle、Project、Task、Plugin和Dependency之间是怎么连起来的:

Gradle构建脚本核心结构 build.gradle Project Task (DAG) Plugin Dependency Configuration build.gradle → Project → 组合Task/Plugin/Dependency/Configuration → 生成执行图

避坑指南:我踩过的几个坑

  • Task注册用register还是create? 我早期一直用create,后来才发现register是延迟创建(Lazy Configuration),在多配置场景下能避免不必要的配置耗时。建议无脑用register。
  • Kotlin DSL里漏了构造参数 比如tasks.register("myTask")后直接写action,IDE不会报错,但运行时可能找不到类型。解决方法:显式指定类型,如tasks.register<Copy>("copyTask")。
  • Groovy DSL里的def作用域 在build.gradle里用def定义的变量只在当前作用域可见,跨subprojects会丢失。我建议用ext扩展属性来传递公有变量,虽然麻烦,但不会出幺蛾子。
  • 依赖版本冲突 没有声明constraints时,Gradle会默认使用最高版本。你可以用dependencyLocking锁定版本,或者在buildSrc中统一管理版本号。

我习惯在项目根目录放一个gradle.properties,把公共版本号提出来,比如springBootVersion=2.7.0。这样所有子模块引用时就用springBootVersion变量,改一个地方全局生效。注意:Kotlin DSL里引用方式是property("springBootVersion")。

说实话,build.gradle的精髓不在于你写多少配置,而在于理解Project和Task的关系。你想想看,Gradle把构建过程拆成了一个个Task,再通过依赖将它们串成一张有向无环图。这个设计思想在很多构建工具里都能看到影子。学会了它,以后用Maven的execution或者Bazel的规则,你也不会觉得陌生。