提到 Gradle,熟悉 Android 的人都不会陌生,在我们开始把 Android Studio 这个 IDE 扶正的时候,gradle 就彻底进入了我们的视野。但是大多数人对于 gradle 执行构建和构建流程都比较陌生,本文从编写 Gradle Plugin 的角度,希望把 Gradle 体系的一些基础结构能讲明白。
首先我们明白,gradle 的工作是把所有的构建动作管理起来 —— 任务是否应该执行,什么时候执行,执行某个任务前先做一些什么事情,某几个动作是否可以并行执行。
对于 gradle plugin 的编写就是为了帮我们完成这些事情。如果你单单从任务的纬度去看这个问题的话,又会想到如果 B 需要 A 的产物的话,是否需要把 A 和 B 进行一些耦合。显然,对于任务间的解耦,Gradle 也做了。
什么是任务
上面我们提到了「任务」这个词,任务是什么呢?一个任务我们可以理解为把一次指定的输入,转换成想要的输出。比如「编译」这个动作,就是把 .java 文件编译成 .class,或者执行 aapt,把资源文件编译成一个resource.ap_
文件等。任务的基础就是这么简单,然后为了加快执行速度,gradle 增加了 UP-TO-DATE 检查(只要输入和输出的文件不发生变化,那么这个任务就不再执行),也增加了 incremental build 的特性(下一次的编译,并不只是把.class
全部删除,重新编译一次这样粗暴,而是只编译变化了几个文件)
在这种细颗粒度的情况下,我们对于任务执行的正确性和效率都有了保障。
「构建」的生命周期
关于 Gradle 的构建任务,其实网上有很多文章介绍了,无非是介绍任务的定义方式,任务的doFirst
和doLast
,但是很少介绍其他的元素,我们从 gradle plugin 的视角介绍一下这些概念。在一切开始之前,我们要了解下 gradle 这个容器的一些最最基础的流程 —— gradle 构建生命周期。
官方文档: https://docs.gradle.org/current/userguide/build_lifecycle.html#build_lifecycle
如文档所示,gradle 在执行的时候,会经历三个过程 —— 初始化,配置,执行。初始化过程对于我们来说,体感比较弱;配置阶段是一个重要阶段,我们需要告诉每一个 Task,它的输入文件是什么(比如源码文件,资源文件),输出文件或者文件夹是什么(比如编译后的 .class 文件,ap_ 等资源包放在哪个文件夹下)等等。那么执行阶段,就是真正执行任务的时候了,我们这时候需要在执行的函数中,拿到在配置阶段定义的 Input,然后生产出 Output,放到规定的目录下,或者写入指定的文件即可。
对于我们来说,理解生命周期尤为重要,如果你在configuration
阶段去获取一个 task 的结果,从逻辑上来说是很愚蠢的。所以你很需要知道你的代码是在“什么状态下”执行这一步操作。
任务间的依赖
我们知道了生命周期以后,就要开始思考一个问题,比如 B 任务的一些输入依赖于 A 任务的一些输出,这时候就需要配置 B 任务依赖 A 任务,那么我如何保证这一点呢?
有一个办法,那就是对 B 任务调用显式依赖B.dependsOn(A)
这样 B 一定在 A 之后执行的,B 任务中对于某个由 A 产生的文件的读取是一定能读到的。不错,它是个好办法,但问题就在于,这样的指定方式耦合度非常高,如果你需要加入一些对A
产物的一些修改,然后再传给B
的时候,就没有任何办法了。B
同时知道了A
的存在,如果我们这时候不希望由A
任务提供这个文件,而是由A'
来提供这个输出,在这里也做不到,所以需要换一个思路。
Gradle 提供了并使用了非常多像 Provider,Property,FileCollection 之类这样的类。看名字我们大概能知道,这些方法都提供了一个 get() 方法,获取到里面保存的实例。但是 Gradle 对于这个 get() 方法赋予了更多的意义,它可以把依赖关系放进去,当你调用get()
的时候,可以检查它的依赖的任务是否已经执行完成,如果已经完成,那么再返回这个值。
@NonExtensible
public interface Provider<T> {
/**
* Returns the value of this provider if it has a value present, otherwise throws {@code java.lang.IllegalStateException}.
*
* @return the current value of this provider.
* @throws IllegalStateException if there is no value present
*/
T get();
//.....
}
有了上面这个特性,我们定义起依赖关系就简单多了,我们把一个任务的输出文件用 Provider 包裹起来,也就是Provider<File>
这样的类型提供,由 Gradle 或者自行为这些 Provider 设置dependsOn
,然后再把这些 Provider 分发给其他 Task。
另外的 Task 只要保证它只在执行阶段去调用这些 Provider 的 get 方法即可。Provider 只是一种意图,因此他们可以先把 Provider 存到 Task 实例的成员变量里,同时使用 Gradle 提供的@Input/@InputFile/@OutputFile
等注解为这些 Provider 的 getter 进行标注,这样能让 Gradle 把这些值管理起来。
这样我们解决了第一个问题 —— Task 之间不在显式依赖。如果我们想实现在 Task A 和 Task B 之间做一些 Hook 的话,我们这时候要对 Provider 做一个管理,我们可以做一个全局管理器,为每一个产物集合做一个名字或者枚举的标记,然后对对应的标记定义一系列的动作,比如替换这个标记的产物,或者追加产物等,以便于后续的任务能更好的处理这里产生的产物。
这张图是原来的显式依赖方式
解耦后的方式是
这样任务和任务之间就这么联系在了一起,当我们执行一条熟悉的命令:
./gradlew assembleDebug
它会把依赖产物的所有 task 全部执行一遍,事实上,assembleDebug
这个任务根本不知道自己依赖了哪些具体的任务,它只知道自己“需要”什么,产出什么(apk)。
举例
上面讲了任务依赖相关的理论知识,我们来举一个具体的例子,就以assembleDebug
为例。
我们把事情说的简单点,比如assembleDebug
的任务是把所有已经处理好的 dex,resources,assets 打包成一个 apk,那么这个 input 就是前面提到的三个,output 是 apk。我们在assembleDebug
这个 Task 里面会看到如下的东西(伪代码):
class AssembleDebugTask {
private Provider<File> dexInput;
private Provider<File> resourcesInput;
private Provider<File> assetsInput;
private Provider<File> outputAPK;
@InputFile
public Provider<File> getDexInput() {
return dexInput;
}
@InputFile
public Provider<File> getResourcesInput() {
return resourcesInput;
}
@InputFile
public Provider<File> getAssetsInput() {
return assetsInput;
}
@OutputFile
public Provider<File> getOutputAPK() {
return outputAPK;
}
}
以上是对产物的定义,那么在执行任务的过程中,会有这样的逻辑:
public void doTaskAction() {
File dexInput = this.dexInput.get();
}
在这一步的过程中,Gradle 会去检查这个 Provider 的来源,有没有builtBy
属性,如果有的话,会先执行buildBy
的 Task,比如我们知道Dex
的文件一定来源于产生 Dex 的任务,那么如果我们定义这个任务叫DexTask
的话,就会先执行DexTask
这个任务,才会继续执行assembleDebug
了。
事实上为了加快效率,标记了@Input
之类的注解的属性,gradle 在检查任务的时候,会提前去执行相关的依赖,因为在这个过程中,它可以动用并发的方式,并行执行几个任务,比如我们这依赖了三个输入,那么可以并行执行这三个任务,等到都执行完了,再去执行assemble
的任务,这时候调用get
就能直接返回值了。
欢迎关注我的公众号「TalkWithMobile」
本文由 Gemini Wen 创作,采用 知识共享署名4.0 国际许可协议进行许可
本站文章除注明转载/出处外,均为本站原创或翻译,转载前请务必署名
最后编辑时间为: Dec 19, 2020 at 10:25 am