Gradle 进阶(二):如何优化 Task 的性能?

语言: CN / TW / HK

theme: smartblue highlight: a11y-dark


我报名参加金石计划1期挑战——瓜分10万奖池,这是我的第1篇文章,点击查看活动详情

前言

之前介绍了Gradle自定义Task相关的一些内容:Gradle 进阶(一):深入了解 Tasks

其中也提到了,当Task的输入与输出被注解标注并且都没有发生变化时,Task的状态是up-to-date的,此时可以跳过Task的执行

除了以上所说的编译避免的方式,Gradle还提供了其他方案可以优化Task的性能,本文主要包括以下内容

  1. 支持增量处理的Task
  2. Task惰性配置
  3. 避免不必要的Task配置
  4. 并行Task

支持增量处理的Task

上文提到,当Task的输入与输出被注解标注并且都没有发生变化时,Task的状态是up-to-date的,此时可以跳过Task的执行

但是也存在一种情况,Task的输入只有几个文件发生了更改,而你并不想重新处理其他没有发生更改的文件,这对于那些将输入文件按 1:1 转换为输出文件的Task特别有用,比如我们编译代码的过程,就是将.java文件一对一的编译成.class文件

如果你想要你的Task只重新处理发生了更改的文件,那么可以使用incremental task

实现incremental task

对于一个支持增量处理输入的task,必须包含一个处理增量输入的action。我们知道,Task中的Action就是使用@TaskAction注解的方法,与普通的Action不同的是,支持增量的action拥有一个InputChanges的输入。此外,该Task还需要使用@Incremental@SkipWhenEmpty注解至少一个增量文件输入属性。

增量action可以使用InputChanges.getFileChanges()方法来找出对于指定的输入(类型为RegularFilePropertyDirectoryPropertyConfigurableFileCollection)中哪些文件已更改。该方法返回一个FileChangesIterable类型的结果,然后可以查询以下内容

  • 受影响的文件
  • 更改的类型(ADDED,REMOVEDMODIFIED)
  • 变更了的文件的规范化路径
  • 变更了的文件的文件类型

下面就是一个支持增量处理的Task示例,它有一个目录文件作为输入,这个task的作用就是将这个目录中的文件都拷贝到另一个目录并且将文件的内容反转。代码如下所示:

```kotlin abstract class IncrementalReverseTask : DefaultTask() { @get:Incremental @get:PathSensitive(PathSensitivity.NAME_ONLY) @get:InputDirectory abstract val inputDir: DirectoryProperty

@TaskAction
fun execute(inputChanges: InputChanges) {
    println(
        if (inputChanges.isIncremental) "Executing incrementally"
        else "Executing non-incrementally"
    )

    inputChanges.getFileChanges(inputDir).forEach { change ->
        if (change.fileType == FileType.DIRECTORY) [email protected]

        val targetFile = outputDir.file(change.normalizedPath).get().asFile
        if (change.changeType == ChangeType.REMOVED) {
            targetFile.delete()
        } else {
            targetFile.writeText(change.file.readText().reversed())
        }
    }
}

} ```

可以看出,主要做了以下几点:
1. inputDir使用@Incremental注解标识,表示支持增量处理的输入
2. 通过inputChanges方法获取输入中更快的文件,如果发生了更改则重新处理,如果被删除了则同样删除目标目录中的文件,没有发生更改的文件则不处理

总得来说,对于增量编译Task,只需要为任何过时的输入生成输出文件,并为已删除的输入删除输出文件。

哪些输入被认为是过时的?

如果之前执行过Task,并且自执行以来唯一的更改就是增量输入文件属性,则 Gradle 能够确定需要处理哪些输入文件(即增量执行)。在这种情况下,InputChanges.getFileChanges()方法会返回指定属性的所有已添加、修改或删除的输入文件的详细信息。

但是,在很多情况下,Gradle 无法确定需要处理哪些输入文件(即非增量执行)。比如:

  • 以前的执行没有可用的历史记录。
  • 您正在使用不同版本的 Gradle 进行构建。目前,Gradle 不使用来自不同版本的Task历史记录。
  • 添加的自定义upToDateWhen规则返回false。
  • 自上次执行以来,输入属性已更改。(因为Gradle无法判断这个属性对输出的影响,因此所有输入都需要重新处理)
  • 自上次执行以来,非增量输入文件属性已更改。
  • 自上次执行以来,一个或多个输出文件已更改。

