查看原文
其他

Gradle Task 系列完结,Task执行大剖析

近地小行星 鸿洋 2023-09-13

本文作者


作者:近地小行星

链接:

https://juejin.cn/post/7241492331954012221 

本文由作者授权发布。


前2篇文章探究了gradle是如何处理Task GraphTask调度的,至此Task的前期工作就已经完成了。

深入探索Gradle,Task是如何关联起来的?
图解Gradle, Task运行如何并行优化?


下面就该执行Task了,如果观察过Task执行的话,会留意到console输出中Task后面有的带有执行结果的标识,如SKIPPEDUP-TO-DATE等。
除了不带标识的和带有EXECUTED标识的表示是真正执行过Task的action的,其他的要么是从缓存中读取的结果,要么是不需要执行,这是gradle做的一个task执行的优化,下面也会针对gradle是通过什么判定出这些执行结果的。

我们会看到gradle是如何将inputs/outputs的状态进行记录的,如何进行up-to-date的检测的,又是如何利用build cache来加速构建的完整流程。


1Task执行


先上一张图来对整体概念有所了解。
Task的执行入口在LocalTaskNodeExecutor内,它从LocalTaskNode拿出Task,交给TaskExecuter来执行。
在继续探究执行前,我们先看一下Task执行结果Outcome的类型。
Task Outcome Task结果标识有5种,从名字上能大概看出它们的含义,在下面的执行过程中会看到这些结果产生的具体情况。
SKIPPED
NO-SOURCE
UP-TO-DATE
FROM-CACHE
EXECUTED

TaskExecuter

TaskExecutor从名字上可以看出是用来执行Task的,它使用代理模式,将不同职责划分给了多个子类,我们看一下主要的几个。

SkipOnlyIfTaskExecuter

Task可以通过api控制在某些条件下才执行。
tasks.register('customTask') {  
    onlyIf {  

    }
    enabled = false  
}
onlyIf和enabled都可以控制Task执行条件,如果其结果是false,那这个Task就不需要被执行,SkipOnlyIfTaskExecuter就是用来判断这个的。
如果在控制台看到有Task执行结果后面带有SKIPPED标识,那么通常在这一步处理掉的。
还有一种特殊情况,我们可以在Task的action中抛出StopExecutionException异常,这种情况输出结果后面不会带有SKIPPED标识,不是由SkipOnlyIfTaskExecuter处理,这种情况和上面有相同之处,在Task执行失败之后,依赖于它的Task依旧能够执行。

SkipTaskWithNoActionsExecuter

如果Task没有action,那它就不需要执行,通常这些都是lifecycle tasks。
• lifecycle tasks gradle的LifecycleBasePlugin有一些内置的lifecycle tasks,例如build,test,clean等。
这些Task都没有action,它们代表了构建过程通用的一些逻辑,通常会让它们依赖 actionable tasks,例如java项目通过java plugin,让build依赖compileJava,kotlin项目会让其依赖compileKotlin。
• actionable tasks 这些就是真正干活的Task了,它们都有action,有真正可以执行的逻辑在。
这类Task的执行结果需要看其所依赖的Task的执行结果,如果依赖的Task都不是EXECUTED,那它的执行结果是UP-TO-DATE,否则为EXECUTED

ResolveTaskExecutionModeExecuter

这一步是通过分析Task的属性得出其执行模式,对后续步骤最主要的影响是其是否可以支持增量构建。
Task的执行模式分5种:
INCREMENTAL
NO_OUTPUTS
RERUN_TASKS_ENABLED
UP_TO_DATE_WHEN_FALSE
UNTRACKED
  1. UNTRACKED 当Task被注解上了@UntrackedTask时。
  2. RERUN_TASKS_ENABLED 执行gradle命令时,后面加了--rerun-tasks时。
  3. NO_OUTPUTS Task outputs可以设置upToDateWhen来决定其是否复用之前的结果,如果Task既没有声明任何outputs属性,也没有设置upToDateWhen的话,为此执行模式。
  4. UP_TO_DATE_WHEN_FALSE 当Task的upToDateWhen返回false时。
  5. INCREMENTAL 其他情况时的执行模式,但Task是否能够真正增量执行还有很多因素影响。
这些类型主要是对下面3种属性的封装,这3种属性对Task的增量build有影响。从名称上比较容易理解,在后面的分析中会了解到它们如何影响构建过程。
rebuildReason
taskHistoryMaintained
allowedToUseCachedResults

FinalizePropertiesTaskExecuter

