任何构建工具的一个重要部分是避免做已经完成的工作的能力。考虑编译过程。一旦源文件被编译,除非有影响输出的改动,比如源文件被修改或输出文件被删除,否则无需重新编译。编译可能需要大量时间,因此在不需要时跳过该步骤可以节省大量时间。

Gradle 通过一项名为增量构建的功能开箱即用地支持此行为。您几乎肯定已经见过它的作用了。当您运行一个任务,并且该任务在控制台输出中标记为 UP-TO-DATE 时,这意味着增量构建正在工作。

增量构建是如何工作的?如何确保您的任务支持增量运行?让我们来看看。

任务输入和输出

在最常见的情况下,一个任务会接受一些输入并生成一些输出。我们可以将 Java 编译过程视为一个任务的例子。Java 源文件充当任务的输入,而生成的类文件(即编译结果)是任务的输出。

taskInputsOutputs
图 1. 任务输入和输出示例

输入的一个重要特征是它会影响一个或多个输出,正如您从上图中看到的。根据源文件的内容以及您希望代码运行的最低 Java 运行时版本,会生成不同的字节码。这使得它们成为任务输入。但是,由 memoryMaximumSize 属性确定的编译是否有 500MB 或 600MB 的最大可用内存,对生成的字节码没有影响。在 Gradle 术语中,memoryMaximumSize 只是一个内部任务属性。

作为增量构建的一部分,Gradle 会测试自上次构建以来,任务的任何输入或输出是否已更改。如果未更改,Gradle 会认为该任务是最新的,因此会跳过执行其操作。另请注意,除非任务至少有一个任务输出,否则增量构建将不起作用,尽管任务通常也至少有一个输入。

这对构建作者来说很简单:您需要告诉 Gradle 哪些任务属性是输入,哪些是输出。如果任务属性影响输出,请务必将其注册为输入,否则任务将在不是最新时被视为最新。相反,如果属性不影响输出,请不要将其注册为输入,否则任务可能会在不需要时执行。同时,请注意非确定性任务,它们可能为完全相同的输入生成不同的输出:不应为这些任务配置增量构建,因为最新检查将不起作用。

现在,让我们看看如何将任务属性注册为输入和输出。

通过注解声明输入和输出

如果您将自定义任务实现为一个类,那么只需两个步骤即可使其与增量构建配合使用

  1. 为您的每个任务输入和输出创建类型化属性(通过 getter 方法)

  2. 为每个属性添加适当的注解

注解必须放置在 getter 或 Groovy 属性上。放置在 setter 上,或没有相应注解 getter 的 Java 字段上的注解会被忽略。

Gradle 支持四种主要的输入和输出类别

  • 简单值

    例如字符串和数字。更一般地说,简单值可以是实现 Serializable 的任何类型。

  • 文件系统类型

    这些包括 RegularFileDirectory 和标准的 File 类,但也包括 Gradle 的 FileCollection 类型的派生类,以及任何可以传递给 Project.file(java.lang.Object) 方法(用于单个文件/目录属性)或 Project.files(java.lang.Object...) 方法的内容。

  • 依赖项解析结果

    这包括用于 artifact 元数据的 ResolvedArtifactResult 类型和用于依赖图的 ResolvedComponentResult 类型。请注意,它们仅支持包装在 Provider 中。

  • 嵌套值

    不符合前两类,但本身包含作为输入或输出的属性的自定义类型。实际上,任务输入或输出嵌套在这些自定义类型中。

举个例子,假设您有一个任务处理不同类型的模板,例如 FreeMarker、Velocity、Moustache 等。它接受模板源文件,并将其与一些模型数据结合,生成填充内容的模板文件版本。

此任务将有三个输入和一个输出

  • 模板源文件

  • 模型数据

  • 模板引擎

  • 输出文件的写入位置

在编写自定义任务类时,通过注解轻松将属性注册为输入或输出。为演示,以下是一个具有一些合适输入和输出及其注解的骨架任务实现

buildSrc/src/main/java/org/example/ProcessTemplates.java
package org.example;

import java.util.HashMap;
import org.gradle.api.DefaultTask;
import org.gradle.api.file.ConfigurableFileCollection;
import org.gradle.api.file.DirectoryProperty;
import org.gradle.api.file.FileSystemOperations;
import org.gradle.api.provider.Property;
import org.gradle.api.tasks.*;

import javax.inject.Inject;

public abstract class ProcessTemplates extends DefaultTask {

    @Input
    public abstract Property<TemplateEngineType> getTemplateEngine();

    @InputFiles
    public abstract ConfigurableFileCollection getSourceFiles();

    @Nested
    public abstract TemplateData getTemplateData();

    @OutputDirectory
    public abstract DirectoryProperty getOutputDir();

    @Inject
    public abstract FileSystemOperations getFs();

    @TaskAction
    public void processTemplates() {
        // ...
    }
}
buildSrc/src/main/java/org/example/TemplateData.java
package org.example;

import org.gradle.api.provider.MapProperty;
import org.gradle.api.provider.Property;
import org.gradle.api.tasks.Input;

public abstract class TemplateData {

    @Input
    public abstract Property<String> getName();

    @Input
    public abstract MapProperty<String, String> getVariables();
}
gradle processTemplates 的输出
> gradle processTemplates
> Task :processTemplates

BUILD SUCCESSFUL in 0s
3 actionable tasks: 3 up-to-date
gradle processTemplates 的输出(再次运行)
> gradle processTemplates
> Task :processTemplates UP-TO-DATE

BUILD SUCCESSFUL in 0s
3 actionable tasks: 3 up-to-date

本示例有很多值得讨论的地方,因此让我们依次介绍每个输入和输出属性

  • templateEngine

    表示处理源模板时使用的引擎,例如 FreeMarker、Velocity 等。您可以将其实现为字符串,但在本例中,我们选择了一个自定义枚举,因为它提供了更好的类型信息和安全性。由于枚举自动实现 Serializable,我们可以将其视为简单值并使用 @Input 注解,就像用于 String 属性一样。

  • sourceFiles

    任务将要处理的源模板。单个文件和文件集合需要它们自己特殊的注解。在本例中,我们处理的是一个输入文件集合,因此我们使用 @InputFiles 注解。稍后会在表格中看到更多文件相关的注解。

  • templateData

    在本示例中,我们使用一个自定义类来表示模型数据。但是,它没有实现 Serializable,所以我们不能使用 @Input 注解。这不是问题,因为 TemplateData 中的属性(一个字符串和一个带有可序列化类型参数的哈希映射)是可序列化的,并且可以使用 @Input 进行注解。我们在 templateData 上使用 @Nested,以告知 Gradle 这是一个具有嵌套输入属性的值。

  • outputDir

    生成文件存放的目录。与输入文件一样,输出文件和目录也有几种注解。表示单个目录的属性需要 @OutputDirectory。稍后您将了解其他注解。