在以上所有这些情况下,Gradle 会将所有的输入文件标记为ADDED状态,并且通过getFileChanges()方法返回

您可以如上面的示例,使用InputChanges.isIncremental()方法检查Task执行是否是增量的。

Task惰性配置

随着构建的复杂性增加,有时我们很难了解特定值的配置时间和位置。Gradle 提供了几种使用惰性配置来管理这种复杂性的方法。

惰性属性

Gradle 提供了惰性属性,它会延迟属性值的计算,直到实际需要使用该属性的时候,类似于Kotlinby lazy,惰性属性主要有以下收益

  1. 用户可以将 Task 属性连接在一起,而不必担心特定属性何时会被赋值。例如Task有一个inputFiles的惰性属性输入,它与inputDirectory惰性属性存在一个连接关系(即它依赖于inputDirectory惰性属性),你不用关心inputDirectory何时被赋值,inputFiles会追踪inputDirectory的值的变化,并且在真正用到时才去计算
  2. 用户可以将Task的输出属性连接到其他Task的输入属性,Gradle 会根据此连接自动确定Task的依赖关系,这样可以有效避免用户忘记显式的声明依赖关系
  3. 用户可以避免在配置阶段进行资源密集型工作,这会对构建性能产生很大影响。因为只有在真正使用属性时才会真正去计算,这往往是在执行阶段。

Gradle提供了两个接口表示惰性属性

  • Provider接口表示一个只读的属性,它只能读不能被修改,通过Provider.get()方法返回值,可以通过Provider.map(Transformer)方法变换成另一个Provider
  • Property接口表示可读可改的属性,Property接口继承了Provider,可通过Property.set(T)方法设置值,也可以通过Property.set(Provider)方法建立与另一个Provider的连接。

惰性属性的目的就是在配置阶段传递并且仅在需要时进行查询,而查询通常发生在执行阶段,下面我们来看个例子

```kotlin abstract class Greeting : DefaultTask() { // Configurable by the user @get:Input abstract val greeting: Property

// Read-only property calculated from the greeting
@Internal
val message: Provider<String> = greeting.map { it + " from Gradle" }

@TaskAction
fun printMessage() {
    logger.quiet(message.get())
}

}

tasks.register("greeting") { // Configure the greeting greeting.set("Hi") } ```

如上所示,该Task有两个属性,一个由用户配置的greeting与一个派生的属性message

文件处理

上面的示例属性都是常规类型,如果要使用文件类型的惰性属性的话,要怎么处理呢?

Gradle 专门提供了两个Property的子类型来处理文件类型:RegularFilePropertyDirectoryProperty,分别表示惰性的文件和目录

一个DirectoryProperty也可以通过DirectoryProperty.dir(String)DirectoryProperty.file(String)方法创建新的provider,新的provider的路径是相对于创建它的DirectoryProperty 计算的,如下面示例所示

```kotlin abstract class GenerateSource : DefaultTask() { @get:InputFile abstract val configFile: RegularFileProperty

@get:OutputDirectory
abstract val outputDir: DirectoryProperty

@TaskAction
fun compile() {
    val inFile = configFile.get().asFile
    logger.quiet("configuration file = $inFile")
    val dir = outputDir.get().asFile
    logger.quiet("output dir = $dir")
}

}

tasks.register("generate") { // 相对于projectDirectory与buildDirectory配置Task属性 configFile.set(layout.projectDirectory.file("src/config.txt")) outputDir.set(layout.buildDirectory.dir("generated-source")) }

// 修改build directory // 不需要重新配置Task的属性,当build directory变化时,task的outputDir属性会自动变化 layout.buildDirectory.set(layout.projectDirectory.dir("output")) ```

上面的Task输出如下

```kotlin

Task :generate configuration file = /home/user/gradle/samples/kotlin/src/config.txt output dir = /home/user/gradle/samples/kotlin/output/generated-source ```

主要需要注意以下两点:
1. configFileoutputDir的路径都是相对于创建它们的路径计算出来的 2. 当修改build directory的值时,不需要重新配置Task的属性,当build directory变化时,taskoutputDir属性会自动变化,这也是惰性配置的特性带来的

