文章

Android 视角下的 Gradle

Groovy 语法解析

函数调用与闭包

默认情况先 Gradle 使用 Groovy 语言,可选使用 Kotlin DSL 语法。这里就以 Groovy 为例,看一个常见的代码段:

android {
  compileSdkVersion 30

  defaultConfig {
    applicationId "com.hencoder.gradlebuildscript"
    minSdkVersion 21
    targetSdkVersion 30
    versionCode 1
    versionName "1.0"

    testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
  }
}

上面配置文件中都是 Groovy 语言写的程序。Groovy 函数调用允许省略小括号,或者和 Kotlin 类似,即使不省略小括号,如果最后一个参数是闭包,那么闭包可以写在括号外面。android, defaultConfig 其实都是函数,把括号加上也是合法的:

android ({
  defaultConfig ({
    applicationId "com.hencoder.gradlebuildscript"
    // ...
  })
})

而大括号 {} 是创建一个 Groovy 闭包。目前可以理解成 Kotlin 中的高阶函数,但实际上它更强大一点。那么 android 就是接收一个闭包作为参数的函数。由此可以推断出,android{} 块里的代码很可能不是立即执行,也可能多次执行。

missingMethod

Root 级别的配置文件中经常这么写:

buildscript {
  repositories {
    google()
  }
  dependencies {
    classpath ('com.android.tools.build:gradle:4.1.1')
  }
}

跟踪进 classpath 源码,发现是个 DependencyHandler 接口,而这个接口并没有 classpath 方法定义。

如果无法跟踪源码,把 gradle-wrapper.properties 文件里的 distributionUrl 改成完整版本的:

distributionUrl=https\://services.gradle.org/distributions/gradle-xxx-all.zip

在 Groovy 中有一个 methodMissing 特殊机制,定义下面方法:

def methodMissing(String name, def args)

那么如果调用一个类不存在的方法,这个 Fallback 就会被调用,并可以接收尝试调用的方法名和参数。

比如这样:

class A {
  def methodMissing(String name, def args) {
    println(name)
  }
}
new A().abcdefg()
// 打印 abcdefg

Gradle 生命周期

Gradle 每次执行有三个阶段:

  1. 初始化阶段
  2. 配置阶段
  3. 执行阶段

初始化阶段

初始化阶段会执行 settings.gradle 文件,加载项目结构。

配置阶段

Gradle 以 Task 为核心。配置阶段会执行所有的 build.gradle 文件,读取 Task 配置,最终绘制出完整的 Task 有向无环图。

执行阶段

执行指定的 Task,如果有依赖的话就先递归执行依赖的 Task。

Task

Gradle 面向 Task 执行工作,那些零散的 gradle 文件都是为 Task 服务的。Task 反直觉的执行时机往往搞晕一大堆萌新(包括我)。以最简单的 clean 为例,我们做一点修改,代码如下:

task clean(type: Delete) {
    println(1)
    delete rootProject.buildDir
    println(2)
}

现在执行 ./gradlew clean,看到按顺序输出了 1,2,不错,符合预期。那执行 ./gradlew 呢?1,2 还是输出了,诡异的是 build 文件夹并没有被删掉?!

Task 的闭包在配置与执行两个阶段都会被执行delete 函数没有被跳过,只是它的函数名有点迷惑。其实它内部不是真正删除文件,而仅仅配置一下要删除哪些文件。clean() 的参数 type: Delete 才是在负责在执行阶段真正删除文件的。因此 Task 闭包内应该写配置代码,而不是执行代码

那执行代码应该写在哪?作为闭包传给 doLastdoFirst 函数。如下:

task clean(type: Delete) {
    println(1)
    delete rootProject.buildDir
    println(2)
    doLast {
        println('clean done')
    }
}

只有这个 Task 执行后才会打印 'clean done' 提示,配置阶段不会打印。⚠️ 新坑 doFitst 的执行时机和直觉又不太一样。可能有人以为是这样的:doFirst -> task -> doLast,这就错了。其实是这样的:task -> doFirst -> doLast。doFirst/doLast 相等于在 Task 执行完毕的回调列表中前插/后插新的闭包,Task 本身永远先执行

了解了这个本质,多个 doFirst 的执行顺序也就明白了。比如下面的例子:

task clean(type: Delete) {
    println(1)
    delete rootProject.buildDir
    println(2)
    doLast { println('clean done') }
    doFirst { println('F1') }
    doFirst { println('F2') }
    doLast { println('END') }
}

执行 Task 后的输出为:1, 2, F2, F1, clean done, END注意 F2 先输出,因为后执行的前插肯定在队列最前面;END 最后输出,因为后执行的尾插在队列最后面

Plugin

基本插件

插件的作用主要是封装自定义的 Gradle 脚本供复用。尽管理论上可以把插件代码写在任意 build.gradle 文件中,但这样是失去了复用价值,插件也就多此一举。所以通常应该把 Gradle Plugin 写在一个特殊的工程里,这个工程的代码不用于业务,而是用于 Gradle 本身。Gradle 预留了这样一个 Project Name 叫 buildSrc,它会自动识别这一工程,不需要也不能手动加入 settings.gradle 里,这里面的工程是用于业务的

Android Studio 目前(Dolphin 2021.3.1)还不支持用模板创建 buildSrc,那就手动创建以下目录与文件:

.
├── buildSrc
│   ├── build.gradle
│   └── src
│       └── main
│           └── groovy
│               └── Hello.groovy

Sync 后就能被识别。build.gradle 和平常一样可写依赖等配置,不需要的话可以留空或不创建。src 的结构与普通项目一样,需要的话也可以按照包名创建子目录,这里偷懒了。

