增量构建与构建缓存

Booster 自第一个版本发布开始,就已经支持 Transform 的增量构建,当部分源文件或者依赖发生改变时,并不会对所有的输入进行构建,而只选择构建变化的那部分输入,不仅如此,BoosterTransform 还支持构建缓存。

除了 BoosterTransform 之外,支持构建缓存的还有:

  1. booster-task-compression-pngquantopen in new window
  2. booster-task-compression-cwebpopen in new window

增量构建原理

在第一次执行任务之前,Gradle 会对输入进行计算。计算包含输入文件的路径以及每个文件内容的哈希;任务成功完成后,Gradle 将对输出进行进行计算。计算包含一组输出文件以及每个文件内容的哈希值。Gradle 会在下一次执行任务前保留两个计算结果。

之后每次任务执行前,Gradle 都会计算本次任务输入和输出进行计算,如果新旧计算相同,则Gradle会假定输出是最新的 (UP-TO-DATE),并跳过该任务。如果它们不相同,则 Gradle 执行任务。同样,Gradle 会在下一次执行任务前保留两个计算结果。

如果文件的统计信息未更改,则 Gradle 将复用上次运行的计算结果。这意味着当文件的统计信息未更改时,Gradle 不会检测到更改。以 Gradle 4.6 版本为例,关键代码如下:

@NonNullApi
public class DefaultTaskUpToDateState implements TaskUpToDateState {

     private final TaskStateChanges inputFileChanges;
     private final OutputFileTaskStateChanges outputFileChanges;
     private final TaskStateChanges allTaskChanges;
     private final TaskStateChanges rebuildChanges;
     private final TaskStateChanges outputFilePropertyChanges;

     /**
      * @param lastExecution 上次历史的执行记录,包含上次的计算结果
      * @param thisExecution 本次执行记录,包含最新的计算结果
      * @param task          此次执行的任务
      */
     public DefaultTaskUpToDateState(TaskExecution lastExecution, TaskExecution thisExecution, TaskInternal task) {
         // 略过部分代码
         this.allTaskChanges = new ErrorHandlingTaskStateChanges(task, new SummaryTaskStateChanges(
                 MAX_OUT_OF_DATE_MESSAGES,
                 previousSuccessState,
                 taskTypeState,
                 inputPropertyChanges,
                 inputPropertyValueChanges,
                 outputFilePropertyChanges,
                 outputFileChanges,
                 inputFilePropertyChanges,
                 inputFileChanges,
                 discoveredInputFileChanges));
         this.rebuildChanges = new ErrorHandlingTaskStateChanges(task, new SummaryTaskStateChanges(
                 1,
                 previousSuccessState,
                 taskTypeState,
                 inputPropertyChanges,
                 inputPropertyValueChanges,
                 inputFilePropertyChanges,
                 outputFilePropertyChanges,
                 outputFileChanges));
     }

     /* 👇👇👇👇 UP-TO-DATA 调用这个方法 👇👇👇👇 */
     @Override
     public TaskStateChanges getAllTaskChanges() {
         return allTaskChanges;
     }

     /* 👇👇👇👇 是否可以执行增量构建 调用这个方法 👇👇👇👇 */
     @Override
     public TaskStateChanges getRebuildChanges() {
         return rebuildChanges;
     }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50

Task 的输入输出

我们知道,增量的实现是根据 Task 的输入输出进行计算来判断是否需要重新执行,那 Task 的输入输出到底是什么呢?以大家最熟悉的 JavaCompile Task 为例,在大多数人的理解中,它的输出是 class 类文件,输入是 java 源文件,真的是这样吗?

Gradle 中,JavaCompile 任务的输入输出如下图所示:

Task inputs and outputs

对于 Gradle Task 而言,任何会对输出结果产生的影响的便是输入。比如:输入文件、编译环境、目标版本号等。

Task 的输入输出作为增量构建的一部分,Gradle 会检查自上次构建以来输入或输出是否有更改。如果没有任何变化,Gradle 认为该任务是最新的,因此跳过执行其动作。

WARNING

除非一个任务至少有一个任务输出,否则增量构建将无法工作

增量构建相关注解

为了简化自定义 Task 对增量构建的支持,Gradle 提供了一种用 AnnotationTask 属性进行注释,并在构建的过程中自动完成 Task 增量检查与计算的机制,下表中是 Gradle 提供的 Annotation

注解类型描述
@InputSerializable可序列化的输入对象
@InputFileFile单个输入文件 (不包括文件夹)
@InputDirectoryFile单个输入文件夹 (不包括文件)
@InputFilesIterable<File>输入文件或文件夹的集合
@ClasspathIterable<File>classpath 路径的输入文件和文件夹的集合
@CompileClasspathIterable<File>编译 classpath 路径的输入文件和文件夹的集合
@OutputFileFile单一输出文件
@OutputDirectoryFile单一输出文件夹
@OutputFilesMap<String, File>Iterable<File>输出文件的集合或者映射
@OutputDirectoriesMap<String, File>Iterable<File>输出文件夹的集合或者映射
@DestroysFileIterable<File>指定此任务销毁的一个或文件集合。 请注意,任务可以定义输入/输出或可销毁对象,但不能同时定义两者
@LocalStateFileIterable<File>本地任务状态,当任务从 cache 恢复时候,这些文件将会被移除.
@Nested任意自定义类型嵌套属性
@Console任意类型辅助属性,指出修饰属性不为输入或者输出属性
@Internal任意类型内部使用属性,指出修饰属性不为输入或者输出属性
@ReplacedBy任意类型指示该属性已被另一个替换,作为输入或输出被忽略。
@SkipWhenEmptyFile@InputFiles / @InputDirectory 配合使用, 告诉 Gradle 如果相应的文件或目录为空以及使用此注释声明的所有其他输入文件为空,则跳过任务
@IncrementalProvider<FileSystemLocation>FileCollection@InputFiles / @InputDirectory 配合使用, 告诉 Gradle 跟踪对文件属性的更改。可以通过 InputChanges.getFileChanges() 查询更改
@Optional任意类型可选属性
@PathSensitiveFile文件属性的类型

增量构建不生效

在以下几种情况下,增量构建不会生效:

