任何构建工具的一个重要部分是能够避免执行已经完成的工作。考虑一下编译过程。一旦编译了源文件,就不需要重新编译它们,除非发生了影响输出的更改,例如源文件的修改或输出文件的删除。编译可能会花费大量时间,因此在不需要时跳过该步骤可以节省大量时间。

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

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

任务输入和输出

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

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

输入的一个重要特征是它会影响一个或多个输出,如上图所示。根据源文件的内容和要运行代码的 Java 运行时的最低版本,生成不同的字节码。这使他们成为任务输入。但是编译是否具有 500MB 还是 600MB 的最大可用内存(由属性决定memoryMaximumSize)对生成的字节码没有影响。在 Gradle 术语中,memoryMaximumSize只是一个内部任务属性。

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

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

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

通过注释声明输入和输出

如果您将自定义任务作为类实现,则只需两个步骤即可使其与增量构建一起使用:

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

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

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

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

举个例子,假设您有一个处理不同类型模板的任务,例如 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();
}
Output of gradle processTemplates
> gradle processTemplates
> Task :processTemplates

BUILD SUCCESSFUL in 0s
3 actionable tasks: 3 up-to-date
Output of gradle processTemplates (run again)
> 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.我们使用@NestedontemplateData让 Gradle 知道这是一个具有嵌套输入属性的值。

  • outputDir

    生成的文件所在的目录。与输入文件一样,输出文件和目录也有多个注释。代表单个目录的属性需要@OutputDirectory.您很快就会了解其他人。

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

这个示例特别有趣,因为它适用于源文件集合。如果仅更改一个源文件会发生什么情况?该任务是再次处理所有源文件还是仅处理修改后的文件?这取决于任务的执行情况。如果是后者,那么任务本身就是增量的,但这与我们在这里讨论的功能不同。 Gradle 确实通过其增量任务输入功能帮助任务实施者完成此任务。

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

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

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

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

File*

单个输入文件(不是目录)

File*

单个输入目录(不是文件)

Iterable<File>*

输入文件和目录的可迭代

Iterable<File>*

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

注意:@Classpath注解是在 Gradle 3.2 中引入的。为了与早期的 Gradle 版本保持兼容,类路径属性还应该使用@InputFiles.

Iterable<File>*

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

以下类型的类路径更改将被忽略:

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

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

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

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

  • 对代码的更改,例如方法体、静态初始值设定项和字段初始值设定项(常量除外)。

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

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

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

File*

单个输出文件(不是目录)

File*

单个输出目录(不是文件)

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

输出文件的可迭代或映射。使用文件树会关闭任务的缓存。

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

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

File或者Iterable<File>*

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

File或者Iterable<File>*

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

任何自定义类型

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

任何类型

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

任何类型

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

任何类型

指示该属性已被另一个属性替换,并且作为输入或输出应被忽略。

File或者Iterable<File>*

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

暗示@Incremental

Provider<FileSystemLocation>或者FileCollection

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

任何类型

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

File或者Iterable<File>*

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

File或者Iterable<File>*

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

File或者Iterable<File>*

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

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

与上面类似,File可以是Project.file(java.lang.Object)接受的任何类型。本身Map可以用 s 包裹起来Callable,比如闭包。

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

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

表中的控制台和内部注释是特殊情况因为它们不声明任务输入或任务输出。那么为什么要使用它们呢?这样您就可以利用Java Gradle Plugin Development 插件来帮助您开发和发布自己的插件。该插件检查自定义任务类的任何属性是否缺少增量构建注释。这可以防止您在开发过程中忘记添加适当的注释。

使用依赖性解析结果

依赖关系解析结果可以通过两种方式用作任务输入。首先,使用ResolvedComponentResult使用已解析元数据的图表。其次,使用ResolvedArtifactResult消耗已解析工件的平面集。

可以从 a 的传入解析结果中延迟获取解析图Configuration并将其连接到@Input属性:

Task declaration
@Input
public abstract Property<ResolvedComponentResult> getRootComponent();
Task configuration
Configuration runtimeClasspath = configurations.getByName("runtimeClasspath");