一般插件分为两个部分:插件本身和 Extention。所谓 Extension 就是用于承载插件配置项的数据类。比如 Android 插件可以使用 android{} 语法进行配置,这就是一个 Extension。

Extension 就是一个普通的类,不需要任何继承或实现,这里就先定义一个 name 配置项:

class HelloExtension {
    def name = "gradle"
}

插件本身需要实现 Plugin 接口,apply() 方法在插件被应用的时候调用,也就是 apply plugin: 'xxx' 处。

class HelloPlugin implements Plugin<Project> {
    @Override
    void apply(Project project) {
        def ext = project.extensions.create('hello', HelloExtension.class)
        project.afterEvaluate {
            println("Hello ${ext.name}!")
        }
    }
}

使用 project.extensions.create() 来创建(读取)Extension,第一个参数指定 Extension 的名字,也是未来使用这个插件时要在 Gradle 里写的配置块。第二个参数是 Extension 的类,根据 Groovy 语法,.class 允许省略。

注意,既然 apply() 在插件应用的时候调用,而只有先应用插件,才可以调用 Extension 方法,所以不能在 apply() 里直接读取 Extension,否则只能得到默认值。 常见的做法是在 project.afterEvaluate 里读取,它发生在配置阶段与执行阶段之间。

到此为止已经写完了我们的 demo 插件,但还没有与 Gradle 关联起来,它没有那么智能,不会扫描到 Plugin 实现类就自动加入可用插件列表。为此创建一个元文件:

./buildSrc/src/main/resources/META-INF/gradle-plugins/{name}.properties

其中 {name} 替换为插件的名字,比如我用 me.chenhe.hello.properties

文件里指明插件的实现类:

# 如果有包名,记得写全
implementation-class=HelloPlugin

这样 Gradle 就可以识别插件了,在 app 的 build.gradle 里可以这么使用:

apply plugin: 'me.chenhe.hello'

hello {
  name 'chenhe'
}

Transform

Transform 是 Android Gradle Plugin 提供的工具,它允许我们在打包之前操作编译之后的文件。可以在 Plugin 中使用这个工具实现面向切面编程。例如插入性能检测代码等。

要使用 Transform 得先给 buildSrc 工程添加 AGP 依赖,在 ./buildSrc/build.gradle 里写上:

repositories {
    google()
    mavenCentral()
}
dependencies {
    implementation 'com.android.tools.build:gradle:4.1.1'
}

然后就能继承 com.android.build.api.transform.Transform 编写自己的 Transform 了。有四个必须实现的方法:

  • getName() 返回这个 Transform 的名称,用于生成 Gradle Task:transformClassesWith{name}ForXxx
  • getInputTypes() 指明希望处理哪些类型的文件。可以使用 TransformManager.CONTENT_ 预定义的类型集合,或创建自己的集合。
  • getScopes() 指明希望处理哪些位置的文件。可以使用 TransformManager.SCOPE_ 预定义的范围集合,或创建自己的。
  • isIncremental() 指明是否支持 Gradle 增量编译。支持后可提高后续编译速度但实现比较繁琐。默认返回 false

Demo 实现如下:

class MyTransform extends Transform {
    @Override
    String getName() {
        return "Demo"
    }

    @Override
    Set<QualifiedContent.ContentType> getInputTypes() {
        return TransformManager.CONTENT_CLASS
    }

    @Override
    Set<? super QualifiedContent.Scope> getScopes() {
        return TransformManager.SCOPE_FULL_PROJECT
    }

    @Override
    boolean isIncremental() {
        return false
    }
}

有了初步的 Transform 类,接下来得把它实例化注册到 AGP 才能生效。

  1. 获取到 android{} 块对应的 Extension。
  2. 调用 Extension 方法注册 Transform。

android{} 配置块对应的 Extension 是 com.android.build.gradle.BaseExtension,这是一个 AGP 的类。具体注册方法如下:

@Override
void apply(Project project) {
  // 使用 findByType() 而不是 create()
  def baseExt = project.extensions.findByType(BaseExtension)
  baseExt.registerTransform(new MyTransform())
}

注意我们要修改已有的编译配置,而不是创建新的,所以使用 findXXX() 方法。这样注册之后 sync 一下工程再编译安装,会得到下面的错误:

List of apks:
[0] '/Volumes/Data/tmp/app/build/outputs/apk/debug/app-debug.apk'
The APKs are invalid.

这是因为 Transform 默认情况下丢弃所有传入的文件,即:不把它们打包到 apk。显然这样整出来的 apk 不能安装。 要更改默认行为,得覆盖 transform() 方法。简单起见,这个 Demo 就不修改文件了,直接把它们全部打包:

@Override
void transform(TransformInvocation transformInvocation) throws TransformException, InterruptedException, IOException {
  def outProvider = transformInvocation.outputProvider
  transformInvocation.inputs.each {
    it.jarInputs.each {
      def dst = outProvider.getContentLocation(it.name, it.contentTypes, it.scopes, Format.JAR)
      FileUtils.copyFile(it.file, dst)
    }
    it.directoryInputs.each {
      def dst = outProvider.getContentLocation(it.name, it.contentTypes, it.scopes, Format.DIRECTORY)
      FileUtils.copyDirectory(it.file, dst)
    }
  }
}

transformInvocation.outputProvider 可以获取不同来源不同类型的文件应该复制到哪里才能被打包进去,遍历所有传入文件,复制到规定的位置,就能模拟不注册 Transform 时的默认行为了。