这些带注解的属性意味着,如果源文件、模板引擎、模型数据或生成的文件自上次 Gradle 执行任务以来都没有更改,Gradle 将跳过该任务。这通常会节省大量时间。您可以稍后了解 Gradle 如何检测更改

这个例子特别有趣,因为它处理源文件的集合。如果只有一个源文件更改会怎样?任务是重新处理所有源文件还是只处理修改过的文件?这取决于任务实现。如果是后者,那么任务本身是增量的,但这与我们此处讨论的功能不同。Gradle 通过其增量任务输入功能帮助任务实现者实现这一点。

既然您已经看到了一些输入和输出注解的实际应用,现在让我们看看您可以使用的所有注解以及何时使用它们。下表列出了可用的注解以及每个注解对应的属性类型。

表 1. 增量构建属性类型注解
注解 预期属性类型 描述

任何 Serializable 类型或依赖项解析结果类型

简单的输入值或依赖项解析结果

File*

单个输入文件(非目录)

File*

单个输入目录(非文件)

Iterable<File>*

输入文件和目录的可迭代集合

Iterable<File>*

表示 Java classpath 的输入文件和目录的可迭代集合。这允许任务忽略属性中不相关的更改,例如相同文件的不同名称。它类似于使用 @PathSensitive(RELATIVE) 注解属性,但它会忽略直接添加到 classpath 的 JAR 文件名称,并将文件顺序的更改视为 classpath 的更改。Gradle 将检查 classpath 上 jar 文件的内容,并忽略不影响 classpath 语义的更改(例如文件日期和条目顺序)。另请参阅使用 classpath 注解

注意:@Classpath 注解是在 Gradle 3.2 中引入的。为了与早期 Gradle 版本保持兼容,classpath 属性也应使用 @InputFiles 进行注解。

Iterable<File>*

表示 Java 编译 classpath 的输入文件和目录的可迭代集合。这允许任务忽略不影响 classpath 中类 API 的不相关更改。另请参阅使用 classpath 注解

classpath 的以下类型的更改将被忽略

  • jar 或顶级目录路径的更改。

  • jar 中时间戳和条目顺序的更改。

  • 资源和 Jar 清单的更改,包括添加或删除资源。

  • 私有类元素的更改,例如私有字段、方法和内部类。

  • 代码的更改,例如方法体、静态初始化器和字段初始化器(常量除外)。

  • 调试信息的更改,例如当注释的更改影响类调试信息中的行号时。

  • 目录的更改,包括 Jar 中的目录条目。

注意 - @CompileClasspath 注解是在 Gradle 3.4 中引入的。为了与 Gradle 3.3 和 3.2 保持兼容,编译 classpath 属性也应使用 @Classpath 进行注解。为了与 3.2 之前的 Gradle 版本保持兼容,该属性还应使用 @InputFiles 进行注解。

File*

单个输出文件(非目录)

File*

单个输出目录(非文件)

Map<String, File>** 或 Iterable<File>*

输出文件或目录的可迭代集合或映射。使用文件树会关闭该任务的缓存功能。

Map<String, File>** 或 Iterable<File>*

输出目录的可迭代集合。使用文件树会关闭该任务的缓存功能。

FileIterable<File>*

指定此任务删除的一个或多个文件。请注意,任务可以定义输入/输出或可销毁项,但不能同时定义两者。

FileIterable<File>*

指定表示任务本地状态的一个或多个文件。当从缓存加载任务时,这些文件将被删除。

任何自定义类型

可能未实现 Serializable,但至少有一个字段或属性标有此表中注解之一的自定义类型。它甚至可以是另一个 @Nested

任何类型

表示该属性既不是输入也不是输出。它只是以某种方式影响任务的控制台输出,例如增加或减少任务的详细程度。

任何类型

表示该属性在内部使用,但既不是输入也不是输出。

任何类型

表示该属性已被另一个属性取代,应被忽略为输入或输出。

FileIterable<File>*

@InputFiles@InputDirectory 一起使用,告知 Gradle 如果相应的文件或目录为空,则跳过任务,以及使用此注解声明的所有其他输入文件。由于所有使用此注解声明的输入文件为空而跳过的任务将导致独特的“无源”结果。例如,控制台输出中将显示 NO-SOURCE

隐含 @Incremental

Provider<FileSystemLocation>FileCollection

@InputFiles@InputDirectory 一起使用,指示 Gradle 跟踪带注解的文件属性的更改,以便可以通过 @InputChanges.getFileChanges() 查询更改。这是增量任务所必需的。

任何类型

Optional API 文档中列出的任何属性类型注解一起使用。此注解禁用对相应属性的验证检查。有关更多详细信息,请参阅关于验证的部分

FileIterable<File>*

与任何输入文件属性一起使用,以告知 Gradle 仅将文件路径的给定部分视为重要。例如,如果属性使用 @PathSensitive(PathSensitivity.NAME_ONLY) 进行注解,那么移动文件而不更改其内容不会使任务过期。

FileIterable<File>*

@InputFiles@InputDirectory 一起使用,指示 Gradle 仅跟踪目录内容的更改,而不跟踪目录本身的差异。例如,在目录结构中的某个位置删除、重命名或添加一个空目录不会使任务过期。

FileIterable<File>*

@InputFiles@InputDirectory@Classpath 一起使用,指示 Gradle 在计算最新检查或构建缓存键时规范化行尾。例如,将文件在 Unix 行尾和 Windows 行尾之间切换(或反之)不会使任务过期。

File 可以是 Project.file(java.lang.Object) 接受的任何类型,Iterable<File> 可以是 Project.files(java.lang.Object…​) 接受的任何类型。这包括 Callable 的实例,例如闭包,允许对属性值进行惰性求值。请注意,FileCollectionFileTree 类型都是 Iterable<File> 的实例。