将Property都finalize以确定下来,不再接受对属性的改动。
Property在Task Graph篇章中有介绍过,这里就不再介绍了。
以compileJava举例,就是classpath,destinationDirectorysourceCompatibilitytargetCompatibility等等一些参数。

ExecuteActionsTaskExecuter

Task真正执行的地方,它会将Task转化为Unit Of Work去执行。
Unit Of Work
UnitOfWork,从名字上理解,它是work的最小单元,gradle抽象的一个更细粒度的用来描述Work的接口,先来一张图以对其有个大致的理解。
UnitOfWork的execute方法入参为ExecutionRequest,返回结果为WorkOutput。
ExecutionRequestInputVisitorExecutionBehavior的影响。
WorkOutput也会被OutputVisitor和WorkResult进行分析。
Identity是用来为UnitOfWork提供唯一标识用的。
这些几乎都是接口,gradle制定了UnitOfWork的整体框架,剩下的实现部分并没有约束。


2Step


UnitOfWork的执行是由一系列的Step去执行的,Step和TaskExecuter一样使用了代理模式,它的实现更加复杂。
从接口能看出几个概念:
Step
Context
Result
UnitOfWork
大概的示意图如下:
Context是Step执行Work时的上下文。
Step可以通过wrap的方式给Context添加一些参数给到下一个Step,比如workspace,上一次build的结果等。
并且可以对上一个Step的执行结果Result进行一些操作,例如将上一步的执行结果保存到缓存中。
Task的build cache、增量build等处理逻辑就是在这里处理的,下面来逐一分析Task的执行Step。
Step的涉及到的流程很长,先用一张图来总览整体逻辑。

IdentifyStep

IdentifyStep包了一层来提供自己的IdentityContext
IdentityContext主要负责提供标识,Task使用的是project的全路径加自身Task名字作为唯一标识。

AssignWorkspaceStep

为Step的运行提供workspace,实际就是文件。
UnitOfWork.getWorkspaceProvider方法会返回一个WorkspaceProvider,它用来提供work执行所需的workspace。
withWorkspace表示会在某个工作目录下执行action,action会收到2个参数,workspace和history,history就是上一次构建的结果。
一般是在withWorkspace中调用action的executeInWorkspace,Task的逻辑。
public <T> T withWorkspace(String path, WorkspaceAction<T> action) {  
    return action.executeInWorkspace(null, context.getTaskExecutionMode().isTaskHistoryMaintained()  
        ? executionHistoryStore  
        : null);  
}
可以看到Task不会提供workspace,而history和Task执行模式有关,如果是taskHistoryMaintained=true的情况才会使用,否则为空,根据上面提到过的Task执行模式,也就是说NO_OUTPUTSUNTRACKED这2种是不支持history。
history具体的加载逻辑在后面LoadPreviousExecutionStateStep中。

CleanupStaleOutputsStep

这是用来清理一些腐坏的build文件用的,主要是用于处理gradle版本更新场景的。
也只针对能支持history的Task,因为这些Task才可能会产生输出,而这些输出的文件可能由于各种原因不正常了。
这一步会删除outputs输出的文件中ownedByBuild且非gradle生成的文件,2个条件。
  • ownedByBuild
  • generatedByGradle
2者其实都是通过文件路径来判断的,而判断ownedByBuildgeneratedByGradle所通过的文件路径的集合是不一样的。
• ownedByBuild
通过调用BuildOutputCleanupRegistry.registerOutputs来将build目录添加进来
clean task将build目录添加进来了。
java plugin将sourcSets的output文件目录加进来了。
只要是位于BuildOutputCleanupRegistry中文件目录的文件,都属于是ownedByBuild的。
• generatedByGradle
RecordOutputStep会将输出的文件路径都保存下来,结果保存在 当前项目根目录/.gradle/buildOutputCleanup/outputFiles.bin 中。
例如compileJava task会将build/classes/java/mainbuild/generated/sources/annotationProcessor/java/main等文件路径保存下来。
jar task会将build/libs/xxx.jar路径保存下来。
保存的是outputs属性指定的文件路径,其目录下的文件的路径是不会被保存的。所以compileJava task保存的是classes/java/main路径,这里面编译出来的classes文件路径是不处理的。
虽然感觉它像是每次构建前会去删除不属于上次构建的文件。
但实际如果没有历史构建记录的话,手动在build目录下新建一个文件确实会被删除掉,但是如果有历史构建记录outputFiles.bin,其判断方法是没法将新建的文件删掉的。
它只会判断task outputs的文件路径,比如task a的outputs文件是build/output,那么只会check build/output,像build/output/other这样的是不会被check的。