自动建立Task依赖关系

有时我们需要将多个Task连接起来,其中一个Task的输出作为另一个的输入来完成构建。这个时候我们不仅需要指定Task的输入输出,同时需要显式地配置Task之间的依赖关系。如果其中某个属性的依赖关系发生了变化,这可能会很麻烦且脆弱,因为在不使用惰性配置属性的情况下,Task属性需要以正确的顺序配置,并且Task依赖关系也需要手动同步变更

Property API不仅能够像上面示例那样跟踪属性值,并且可以跟踪产生属性的Task,因此我们不必要手动配置Task依赖关系,我们来看下面的例子:

```kotlin abstract class Producer : DefaultTask() { @get:OutputFile abstract val outputFile: RegularFileProperty

@TaskAction
fun produce() {
    // ...
}

}

abstract class Consumer : DefaultTask() { @get:InputFile abstract val inputFile: RegularFileProperty

@TaskAction
fun consume() {
    // ...
}

}

val producer = tasks.register("producer") val consumer = tasks.register("consumer")

consumer { // 将 producer的输出与consumer的输入建立联系,你不必手动添加Task依赖关系 inputFile.set(producer.flatMap { it.outputFile }) }

producer { // 更新 producer的值,你不必手动更新 consumer.inputFile,它会随着 producer.outputFile的变化自动变化 outputFile.set(layout.buildDirectory.file("file.txt")) }

// 修改 build directory,也不必手动更新 producer.outputFile 与 consuer.inputFile,它们会自动更新 layout.buildDirectory.set(layout.projectDirectory.dir("output")) ```

  1. 上面的代码通过Provider的依赖关系自动地建立了Task的依赖关系,当运行consumer时会自动运行它的依赖Task(即producer)
  2. 使用惰性属性不必关心属性何时被赋值,哪个属性先赋值,只要在Task真正执行前赋值就可以了,并且更改将自动影响所有相关的输入和输出属性。

惰性属性集合API

类似于文件,对于各种集合,Gradle也提供了相应的API供我们使用

  • 对于List值,该接口称为ListProperty
  • 对于Set值,该接口称为SetProperty
  • 对于Map值,该接口称为MapProperty

这几个接口的使用示例就不在这里缀述了,感兴趣的同学可查看:http://docs.gradle.org/current/userguide/lazy_configuration.html#working_with_collections

惰性属性默认值

有时我们也可能需要给惰性属性设置一个默认值,在没有为属性配置值时使用

```kotlin // 创建 property val property = objects.property(String::class)

// 设置默认值 property.convention("convention 1") println("value = " + property.get())

// 设置之后也可以重新设置默认值 property.convention("convention 2") println("value = " + property.get())

// 设置真正的值 property.set("value")

// 一旦设置了真正的值,默认值设置就会被忽略 property.convention("ignored convention") println("value = " + property.get()) ```

使Property不可更改

上文我们提到,Provider接口是不可设值的,Property接口是可以设值的。但是,有时候我们希望在Task开始执行之后,禁止对Property的修改(即只在配置阶段修改)

惰性属性提供了finalizeValue()来实现这个需求,它会计算属性的最终值并防止对属性进行进一步更改。当属性的值来自于一个 Provider时,会向提供者查询其当前值,结果将成为该属性的最终值。并且该属性不再跟踪提供者的值。调用此方法还会使属性实例不可修改,并且任何进一步更改属性值的尝试都将失败。

finalizeValueOnRead()方法类似,只是在查询到属性的值之前不计算属性的最终值。换句话说,该方法根据需要延迟计算最终值,而finalizeValue()急切地计算最终值。

小结

本节主要介绍了Provider API的使用,它有着如上文所说的一系列优点,现在官方的Task属性都改成Provider了,我们在开发自定义Task时也应该尽量使用惰性属性

避免不必要的Task配置

配置避免,简而言之,就是避免在的配置阶段创建和配置Task,因为这些Task可能永远不会被执行。例如,在运行编译Task时,不会执行其他不相关的Task,如代码质量、测试和发布,因此无需花费任何时间创建和配置这些Task。如果在构建过程中不需要配置Task,则配置避免 API 会避免配置Task,这可能会对总配置时间产生重大影响。