与上面类似,File 可以是 Project.file(java.lang.Object) 接受的任何类型。Map 本身可以包装在 Callable 中,例如闭包。

注解继承自所有父类型,包括实现的接口。属性类型注解会覆盖父类型中声明的任何其他属性类型注解。这样,一个 @InputFile 属性可以在子任务类型中转换为 @InputDirectory 属性。

在类型中声明的属性上的注解会覆盖超类和任何实现的接口中声明的类似注解。超类注解优先于在实现的接口中声明的注解。

表中的 ConsoleInternal 注解是特例,因为它们既不声明任务输入也不声明任务输出。那为什么使用它们呢?这是为了让您可以利用Java Gradle Plugin Development plugin来帮助您开发和发布自己的插件。此插件会检查您的自定义任务类的任何属性是否缺少增量构建注解。这可以防止您在开发过程中忘记添加适当的注解。

使用 classpath 注解

除了 @InputFiles,对于 JVM 相关任务,Gradle 理解 classpath 输入的概念。当 Gradle 检查更改时,运行时和编译 classpath 的处理方式是不同的。

与使用 @InputFiles 注解的输入属性不同,对于 classpath 属性,文件集合中条目的顺序很重要。另一方面,classpath 本身上的目录和 jar 文件的名称和路径会被忽略。classpath 上 jar 文件内类文件和资源的时间戳和顺序也会被忽略,因此使用不同的文件日期重新创建 jar 文件不会使任务过期。

运行时 classpath 使用 @Classpath 标记,并且可以通过classpath 规范化提供进一步的自定义。

使用 @CompileClasspath 注解的输入属性被视为 Java 编译 classpath。除了上述一般 classpath 规则外,编译 classpath 会忽略除类文件之外的所有更改。Gradle 使用与Java 编译避免中描述的相同的类分析来进一步过滤不影响类的 ABI 的更改。这意味着仅触及类实现的更改不会使任务过期。

嵌套输入

在分析 @Nested 任务属性以获取声明的输入和输出子属性时,Gradle 使用实际值的类型。因此,它可以发现运行时子类型声明的所有子属性。

当向 Provider 添加 @Nested 时,Provider 的值被视为嵌套输入。

当向可迭代集合添加 @Nested 时,每个元素都被视为一个独立的嵌套输入。可迭代集合中的每个嵌套输入都被分配一个名称,默认情况下是美元符号后跟在可迭代集合中的索引,例如 $2。如果可迭代集合的元素实现了 Named,则该名称被用作属性名称。如果并非所有元素都实现了 Named,则可迭代集合中元素的顺序对于可靠的最新检查和缓存至关重要。具有相同名称的多个元素是不允许的。

当向 map 添加 @Nested 时,则为每个值添加一个嵌套输入,使用键作为名称。

嵌套输入的类型和 classpath 也会被跟踪。这确保了嵌套输入的实现的更改会导致构建过期。通过这种方式,还可以将用户提供的代码添加为输入,例如通过使用 @Nested 注解 @Action 属性。请注意,对此类 action 的任何输入都应该被跟踪,无论是通过 action 上的带注解属性,还是手动将它们注册到任务中。

使用嵌套输入可以为任务实现更丰富的建模和扩展性,例如Test.getJvmArgumentProviders() 所示。

这使我们能够建模 JaCoCo Java 代理,从而声明必需的 JVM 参数并将输入和输出提供给 Gradle

JacocoAgent.java
class JacocoAgent implements CommandLineArgumentProvider {
    private final JacocoTaskExtension jacoco;

    public JacocoAgent(JacocoTaskExtension jacoco) {
        this.jacoco = jacoco;
    }

    @Nested
    @Optional
    public JacocoTaskExtension getJacoco() {
        return jacoco.isEnabled() ? jacoco : null;
    }

    @Override
    public Iterable<String> asArguments() {
        return jacoco.isEnabled() ? ImmutableList.of(jacoco.getAsJvmArg()) : Collections.<String>emptyList();
    }
}

test.getJvmArgumentProviders().add(new JacocoAgent(extension));

为了使此工作,JacocoTaskExtension 需要具有正确的输入和输出注解。

该方法适用于 Test JVM 参数,因为 Test.getJvmArgumentProviders() 是一个用 @Nested 注解的 Iterable

还有其他任务类型可以使用这种嵌套输入

同样,自定义任务也可以使用这种建模方式。

运行时验证

执行构建时,Gradle 会检查任务类型是否使用适当的注解声明。它会尝试识别问题,例如在不兼容的类型或 setter 上使用注解等。任何未用输入/输出注解标记的 getter 也会被标记。然后这些问题会导致构建失败,或者在执行任务时变成弃用警告。

存在验证警告的任务会在没有任何优化的情况下执行。具体来说,它们永远不会

  • 最新,

  • 构建缓存加载或存储到构建缓存中,

  • 与其他任务并行执行,即使并行执行已启用,

  • 增量执行。

文件系统状态的内存表示(虚拟文件系统)在执行无效任务之前也会失效。

通过运行时 API 声明输入和输出

自定义任务类是将您自己的构建逻辑引入增量构建领域的一种简单方法,但您并非总是有此选项。因此,Gradle 还提供了可用于任何任务的替代 API,我们接下来将对此进行介绍。

当您无法访问自定义任务类的源代码时,就无法添加我们在上一节中介绍的任何注解。幸运的是,Gradle 为像这样的场景提供了运行时 API。正如您接下来将看到的,它也可以用于临时任务。

声明临时任务的输入和输出

这个运行时 API 是通过几个恰当命名的属性提供的,这些属性在每个 Gradle 任务上都可用。

这些对象具有允许您指定文件、目录和值的 方法,这些 方法构成了任务的输入和输出。事实上,运行时 API 在功能上几乎与注解 对等。

它缺乏以下对应的功能:

让我们以前面的模板处理示例为例,看看它使用运行时 API 的临时任务是什么样子。