LoadPreviousExecutionStateStep

AssignWorkSpaceStep已经提供了ExecutionHistoryStore,这一步就是从history还原出上次的build执行状态。
ExecutionHistoryStore接口也很简单,就3个方法,分别用来加载、保存和删除历史。
这里需要重点看下这个PreviousExecutionState
这里的key是identity.getUniqueId(),对于task来说就是它的完整路径,如:lib:compileJava
这里的实现中会有一个keySerializer和一个valueSerializer,分别负责key和value的序列化和反序列化工作,这里的key为字符串所以不需要特别处理,valueSerializer会将缓存反序列化为PreviousExecutionState
这里序列化/反序列化具体实现使用的是之前在gradle脚本篇章中提到过的kryo三方库。
使用到了内存、文件双缓存。
保存的路径为 当前项目根目录/.gradle/8.0(gradle版本)/executionHistory/executionHistory.bin
PreviousExecutionState

ExecutionState的属性比较多,先看看脑图好有个整体印象。

  • originMetadata
    • buildInvocationId 每次build都会生成一个uuid,此次build所有的task使用的都是这个id。
    • executionTime 执行耗时。
  • taskImplementation
    分为2种Class和Lambda,一般都是Class类型的。
    Task的类型信息,包括:
    1. class identifier(class全路径名)
    2. classLoaderHash 根据加载Task的classloader计算出来的hash值,gradle有很多classloader,用于加载gradle-api的,用于加载plugin的等等。
    Lambda还额外包括其实现的方法签名,实现类类型等信息。
  • taskActionImplementations
    taskImplementation相似,记录的是Task内action的类型信息。
  • inputProperties
    是一个key是属性名,value的类型为ValueSnapshot的map,ValueSnapshot有很多子类,是对各种原生类型,file,list,set,serializable等等的封装类。
  • inputFileProperties
    从input file属性指定的文件提取的指纹信息,或者称为inputFilesFingerprints
    实际也是一个map。
    key为属性名,value的类型为FileCollectionFingerprint的map。
    FileCollectionFingerprint,这是对FileSystemSnapshot,也就是从文件类型的快照提取的指纹信息,这也是一个map,key是文件absolutePath,value包含。
    • absolutePath
    • fileType(RegularFile,Directory,Missing)
    • contentHash - RegularFile是其内容的hash,Directory和Missing类型是常量,重要的就是这个hash值了。
    • normalizedPath 根据normalization策略而来的path,具体在后续说明 rootHashes 基于子文件hash值计算出的hash。
      strategyConfigurationHash 采用的normalization策略本身的hash值。
  • outputFilesProducedByWork
    outputs属性指定的输出文件的快照,返回值类型为FileSystemSnapshots
    记录下整个outputs文件树结构的快照FileSystemSnapshot,本身包含absolutePath,name(文件名)属性,会遵守文件的顺序和文件的树形结构,分为3种类型。
    目录为DirectorySnapshot
    • children 子文件。
    • contentHash 基于子文件hash值计算出的hash 文件为RegularFileSnapshot,包含。
    • contentHash 文件内容的hash。
    • lastModified。
    • length 缺失情况为MissingFileSnapshot
  • successful: Boolean 是否执行成功。
有3个ExecutionState,3者所包含的信息基本一致。
PreviousExecutionState 上一次task执行后的状态。
BeforeExecutionState 本次task执行前的状态。
AfterExecutionState 本次task执行后的状态。
ExecutionState记录了Task本身以及inputs/outputs的所有信息,这些信息有几个主要的作用。
  1. 是用于和task上次执行的结果进行比较,如果属性全部没有改变过,那它符合up-to-date。
  2. 用于找出增量构建时发生改变的属性、文件等,具体在后面的ResolveChangesStep会详细说明。
  3. build cache key的计算。

MarkSnapshottingInputsStartedStep

标记一下input snapshot开始。

RemoveUntrackedExecutionStateStep

这是执行善后工作的,它会先让后续Step执行完,执行的Result可能会带有一个 AfterExecutionState,用来记录本次执行的状态,和PreviousExecutionState对应。
如果有PreviousExecutionState,那就会有AfterExecutionState
如果PreviousExecutionState没有,那AfterExecutionState也没有。
PreviousExecutionState是取决于是否支持history的,也就是说NO_OUTPUTUNTRACKED这2种执行方式,在后续Step中有可能产生缓存,而在这一步会将其缓存清除掉。