配置避免的一个常用手段就是使用register代替create来创建Task,除了这个还有哪些常用手段呢?

如何推迟Task配置?

避免使用DomainObjectCollection.all(org.gradle.api.Action)DomainObjectCollection.withType(java.lang.Class, org.gradle.api.Action)这样的API
它们将立即创建和配置任何已注册的Task。要推迟Task配置,您需要迁移到配置避免 API 等效项,即withType(java.lang.Class).configureEach(org.gradle.api.Action)

如何在不创建/配置Task的情况下引用它?

您可以通过TaskProvider对象来引用已注册的Task,而不是直接引用Task对象。可以通过多种方式获取 TaskProvider,包括调用TaskContainer.register(java.lang.String)或使用TaskCollection.named(java.lang.String)方法。

调用Provider.get()或使用TaskCollection.getByName(java.lang.String)方法将导致立即创建和配置TaskTask.dependsOn (java.lang.Object...​)之类的方法的参数可以是TaskProvider,因此您无需取出Task对象

总得来说,即尽量使用TaskProvider而不是Task

依赖关系对配置的影响

依赖关系可以分为软关系与强关系两类

Task.mustRunAfter(…​)Task.shouldRunAfter(…​)代表软关系,只能改变现有Task的顺序,不能触发它们的创建。

Task.dependsOn(…​)Task.finalizedBy(…​)代表强关系,这将强制执行引用的Task

  • 如果未执行Task,定义的关系将不会在配置时触发任何Task创建。
  • 如果执行一个Task,所有强关联的Task都需要在配置时创建和配置,因为它们可能有其他dependsOnfinalizedBy关系。这将传递地发生,直到任务图包含所有强关系。

问题定位

如果想具体了解我们项目目前Task的配置情况,可以使用gradle --scan命令

可以看到performanceconfiguration部分,如上图所示:

  • Created immediately表示使用急切Task API创建的任务。
  • Created during configuration表示使用配置避免 API 创建的Task,但是可能使用了TaskProvider#get()或者来TaskCollection.getByName(java.lang.String)查询
  • Created immediatelyCreated during configuration数字都被认为是“坏”数字,应尽可能减少。
  • Created during task graph calculation表示在构建执行Task图时创建的任务。理想情况下,这个数字将等于执行的任务数。
  • Not created表示在此构建会话中避免的任务。

小结

总得来说,配置避免主要有以下实用手段

  1. 迁移配置tasks.all {}tasks.withType(…​) {}APIwithType(java.lang.Class).configureEach(...)
  2. 迁移查找方法TaskContainer#getByName(String)TaskContainer#named(String),尽量使用TaskProvider而不是直接使用Task
  3. 还有最常用的,使用register方法创建Task而不是create

并行Task

在我们自定义Task时,Task的输入有时是个列表,而列表的每一项可以单独处理,这个时候就可以使用Worker API来加速构建

Worker API 提供了将Task操作的执行分解为离散的工作单元然后并发和异步执行该工作的能力。这允许 Gradle 充分利用可用资源并更快地完成构建。

下面我们创建一个自定义Task,该Task为一组可配置的文件生成 MD5 哈希。然后,我们使用Worker API优化该Task

创建自定义Task

首先我们创建一个Task,该Task为一组可配置的文件生成 MD5 哈希

```java abstract public class CreateMD5 extends SourceTask {

@OutputDirectory
abstract public DirectoryProperty getDestinationDirectory();

@TaskAction
public void createHashes() {
    for (File sourceFile : getSource().getFiles()) { 
        try {
            InputStream stream = new FileInputStream(sourceFile);
            System.out.println("Generating MD5 for " + sourceFile.getName() + "...");
            // 模拟耗时操作
            Thread.sleep(3000); 
            Provider<RegularFile> md5File = getDestinationDirectory().file(sourceFile.getName() + ".md5");  
            FileUtils.writeStringToFile(md5File.get().getAsFile(), DigestUtils.md5Hex(stream), (String) null);
        } catch (Exception e) {
            throw new RuntimeException(e);
        }
    }
}

} ```

代码其实很简单,主要是遍历输入文件列表,为每个文件生成md5哈希并输出。如果输入文件数量是3个的话,该Task至少需要 9 秒才能运行,因为它一次对每个文件进行一个哈希处理(每个文件大约 3 秒)。