示例 2. 临时任务
build.gradle.kts
tasks.register("processTemplatesAdHoc") {
    inputs.property("engine", TemplateEngineType.FREEMARKER)
    inputs.files(fileTree("src/templates"))
        .withPropertyName("sourceFiles")
        .withPathSensitivity(PathSensitivity.RELATIVE)
    inputs.property("templateData.name", "docs")
    inputs.property("templateData.variables", mapOf("year" to "2013"))
    outputs.dir(layout.buildDirectory.dir("genOutput2"))
        .withPropertyName("outputDir")

    doLast {
        // Process the templates here
    }
}
build.gradle
tasks.register('processTemplatesAdHoc') {
    inputs.property('engine', TemplateEngineType.FREEMARKER)
    inputs.files(fileTree('src/templates'))
        .withPropertyName('sourceFiles')
        .withPathSensitivity(PathSensitivity.RELATIVE)
    inputs.property('templateData.name', 'docs')
    inputs.property('templateData.variables', [year: '2013'])
    outputs.dir(layout.buildDirectory.dir('genOutput2'))
        .withPropertyName('outputDir')

    doLast {
        // Process the templates here
    }
}
gradle processTemplatesAdHoc 的输出
> gradle processTemplatesAdHoc
> Task :processTemplatesAdHoc

BUILD SUCCESSFUL in 0s
3 actionable tasks: 3 executed

和以前一样,有很多话要说。首先,对于这个任务,您确实应该编写一个自定义任务类,因为它是一个非平凡的实现,具有多个配置选项。在这种情况下,没有任务属性来存储根源文件夹、输出目录的位置或任何其他设置。这是有意为之的,旨在强调运行时 API 不需要任务拥有任何状态。在增量构建方面,上面的临时任务将与自定义任务类表现相同。

所有输入和输出定义都是通过 inputsoutputs 上的方法完成的,例如 property()files()dir()。Gradle 对参数值执行最新检查,以确定任务是否需要再次运行。每个方法对应一个增量构建注解,例如 inputs.property() 对应于 @Inputoutputs.dir() 对应于 @OutputDirectory

任务移除的文件可以通过 destroyables.register() 指定。

build.gradle.kts
tasks.register("removeTempDir") {
    val tmpDir = layout.projectDirectory.dir("tmpDir")
    destroyables.register(tmpDir)
    doLast {
        tmpDir.asFile.deleteRecursively()
    }
}
build.gradle
tasks.register('removeTempDir') {
    def tempDir = layout.projectDirectory.dir('tmpDir')
    destroyables.register(tempDir)
    doLast {
        tempDir.asFile.deleteDir()
    }
}

运行时 API 和注解之间一个值得注意的区别是缺少直接对应于 @Nested 的方法。这就是为什么示例为模板数据使用了两个 property() 声明,每个 TemplateData 属性一个。在使用运行时 API 处理嵌套值时,您应该采用相同的技术。任何给定任务都可以声明可销毁项或输入/输出,但不能同时声明两者。

细粒度配置

运行时 API 方法只允许您声明输入和输出本身。但是,面向文件的方法会返回一个构建器——类型为 TaskInputFilePropertyBuilder——允许您提供有关这些输入和输出的附加信息。

您可以在构建器的 API 文档中了解构建器提供的所有选项,但我们在这里将展示一个简单的示例,让您了解您可以做什么。

假设我们不想在没有源文件时运行 processTemplates 任务,无论它是干净构建还是非干净构建。毕竟,如果没有源文件,任务就无事可做。构建器允许我们这样配置:

build.gradle.kts
tasks.register("processTemplatesAdHocSkipWhenEmpty") {
    // ...

    inputs.files(fileTree("src/templates") {
            include("**/*.fm")
        })
        .skipWhenEmpty()
        .withPropertyName("sourceFiles")
        .withPathSensitivity(PathSensitivity.RELATIVE)
        .ignoreEmptyDirectories()

    // ...
}
build.gradle
tasks.register('processTemplatesAdHocSkipWhenEmpty') {
    // ...

    inputs.files(fileTree('src/templates') {
            include '**/*.fm'
        })
        .skipWhenEmpty()
        .withPropertyName('sourceFiles')
        .withPathSensitivity(PathSensitivity.RELATIVE)
        .ignoreEmptyDirectories()

    // ...
}
gradle clean processTemplatesAdHocSkipWhenEmpty 的输出
> gradle clean processTemplatesAdHocSkipWhenEmpty
> Task :processTemplatesAdHocSkipWhenEmpty NO-SOURCE

BUILD SUCCESSFUL in 0s
3 actionable tasks: 2 executed, 1 up-to-date

TaskInputs.files() 方法返回一个具有 skipWhenEmpty() 方法的构建器。调用此方法相当于使用 @SkipWhenEmpty 注解该属性。

现在您已经了解了注解和运行时 API,您可能想知道应该使用哪种 API。我们的建议是尽可能使用注解,有时甚至值得为了使用注解而创建一个自定义任务类。运行时 API 更适用于无法使用注解的情况。

为自定义任务类型声明输入和输出

另一种示例是为自定义任务类的实例注册额外的输入和输出。例如,假设 ProcessTemplates 任务还需要读取 src/headers/headers.txt(例如,因为它包含在某个源文件中)。您会希望 Gradle 知道这个输入文件,以便每当此文件内容更改时,它都能重新执行任务。使用运行时 API 就可以做到这一点。

build.gradle.kts
tasks.register<ProcessTemplates>("processTemplatesWithExtraInputs") {
    // ...

    inputs.file("src/headers/headers.txt")
        .withPropertyName("headers")
        .withPathSensitivity(PathSensitivity.NONE)
}
build.gradle
tasks.register('processTemplatesWithExtraInputs', ProcessTemplates) {
    // ...

    inputs.file('src/headers/headers.txt')
        .withPropertyName('headers')
        .withPathSensitivity(PathSensitivity.NONE)
}

像这样使用运行时 API 有点像使用 doLast()doFirst() 为任务附加额外动作,不同之处在于这里我们附加的是有关输入和输出的信息。

如果任务类型已经使用了增量构建注解,使用相同的属性名称注册输入或输出将导致错误。

声明任务输入和输出的好处

一旦您声明了任务的正式输入和输出,Gradle 就可以推断出这些属性的一些信息。例如,如果一个任务的输入被设置为另一个任务的输出,这意味着第一个任务依赖于第二个任务,对吗?Gradle 知道这一点并可以据此行动。