SkipEmptyWorkStep

gradle Task执行结果后面带有的NO_SOURCE标识,就是在这一步处理掉的。
@SkipWhenEmpty的文件属性或者调用了skipWhenEmpty给属性强制设置不能为空,如果没有对应的inputs文件存在的话,会跳过它的执行,返回NO_SOURCE结果。
不止如此,如果这个Task有上一次构建的历史文件存在,而这次没有inputs文件存在的话,会将上次的缓存清楚,此时执行结果是EXECUTED的。
例如 Copy task,可以通过from和to来设置待复制的文件和目标路径。
from最终是给Copy task添加一个source路径,而它给inputs设置了skipWhenEmpty 导致如果没有传入要拷贝的文件时,它实际不会执行。
tasks.register('copy', Copy) {  

}
./gradlew copy结果为:
Task :copy NO-SOURCE
Skipping task ':copy' as it has no source files and no previous output files.

CaptureStateBeforeExecutionStep

LoadPreviousExecutionStateStep中我们对ExecutionState有了一定的了解,但是那里是从缓存中反序列化的数据,而在这里我们将会看到BeforeExecutionState是如何生成的。
BeforeExecutionState记录的信息和PreviousExecutionState差不多,主要是记录当前的inputs/outputs情况,依旧是使用Visitor模式。
Task和Action类型信息提取比较简单,这里不展开了,原生类型的属性也好处理,对于不同类型有相对应的ValueSnapshotter处理。
重点是文件类型的InputFilesFingerprint的生成,和OverlappingOutputs的侦测。

InputFilesFingerprints

前面有提到过fingerprints是从snapshot生成的,snapshot也有文件路径,文件hash相关的信息,那为什么还要有fingerprints呢?
这还得回到记录inputs属性信息的目的上来,inputs信息的记录是为了对比2次构建,比较看是否有发生变化,已经发生了什么变化。
那我们现在通过对文件进行snapshot操作,得到了目录的路径和hash,得到了目录内文件的路径和hash,记录着它们保存的顺序,看上去已经能够通过这些信息的对比来得到我们想要的东西了。
那我们从几个case入手看看是否这就够了。
  1. 我们会记录文件路径,那该记录什么路径呢?
文件路径有绝对路径和相对路径,我们该记录哪个?
看上去相对路径更合理,但是否这样就满足所有需求了呢?
比如有一个对jar包进行transform的action,只要jar包名称没变,内容没变,我就认为它是没有变化的,但是如果它生成的目录层级变化了,如果使用相对路径记录,就会认为它发生了变化。
还有些情况,目录下面有空目录,这些路径是否需要记录,比如编译java代码的时候,这些空目录文件不会对结果产生任何影响,我们可以忽略掉,但如果记录了它们,那删除空目录会导致前后2次构建的inputs不同,而重新构建。
  1. 文件的内容hash不变是否能等同于表示文件没有变?
文件内容hash没有变,文件内容肯定是一样的。
那么反过来呢,有没有什么场景是我们虽然修改了文件,但我们这种改动对文件是没有影响的呢 比如properties文件,里面可以添加多个配置,如果加一行注释,该影响Task up-to-date的检测吗,如果将2个属性位置换一下又如何呢?
再比如class path,我们在编译java代码的时候通常需要依赖,这些依赖都是通过jar包或者目录的方式添加到class path中的,这些jar包内添加了一些资源文件,又或者是某个private方法改了,需要我们的代码重新编译吗?
所以针对这些问题,gradle需要对文件的snapshot进行fingerprint操作,这个过程也叫做normalization

Normalization

