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 每次执行有三个阶段:
- 初始化阶段
- 配置阶段
- 执行阶段
初始化阶段
初始化阶段会执行 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 闭包内应该写配置代码,而不是执行代码。
那执行代码应该写在哪?作为闭包传给 doLast
或 doFirst
函数。如下:
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 才能生效。
- 获取到
android{}
块对应的 Extension。 - 调用 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
时的默认行为了。