接下来我们将看看这个功能,以及 Gradle 了解输入和输出信息所带来的其他一些功能。

推断的任务依赖

考虑一个归档任务,它将 processTemplates 任务的输出打包。构建作者会看到归档任务显然需要先运行 processTemplates,因此可能会添加一个明确的 dependsOn。但是,如果您像这样定义归档任务:

build.gradle.kts
tasks.register<Zip>("packageFiles") {
    from(processTemplates.map { it.outputDir })
}
build.gradle
tasks.register('packageFiles', Zip) {
    from processTemplates.map { it.outputDir }
}
gradle clean packageFiles 的输出
> gradle clean packageFiles
> Task :processTemplates
> Task :packageFiles

BUILD SUCCESSFUL in 0s
5 actionable tasks: 4 executed, 1 up-to-date

Gradle 将自动使 packageFiles 依赖于 processTemplates。它之所以能做到这一点,是因为它知道 packageFiles 的其中一个输入需要 processTemplates 任务的输出。我们称之为推断的任务依赖。

上面的示例也可以写成:

build.gradle.kts
tasks.register<Zip>("packageFiles2") {
    from(processTemplates)
}
build.gradle
tasks.register('packageFiles2', Zip) {
    from processTemplates
}
gradle clean packageFiles2 的输出
> gradle clean packageFiles2
> Task :processTemplates
> Task :packageFiles2

BUILD SUCCESSFUL in 0s
5 actionable tasks: 4 executed, 1 up-to-date

这是因为 from() 方法可以接受一个任务对象作为参数。在后台,from() 使用 project.files() 方法包装参数,这反过来将任务的正式输出暴露为文件集合。换句话说,这是一个特例!

输入和输出验证

增量构建注解提供了足够的信息,以便 Gradle 对注解的属性执行一些基本验证。特别是,在任务执行之前,它会为每个属性执行以下操作:

  • @InputFile - 验证属性有值,并且路径对应于一个存在的文件(不是目录)。

  • @InputDirectory - 与 @InputFile 相同,只是路径必须对应于一个目录。

  • @OutputDirectory - 验证路径不与文件匹配,并且如果目录不存在,则创建该目录。

如果一个任务在一个位置产生输出,而另一个任务通过将其引用为输入来消费该位置,则 Gradle 会检查消费任务是否依赖于生产任务。当生产任务和消费任务同时执行时,构建会失败,以避免捕获不正确的状态。

这种验证提高了构建的健壮性,使您能够快速识别与输入和输出相关的问题。

您有时会希望禁用部分验证,特别是在输入文件可能合法不存在的情况下。这就是 Gradle 提供 @Optional 注解的原因:您使用它来告诉 Gradle 某个输入是可选的,因此如果相应的文件或目录不存在,构建不应失败。

持续构建

定义任务输入和输出的另一个好处是持续构建。由于 Gradle 知道任务依赖哪些文件,因此如果其任何输入发生变化,它可以自动再次运行任务。通过在运行 Gradle 时激活持续构建(通过 --continuous-t 选项),您将使 Gradle 进入一种状态,它会持续检查更改并在遇到此类更改时执行请求的任务。

您可以在持续构建中找到有关此功能的更多信息。

任务并行性

定义任务输入和输出的最后一个好处是,在使用 "--parallel" 选项时,Gradle 可以利用这些信息来决定如何运行任务。例如,Gradle 在选择下一个要运行的任务时会检查任务的输出,并避免同时执行写入同一输出目录的任务。类似地,Gradle 会使用关于任务销毁哪些文件(例如,由 Destroys 注解指定)的信息,并避免在另一个任务正在运行并使用或创建这些文件时(反之亦然)运行移除一组文件的任务。它还可以确定创建一组文件的任务已经运行,而消费这些文件的任务尚未运行,并避免在此期间运行移除这些文件的任务。通过以这种方式提供任务输入和输出信息,Gradle 可以推断任务之间的创建/消费/销毁关系,并确保任务执行不违反这些关系。

它是如何工作的?

在任务第一次执行之前,Gradle 会对输入进行指纹识别。此指纹包含输入文件的路径和每个文件内容的哈希值。然后 Gradle 执行任务。如果任务成功完成,Gradle 会对输出进行指纹识别。此指纹包含输出文件集和每个文件内容的哈希值。Gradle 会将这两个指纹持久化,以备下次执行任务时使用。

此后每次,在任务执行之前,Gradle 都会获取新的输入和输出指纹。如果新的指纹与之前的指纹相同,Gradle 会假定输出是最新的并跳过任务。如果不同,Gradle 会执行任务。Gradle 会将这两个指纹持久化,以备下次执行任务时使用。

如果文件的统计信息(即 lastModifiedsize)没有改变,Gradle 将重用上次运行时的文件指纹。这意味着当文件的统计信息没有改变时,Gradle 不会检测到更改。

Gradle 还将任务的代码视为任务输入的一部分。当任务、其动作或其依赖项在执行之间发生变化时,Gradle 会认为任务已过期(out-of-date)。

Gradle 知道文件属性(例如,保存 Java 类路径的属性)是否对顺序敏感。比较此类属性的指纹时,即使文件顺序发生变化,也会导致任务变为过期(out-of-date)。

请注意,如果任务指定了输出目录,则自上次执行以来添加到该目录的任何文件都会被忽略,并且不会导致任务过期。这样做是为了不相关的任务可以共享输出目录而互不干扰。如果出于某种原因这不是您想要的行为,请考虑使用 TaskOutputs.upToDateWhen(groovy.lang.Closure)

另请注意,更改不可用文件的可用性(例如,将损坏的符号链接目标修改为有效文件,反之亦然)将被最新检查检测并处理。

任务的输入还用于计算 构建缓存 密钥,该密钥在启用时用于加载任务输出。有关更多详细信息,请参阅任务输出缓存

为了跟踪任务、任务动作和嵌套输入的实现,Gradle 使用类名以及包含实现的类路径的标识符。在某些情况下,Gradle 无法精确跟踪实现:

未知的类加载器

当加载实现的类加载器不是由 Gradle 创建时,无法确定类路径。

Java lambda 表达式

Java lambda 类是在运行时创建的,类名是非确定性的。因此,类名无法标识 lambda 的实现,并且在不同的 Gradle 运行之间会发生变化。