normalization有标准化,归一化的意思,影响normalization的主要有3个方面FileNormalizerDirectorySensitivityLineEndingSensitivity,下面我们来看看它们究竟都做了些什么。
FileNormalizer
FileNormalizer主要影响normalizationPath和文件内容hash的生成,结合上面对文件路径的讨论,normalizationPath就是用来标准化文件路径的。
PathSensitivity
  1. ABSOLUTE
    normalizationPath为绝对路径,这会对build cache的共享有影响,绝对路径不同会导致hash不同,缓存没法复用。
    默认是这个,所以自定义Task想要使用build cache时需要注意这点!
  2. RELATIVE
    一般想要有缓存复用的属性尽量使用这个,这样就不会受项目目录的影响,也可以和其他机器共用缓存。
    例如compileJava task的stableSources,也就是sourceSet定义的目录,默认是src/main
    位于根目录的文件,normalizedPath取文件名。
    目录内的文件,normalizedPath取和根目录路径的相对路径。
  3. NAME_ONLY
    normalizedPath为文件名,文件名不变,层级改变也没关系。
    Transformation,和@InputArtifact一起用的情况比较多,只要artifact的文件名、内容没变,outputs没变,层级变了不影响up-to-date的check。
  4. NONE
    只对文件类型计算hash。
    normalizedPath为空字符串。
    文件路径不重要,只关心文件内容。
    例如Pmd plugin的ruleSetFiles使用的就是PathSensitivity.NONEruleSetFiles是xml文件,里面是对issue的一些自定义操作,比如排除掉对某些目录的检测等等,不关心xml的名称,只关心里面的内容是否发生了变化。
    但有一点值得注意,使用PathSensitivity.NONE时,如果你改了脚本文件的文件路径,但是没有改动文件内容,虽然文件本身的hash没有改变,但是Action实现的hash可能因此改变,所以还是有变动的。
    所以这个属性用在目录上更适合,其内部文件层级变动、名称改变不会产生影响,或者使用通配符的方式。
CompileClasspath
classpath情况比较复杂,需要单拎出来说,甚至要区分runtime和compile。
@CompileClasspath注解的属性,其normalization使用的即是CompileClasspath
指纹提取的逻辑在CompileClasspathFingerprinter中。
compile classpath可能有目录和jar包,里面除了class文件外可能还有其他文件 它只关心class文件,文件的顺序也不关心,其中对class文件hash的工作是交由AbiExtractingClasspathResourceHasher处理的。
AbiExtractingClasspathResourceHasher使用org.objectweb.asm库来从类字节码提取信息,对于private类会忽略,其他访问修饰符声明的类,将它们的public、protect、default声明的方法的方法名、返回值、入参类型、注解、异常抛出等等信息进行记录,还有对字段的相关信息的记录。
这里有一个ABI的概念。

ABI(application binary interface)

https://en.wikipedia.org/wiki/Application_binary_interface


ABI是二进制程序模块间的接口,通常是用machine code定义的数据结构、计算流程的访问,使用偏底层的,硬件依赖的格式。
API是源码定义的,相对高级的,不依赖硬件且一般是可读的格式。
实际上这里的ABI和API基本内容是一样的,这里说的API是指定义的外部可用的方法,字段等,通常是public的。
因为拿到一个jar包,它里面public声明的类,方法等,其实我们都能够使用,就相当于是其暴露出来的API。只不过因为是从class字节码提取的信息,所以这里的ABI可以简单看作是API的字节码版本。
下面这些case对于compile classpath没有影响,也就是说下面这些情况不会导致项目重新编译。
  • jar或者根目录路径的变动。
  • jar包内的时间戳、entry的顺序变化。
  • jar包内resources和manifest的变动,包括添加删除resources。
  • class内private元素的改动,比如私有方法、私有fields、内部类等。
  • 对方法体、静态初始化代码块,fields的初始化代码块等代码的改动(除了常量)。
  • debug信息的改动,例如删除一行注释导致了debug信息行号变动。
  • 对jar包内directories,包括directories内的entries的改动。
简单概括一下就是,声明了@CompileClasspath的属性,只会对其中的class文件进行hash,使用的是相对路径,对非private修饰的类,其中的非private的方法或者字段,其方法签名的改动,像是返回类型,参数增删,异常抛出等的修改会影响对改属性是否变动的判断。
RuntimeClasspath
@Classpath注解的属性,其normalization使用的即是RuntimeClasspath
RuntimeClasspath的hash策略并不像CompileClasspath会对class文件进行ABI信息提取,只是单纯的对文件内容进行hash。

RuntimeClasspath有一定的灵活性,可以通过脚本进行一些配置,比如忽略某些文件,使它们不对最终的hash造成影响,可以从properties、metaInf、resources3个方面进行自定义,相关API的使用可以参考configure_input_normalization

https://docs.gradle.org/current/userguide/incremental_build.html#sec:configure_input_normalization


