Gradle Builds Everything —— 基础概念

860 阅读7分钟

提到 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 的构建任务,其实网上有很多文章介绍了,无非是介绍任务的定义方式,任务的doFirstdoLast,但是很少介绍其他的元素,我们从 gradle plugin 的视角介绍一下这些概念。在一切开始之前,我们要了解下 gradle 这个容器的一些最最基础的流程 —— gradle 构建生命周期。

官方文档: docs.gradle.org/current/use…

如文档所示,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」

公众号