当任务、任务动作或嵌套输入的实现无法精确跟踪时,Gradle 会禁用该任务的任何缓存。这意味着该任务永远不会是最新的,也不会从 构建缓存 加载。

高级技巧

本节到目前为止您所看到的一切涵盖了您将遇到的大多数用例,但有些场景需要特殊处理。接下来我们将介绍其中的一些以及适当的解决方案。

添加您自己的缓存输入/输出方法

您是否曾想过 Copy 任务的 from() 方法是如何工作的?它没有用 @InputFiles 注解,但传递给它的任何文件都被视为任务的正式输入。这是怎么回事?

实现很简单,您可以使用相同的技术来改进您自己任务的 API。编写您的方法,使其将文件直接添加到适当的注解属性中。例如,这里是向我们之前介绍的自定义 ProcessTemplates 类添加 sources() 方法的方法:

build.gradle.kts
tasks.register<ProcessTemplates>("processTemplates") {
    templateEngine = TemplateEngineType.FREEMARKER
    templateData.name = "test"
    templateData.variables = mapOf("year" to "2012")
    outputDir = layout.buildDirectory.dir("genOutput")

    sources(fileTree("src/templates"))
}
build.gradle
tasks.register('processTemplates', ProcessTemplates) {
    templateEngine = TemplateEngineType.FREEMARKER
    templateData.name = 'test'
    templateData.variables = [year: '2012']
    outputDir = file(layout.buildDirectory.dir('genOutput'))

    sources fileTree('src/templates')
}
ProcessTemplates.java
public abstract class ProcessTemplates extends DefaultTask {
    // ...
    @SkipWhenEmpty
    @InputFiles
    @PathSensitive(PathSensitivity.NONE)
    public abstract ConfigurableFileCollection getSourceFiles();

    public void sources(FileCollection sourceFiles) {
        getSourceFiles().from(sourceFiles);
    }

    // ...
}
gradle processTemplates 的输出
> gradle processTemplates
> Task :processTemplates

BUILD SUCCESSFUL in 0s
3 actionable tasks: 3 executed

换句话说,只要您在配置阶段将值和文件添加到正式任务输入和输出中,无论您在构建的何处添加它们,它们都将被如此处理。

如果我们也想支持将任务作为参数,并将其输出视为输入,我们可以直接使用 TaskProvider,如下所示:

build.gradle.kts
val copyTemplates by tasks.registering(Copy::class) {
    into(file(layout.buildDirectory.dir("tmp")))
    from("src/templates")
}

tasks.register<ProcessTemplates>("processTemplates2") {
    // ...
    sources(copyTemplates)
}
build.gradle
def copyTemplates = tasks.register('copyTemplates', Copy) {
    into file(layout.buildDirectory.dir('tmp'))
    from 'src/templates'
}

tasks.register('processTemplates2', ProcessTemplates) {
    // ...
    sources copyTemplates
}
ProcessTemplates.java
    // ...
    public void sources(TaskProvider<?> inputTask) {
        getSourceFiles().from(inputTask);
    }
    // ...
gradle processTemplates2 的输出
> gradle processTemplates2
> Task :copyTemplates
> Task :processTemplates2

BUILD SUCCESSFUL in 0s
4 actionable tasks: 4 executed

这项技术可以使您的自定义任务更易于使用,并使构建文件更整洁。另一个额外的好处是,我们使用 TaskProvider 意味着我们的自定义方法可以建立推断的任务依赖。

最后一点需要注意:如果您正在开发一个像本示例一样接受源文件集合作为输入的任务,请考虑使用内置的 SourceTask。它将为您省去实现我们在 ProcessTemplates 中添加的一些底层代码。

当您想将一个任务的输出链接到另一个任务的输入时,类型通常会匹配,简单的属性赋值就可以实现该链接。例如,一个 File 输出属性可以赋值给一个 File 输入。

不幸的是,当您希望任务的 @OutputDirectory(类型为 File)中的文件成为另一个任务的 @InputFiles 属性(类型为 FileCollection)的源时,这种方法就会失效。由于两者的类型不同,属性赋值不起作用。

举个例子,假设您想使用 Java 编译任务的输出(通过 destinationDir 属性)作为自定义任务的输入,该任务对包含 Java 字节码的文件集进行检测。这个自定义任务,我们称之为 Instrument,有一个使用 @InputFiles 注解的 classFiles 属性。您最初可能会尝试这样配置任务:

build.gradle.kts
plugins {
    id("java-library")
}

tasks.register<Instrument>("badInstrumentClasses") {
    classFiles.from(fileTree(tasks.compileJava.flatMap { it.destinationDirectory }))
    destinationDir = layout.buildDirectory.dir("instrumented")
}
build.gradle
plugins {
    id 'java-library'
}

tasks.register('badInstrumentClasses', Instrument) {
    classFiles.from fileTree(tasks.named('compileJava').flatMap { it.destinationDirectory }) {}
    destinationDir = file(layout.buildDirectory.dir('instrumented'))
}
gradle clean badInstrumentClasses 的输出
> gradle clean badInstrumentClasses
> Task :clean UP-TO-DATE
> Task :badInstrumentClasses NO-SOURCE

BUILD SUCCESSFUL in 0s
3 actionable tasks: 2 executed, 1 up-to-date

这段代码表面上看没有什么问题,但从控制台输出可以看到编译任务缺失。在这种情况下,您需要通过 dependsOninstrumentClassescompileJava 之间添加一个显式的任务依赖。使用 fileTree() 意味着 Gradle 无法自行推断任务依赖。

一种解决方案是使用 TaskOutputs.files 属性,如下面的示例所示:

build.gradle.kts
tasks.register<Instrument>("instrumentClasses") {
    classFiles.from(tasks.compileJava.map { it.outputs.files })
    destinationDir = layout.buildDirectory.dir("instrumented")
}
build.gradle
tasks.register('instrumentClasses', Instrument) {
    classFiles.from tasks.named('compileJava').map { it.outputs.files }
    destinationDir = file(layout.buildDirectory.dir('instrumented'))
}
gradle clean instrumentClasses 的输出
> gradle clean instrumentClasses
> Task :clean UP-TO-DATE
> Task :compileJava
> Task :instrumentClasses

BUILD SUCCESSFUL in 0s
5 actionable tasks: 4 executed, 1 up-to-date