  1. 没有历史构建记录
  2. Gradle 版本发生变化
  3. Task 实现 的 upToDateWhen 条件返回 false
  4. 和上次构建的输入属性、非增量文件属性、输出文件发生变化。

如何编写增量构建任务

如果 Gradle 具有先前任务执行的历史记录,并且自执行以来对任务执行上下文的唯一更改就是输入文件,则Gradle能够确定任务需要重新处理哪些输入文件。在这种情况下,将为添加或修改的任何输入文件执行IncrementalTaskInputs.outOfDate() 操作。为所有已删除的输入文件执行 IncrementalTaskInputs.removed() 操作。使用 InputChanges.isIncremental() 方法检查任务执行是否为增量执行,以下是示例代码:

class IncrementalTask : DefaultTask() {

    @get:Input
    private int fileCount = 10

    @get:OutputDirectory
    private val outputDir: File
        get() = project.file("${project.buildDir}/booster/test/")

    @TaskAction
    void perform(inputs: IncrementalTaskInputs) {
        if (!inputs.isIncremental()) {
            project.delete(outputDir.listFiles())
        }

        inputs.outOfDate { change ->
            println("out of date: ${change.file.name}")
        }

        inputs.removed { change ->
            println("removed: ${change.file.name}")
        }
    }

}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25

Android Grale Plugin 增量构建

Android Gradle Plugin (下文简称 AGP) 以 Gradle 为构建基础,在 2.0.0 版本添加了对增量编译功能的支持。并在后面的版本中逐步完善这部分功能。

Transform 中的增量构建

字节码处理流水线 这一章中,已经介绍了 Transform API 的概念,Transform 并不是一个 Task,它本身所属的 TransformTask 支持增量构建,Transform API 本身提供了 isIncremental() 方法,只有该方法返回结果为 true 的时候,TransformTask 才会将增量构建的信息传递到上下文 TransformInvocation 中,提供给相应 Transform 使用。

TransformTask 增量构建的稳定性

开发者在某些情况下,会碰到增量编译失效,甚至编译失败的情况。造成这个的原因很多,我们不能对情况一一列举,只给出部分建议:

  • 如果是App开发者

    • 可以升级 Gradle 和项目中构建过程中使用到的插件到官方提供的最新稳定版
    • 通过编译环境,减少任务产生
  • 如果是插件开发者

    • 尽量不要修改其他任务的输入
    • Transform 阶段,将 isIncremental() 方法实现设置为 true

什么是构建缓存

Gradle 构建缓存是 Gradle 任务为粒度的一种缓存机制。如果一个任务是可以被缓存 (CacheableTask),则可以将它的输出结果缓存起来;如果后续构建过程中发现该任务已经有符合条件的构建缓存 (Build-Cache),则可以直接复用构建产物,从而缩短全量编译时间。

Gradle 构建缓存分为本地构建缓存 (Local Build Cache) 和远端构建缓存 (Remote Build Cache) 两种,分别适用于不同的场景:

  • 本地 Build-Cache 主要用于开发者自身的开发。比如:切换分支后的构建;
  • 远端 Build-Cache 更适用于远端 CI 打包的情景。通过机器之间复用构建缓存,来提升整体打包效率。

Build Cache 的计算规则

官网关于 Build-Cache 的章节 Cacheable Tasksopen in new window 简单列举了影响 Build-Cache 计算的要素,比如任务属性和类路径,输入输出属性名称等。事实上,Gradle 是将这些属性进行计算,最后得到一个唯一标识,用该标识符来查找和缓存文件输出结果,以下是 Gradle 4.6Cache-Key 计算的关键代码:

public TaskOutputCachingBuildCacheKey calculate(TaskInternal task, TaskExecution execution) {

    TaskOutputCachingBuildCacheKeyBuilder builder = new DefaultTaskOutputCachingBuildCacheKeyBuilder(task.getIdentityPath());
    if (buildCacheDebugLogging) {
        builder = new DebuggingTaskOutputCachingBuildCacheKeyBuilder(builder);
    }
    builder.appendTaskImplementation(execution.getTaskImplementation());
    builder.appendTaskActionImplementations(execution.getTaskActionImplementations());

    SortedMap<String, ValueSnapshot> inputProperties = execution.getInputProperties();
    for (Map.Entry<String, ValueSnapshot> entry : inputProperties.entrySet()) {
        DefaultBuildCacheHasher newHasher = new DefaultBuildCacheHasher();
        entry.getValue().appendToHasher(newHasher);
        if (newHasher.isValid()) {
            HashCode hash = newHasher.hash();
            builder.appendInputPropertyHash(entry.getKey(), hash);
        } else {
            builder.inputPropertyLoadedByUnknownClassLoader(entry.getKey());
        }
    }

    SortedMap<String, FileCollectionSnapshot> inputFilesSnapshots = execution.getInputFilesSnapshot();
    for (Map.Entry<String, FileCollectionSnapshot> entry : inputFilesSnapshots.entrySet()) {
        FileCollectionSnapshot snapshot = entry.getValue();
        builder.appendInputPropertyHash(entry.getKey(), snapshot.getHash());
    }

    SortedSet<String> outputPropertyNamesForCacheKey = execution.getOutputPropertyNamesForCacheKey();
    for (String cacheableOutputPropertyName : outputPropertyNamesForCacheKey) {
        builder.appendOutputPropertyName(cacheableOutputPropertyName);
    }

    return builder.build();
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34

如何启用构建缓存

命令行

使用命令行运行构建的时候,添加 --build-cache 参数:

$ ./gradlew assembleDebug --build-cache
1

gradle.properties

gradle.properties 文件里面添加属性 org.gradle.caching,如下所示:

org.gradle.caching=true
1

Gradle 内置可缓存 Task

Gradle 内置了很多 Cacheable Task,例如:

如何编写可缓存任务

使用 @CacheableTask 注解

booster-task-compression-cwebpopen in new windowbooster-task-compression-pngquantopen in new window 为例,所有 CompressImages 的子类都是通过 @CacheableTask 来实现任务的缓存。

使用 runtime API

除了 @CacheableTask 以外,Gradle 还提供了缓存相关的 API,如果要启用任务缓存,需要调用 TaskOutputs.cacheIf() 方法,例如:

tasks.register<NpmTask>("bundle") {
    args.set(listOf("run", "bundle"))

    outputs.cacheIf { true }

    inputs.dir(file("scripts"))
        .withPropertyName("scripts")
        .withPathSensitivity(PathSensitivity.RELATIVE)

    inputs.files("package.json", "package-lock.json")
        .withPropertyName("configFiles")
        .withPathSensitivity(PathSensitivity.RELATIVE)

    outputs.file("$buildDir/bundle.js")
        .withPropertyName("bundle")
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

关于 Cacheable Task 更多详细内容,请参阅:https://docs.gradle.org/current/userguide/build_cache.html#enable_caching_of_non_cacheable_tasksopen in new window

WARNING

并不是所有的任务都适合缓存,缓存文件其实是一个压缩包,如果一个任务执行时间较短,或者相应输出每次都会发生改变,将该任务配置成可缓存的没有任何意义,甚至会降低编译效率,对于该类任务,我们可以将该任务配置成增量任务即可

配置构建缓存

通过在 settings.gradle 配置文件来进行配置缓存,简单如下:

buildCache {
  local {
      directory = File(rootDir, "build-cache")
      removeUnusedEntriesAfterDays = 30
  }
  remote<HttpBuildCache> {
      url = uri("https://booster.johnson.io:8123/cache/")
      credentials {
          username = "$USERNAME"
          password = "$PASSWORD"
      }
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13

更多关于 Gradle Build Cache 相关内容,请参阅:https://docs.gradle.org/current/userguide/build_cache.htmlopen in new window

部署构建缓存服务

Gradle 官方提供了 Build CacheDocker 镜像 gradle/build-cache-nodeopen in new window,关于如何使用 Build Cache Node,请参阅:https://docs.gradle.com/build-cache-node/open in new window

Android Gradle Plugin 构建缓存

AGP 2.3.0 以后,在构建任务默认开启自己的构建缓存,该缓存与 Gradle 缓存不一样,缓存默认存储在 ~/.android/build-cache/ 路径下,更多详细内容请参阅:使用构建缓存加快整洁构建的速度open in new window