Gradle 进阶(二):如何优化 Task 的性能?
theme: smartblue highlight: a11y-dark
我报名参加金石计划1期挑战——瓜分10万奖池,这是我的第1篇文章,点击查看活动详情
前言
之前介绍了Gradle
自定义Task
相关的一些内容:Gradle 进阶(一):深入了解 Tasks
其中也提到了,当Task
的输入与输出被注解标注并且都没有发生变化时,Task
的状态是up-to-date
的,此时可以跳过Task
的执行
除了以上所说的编译避免的方式,Gradle
还提供了其他方案可以优化Task
的性能,本文主要包括以下内容
- 支持增量处理的
Task
Task
惰性配置- 避免不必要的
Task
配置 - 并行
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()
方法来找出对于指定的输入(类型为RegularFileProperty
,DirectoryProperty
或ConfigurableFileCollection
)中哪些文件已更改。该方法返回一个FileChangesIterable
类型的结果,然后可以查询以下内容
- 受影响的文件
- 更改的类型(
ADDED
,REMOVED
或MODIFIED
) - 变更了的文件的规范化路径
- 变更了的文件的文件类型
下面就是一个支持增量处理的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
提供了惰性属性,它会延迟属性值的计算,直到实际需要使用该属性的时候,类似于Kotlin
的by lazy
,惰性属性主要有以下收益
- 用户可以将
Task
属性连接在一起,而不必担心特定属性何时会被赋值。例如Task
有一个inputFiles
的惰性属性输入,它与inputDirectory
惰性属性存在一个连接关系(即它依赖于inputDirectory
惰性属性),你不用关心inputDirectory
何时被赋值,inputFiles
会追踪inputDirectory
的值的变化,并且在真正用到时才去计算 - 用户可以将
Task
的输出属性连接到其他Task
的输入属性,Gradle
会根据此连接自动确定Task
的依赖关系,这样可以有效避免用户忘记显式的声明依赖关系 - 用户可以避免在配置阶段进行资源密集型工作,这会对构建性能产生很大影响。因为只有在真正使用属性时才会真正去计算,这往往是在执行阶段。
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
如上所示,该Task
有两个属性,一个由用户配置的greeting
与一个派生的属性message
文件处理
上面的示例属性都是常规类型,如果要使用文件类型的惰性属性的话,要怎么处理呢?
Gradle
专门提供了两个Property
的子类型来处理文件类型:RegularFileProperty
和DirectoryProperty
,分别表示惰性的文件和目录
一个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
// 修改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. configFile
与outputDir
的路径都是相对于创建它们的路径计算出来的
2. 当修改build directory
的值时,不需要重新配置Task
的属性,当build directory
变化时,task
的outputDir
属性会自动变化,这也是惰性配置的特性带来的
自动建立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
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")) ```
- 上面的代码通过
Provider
的依赖关系自动地建立了Task
的依赖关系,当运行consumer
时会自动运行它的依赖Task
(即producer
) - 使用惰性属性不必关心属性何时被赋值,哪个属性先赋值,只要在
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)
方法将导致立即创建和配置Task
。Task.dependsOn (java.lang.Object...)
之类的方法的参数可以是TaskProvider
,因此您无需取出Task
对象
总得来说,即尽量使用TaskProvider
而不是Task
依赖关系对配置的影响
依赖关系可以分为软关系与强关系两类
Task.mustRunAfter(…)
和Task.shouldRunAfter(…)
代表软关系,只能改变现有Task
的顺序,不能触发它们的创建。
Task.dependsOn(…)
和Task.finalizedBy(…)
代表强关系,这将强制执行引用的Task
- 如果未执行
Task
,定义的关系将不会在配置时触发任何Task
创建。 - 如果执行一个
Task
,所有强关联的Task
都需要在配置时创建和配置,因为它们可能有其他dependsOn
或finalizedBy
关系。这将传递地发生,直到任务图包含所有强关系。
问题定位
如果想具体了解我们项目目前Task
的配置情况,可以使用gradle --scan
命令
可以看到performance
的configuration
部分,如上图所示:
Created immediately
表示使用急切Task API
创建的任务。Created during configuration
表示使用配置避免API
创建的Task
,但是可能使用了TaskProvider#get()
或者来TaskCollection.getByName(java.lang.String)
查询Created immediately
和Created during configuration
数字都被认为是“坏”数字,应尽可能减少。Created during task graph calculation
表示在构建执行Task
图时创建的任务。理想情况下,这个数字将等于执行的任务数。Not created
表示在此构建会话中避免的任务。
小结
总得来说,配置避免主要有以下实用手段
- 迁移配置
tasks.all {}
和tasks.withType(…) {}
等API
到withType(java.lang.Class).configureEach(...)
- 迁移查找方法
TaskContainer#getByName(String)
到TaskContainer#named(String)
,尽量使用TaskProvider
而不是直接使用Task
- 还有最常用的,使用
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();
}
- 这个接口需要继承
WorkParameters
,但是不需要实现 - 每个工作单元主要需要两个参数,输入的文件与输出文件,也就是接口中声明的对象
第二步:您需要将自定义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);
});
}
}
} ```
- 您需要拥有
WorkerExecutor
服务才能提交您的工作。这里我们添加了一个抽象的getWorkerExecutor
方法并添加注解,Gradle
将在运行时注入服务。 - 在提交工作之前,我们需要通过不同的隔离模式获取
WorkQueue
。关于隔离模式我们稍后再讨论。 - 提交工作单元时,指定工作单元实现,在这种情况下调用
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
的性能,希望对你有所帮助~
参考资料
- kotlin-android-extensions 插件到底是怎么实现的?
- 江同学的 2022 年终总结,请查收~
- kotlin-android-extensions 插件将被正式移除,如何无缝迁移?
- 学习一下 nowinandroid 的构建脚本
- Kotlin 默认可见性为 public,是不是一个好的设计?
- 2022年编译加速的8个实用技巧
- 落地 Kotlin 代码规范,DeteKt 了解一下~
- Gradle 进阶(二):如何优化 Task 的性能?
- 开发一个支持跨平台的 Kotlin 编译器插件
- 开发你的第一个 Kotlin 编译器插件
- Kotlin 增量编译是怎么实现的?
- Gradle 都做了哪些缓存?
- K2 编译器是什么?世界第二高峰又是哪座?
- Android 性能优化之 R 文件优化详解
- Kotlin 快速编译背后的黑科技,了解一下~
- 别了 KAPT , 使用 KSP 快速实现 ButterKnife
- Android Apk 编译打包流程,了解一下~
- 如何优雅地扩展 AGP 插件
- ASM 插桩采集方法入参,出参及耗时信息
- Transform 被废弃,ASM 如何适配?