Android 构建脚本从 Groovy 迁移到 Kotlin DSL

网上已经有不少关于 Kotlin-DSL 很好的文章,但并未能完全解答我迁移过程中遇到的疑问。通过查阅各类文档和源码,我找到了想要的答案。这篇文章记录了我的迁移过程以及对各类问题追溯到源头的答案。

为什么要迁移?

因为 Groovy 是动态语言,在用作 Android 构建脚本的时候,经常有些问题:

  • 很差的IDE支持(自动提示等)
  • 性能问题
  • 很多错误在build时才报出,而不是编译期
  • 难以debug
  • 重构很麻烦

kotlin 并非动态语言,但却兼具了 Groovy 的灵活性和静态语言的特点,是一种类型安全的 DSL,很大程度上解决了上述的问题。

所以,开始吧!!

环境准备:

  • 升级 Gradle Wrapper 到5.0+
    1
    2
    3
    4
    5
    6
    # gradle-wrapper.properties
    distributionBase=GRADLE_USER_HOME
    distributionPath=wrapper/dists
    zipStoreBase=GRADLE_USER_HOME
    zipStorePath=wrapper/dists
    distributionUrl=https\://services.gradle.org/distributions/gradle-5.1-all.zip
  • Gradle 5.0 只支持Java 8及以上,确保你的Java环境满足要求
  • 升级所有的Gradle 插件

基本原则

由于Groovy和kotlin语言特性上的不同,我们迁移时需要遵循以下基本原则。

字符串

Groovy 中字符串单双引号都行,但是kotlin中字符串必须是双引号。所以第一步先把所有的build.gradlesettings.gradle里的''全部替换为""

赋值/传值

在Groovy中xx yy可以表示对变量xx赋与值yy,也可以表示调用函数xxyy是参数,如Android中常见的:

1
2
3
4
5
6
7
8
9
10
11
defaultConfig { 
// 赋值
versionName "1.0"
// 函数调用
minSdkVersion 16
}

dependencies {
// 函数调用
api 'org.greenrobot:eventbus:3.1.1'
}

而在kotlin中则是和Java一样的语法,相应的,上面的栗子迁移后:

1
2
3
4
5
6
7
8
defaultConfig {
versionName = "1.0"
minSdkVersion(16)
}

dependencies {
api("org.greenrobot:eventbus:3.1.1")
}

知道了这个区别,但是很多xx yy 到底是赋值还是函数调用不好分辨,比如versionName "1.0" 是赋值,而minSdkVersion 16居然是函数调用。我们可以利用Android Studio的辅助功能,在kotlin-dsl 中是可以快速点选查看方法的源码的(mac 下 cmd + click),而对属性变量就没啥反应,所以可以根据这个小trick来分辨。

Task

由于Koltin 是静态类型语言,Groovy是动态语言,前者是类型安全的,他们的性质区别很明显的体现在了 task 的创建和配置上。详情可以参考Gradle官方迁移教程

1
2
3
4
5
6
7
8
// groovy
task clean(type: Delete) {
delete rootProject.buildDir
}
// kotiln-dsl
tasks.register("clean", Delete::class) {
delete(rootProject.buildDir)
}

迁移步骤

  1. 字符串单引号变双引号
  2. 重命名所有xx.gradle脚本文件,加上.kts后缀
  3. 插件声明 DSL的目标是尽可能具有声明性。 Kotlin DSL 使用plugins代码块来生成静态扩展函数以利用这些插件。因此,我们需要将之前Groovy中所有apply plugin xxx 移动到一个plugins代码块中:
    1
    2
    3
    4
    5
    // Groovy
    apply plugin: 'com.android.application'
    apply plugin: 'kotlin-android'
    apply plugin: 'kotlin-android-extensions'
    apply plugin: 'kotlin-kapt'
    1
    2
    3
    4
    5
    6
    7
    // Kotlin-dsl
    plugins {
    id("com.android.application")
    kotlin("android")
    kotlin("android.extensions")
    kotlin("kapt")
    }
    id()是应用插件的标准方法,kotlin()是应用Kotlin插件的方法,我们查看源码可以发现,它只是封装了一下id()方法,省略了org.jetbrains.kotlin.:
    1
    2
    fun PluginDependenciesSpec.kotlin(module: String): PluginDependencySpec =
    id("org.jetbrains.kotlin.$module")
  4. 按前面提到的基本原则改写所有语句

示例

apply

除了apple 插件外,apply 还可以用来应用其他的gradle文件:

