任何构建工具的重要组成部分是能够避免重复已完成的工作。以编译过程为例。一旦源文件被编译,除非有影响输出的更改(例如修改源文件或删除输出文件),否则无需重新编译。编译可能需要相当长的时间,因此在不需要时跳过此步骤可以节省大量时间。
Gradle 通过一个名为增量构建的功能,开箱即用地支持此行为。您几乎肯定已经见过它的实际应用。当您运行一个 Task 并且该 Task 在控制台输出中标记为 UP-TO-DATE
时,这意味着增量构建正在工作。
增量构建是如何工作的?如何确保您的 Task 支持增量运行?让我们来看一下。
Task 输入和输出
在最常见的情况下,一个 Task 接受一些输入并生成一些输出。我们可以将 Java 编译过程作为一个 Task 的示例。Java 源文件充当 Task 的输入,而生成的 class 文件,即编译的结果,是 Task 的输出。

输入的一个重要特征是它会影响一个或多个输出,正如您从上图中看到的那样。根据源文件的内容和您希望代码运行的 Java 运行时的最低版本,会生成不同的字节码。这使它们成为 Task 输入。但是,编译是否具有 500MB 或 600MB 的最大可用内存(由 memoryMaximumSize
属性确定)对生成的字节码没有影响。在 Gradle 术语中,memoryMaximumSize
只是一个内部 Task 属性。
作为增量构建的一部分,Gradle 测试自上次构建以来,Task 的任何输入或输出是否已更改。如果未更改,Gradle 可以认为该 Task 是最新的,因此跳过执行其操作。另请注意,除非 Task 至少有一个 Task 输出,否则增量构建将无法工作,尽管 Task 通常也至少有一个输入。
这对构建作者来说意味着很简单:您需要告诉 Gradle 哪些 Task 属性是输入,哪些是输出。如果 Task 属性影响输出,请务必将其注册为输入,否则,即使 Task 不是最新的,也会被认为是最新状态。相反,如果属性不影响输出,则不要将其注册为输入,否则 Task 可能会在不需要时执行。还要注意非确定性 Task,它们可能为完全相同的输入生成不同的输出:这些 Task 不应配置为增量构建,因为最新状态检查将不起作用。
现在让我们看看如何将 Task 属性注册为输入和输出。
通过注解声明输入和输出
如果您正在将自定义 Task 实现为一个类,那么只需两个步骤即可使其与增量构建一起工作
-
为每个 Task 输入和输出创建类型化属性(通过 getter 方法)
-
为每个属性添加适当的注解
注解必须放在 getter 或 Groovy 属性上。放在 setter 上或没有相应注解 getter 的 Java 字段上的注解将被忽略。 |
Gradle 支持四种主要的输入和输出类别
-
简单值
例如字符串和数字。更一般地,简单值可以是任何实现
Serializable
接口的类型。 -
文件系统类型
这些类型包括
RegularFile
、Directory
和标准的File
类,以及 Gradle 的 FileCollection 类型的派生类型,以及任何可以传递给 Project.file(java.lang.Object) 方法(用于单个文件/目录属性)或 Project.files(java.lang.Object...) 方法的任何其他类型。 -
依赖解析结果
这包括用于构件元数据的 ResolvedArtifactResult 类型和用于依赖图的 ResolvedComponentResult 类型。请注意,它们仅支持包装在
Provider
中。 -
嵌套值
不符合其他两个类别但具有自己的输入或输出属性的自定义类型。实际上,Task 输入或输出嵌套在这些自定义类型内部。
例如,假设您有一个 Task,用于处理各种类型的模板,例如 FreeMarker、Velocity、Moustache 等。它接受模板源文件,并将它们与一些模型数据组合,以生成模板文件的填充版本。
此 Task 将有三个输入和一个输出
-
模板源文件
-
模型数据
-
模板引擎
-
输出文件写入的位置
当您编写自定义 Task 类时,通过注解将属性注册为输入或输出很容易。为了演示,这是一个 Task 骨架实现,其中包含一些合适的输入和输出,以及它们的注解
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() {
// ...
}
}
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
Task 将要处理的源模板。单个文件和文件集合需要它们自己的特殊注解。在本例中,我们处理的是输入文件集合,因此我们使用
@InputFiles
注解。您将在后面的表格中看到更多面向文件的注解。 -
templateData
在本例中,我们使用自定义类来表示模型数据。但是,它没有实现
Serializable
接口,因此我们不能使用@Input
注解。这不是问题,因为TemplateData
中的属性(一个字符串和一个带有可序列化类型参数的哈希映射)是可序列化的,并且可以用@Input
注解。我们在templateData
上使用@Nested
来告知 Gradle 这是一个具有嵌套输入属性的值。 -
outputDir
生成文件所在的目录。与输入文件一样,输出文件和目录也有几个注解。表示单个目录的属性需要
@OutputDirectory
。您很快将了解其他注解。
这些注解属性意味着,如果自上次 Gradle 执行 Task 以来,源文件、模板引擎、模型数据或生成的文件都没有更改,Gradle 将跳过该 Task。这通常会节省大量时间。您可以在后面了解 Gradle 如何检测更改。
这个例子特别有趣,因为它处理源文件集合。如果只有一个源文件更改会发生什么?Task 是重新处理所有源文件还是只处理修改的文件?这取决于 Task 的实现。如果是后者,那么 Task 本身就是增量的,但这与我们在这里讨论的特性不同。Gradle 通过其 增量 Task 输入 特性来帮助 Task 实现者处理这个问题。
现在您已经实际看到了一些输入和输出注解,让我们看一下所有可用的注解以及您应该何时使用它们。下表列出了可用的注解以及您可以与每个注解一起使用的相应属性类型。
注解 | 预期属性类型 | 描述 |
---|---|---|
任何 |
一个简单的输入值或依赖解析结果 |
|
|
单个输入文件(非目录) |
|
|
单个输入目录(非文件) |
|
|
输入文件和目录的可迭代对象 |
|
|
表示 Java 类路径的输入文件和目录的可迭代对象。这允许 Task 忽略属性的无关更改,例如相同文件的不同名称。它类似于使用 注意: |
|
|
表示 Java 编译类路径的输入文件和目录的可迭代对象。这允许 Task 忽略不影响类路径中类 API 的无关更改。另请参阅 使用类路径注解。 以下类型的类路径更改将被忽略
注意 - |
|
|
单个输出文件(非目录) |
|
|
单个输出目录(非文件) |
|
|
输出文件的可迭代对象或 Map。使用文件树会关闭 Task 的 缓存。 |
|
|
输出目录的可迭代对象。使用文件树会关闭 Task 的 缓存。 |
|
|
指定由此 Task 删除的一个或多个文件。请注意,一个 Task 可以定义输入/输出或可销毁项,但不能同时定义两者。 |
|
|
指定表示 Task 本地状态的一个或多个文件。当 Task 从缓存加载时,这些文件将被删除。 |
|
任何自定义类型 |
可能未实现 |
|
任何类型 |
指示该属性既不是输入也不是输出。它只是以某种方式影响 Task 的控制台输出,例如增加或减少 Task 的详细程度。 |
|
任何类型 |
指示该属性在内部使用,但既不是输入也不是输出。 |
|
任何类型 |
指示该属性已被另一个属性替换,应作为输入或输出忽略。 |
|
|
与 暗示 |
|
|
与 |
|
任何类型 |
||
|
||
|
与 |
|
|
与 |
与上面类似, |
注解从所有父类型(包括实现的接口)继承。属性类型注解覆盖在父类型中声明的任何其他属性类型注解。这样,@InputFile
属性可以在子 Task 类型中转换为 @InputDirectory
属性。
在类型中声明的属性上的注解会覆盖超类和任何已实现接口声明的类似注解。超类注解优先于已实现接口中声明的注解。
表中的 Console 和 Internal 注解是特殊情况,因为它们既不声明 Task 输入也不声明 Task 输出。那么为什么要使用它们呢?这是为了让您可以利用 Java Gradle 插件开发插件 来帮助您开发和发布自己的插件。此插件检查您的自定义 Task 类的任何属性是否缺少增量构建注解。这可以防止您在开发过程中忘记添加适当的注解。
使用类路径注解
除了 @InputFiles
之外,对于 JVM 相关的 Task,Gradle 理解类路径输入的概念。当 Gradle 查找更改时,运行时类路径和编译类路径的处理方式不同。
与使用 @InputFiles
注解的输入属性相反,对于类路径属性,文件集合中条目的顺序很重要。另一方面,类路径本身上的目录和 jar 文件的名称和路径将被忽略。时间戳以及类路径上 jar 文件中类文件和资源的顺序也被忽略,因此,重新创建具有不同文件日期的 jar 文件不会使 Task 过时。
使用 @CompileClasspath
注解的输入属性被视为 Java 编译类路径。除了上述通用类路径规则外,编译类路径还会忽略除类文件之外的所有更改。Gradle 使用 Java 编译避免 中描述的相同类分析来进一步过滤不影响类 ABI 的更改。这意味着仅触及类实现的更改不会使 Task 过时。
嵌套输入
当分析 @Nested
Task 属性的声明输入和输出子属性时,Gradle 使用实际值的类型。因此,它可以发现运行时子类型声明的所有子属性。
当将 @Nested
添加到可迭代对象时,每个元素都被视为单独的嵌套输入。可迭代对象中的每个嵌套输入都被分配一个名称,默认情况下是美元符号后跟可迭代对象中的索引,例如 $2
。如果可迭代对象的元素实现了 Named
接口,则该名称将用作属性名称。如果并非所有元素都实现了 Named
接口,则可迭代对象中元素的顺序对于可靠的最新状态检查和缓存至关重要。不允许使用具有相同名称的多个元素。
当将 @Nested
添加到 Map 时,则为每个值添加一个嵌套输入,使用键作为名称。
嵌套输入的类型和类路径也会被跟踪。这确保了对嵌套输入实现的更改会导致构建过时。通过这种方式,也可以将用户提供的代码添加为输入,例如,通过使用 @Nested
注解 @Action
属性。请注意,对此类 Action 的任何输入都应进行跟踪,可以通过 Action 上的注解属性进行跟踪,也可以通过手动将其注册到 Task 进行跟踪。
使用嵌套输入可以为 Task 提供更丰富的建模和可扩展性,例如 Test.getJvmArgumentProviders() 中所示。
这使我们能够对 JaCoCo Java Agent 进行建模,从而声明必要的 JVM 参数并向 Gradle 提供输入和输出
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
。
还有其他类型的任务也提供这种嵌套输入。
-
JavaExec.getArgumentProviders() - 例如,自定义工具的模型
-
JavaExec.getJvmArgumentProviders() - 用于 Jacoco Java 代理
-
CompileOptions.getCompilerArgumentProviders() - 例如,注解处理器的模型
-
Exec.getArgumentProviders() - 例如,自定义工具的模型
-
JavaCompile.getOptions().getForkOptions().getJvmArgumentProviders() - Java 编译器守护进程命令行参数的模型
-
GroovyCompile.getGroovyOptions().getForkOptions().getJvmArgumentProviders() - Groovy 编译器守护进程命令行参数的模型
-
ScalaCompile.getScalaOptions().getForkOptions().getJvmArgumentProviders() - Scala 编译器守护进程命令行参数的模型
同样地,这种建模方式也适用于自定义任务。
通过运行时 API 声明输入和输出
自定义任务类是将您自己的构建逻辑引入增量构建领域的一种简单方法,但您并非总是拥有这种选择。这就是 Gradle 还提供了另一种 API 的原因,该 API 可以与任何任务一起使用,我们将在接下来进行介绍。
当您无法访问自定义任务类的源代码时,就无法添加我们在上一节中介绍的任何注解。幸运的是,Gradle 为此类场景提供了运行时 API。它也可以用于临时任务,正如您接下来将看到的那样。
声明临时任务的输入和输出
此运行时 API 通过几个恰当命名的属性提供,这些属性在每个 Gradle 任务上都可用
这些对象具有允许您指定构成任务输入和输出的文件、目录和值的方法。实际上,运行时 API 几乎与注解具有相同的功能。
它缺少以下内容的等效项:
让我们采用之前的模板处理示例,看看它作为使用运行时 API 的临时任务会是什么样子
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
}
}
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 不要求任务具有任何状态这一事实。在增量构建方面,上述临时任务的行为将与自定义任务类相同。
所有输入和输出定义都通过 inputs
和 outputs
上的方法完成,例如 property()
、files()
和 dir()
。Gradle 对参数值执行最新检查,以确定任务是否需要再次运行。每个方法都对应一个增量构建注解,例如 inputs.property()
映射到 @Input
,outputs.dir()
映射到 @OutputDirectory
。
任务删除的文件可以通过 destroyables.register()
指定。
tasks.register("removeTempDir") {
val tmpDir = layout.projectDirectory.dir("tmpDir")
destroyables.register(tmpDir)
doLast {
tmpDir.asFile.deleteRecursively()
}
}
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
任务,无论它是全新构建还是非全新构建。毕竟,如果没有源文件,任务就无事可做。构建器允许我们像这样配置它
tasks.register("processTemplatesAdHocSkipWhenEmpty") {
// ...
inputs.files(fileTree("src/templates") {
include("**/*.fm")
})
.skipWhenEmpty()
.withPropertyName("sourceFiles")
.withPathSensitivity(PathSensitivity.RELATIVE)
.ignoreEmptyDirectories()
// ...
}
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,您可以做到这一点
tasks.register<ProcessTemplates>("processTemplatesWithExtraInputs") {
// ...
inputs.file("src/headers/headers.txt")
.withPropertyName("headers")
.withPathSensitivity(PathSensitivity.NONE)
}
tasks.register('processTemplatesWithExtraInputs', ProcessTemplates) {
// ...
inputs.file('src/headers/headers.txt')
.withPropertyName('headers')
.withPathSensitivity(PathSensitivity.NONE)
}
像这样使用运行时 API 有点像使用 doLast()
和 doFirst()
将额外的操作附加到任务,不同之处在于,在这种情况下,我们附加的是有关输入和输出的信息。
如果任务类型已经在使用增量构建注解,则使用相同属性名称注册输入或输出将导致错误。 |
声明任务输入和输出的好处
一旦您声明了任务的正式输入和输出,Gradle 就可以推断出关于这些属性的信息。例如,如果一个任务的输入被设置为另一个任务的输出,这意味着第一个任务依赖于第二个任务,对吗?Gradle 知道这一点并可以据此采取行动。
我们将在接下来介绍此功能,以及 Gradle 了解输入和输出信息所带来的一些其他功能。
推断的任务依赖
考虑一个归档任务,该任务打包 processTemplates
任务的输出。构建作者会看到归档任务显然需要先运行 processTemplates
,因此可能会添加显式的 dependsOn
。但是,如果您像这样定义归档任务
tasks.register<Zip>("packageFiles") {
from(processTemplates.map { it.outputDir })
}
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 任务的输出。我们称之为推断的任务依赖。
上面的示例也可以写成
tasks.register<Zip>("packageFiles2") {
from(processTemplates)
}
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 会持久化这两个指纹,以便下次执行任务时使用。
如果文件的统计信息(即 lastModified
和 size
)没有更改,Gradle 将重用上次运行的文件指纹。这意味着当文件的统计信息没有更改时,Gradle 不会检测到更改。
Gradle 还会将任务的代码视为任务输入的一部分。当任务、其操作或其依赖项在执行之间发生更改时,Gradle 会将任务视为已过期。
Gradle 了解文件属性(例如,包含 Java 类路径的属性)是否对顺序敏感。在比较此类属性的指纹时,即使文件顺序发生更改,也会导致任务过期。
请注意,如果任务指定了输出目录,则自上次执行以来添加到该目录的任何文件都将被忽略,并且不会导致任务过期。这是为了使不相关的任务可以共享一个输出目录,而不会相互干扰。如果出于某种原因,这不是您想要的行为,请考虑使用 TaskOutputs.upToDateWhen(groovy.lang.Closure)
另请注意,更改不可用文件的可用性(例如,将损坏的符号链接的目标修改为有效文件,或反之亦然)将被检测到并由最新检查处理。
为了跟踪任务、任务操作和嵌套输入的实现,Gradle 使用类名和类路径的标识符,该类路径包含实现。在某些情况下,Gradle 无法精确跟踪实现
- 未知的类加载器
-
当加载实现的类加载器不是由 Gradle 创建时,无法确定类路径。
- Java lambda
-
Java lambda 类在运行时使用非确定性的类名创建。因此,类名不能标识 lambda 的实现,并且在不同的 Gradle 运行之间会发生更改。
当无法精确跟踪任务、任务操作或嵌套输入的实现时,Gradle 将禁用任务的任何缓存。这意味着该任务永远不会是最新的,也不会从 构建缓存 加载。
高级技术
您在本节中到目前为止看到的所有内容将涵盖您将遇到的大多数用例,但有些场景需要特殊处理。接下来,我们将介绍其中的一些场景以及相应的解决方案。
添加您自己的缓存输入/输出方法
您是否曾经想过 Copy
任务的 from()
方法是如何工作的?它没有使用 @InputFiles
注解,但传递给它的任何文件都被视为任务的正式输入。发生了什么?
实现非常简单,您可以对自己的任务使用相同的技术来改进其 API。编写您的方法,以便它们将文件直接添加到适当的注解属性。例如,以下是如何向我们之前介绍的自定义 ProcessTemplates
类添加 sources()
方法
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"))
}
tasks.register('processTemplates', ProcessTemplates) {
templateEngine = TemplateEngineType.FREEMARKER
templateData.name = 'test'
templateData.variables = [year: '2012']
outputDir = file(layout.buildDirectory.dir('genOutput'))
sources fileTree('src/templates')
}
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
val copyTemplates by tasks.registering(Copy::class) {
into(file(layout.buildDirectory.dir("tmp")))
from("src/templates")
}
tasks.register<ProcessTemplates>("processTemplates2") {
// ...
sources(copyTemplates)
}
def copyTemplates = tasks.register('copyTemplates', Copy) {
into file(layout.buildDirectory.dir('tmp'))
from 'src/templates'
}
tasks.register('processTemplates2', ProcessTemplates) {
// ...
sources copyTemplates
}
// ...
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
中的一些管道。
将 @OutputDirectory
链接到 @InputFiles
当您想将一个任务的输出链接到另一个任务的输入时,类型通常匹配,简单的属性赋值将提供该链接。例如,File
输出属性可以分配给 File
输入。
不幸的是,当您希望任务的 @OutputDirectory
(类型为 File
)中的文件成为另一个任务的 @InputFiles
属性(类型为 FileCollection
)的源时,此方法会失效。由于两者具有不同的类型,因此属性赋值将不起作用。
例如,假设您想使用 Java 编译任务的输出 — 通过 destinationDir
属性 — 作为自定义任务的输入,该自定义任务检测一组包含 Java 字节码的文件。此自定义任务(我们将其称为 Instrument
)具有一个使用 @InputFiles
注解的 classFiles
属性。您最初可能会尝试像这样配置任务
plugins {
id("java-library")
}
tasks.register<Instrument>("badInstrumentClasses") {
classFiles.from(fileTree(tasks.compileJava.flatMap { it.destinationDirectory }))
destinationDir = layout.buildDirectory.dir("instrumented")
}
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
此代码没有任何明显的错误,但您可以从控制台输出中看到缺少编译任务。在这种情况下,您需要通过 dependsOn
在 instrumentClasses
和 compileJava
之间添加显式的任务依赖关系。fileTree()
的使用意味着 Gradle 无法自行推断任务依赖关系。
一种解决方案是使用 TaskOutputs.files
属性,如下例所示
tasks.register<Instrument>("instrumentClasses") {
classFiles.from(tasks.compileJava.map { it.outputs.files })
destinationDir = layout.buildDirectory.dir("instrumented")
}
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 访问适当的属性本身
tasks.register<Instrument>("instrumentClasses2") {
classFiles.from(layout.files(tasks.compileJava))
destinationDir = layout.buildDirectory.dir("instrumented")
}
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 哪个任务生成输入文件
tasks.register<Instrument>("instrumentClassesBuiltBy") {
classFiles.from(fileTree(tasks.compileJava.flatMap { it.destinationDirectory }) {
builtBy(tasks.compileJava)
})
destinationDir = layout.buildDirectory.dir("instrumented")
}
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()
方法的用武之地。可以使用它来完全禁用任务的最新检查,如下所示
tasks.register<Instrument>("alwaysInstrumentClasses") {
classFiles.from(layout.files(tasks.compileJava))
destinationDir = layout.buildDirectory.dir("instrumented")
doNotTrackState("Instrumentation needs to re-run every time")
}
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 存储库的任务。
@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
// ...
}
}
tasks.register<GitClone>("cloneGradleProfiler") {
destinationDir = layout.buildDirectory.dir("gradle-profiler") // <3
remoteUri = "https://github.com/gradle/gradle-profiler.git"
commitId = "d6c18a21ca6c45fd8a9db321de4478948bdf801b"
}
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 文件(例如,jars、wars、aars、apks 等)。这允许 Gradle 识别两个 zip 文件在功能上是否相同,即使 zip 文件本身由于元数据(例如时间戳或文件顺序)可能略有不同。规范化不仅适用于类路径上直接的 zip 文件,还适用于嵌套在目录内或类路径上其他 zip 文件内的 zip 文件。
可以自定义 Gradle 用于运行时类路径规范化的内置策略。所有使用 @Classpath
注解的输入都被认为是运行时类路径。
假设您想向所有生成的 jar 文件添加一个 build-info.properties
文件,该文件包含有关构建的信息,例如,构建开始时的时间戳或标识发布工件的 CI 作业的 ID。此文件仅用于审计目的,对运行测试的结果没有影响。尽管如此,此文件是 test
任务的运行时类路径的一部分,并且每次构建调用都会更改。因此,test
永远不会是最新的或从构建缓存中拉取。为了再次从增量构建中受益,您可以使用 Project.normalization(org.gradle.api.Action)(在使用者项目中)告诉 Gradle 忽略运行时类路径上的此文件
normalization {
runtimeClasspath {
ignore("build-info.properties")
}
}
normalization {
runtimeClasspath {
ignore 'build-info.properties'
}
}
如果将此类文件添加到您的 jar 文件是您在构建中的所有项目中都执行的操作,并且您想为所有使用者过滤此文件,则应考虑在 约定插件 中配置此类规范化,以便在子项目之间共享它。
此配置的效果是,对 build-info.properties
的更改将被最新检查和 构建缓存 密钥计算忽略。请注意,这不会更改 test
任务的运行时行为 — 即,任何测试仍然能够加载 build-info.properties
,并且运行时类路径仍然与以前相同。
属性文件规范化
默认情况下,属性文件(即以 .properties
扩展名结尾的文件)将被规范化,以忽略注释、空格和属性顺序的差异。Gradle 通过加载属性文件并在最新检查或构建缓存密钥计算期间仅考虑单个属性来做到这一点。
但是,有时某些属性具有运行时影响,而其他属性则没有。如果正在更改的属性对运行时类路径没有影响,则可能需要将其从最新检查和 构建缓存 密钥计算中排除。但是,排除整个文件也会排除具有运行时影响的属性。在这种情况下,可以从运行时类路径上的任何或所有属性文件中选择性地排除属性。
可以使用 RuntimeClasspathNormalization 中描述的模式将忽略属性的规则应用于特定文件集。如果文件与规则匹配,但无法作为属性文件加载(例如,因为它格式不正确或使用非标准编码),则它将作为普通文件合并到最新或构建缓存密钥计算中。换句话说,如果文件无法作为属性文件加载,则对空格、属性顺序或注释的任何更改都可能导致任务过期或导致缓存未命中。
normalization {
runtimeClasspath {
properties("**/build-info.properties") {
ignoreProperty("timestamp")
}
}
}
normalization {
runtimeClasspath {
properties('**/build-info.properties') {
ignoreProperty 'timestamp'
}
}
}
normalization {
runtimeClasspath {
properties {
ignoreProperty("timestamp")
}
}
}
normalization {
runtimeClasspath {
properties {
ignoreProperty 'timestamp'
}
}
}
Java META-INF
规范化
对于 jar 归档文件的 META-INF
目录中的文件,由于其运行时影响,并非总是可以完全忽略文件。
META-INF
中的清单文件被规范化,以忽略注释、空格和顺序差异。清单属性名称以不区分大小写和不区分顺序的方式进行比较。清单属性文件根据 属性文件规范化 进行规范化。
META-INF
清单属性normalization {
runtimeClasspath {
metaInf {
ignoreAttribute("Implementation-Version")
}
}
}
normalization {
runtimeClasspath {
metaInf {
ignoreAttribute("Implementation-Version")
}
}
}
META-INF
属性键normalization {
runtimeClasspath {
metaInf {
ignoreProperty("app.version")
}
}
}
normalization {
runtimeClasspath {
metaInf {
ignoreProperty("app.version")
}
}
}
META-INF/MANIFEST.MF
normalization {
runtimeClasspath {
metaInf {
ignoreManifest()
}
}
}
normalization {
runtimeClasspath {
metaInf {
ignoreManifest()
}
}
}
META-INF
内的所有文件和目录normalization {
runtimeClasspath {
metaInf {
ignoreCompletely()
}
}
}
normalization {
runtimeClasspath {
metaInf {
ignoreCompletely()
}
}
}
提供自定义最新逻辑
Gradle 自动处理输出文件和目录的最新检查,但如果任务输出完全是其他内容怎么办?也许是对 Web 服务或数据库表的更新。在这种情况下,Gradle 无法知道如何检查任务是否为最新。
这就是 TaskOutputs
上的 upToDateWhen()
方法的用武之地。这需要一个谓词函数,该函数用于确定任务是否为最新。例如,您可以从数据库读取数据库架构的版本号。或者,您可以检查数据库表中的特定记录是否存在或是否已更改。
请注意,最新的检查应该能为您节省时间。 不要添加比标准任务执行花费更多时间的检查。 实际上,如果一个任务最终还是频繁运行,因为它很少是最新的,那么可能根本不值得进行最新的检查,正如禁用最新检查中所述。 请记住,如果任务在执行任务图中,您的检查将始终运行。
一个常见的错误是使用 upToDateWhen()
而不是 Task.onlyIf()
。 如果您想根据与任务输入和输出无关的某些条件跳过任务,则应使用 onlyIf()
。 例如,在您想要在设置或未设置特定属性时跳过任务的情况下。
过时的任务输出
当 Gradle 版本更改时,Gradle 会检测到需要删除使用旧版本 Gradle 运行的任务的输出,以确保最新版本的任务从已知的干净状态开始。
过时输出目录的自动清理仅针对源集(Java/Groovy/Scala 编译)的输出实现。 |