task.getRootComponent().set(
    runtimeClasspath.getIncoming().getResolutionResult().getRootComponent()
);

已解析的工件集可以从 的传入工件中延迟获取Configuration。鉴于ResolvedArtifactResult类型包含元数据和文件信息,实例仅在连接到属性之前需要转换为元数据@Input

Task declaration
@Input
public abstract ListProperty<ComponentArtifactIdentifier> getArtifactIds();
Task configuration
Configuration runtimeClasspath = configurations.getByName("runtimeClasspath");
Provider<Set<ResolvedArtifactResult>> artifacts = runtimeClasspath.getIncoming().getArtifacts().getResolvedArtifacts();

task.getArtifactIds().set(artifacts.map(new IdExtractor()));

static class IdExtractor
    implements Transformer<List<ComponentArtifactIdentifier>, Collection<ResolvedArtifactResult>> {
    @Override
    public List<ComponentArtifactIdentifier> transform(Collection<ResolvedArtifactResult> artifacts) {
        return artifacts.stream().map(ResolvedArtifactResult::getId).collect(Collectors.toList());
    }
}

图形和平面结果都可以与解析的文件信息组合和增强。这一切都在具有依赖性解析结果输入的任务示例中进行了演示。

使用类路径注释

此外@InputFiles,对于 JVM 相关任务,Gradle 理解类路径输入的概念。当 Gradle 寻找更改时,运行时和编译类路径的处理方式有所不同。

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

运行时类路径用 标记,并且它们通过类路径规范化提供进一步的定制。@Classpath

用 注释的输入属性被视为 Java 编译类路径。除了前面提到的一般类路径规则之外,编译类路径会忽略除类文件之外的所有内容的更改。 Gradle 使用Java 避免编译中描述的相同类分析来进一步过滤不影响类 ABI 的更改。这意味着仅涉及类实现的更改不会使任务过时。@CompileClasspath

嵌套输入

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

当添加到 a 时, the 的值被视为嵌套输入。@NestedProviderProvider

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

添加到映射时,将为每个值添加一个嵌套输入,使用键作为名称。@Nested

嵌套输入的类型和类路径也会被跟踪。这确保了对嵌套输入的实现的更改会导致构建过时。通过这种方式,还可以添加用户提供的代码作为输入,例如通过用 注释属性。请注意,应通过操作上带注释的属性或通过在任务中手动注册它们来跟踪此类操作的任何输入。@Action@Nested

这使我们能够对 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需要有正确的输入和输出注释。

该方法适用于测试 JVM 参数,因为Test.getJvmArgumentProviders()Iterable用.@Nested

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

同样,这种建模也可用于自定义任务。

运行时验证

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

具有验证警告的任务将在不进行任何优化的情况下执行。具体来说,它们永远不可能:

  • 最新,

  • 从构建缓存加载或存储在构建缓存中,

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

  • 增量执行。

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

自定义任务类是将您自己的构建逻辑带入增量构建领域的简单方法,但您并不总是有这种选择。这就是为什么 Gradle 还提供了一个可用于任何任务的替代 API,我们接下来会介绍它。

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

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

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

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

它缺乏等价物

让我们看一下之前的模板处理示例,看看它作为使用运行时 API 的临时任务会是什么样子:

Example 4. Ad-hoc task
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
    }
}
Output of gradle processTemplatesAdHoc
> gradle processTemplatesAdHoc
> Task :processTemplatesAdHoc

BUILD SUCCESSFUL in 0s
3 actionable tasks: 3 executed

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

inputs所有的输入和输出定义都是通过和上的方法完成的outputs,例如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()

    // ...
}
Output of 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 }
}
Output of 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
}
Output of 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 进入一种状态,在这种状态下,它会不断检查更改并在遇到此类更改时执行请求的任务。

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

任务并行性

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

它是如何工作的?

在第一次执行任务之前,Gradle 会获取输入的指纹。该指纹包含输入文件的路径以及每个文件内容的哈希值。然后 Gradle 执行该任务。如果任务成功完成,Gradle 会获取输出的指纹。该指纹包含输出文件集以及每个文件内容的哈希值。 Gradle 会在下次执行任务时保留这两个指纹。