比如下面这个例子,忽略所有.properties文件中时间戳属性,它不会参与到hash的计算中去,如果timestamp变了,也不会影响到Task up-to-date的check。
normalization {
    runtimeClasspath {
        properties {
            ignoreProperty 'timestamp'
        }
    }
}
javaDoc就使用到了@Classpath注解。
这里的CompileClasspathRuntimeClasspath不是完全和java编译、执行过程的术语等同,更偏向于对这些类型的class path的通常的处理方式。
这也能给缓存优化提供一些思路,java定义依赖有2种基础的方式api和implementation
其区别大家肯定都知道,api建立的依赖关系,它会导致transitive dependencies影响到项目的compile classpath。
比如 projectA api 依赖了 libA, libA 又依赖了 libB,不管 libA 是用什么方式依赖的libB。
projectA的compile classpath都会有 libB,那如果 libB 修改了影响ABI的代码,则会导致projectA rebuild,即使 projectA 内没有使用到任何 libB 的代码
这也是官方建议尽量使用
implementation的原因。
DirectorySensitivity
是否忽略目录,默认是不忽略的。
使用注解@IgnoreEmptyDirectories,也有对应的api可以调用 compileJava task的source属性就标记上了这个注解,这个比较容易理解,源代码只要记录了相对路径就可以区分了,至于目录不需要我们是不关心的,而且可以排除掉空目录的影响。
LineEndingSensitivity
换行符的处理。
使用注解@NormalizeLineEndings,也有对应的api可以调用。
有2种逻辑:
  • 复用snapshot的hash。
  • 替换换行符的hash。
由于不同操作系统之间对换行的处理有可能是不一致的,这就导致如果使用了文件的原始内容,那么得到的hash值是没办法和其他操作系统的进行比较的。
使用@NormalizeLineEndings注解,gradle计算hash时会将碰到的 \r,\r\n换行符都替换为\n,当然这些都是针对文本文件的,二进制文件的hash和snapshot的一样。
gradle默认是不会使用替换换行符来计算hash的,所以另一种做法是让项目强制统一的换行符。
总的来看,FileNormalizer有6种,DirectorySensitivity有2种,LineEndingSensitivity有2种。
这几种是可以组合使用的,一共有24种排列组合方式。
但实际没有那么多,因为3者关系不是完全正交的。
例如PathSensitivity.NONE这种情况本身就忽略了所有文件名称,所以本身也就对文件目录不敏感。

OverlappingOutputs

gradle是期望不同Task的outputs目录都是不同的,没有重合,相互之间互不影响,但实际上可能会出现这种不同Task outputs目录重合的case,gradle将这种情况称为OverlappingOutput
OverlappingOutputs对增量构建、stale output的清除都会有影响,如果一个Task在另一个Task的outputs目录中也生成了文件,那么无法判断这个文件是否是stale的。
找到OverlappingOutputs也比较简单,首先对Task当前的outputs文件进行snapshot,再和PreviousExecutionState的进行对比,previous和before的对比多出来的部分就是OverlappingOutputs,还能区分具体是哪个属性的。
理解起来也比较简单,排除人为干扰的情况,正常在当前Task执行前,outputs的文件应该和PreviousExecutionState所记录的一致,如果有多出来的部分,那应该就是有后续Task的输出占用了同样的路径。

ValidateStep

这一步主要验证@CacheableTask注解标注的Task的问题。
如果其Task有标注@CacheableTask,那么其相应的@InputFile等注解需要额外标注上normalization相关注解。
normalizationCaptureStateBeforeExecutionStep中有详细说明,这些会影响到缓存的有效性。
Task默认都是@DisableCachingByDefault的。

ResolveCachingStateStep

这一步是判断Task能否使用缓存,并生成BuildCacheKey
  1. 如果build_cache没有开启(org.gradle.caching为false)不能够使用缓存。
    如果开启了,但是Task有验证问题也不行,因为只有error的问题会打断构建,warning的不会,所以需要先将所有error、warning等报错修复,才能使用caching。
  2. 如果Task被注解上了@DisableCachingByDefault的话那也不支持caching。
  3. 如果Task没有outputs文件,缓存就是针对输出的文件做的,没有输出自然不需要缓存。不止如此,如果outputs存在属性返回类型为FileTree,也是不支持caching的,返回File、FileCollection可以。
  4. OverlappingOutputs的情况。
  5. cacheIf、doNotCacheIf中的判断条件也会有影响。
FileTree和FileCollection的区别是FileTree有层级,而FileCollection是展平的。

详细可以参阅官方文档Working With Files

https://docs.gradle.org/current/userguide/working_with_files.html#sec:file_trees


BuildCacheKey的生成逻辑基本上就是用BeforeExecutionState所记录的所有信息计算出一个hash值,具体有哪些信息在LoadPreviousExecutionStateStep中已经列出了。
这里只提下inputs/outputs的几个。
input属性: 属性name和具体的值都会参与key的计算,属性一般为原生类型,hash的计算比较容易。
inputFilesFingerprint: 属性name和fingerprint的hash(也就是文件内容的hash)会参与key的计算。
outputs属性: 只有属性name会参与key的计算。