另一种方法是,您可以使用 project.files()project.layout.files()project.objects.fileCollection() 中的一个来代替 project.fileTree(),让 Gradle 自己访问适当的属性。

build.gradle.kts
tasks.register<Instrument>("instrumentClasses2") {
    classFiles.from(layout.files(tasks.compileJava))
    destinationDir = layout.buildDirectory.dir("instrumented")
}
build.gradle
tasks.register('instrumentClasses2', Instrument) {
    classFiles.from layout.files(tasks.named('compileJava'))
    destinationDir = file(layout.buildDirectory.dir('instrumented'))
}
gradle clean instrumentClasses2 的输出
> gradle clean instrumentClasses2
> Task :clean UP-TO-DATE
> Task :compileJava
> Task :instrumentClasses2

BUILD SUCCESSFUL in 0s
5 actionable tasks: 4 executed, 1 up-to-date

请记住,files()layout.files()objects.fileCollection() 可以接受任务作为参数,而 fileTree() 不能。

这种方法的缺点是源任务的所有文件输出都成为目标任务的输入文件——在本例中是 instrumentClasses。只要源任务只有一个基于文件的输出(例如 JavaCompile 任务),这都没问题。但是,如果您必须只链接多个输出属性中的一个,那么您需要使用 builtBy 方法明确告诉 Gradle 哪个任务生成了输入文件。

build.gradle.kts
tasks.register<Instrument>("instrumentClassesBuiltBy") {
    classFiles.from(fileTree(tasks.compileJava.flatMap { it.destinationDirectory }) {
        builtBy(tasks.compileJava)
    })
    destinationDir = layout.buildDirectory.dir("instrumented")
}
build.gradle
tasks.register('instrumentClassesBuiltBy', Instrument) {
    classFiles.from fileTree(tasks.named('compileJava').flatMap { it.destinationDirectory }) {
        builtBy tasks.named('compileJava')
    }
    destinationDir = file(layout.buildDirectory.dir('instrumented'))
}
gradle clean instrumentClassesBuiltBy 的输出
> gradle clean instrumentClassesBuiltBy
> Task :clean UP-TO-DATE
> Task :compileJava
> Task :instrumentClassesBuiltBy

BUILD SUCCESSFUL in 0s
5 actionable tasks: 4 executed, 1 up-to-date

您当然也可以通过 dependsOn 添加显式的任务依赖,但上面的方法提供了更丰富的语义含义,解释了为什么 compileJava 需要提前运行。

禁用最新检查

Gradle 自动处理输出文件和目录的最新检查,但如果任务输出完全是别的东西呢?也许是更新 web 服务或数据库表。或者有时您有一个任务应该始终运行。

这就是 Task 上的 doNotTrackState() 方法的作用。可以使用它完全禁用任务的最新检查,如下所示:

build.gradle.kts
tasks.register<Instrument>("alwaysInstrumentClasses") {
    classFiles.from(layout.files(tasks.compileJava))
    destinationDir = layout.buildDirectory.dir("instrumented")
    doNotTrackState("Instrumentation needs to re-run every time")
}
build.gradle
tasks.register('alwaysInstrumentClasses', Instrument) {
    classFiles.from layout.files(tasks.named('compileJava'))
    destinationDir = file(layout.buildDirectory.dir('instrumented'))
    doNotTrackState("Instrumentation needs to re-run every time")
}
gradle clean alwaysInstrumentClasses 的输出
> gradle clean alwaysInstrumentClasses
> Task :compileJava
> Task :alwaysInstrumentClasses

BUILD SUCCESSFUL in 0s
4 actionable tasks: 1 executed, 3 up-to-date
gradle alwaysInstrumentClasses 的输出
> gradle alwaysInstrumentClasses
> Task :compileJava UP-TO-DATE
> Task :alwaysInstrumentClasses

BUILD SUCCESSFUL in 0s
4 actionable tasks: 1 executed, 3 up-to-date

如果您正在编写一个始终应该运行的任务,那么您也可以在任务类上使用 @UntrackedTask 注解,而不是调用 Task.doNotTrackState()

集成进行自身最新检查的外部工具

有时您想集成像 Git 或 Npm 这样的外部工具,它们都自己进行最新检查。在这种情况下,Gradle 也进行最新检查意义不大。您可以通过在包装该工具的任务上使用 @UntrackedTask 注解来禁用 Gradle 的最新检查。或者,您可以使用运行时 API 方法 Task.doNotTrackState()

例如,假设您想实现一个克隆 Git 仓库的任务。

buildSrc/src/main/java/org/example/GitClone.java
@UntrackedTask(because = "Git tracks the state") (1)
public abstract class GitClone extends DefaultTask {

    @Input
    public abstract Property<String> getRemoteUri();

    @Input
    public abstract Property<String> getCommitId();

    @OutputDirectory
    public abstract DirectoryProperty getDestinationDir();

    @TaskAction
    public void gitClone() throws IOException {
        File destinationDir = getDestinationDir().get().getAsFile().getAbsoluteFile(); (2)
        String remoteUri = getRemoteUri().get();
        // Fetch origin or clone and checkout
        // ...
    }

}
build.gradle.kts
tasks.register<GitClone>("cloneGradleProfiler") {
    destinationDir = layout.buildDirectory.dir("gradle-profiler") // <3
    remoteUri = "https://github.com/gradle/gradle-profiler.git"
    commitId = "d6c18a21ca6c45fd8a9db321de4478948bdf801b"
}
build.gradle
tasks.register("cloneGradleProfiler", GitClone) {
    destinationDir = layout.buildDirectory.dir("gradle-profiler") (3)
    remoteUri = "https://github.com/gradle/gradle-profiler.git"
    commitId = "d6c18a21ca6c45fd8a9db321de4478948bdf801b"
}
1 将任务声明为不受跟踪。
2 使用输出目录来运行外部工具。
3 在您的构建中添加任务并配置输出目录。

配置输入规范化

为了进行最新检查和 构建缓存,Gradle 需要确定两个任务输入属性的值是否相同。为此,Gradle 首先对两个输入进行规范化,然后比较结果。例如,对于编译类路径,Gradle 从类路径上的类中提取 ABI 签名,然后比较上次 Gradle 运行和当前 Gradle 运行之间的签名,如 Java 编译避免 中所述。