此后每次执行任务之前,Gradle 都会获取输入和输出的新指纹。如果新指纹与之前的指纹相同,Gradle 会假定输出是最新的并跳过该任务。如果它们不相同,Gradle 就会执行任务。 Gradle 会在下次执行任务时保留这两个指纹。

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

Gradle 还将任务的代码视为任务输入的一部分。当任务、其操作或其依赖项在执行之间发生变化时,Gradle 会认为该任务已过时。

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

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

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

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

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

未知的类加载器

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

Java lambda

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

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

先进技术

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

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

你有没有想过任务from()的方法是如何Copy运作的?它没有注释@InputFiles,但传递给它的任何文件都被视为任务的正式输入。发生了什么?

实现非常简单,您可以对自己的任务使用相同的技术来改进他们的 API。编写您的方法,以便它们将文件直接添加到适当的带注释的属性中。作为示例,以下是如何向我们之前介绍的sources()自定义类添加方法:ProcessTemplates

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);
    }

    // ...
}
Output of 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);
    }
    // ...
Output of 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)有一个classFiles用 注释的属性@InputFiles。您最初可能会尝试像这样配置任务:

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'))
}
Output of 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

这段代码没有什么明显的错误,但是从控制台输出可以看到缺少编译任务。在这种情况下,您需要在instrumentClassescompileJavavia之间添加显式任务依赖关系dependsOn。使用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'))
}
Output of 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

或者,您可以使用以下之一来让 Gradle 本身访问适当的属性project.files()project.layout.files()project.objects.fileCollection()project.fileTree()

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'))
}
Output of 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'))
}
Output of 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 服务或数据库表的更新。或者有时您有一个应该始终运行的任务。

这就是doNotTrackState()on 方法的Task用武之地。人们可以使用它来完全禁用任务的最新检查,如下所示:

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")
}
Output of gradle clean alwaysInstrumentClasses
> gradle clean alwaysInstrumentClasses
> Task :compileJava
> Task :alwaysInstrumentClasses

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

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

如果您编写自己的始终应运行的任务,那么您还可以在任务类上使用注释,而不是调用.@UntrackedTaskTask.doNotTrackState()

集成一个外部工具,该工具可以进行自己的最新检查

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

例如,假设您想要实现克隆 Git 存储库的任务。

Example 17. Task for Git clone
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、wars、aars、apk 等)。这允许 Gradle 识别两个 zip 文件何时功能相同,即使 zip 文件本身可能由于元数据(例如时间戳或文件顺序)而略有不同。规范化不仅适用于直接位于类路径上的 zip 文件,还适用于嵌套在目录内或类路径上其他 zip 文件内的 zip 文件。

可以自定义 Gradle 的内置策略来实现运行时类路径规范化。所有带注释的输入都被视为运行时类路径。@Classpath

假设您想要build-info.properties向所有生成的 jar 文件添加一个文件,其中包含有关构建的信息,例如构建开始时的时间戳或用于标识发布工件的 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'
        }
    }
}

JavaMETA-INF规范化

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

其中的清单文件META-INF被标准化以忽略注释、空格和顺序差异。清单属性名称的比较不区分大小写和顺序。清单属性文件根据Properties File Normalization进行规范化。

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 无法知道如何检查任务是否是最新的。

这就是upToDateWhen()on 方法的TaskOutputs用武之地。它采用一个谓词函数,用于确定任务是否是最新的。例如,您可以从数据库中读取数据库架构的版本号。或者,您可以检查数据库表中的特定记录是否存在或已更改。

请注意,最新的检查应该可以节省您的时间。不要添加花费与任务的标准执行相同或更多时间的检查。事实上,如果任务最终频繁运行,因为它很少是最新的,那么根本不值得进行最新检查,如禁用最新检查中所述。请记住,如果任务位于执行任务图中,您的检查将始终运行。

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

过时的任务输出

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

仅针对源集的输出(Java/Groovy/Scala 编译)实现了过时输出目录的自动清理。