任何构建工具的重要组成部分是能够避免重复已经完成的工作。考虑编译过程。一旦你的源文件被编译,就不需要重新编译它们,除非某些影响输出的内容发生了变化,例如源文件的修改或输出文件的删除。而编译可能需要相当长的时间,因此在不需要的时候跳过这一步可以节省大量时间。
Gradle 通过名为 **增量构建** 的功能开箱即用地支持这种行为。你几乎肯定已经看到它在行动。当你运行一个任务并且该任务在控制台输出中标记为 UP-TO-DATE
时,这意味着增量构建正在起作用。
增量构建是如何工作的?如何确保你的任务支持增量运行?让我们来看看。
任务输入和输出
在最常见的情况下,一个任务会接收一些输入并生成一些输出。我们可以将 Java 编译过程作为任务的一个例子。Java 源文件充当任务的输入,而生成的类文件,即编译结果,是任务的输出。
输入的一个重要特征是它会影响一个或多个输出,正如您从上图中看到的。根据源文件的内容和您希望运行代码的 Java 运行时的最低版本,会生成不同的字节码。这使得它们成为任务输入。但是,编译是否具有 500MB 或 600MB 的最大可用内存(由 memoryMaximumSize
属性决定)不会影响生成的字节码。在 Gradle 术语中,memoryMaximumSize
只是一个内部任务属性。
作为增量构建的一部分,Gradle 会测试自上次构建以来是否有任何任务输入或输出发生更改。如果它们没有改变,Gradle 可以认为任务是最新的,因此可以跳过执行其操作。还要注意,除非任务至少有一个任务输出,否则增量构建将无法正常工作,尽管任务通常也至少有一个输入。
这对构建作者来说意味着:您需要告诉 Gradle 哪些任务属性是输入,哪些是输出。如果任务属性影响输出,请确保将其注册为输入,否则当任务不是最新的时,它将被视为最新的。相反,如果属性不影响输出,请不要将其注册为输入,否则任务可能会在不需要执行时执行。还要注意可能对完全相同的输入生成不同输出的非确定性任务:这些任务不应配置为增量构建,因为最新检查将无法正常工作。
现在让我们看看如何将任务属性注册为输入和输出。
通过注解声明输入和输出
如果您正在将自定义任务实现为一个类,那么只需两个步骤即可使其与增量构建一起使用
-
为每个任务输入和输出创建类型化属性(通过 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
中时才受支持。 -
嵌套值
不符合其他两类但具有自己的输入或输出属性的自定义类型。实际上,任务输入或输出嵌套在这些自定义类型中。
例如,假设您有一个处理各种类型模板的任务,例如 FreeMarker、Velocity、Moustache 等。它接受模板源文件并将它们与一些模型数据结合起来,以生成模板文件的填充版本。
此任务将有三个输入和一个输出
-
模板源文件
-
模型数据
-
模板引擎
-
输出文件写入的位置
在编写自定义任务类时,可以通过注解轻松地将属性注册为输入或输出。为了演示,这里是一个带有某些合适输入和输出的骨架任务实现,以及它们的注解
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
任务将处理的源模板。单个文件和文件集合需要各自的特殊注释。在本例中,我们处理的是输入文件的集合,因此我们使用
@InputFiles
注释。您将在后面的表格中看到更多与文件相关的注释。 -
templateData
对于此示例,我们使用自定义类来表示模型数据。但是,它没有实现
Serializable
,因此我们无法使用@Input
注释。这不是问题,因为TemplateData
中的属性——一个字符串和一个具有可序列化类型参数的哈希映射——是可序列化的,并且可以使用@Input
进行注释。我们在templateData
上使用@Nested
来让 Gradle 知道这是一个具有嵌套输入属性的值。 -
outputDir
生成的 文件存放的目录。与输入文件一样,输出文件和目录也有几种注释。表示单个目录的属性需要
@OutputDirectory
。您很快就会了解其他注释。
这些带注释的属性意味着如果自 Gradle 上次执行任务以来,源文件、模板引擎、模型数据或生成的文件都没有发生变化,Gradle 将跳过该任务。这通常可以节省大量时间。您可以了解 Gradle 如何检测更改。
此示例特别有趣,因为它使用源文件的集合。如果只有一个源文件发生变化会怎样?任务会再次处理所有源文件,还是只处理修改后的文件?这取决于任务的实现。如果是后者,那么任务本身是增量的,但这与我们正在讨论的功能不同。Gradle 通过其增量任务输入功能帮助任务实现者实现这一点。
现在您已经看到了实践中的一些输入和输出注释,让我们看一下您可以使用的所有注释以及何时应该使用它们。下表列出了可用的注释以及您可以与每个注释一起使用的相应属性类型。
注解 | 预期属性类型 | 描述 |
---|---|---|
任何 |
一个简单的输入值或依赖项解析结果 |
|
|
单个输入文件(不是目录) |
|
|
单个输入目录(不是文件) |
|
|
输入文件和目录的可迭代集合 |
|
|
表示 Java 类路径的输入文件和目录的可迭代集合。这允许任务忽略对属性的无关更改,例如相同文件的不同名称。它类似于用 注意: |
|
|
表示 Java 编译类路径的输入文件和目录的可迭代集合。这允许任务忽略不影响类路径中类 API 的无关更改。另请参阅 使用类路径注解。 以下类型的类路径更改将被忽略
注意 - |
|
|
单个输出文件(不是目录) |
|
|
单个输出目录(不是文件) |
|
|
输出文件的可迭代对象或映射。使用文件树会关闭任务的 缓存。 |
|
|
输出目录的可迭代对象。使用文件树会关闭任务的 缓存。 |
|
|
指定一个或多个由该任务删除的文件。请注意,任务可以定义输入/输出或可销毁项,但不能同时定义两者。 |
|
|
指定一个或多个文件,这些文件代表 任务的本地状态。当任务从缓存中加载时,这些文件将被删除。 |
|
任何自定义类型 |
一个自定义类型,它可能没有实现 |
|
任何类型 |
表示该属性既不是输入也不是输出。它只是以某种方式影响任务的控制台输出,例如增加或减少任务的详细程度。 |
|
任何类型 |
表示该属性在内部使用,但既不是输入也不是输出。 |
|
任何类型 |
表示该属性已被另一个属性替换,应将其忽略为输入或输出。 |
|
|
与 暗示 |
|
|
与 |
|
任何类型 |
||
|
||
|
与 |
|
|
与 |
与上面类似, |
注释从所有父类型继承,包括实现的接口。属性类型注释会覆盖在父类型中声明的任何其他属性类型注释。这样,@InputFile
属性可以在子任务类型中变为 @InputDirectory
属性。
在类型中声明的属性上的注释会覆盖超类和任何实现的接口中声明的类似注释。超类注释优先于在实现的接口中声明的注释。
表中的 Console 和 Internal 注解是特殊情况,因为它们不声明任务输入或任务输出。那么为什么要使用它们呢?这样你就可以利用 Java Gradle Plugin Development 插件 来帮助你开发和发布自己的插件。此插件会检查你的自定义任务类中是否有任何属性缺少增量构建注解。这可以防止你在开发过程中忘记添加适当的注解。
使用依赖解析结果
依赖解析结果可以通过两种方式用作任务输入。首先,通过使用 ResolvedComponentResult 使用解析的元数据图。其次,通过使用 ResolvedArtifactResult 使用解析的工件的扁平集合。
可以从 Configuration
的传入解析结果中延迟获取解析的图,并将其连接到 @Input
属性
@Input
public abstract Property<ResolvedComponentResult> getRootComponent();
Configuration runtimeClasspath = configurations.getByName("runtimeClasspath");
task.getRootComponent().set(
runtimeClasspath.getIncoming().getResolutionResult().getRootComponent()
);
可以从 Configuration
的传入工件中延迟获取解析的工件集。鉴于 ResolvedArtifactResult
类型包含元数据和文件信息,实例需要在连接到 @Input
属性之前转换为仅元数据。
@Input
public abstract ListProperty<ComponentArtifactIdentifier> getArtifactIds();
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 查找更改时,运行时类路径和编译时类路径的处理方式不同。
与使用@InputFiles
注解的输入属性不同,对于类路径属性,文件集合中条目的顺序很重要。另一方面,类路径本身中目录和 jar 文件的名称和路径会被忽略。类路径上 jar 文件中类文件和资源的时间戳和顺序也会被忽略,因此使用不同的文件日期重新创建 jar 文件不会使任务失效。
使用@CompileClasspath
注解的输入属性被视为 Java 编译类路径。除了上述一般类路径规则之外,编译类路径还会忽略除类文件以外的所有更改。Gradle 使用Java 编译避免中描述的相同类分析来进一步过滤不影响类 ABI 的更改。这意味着仅触及类实现的更改不会使任务失效。
嵌套输入
在分析@Nested
任务属性以查找声明的输入和输出子属性时,Gradle 使用实际值的类型。因此,它可以发现由运行时子类型声明的所有子属性。
当将@Nested
添加到可迭代对象时,每个元素都被视为一个单独的嵌套输入。可迭代对象中的每个嵌套输入都分配一个名称,默认情况下是美元符号后跟可迭代对象中的索引,例如$2
。如果可迭代对象的元素实现了Named
,则使用该名称作为属性名称。如果并非所有元素都实现了Named
,则可迭代对象中元素的顺序对于可靠的最新检查和缓存至关重要。不允许多个具有相同名称的元素。
当在映射中添加@Nested
时,对于每个值,都会使用键作为名称添加一个嵌套输入。
嵌套输入的类型和类路径也会被跟踪。这确保了对嵌套输入实现的更改会导致构建过时。通过这种方式,也可以将用户提供的代码作为输入添加,例如,通过使用@Nested
注释@Action
属性。请注意,对这些操作的任何输入都应被跟踪,无论是通过操作上的注释属性,还是通过手动将它们注册到任务中。
使用嵌套输入允许对任务进行更丰富的建模和扩展,例如,如Test.getJvmArgumentProviders()所示。
这使我们能够对 JaCoCo Java 代理进行建模,从而声明必要的 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 的原因,我们将在下面介绍。
当您无法访问自定义任务类的源代码时,就无法添加我们在上一节中介绍的任何注释。幸运的是,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 > 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 进入一种状态,在这种状态下,它会不断检查更改,并在遇到此类更改时执行请求的任务。
您可以在 持续构建 中了解更多有关此功能的信息。
任务并行
定义任务输入和输出的最后一个好处是,Gradle 可以使用此信息来决定在使用 "--parallel" 选项时如何运行任务。例如,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。编写您的方法,以便它们将文件直接添加到相应的带注释的属性中。例如,以下是如何将 sources()
方法添加到我们之前介绍的自定义 ProcessTemplates
类中
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 文件(例如 jar、war、aar、apk 等)。这使得 Gradle 能够识别出两个 zip 文件在功能上是否相同,即使 zip 文件本身可能由于元数据(例如时间戳或文件顺序)而略有不同。规范化不仅适用于类路径上的 zip 文件本身,还适用于类路径上目录或其他 zip 文件中嵌套的 zip 文件。
可以自定义 Gradle 用于运行时类路径规范化的内置策略。所有用 @Classpath
注解的输入都被视为运行时类路径。
假设您希望将一个名为 build-info.properties
的文件添加到所有生成的 jar 文件中,该文件包含有关构建的信息,例如构建开始的时间戳或用于标识发布工件的 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 编译)的输出实现了过时输出目录的自动清理。 |