1
2
3
4
5
//groovy
apply from: 'custom.gradle'

// kotlin-dsl
apply(from = "custom.gradle")

依赖

依赖管理作为 gradle 的基本功能,gradle 提供了非常丰富的方法,下面以分三种情形进行说明。

基本情形

1
2
3
4
5
6
7
// groovy
implementation project(':library')
implementation 'com.jakewharton:butterknife:8.8.1'

// kotlin
implementation(project(":library"))
implementation("com.jakewharton:butterknife:8.8.1")

fileTree

使用 groovy 添加本地目录 jar 包依赖:

1
2
// groovy
implementation fileTree(include: '*.jar', dir: 'libs')

查看 fileTree 函数源码:

1
2
//org.gradle.api.Project
ConfigurableFileTree fileTree(Map<String, ?> args);

需要传入的是Map,因此迁移到 kotlin-dsl 可以这样写:

1
implementation(fileTree(mapOf("include" to listOf("*.jar"), "dir" to "libs")))

特别类型库依赖

之前在groovy中添加特别类型库依赖:

1
implementation(name: 'splibrary', ext: 'aar')

按前面提到的基本原则,我们会这样改写:

1
2
// wrong
implementation (name="splibrary",ext = "aar")

但实际上会导致失败。跳转到 kotlin-dsl implementation的源码找原因:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
fun DependencyHandler.`implementation`(dependencyNotation: Any)...

fun DependencyHandler.`implementation`(
dependencyNotation: String,
dependencyConfiguration: Action<ExternalModuleDependency>
)...

fun DependencyHandler.`implementation`(
group: String,
name: String,
version: String? = null,
configuration: String? = null,
classifier: String? = null,
ext: String? = null,
dependencyConfiguration: Action<ExternalModuleDependency>? = null
)...

...

可以看到implementation有许多重载方法,可以用来处理各种依赖来源及设置的条件。在第三种实现对应的是我们的情况,其中group参数是必填的。

因此在kotlin-dsl中需要这样写:

1
implementation (group="",name="splibrary",ext = "aar")

必须加一个group还是很奇怪的,而目前看来这是一个bug (Gitub issue)

build variants

构建变体代表我们可以为应用构建的一个不同版本,比如“全功能release版”,”全功能”就是一种 productFlavor, “release” 则是一种 buildType,通过自定义并组合多种 productFlavor 和 buildType 可以构建多种不同版本的应用。

在 Groovy 中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
productFlavors {
demo {
dimension "app"
}
full {
dimension "app"
multiDexEnabled true
}
}

buildTypes {
release {
signingConfig signingConfigs.signConfig
minifyEnabled true
debuggable false
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
}

debug {
minifyEnabled false
debuggable true
}
}
signingConfigs {
release {
storeFile file("myreleasekey.keystore")
storePassword "password"
keyAlias "MyReleaseKey"
keyPassword "password"
}
debug {
...
}
}

在 kotlin-dsl 中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
productFlavors {
create("demo") {
dimension = "app"
}
create("full") {
dimension = "app"
multiDexEnabled = true
}
}

buildTypes {
getByName("release") {
signingConfig = signingConfigs.getByName("release")
isMinifyEnabled = true
isDebuggable = false
proguardFiles(getDefaultProguardFile("proguard-android.txtt"), "proguard-rules.pro")
}

getByName("debug") {
isMinifyEnabled = false
isDebuggable = true
}
}

signingConfigs {
create("release") {
storeFile = file("myreleasekey.keystore")
storePassword = "password"
keyAlias = "MyReleaseKey"
keyPassword = "password"
}
getByName("debug") {
...
}
}