规范化适用于类路径上的所有 zip 文件(例如,jar、war、aar、apk 等)。这使得 Gradle 能够识别两个 zip 文件在功能上何时相同,即使 zip 文件本身由于元数据(例如时间戳或文件顺序)而略有不同。规范化不仅适用于直接位于类路径上的 zip 文件,也适用于嵌套在目录中或嵌套在类路径上其他 zip 文件中的 zip 文件。

可以定制 Gradle 内置的运行时类路径规范化策略。所有使用 @Classpath 注解的输入都被视为运行时类路径。

假设您想向所有生成的 jar 文件添加一个 build-info.properties 文件,该文件包含构建信息,例如构建开始的时间戳或用于标识发布构件的 CI 作业的 ID。此文件仅用于审计目的,对运行测试的结果没有影响。然而,此文件是 test 任务运行时类路径的一部分,并且在每次构建调用时都会更改。因此,test 任务将永远不会是最新的,也不会从构建缓存中拉取。为了再次受益于增量构建,您可以在项目级别通过使用 Project.normalization(org.gradle.api.Action)(在消费项目)来告诉 Gradle 在运行时类路径上忽略此文件。

build.gradle.kts
normalization {
    runtimeClasspath {
        ignore("build-info.properties")
    }
}
build.gradle
normalization {
    runtimeClasspath {
        ignore 'build-info.properties'
    }
}

如果将此类文件添加到 jar 文件是您在构建中所有项目都执行的操作,并且您希望为所有消费者过滤此文件,则应考虑在 约定插件 中配置此类规范化,以便在子项目之间共享。

此配置的效果是,对 build-info.properties 的更改将在最新检查和 构建缓存 密钥计算中被忽略。请注意,这不会改变 test 任务的运行时行为——即任何测试仍然能够加载 build-info.properties,并且运行时类路径仍然与以前相同。

属性文件规范化

默认情况下,属性文件(即以 .properties 扩展名结尾的文件)将被规范化,以忽略注释、空白和属性顺序的差异。Gradle 通过加载属性文件,并且在最新检查或构建缓存密钥计算期间只考虑单个属性来做到这一点。

然而,有时某些属性具有运行时影响,而另一些则没有。如果一个属性的更改对运行时类路径没有影响,则可能希望将其从最新检查和 构建缓存 密钥计算中排除。但是,排除整个文件也会排除具有运行时影响的属性。在这种情况下,可以从运行时类路径上的任何或所有属性文件中选择性地排除属性。

可以使用 RuntimeClasspathNormalization 中描述的模式将忽略属性的规则应用于特定的文件集。如果文件匹配规则,但无法作为属性文件加载(例如,格式不正确或使用非标准编码),它将作为普通文件包含在最新检查或构建缓存密钥计算中。换句话说,如果文件无法作为属性文件加载,则空白、属性顺序或注释的任何更改都可能导致任务过期或导致缓存未命中。

build.gradle.kts
normalization {
    runtimeClasspath {
        properties("**/build-info.properties") {
            ignoreProperty("timestamp")
        }
    }
}
build.gradle
normalization {
    runtimeClasspath {
        properties('**/build-info.properties') {
            ignoreProperty 'timestamp'
        }
    }
}
build.gradle.kts
normalization {
    runtimeClasspath {
        properties {
            ignoreProperty("timestamp")
        }
    }
}
build.gradle
normalization {
    runtimeClasspath {
        properties {
            ignoreProperty 'timestamp'
        }
    }
}

Java META-INF 规范化

对于 jar 归档的 META-INF 目录中的文件,由于其运行时影响,并非总是可以完全忽略它们。

META-INF 中的 Manifest 文件会被规范化,以忽略注释、空白和顺序差异。Manifest 属性名称比较不区分大小写和顺序。Manifest 属性文件根据属性文件规范化进行规范化。

build.gradle.kts
normalization {
    runtimeClasspath {
        metaInf {
            ignoreAttribute("Implementation-Version")
        }
    }
}
build.gradle
normalization {
    runtimeClasspath {
        metaInf {
            ignoreAttribute("Implementation-Version")
        }
    }
}
build.gradle.kts
normalization {
    runtimeClasspath {
        metaInf {
            ignoreProperty("app.version")
        }
    }
}
build.gradle
normalization {
    runtimeClasspath {
        metaInf {
            ignoreProperty("app.version")
        }
    }
}
build.gradle.kts
normalization {
    runtimeClasspath {
        metaInf {
            ignoreManifest()
        }
    }
}
build.gradle
normalization {
    runtimeClasspath {
        metaInf {
            ignoreManifest()
        }
    }
}
build.gradle.kts
normalization {
    runtimeClasspath {
        metaInf {
            ignoreCompletely()
        }
    }
}
build.gradle
normalization {
    runtimeClasspath {
        metaInf {
            ignoreCompletely()
        }
    }
}

提供自定义最新逻辑

Gradle 自动处理输出文件和目录的最新检查,但如果任务输出完全是别的东西呢?也许是更新 web 服务或数据库表。在这种情况下,Gradle 没有办法知道如何检查任务是否最新。

这就是 TaskOutputs 上的 upToDateWhen() 方法的作用。它接受一个谓词函数,该函数用于确定任务是否最新。例如,您可以从数据库中读取数据库模式的版本号。或者,您可以检查数据库表中的特定记录是否存在或已更改。

请注意,最新检查应该为您节省时间。不要添加那些耗时与任务标准执行时间相当或更多的检查。事实上,如果一个任务因为很少最新而最终总是频繁运行,那么可能完全没有必要进行最新检查,如 禁用最新检查 中所述。请记住,如果任务在执行任务图中,您的检查将始终运行。

一个常见错误是使用 upToDateWhen() 代替 Task.onlyIf()。如果您想基于与任务输入和输出无关的某个条件跳过任务,那么应该使用 onlyIf()。例如,在您希望在某个特定属性设置或未设置时跳过任务的情况下。

过期的任务输出

当 Gradle 版本更改时,Gradle 会检测到需要移除使用旧版本 Gradle 运行的任务的输出,以确保最新版本的任务从已知干净状态开始。

过期输出目录的自动清理仅在源集输出(Java/Groovy/Scala 编译)中实现。