MarkSnapshottingInputsFinishedStep

标记一下input snapshot结束。

ResolveChangesStep

这一步是用来区分增量和非增量构建的,如果是增量构建,还需将此时的文件和上一次构建时的进行对比来生成InputChanges。
影响增量的因素主要有:
  1. Task执行模式的属性rebuildReason。
  2. Task的ExecutionBehavior
  3. Task是否存在验证问题,有warning的task不支持。
  4. 是否存在上次构建结果的记录,如果没有也不支持。
RebuildReason
5种执行模式中,只有INCREMENTAL没有rebuildReason,其他情况都有,也就是说其他的执行模式都是全量构建的。
ResolveTaskExecutionModeExecuter小节中有列出不同执行模式rebuildReason。
只有增量构建才可以使用构建缓存,其他几种类型的执行模式走到这意味着Task一定会被EXECUTED。
ExecutionBehavior
ExecutionBehavior有2种:
NON_INCREMENTAL
INCREMENTAL
Task是根据自己是否有以InputChanges作为参数action来区别是否支持增量的
InputChanges有所有变动过的文件,以及它们变动的类型changeType,changeType可以区分是新增,删除还是修改,还有fileType可以区分目录和普通文件。
例如:
abstract class IncrementalReverseTask extends DefaultTask {
      @Incremental
      @InputDirectory
      abstract DirectoryProperty getInputDir()

      @OutputDirectory
      abstract DirectoryProperty getOutputDir()

      @TaskAction
    void execute(InputChanges inputChanges) {
        inputChanges.getFileChanges(inputDir).each { change ->
              def fileType = change.fileType == FileType.DIRECTORY
              def targetFile = outputDir.file(change.normalizedPath).get().asFile
              def changeType = change.changeType == ChangeType.REMOVED
          }
    }
}
InputBehavior
NON_INCREMENTAL
INCREMENTAL
PRIMARY
这几种类型也是通过注解区分的,注解了@SkipWhenEmpty的是PRIMARY,注解了@Incremental INCREMENTAL,其他情况都是NON_INCREMENTAL,这些注解都是针对input files的。
根据每个input files的属性注解的不同,其InputBehavior也可能不同。
PRIMARYINCREMENTAL都是支持增量的,它们的区别是在对inputs文件不存在时的处理上。PRIMARY@SkipWhenEmpty的,所以会删除掉上次的构建记录。
这里会将Task所有支持增量的input files属性进行搜集,后续InputChanges的生成需要用到。

Detect Inputs Changes

确定是增量构建的情况下,gradle会去找出此次构建和上次构建input files间的区别,根据LoadPreviousExecutionStateStep加载的PreviousExecutionState上一次构建、CaptureStateBeforeExecutionStep记录的BeforeExecutionState本次构建、和收集到的incrementalInputProperties去找出inputs文件的改动。
具体逻辑交由ExecutionStateChangeDetector.detectChanges处理。
根据上面对LoadPreviousExecutionStateStep部分ExecutionState的描述,我们知道它包含了很多task的信息,基于这些信息来和本次的进行对比就可以得到2次构建input是否发生了改变,具体比较下面几个方面:
  1. Task实现和Action实现是否有变动 比较本次和上次构建的Task及Actions的classIdentifierclassLoaderHash
  2. input属性(非文件部分的属性)是否有变化 一方面需要对比是否有属性新增或减少,一方面需要比较具体的值是否发生了变化。
  3. input files属性是否有变化 input files属性是否有新增或减少。
    非增量input files属性部分的文件是否有变动,这里通过比较fingerprint信息判断的。
  4. output files是否发生了变化 上面对input files的fingerprint做了详细的解释,但是output没提,其实output也有fingerprint,但是比inputs的简单太多了,只是用相对路径作为normalizationPath,因为它的变化主要是来自inputs,所以对于它指纹的提取没有必要那么复杂。
    output files只用比较快照中的文件顺序、文件名、文件hash是否有区别就可以了。
如果上诉的情况有变化,就只能走非增量方式走full rebuild。
如果没有变化,说明可以走增量构建,那么就需要对增量input file属性文件的变化信息进行收集。

SkipToDateStep