说明:

  1. create()getByName()是哪儿来的?demo,full,release等为什么作为字符串变成函数的参数了?

    在Groovy中,buildTypesproductFlavors 以及 signingConfigs 这几个函数都接收一个Action<? super NamedDomainObjectContainer<T>> 的接口作为参数,Action 是一个函数式接口,其唯一的方法接收一个 NamedDomainObjectContainer接口的实现类对象作为参数,而这个接口的主要方法就包含了 create()getByName(),这俩方法有一些重载方法,但都包含一个字符串参数作为 T 的对象标识name。在 Groovy 中的full,release等值都会作为这个参数的值传入。

    signingConfigs为例,用 Java 代码来演示可以更详细的展示这个调用层级:

    1
    2
    3
    4
    5
    6
    7
    signingConfigs(signingConfigContainer -> {

    signingConfigContainer.create("release",signingConfig ->{
    signingConfig.setStorePassword("xxxx");
    signingConfig.setKeyAlias("xx.yy.zz");
    });
    });

    *signingConfigContainer 就是接口 NamedDomainObjectContainer<SigningConfig>的实现类。

  2. create()getByName() 在什么时候使用?

    NamedDomainObjectContainer和其子接口NamedDomainObjectCollection的Api 文档中已详细说明,简而言之,当其中的元素(i.e.productFlavor/buildType)已经存在则调用getByName(),否则使用create()创建新对象。

    buildTypes中的debugrelease以及 signingConfigs 中的debug都是默认存在的,通过查看Android Gradle插件的源码可以发现这一点:

    1
    2
    3
    4
    5
    // com.android.build.gradle.AppPlugin

    signingConfigContainer.create(DEBUG)
    buildTypeContainer.create(DEBUG)
    buildTypeContainer.create(RELEASE)

    因此这三者是调用getByName()方法进行配置的。

  3. 在属性配置的代码块中,boolean 类型的赋值为什么有的在原来的表示(指 Groovy 中的表示)前加了is (e.g. minifyEnabled),有的不加 (e.g. multiDexEnabled)?

    这其实就是 kotlin 和 java 的互调用规则决定的。 我们进行属性配置的这些类,即 SigningConfig BuildType 等,其源码都是 Java。

    从 kotlin 中调用 Java,遵循 getterter 和 setter 的 Java 约定的方法(名称以 get开头的无参数方法和名称以set开头的单参数方法)在 Kotlin 中表示为属性,对boolean类型属性有两种情况:

    1. 当 Java 类中的某boolean类型属性的getter/setter方法形如setXxx() isXxx()的时候,kotlin进行取值或赋值调用都是isXxx:
    1
    2
    3
    if(!isXxx){
    isXxx = true
    }
    1. 形如通常的getXxx()setXxx()的时候,kotlin的调用则不带前缀is
      跳转到BuildType等的源码可以发现,minifyEnabled 属于第一种情况,而multiDexEnabled属于第二种情况,因此转为kotlin时前者需要带前缀is

访问gradle.properties中的配置

我们通常会把签名信息、版本信息等配置写在gradle.properties中,groovy访问的时候直接引用即可:

1
2
3
4
5
6
7
8
# gradle.properties

version_code=1
version_name=1

# build.gradle
versionCode Integer.parseInt(version_code)
versionName version_name

而在kotlin-dsl的早期版本 (v0.16.3, Gradle 4.7 之前) 却无法直接访问,现在可以通过委托属性来访问:

1
2
3
4
5
6
7
# build.gradle.kts

val version_code: String by project
val version_name: String by project
...
versionCode = Integer.parseInt(version_code)
versionName = version_name

关于 ext

Google 官方推荐的一个 Gradle 配置最佳实践是在项目最外层 build.gradle 文件的ext代码块中定义项目范围的属性,然后在所有模块间共享这些属性,比如我们通常会这样存放依赖的版本号。

1
2
3
4
5
6
7
8
9
// build.gradle

ext {
compileSdkVersion = 28
buildToolsVersion = "28.0.3"

supportLibVersion = "28.0.0"
...
}

但是由于缺乏IDE的辅助(跳转查看、全局重构等都不支持),实际使用体验欠佳。

随着 Gradle 5.0的发布,Gradle官方现在推荐将这些内容声明在一个专用文件夹buildSrc中。App目录结构如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
.
├── app
├── build.gradle.kts
└── src
├── main
└── test
├── build.gradle.kts
├── buildSrc
└── src
└── main
└── kotlin
├── Depends.kt
└── Versions.kt
├── build.gradle.kts
...
└── settings.gradle

依赖和版本号等值存放在了Depends.ktVersions.kt文件中。

具体操作可以参考 Sam Edwards视频教程,以及 DroidKaigi 的2018开发者大会的 App 源码

尾巴

如文章开头所述,Kotiln-dsl 是一种更友好的 DSL语言,它是类型安全的,具有完善的IDE辅助支持(代码自动提示补全,快速跳转等),语法也非常灵活,同时也有很好的可读性,虽然目前与Groovy的兼容还没有100%完成,但对功能基本没有什么影响。

但目前的kotlin-dsl也有一些问题,如 ProAndroidDev 的文章中提到的,因为额外的类型安全检查,kotlin-dsl在性能上会有所损失,大型项目在迁移之前做个性能测试会更稳妥。

References