转换为 Worker API

尽管此Task按顺序处理每个文件,但每个文件的处理独立于任何其他文件。如果这项工作能够并行完成那就太好了。这就是 Worker API 的用武之地

第一步:首先我们需要定义一个接口来表示每个工作单元需要的参数

java public interface MD5WorkParameters extends WorkParameters { RegularFileProperty getSourceFile(); RegularFileProperty getMD5File(); }

  1. 这个接口需要继承WorkParameters,但是不需要实现
  2. 每个工作单元主要需要两个参数,输入的文件与输出文件,也就是接口中声明的对象

第二步:您需要将自定义Task中为每个单独文件执行工作的部分重构为单独的类。这个类是你的“工作单元”实现,它应该是一个继承了WorkAction接口的抽象类

java public abstract class GenerateMD5 implements WorkAction<MD5WorkParameters> { @Override public void execute() { try { File sourceFile = getParameters().getSourceFile().getAsFile().get(); File md5File = getParameters().getMD5File().getAsFile().get(); InputStream stream = new FileInputStream(sourceFile); System.out.println("Generating MD5 for " + sourceFile.getName() + "..."); // 模拟耗时操作 Thread.sleep(3000); FileUtils.writeStringToFile(md5File, DigestUtils.md5Hex(stream), (String) null); } catch (Exception e) { throw new RuntimeException(e); } } }

需要注意的是,不要实现getParameters()方法,Gradle 将在运行时注入它。

第三步:您应该重构自定义Task类以将工作提交给 WorkerExecutor,而不是自己完成工作。

```java abstract public class CreateMD5 extends SourceTask {

@OutputDirectory
abstract public DirectoryProperty getDestinationDirectory();

@Inject
abstract public WorkerExecutor getWorkerExecutor();

@TaskAction
public void createHashes() {
    WorkQueue workQueue = getWorkerExecutor().noIsolation();

    for (File sourceFile : getSource().getFiles()) {
        Provider<RegularFile> md5File = getDestinationDirectory().file(sourceFile.getName() + ".md5");
        workQueue.submit(GenerateMD5.class, parameters -> { 
            parameters.getSourceFile().set(sourceFile);
            parameters.getMD5File().set(md5File);
        });
    }
}

} ```

  1. 您需要拥有WorkerExecutor服务才能提交您的工作。这里我们添加了一个抽象的getWorkerExecutor方法并添加注解,Gradle 将在运行时注入服务。
  2. 在提交工作之前,我们需要通过不同的隔离模式获取WorkQueue。关于隔离模式我们稍后再讨论。
  3. 提交工作单元时,指定工作单元实现,在这种情况下调用GenerateMD5并配置其参数。

当我们再次运行这个Task时,可以发现Task运行的速度变快了,这是因为 Worker API 是并行的而不是按顺序对每个文件执行 MD5 计算。

隔离模式

上面的示例中我们使用了noIsolation隔离模式,Gralde提供了三种隔离模式

  • WorkerExecutor.noIsolation():这表明工作应该在具有最小隔离的线程中运行。例如,它将共享加载Task的同一个类加载器。这是最快的隔离级别。
  • WorkerExecutor.classLoaderIsolation():这表明工作应该在具有隔离类加载器的线程中运行。类加载器将具有来自加载工作单元实现类的类加载器的classpath,以及通过ClassLoaderWorkerSpec.getClasspath()添加的任何其他classpath
  • WorkerExecutor.processIsolation(): 这表明工作应该在单独的进程中执行,工作以最大程度的隔离运行。进程的类加载器将使用加载工作单元的类加载器中的classpath以及通过ClassLoaderWorkerSpec.getClasspath()添加的任何其他classpath。此外,该进程将是一个Worker守护进程,它将保持活动状态并且可以重用于未来可能具有相同要求的工作项。可以使用ProcessWorkerSpec.forkOptions(org.gradle.api.Action)方法来配置此进程。

总结

本文主要介绍了通过支持增量处理,惰性配置,避免不必要的Task配置,以及并行Task等方式来优化自定义Task的性能,希望对你有所帮助~

参考资料

http://docs.gradle.org/current/userguide/custom_tasks.html