上一步ResolveChangesStep已经让我们知道了Task是否增量构建,以及Input Changes。
如果是支持增量构建的且input files没有变动,那么也就不需要执行了,这种情况的执行结果后面会打上UP-TO-DATE的标记,它会复用上一次的缓存结果。

ResolveInputChangesStep

InputChanges核心部分已经在ResolveChangesStep处理完了,这里只是封装一下。

StoreExecutionStateStep

这里是将执行结果状态AfterExecutionState进行保存,AfterExecutionState是由后续步骤生成的。
只有执行成功,且outputs files有所变动才进行保存。
outputs files的变动是通过对比AfterExecutionStatePreviousExecutionState得到的,比较过程和InputChanges中对outputs的对比一样。

RecordOutputsStep

CaptureStateAfterExecutionStep会将Task的outputs快照记录下来,添加到AfterExecutionState中。
这里就是将这些outputs文件路径保存下来,存在 当前项目根目录/.gradle/buildOutputCleanup/outputFiles.bin 里。
也是CleanupStaleOutputsStep中用来判断文件是否由gradle生成的依据。

BuildCacheStep

ResolveCachingStateStep提到它是来判断是否可以使用缓存以及生成BuildCacheKey的。
这里就是使用那一步得到的结果的地方了。
不能使用缓存的话就继续往下走,如果可以使用缓存的话,就从缓存中读取结果。
Task执行Execution中,其实只有INCREMENTAL支持缓存读取,其他的几个都不支持,这个在ResolveCachingStateStep是没有处理的,因为虽然它不能读取缓存,但是它的执行结果可以被存到缓存中。
缓存优先读取本地local的,如果本地没有就从读取远程缓存。
本地缓存保存的目录为 ~/.gradle/caches/build-cache-1
远程缓存的读取会先请求服务端,实际就是以BuildCacheKey和服务器地址构造一个GET请求,如果结果返回正常,会将其保存到本地缓存中。
缓存文件找到后需要对其进行解压,本质它是gzip压缩的文件,文件名就是key。
以compileJava task为例,看看build cache文件缓存格式是什么样子的。
METADATA
tree-destinationDirectory
tree-options.generatedSourceOutputDirectory
tree-options.headerOutputDirectory
tree-previousCompilationData
METADATA是记录一些元数据,里面有buildInvocationId、gradle版本、执行耗时、task名称等信息。
然后每个output属性都会对应有以tree-属性名为名称的目录,里面保存着当时执行Task时生成的文件。
gradle先是通过BuildCacheKey在本地缓存目录找到对应的gzip文件,然后unpack它,通过正则匹配到outputs属性的输出文件,进行复用。
如果缓存读取失败,那么就会真正执行task,并在之后将其结果保存到缓存中,缓存会在local和server都进行保存。
server端的保存是HttpBuildCacheService处理的,和读取类似,这里构建了一个PUT请求,将outputs文件pack为gzip文件上传。

CaptureStateAfterExecutionStep

这一步会构造一个OriginMetadata,并将task的outputs指定的文件快照记录下来,作为AfterExecutionStateoutputsProducedByWork
其他参数AfterExecutionState都是和BeforeExecutionState一样的。

CreateOutputsStep

确保outputs属性指定的文件目录存在,对于目录类型会去创建,对于文件类型会创建其父目录。

TimeoutStep

task可以设置超时时间,如果设置了超时时间,会启一个定时器到时interrupt Task的执行线程。

CancelExecutionStep

Task可以被取消,被取消时也是通过interrupt Task的执行线程来实现的。

RemovePreviousOutputsStep

这一步是针对预期增量构建,但因为某些原因导致没有进行增量构建的Task,删除其之前的outputs文件,例如某些input属性变动,导致需要重新构建。

ExecuteStep

真正执行任务的step,也就是依次执行Task的actions。
至此Task的执行逻辑就全部分析完毕。

参考文档

Authoring Tasks

https://docs.gradle.org/current/userguide/more_about_tasks.html

Incremental build

https://docs.gradle.org/current/userguide/incremental_build.html

Developing Custom Gradle. Task Types

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




最后推荐一下我做的网站,玩Android: wanandroid.com ,包含详尽的知识体系、好用的工具,还有本公众号文章合集,欢迎体验和收藏!


推荐阅读

Android 13 媒体权限适配指南
[应用出海] 使用 Gradle 解决 Android 模块化项目中的多语言支持
浅谈Tangram,像七巧板一样快速搭建页面



扫一扫 关注我的公众号

如果你想要跟大家分享你的文章,欢迎投稿~


┏(^0^)┛明天见!

您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存