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.gradle
和settings.gradle
里的''
全部替换为""
赋值/传值
在Groovy中xx yy
可以表示对变量xx
赋与值yy
,也可以表示调用函数xx
,yy
是参数,如Android中常见的:
1 | defaultConfig { |
而在kotlin中则是和Java一样的语法,相应的,上面的栗子迁移后:
1 | defaultConfig { |
知道了这个区别,但是很多
xx yy
到底是赋值还是函数调用不好分辨,比如versionName "1.0"
是赋值,而minSdkVersion 16
居然是函数调用。我们可以利用Android Studio的辅助功能,在kotlin-dsl 中是可以快速点选查看方法的源码的(mac 下cmd + click
),而对属性变量就没啥反应,所以可以根据这个小trick来分辨。
Task
由于Koltin 是静态类型语言,Groovy是动态语言,前者是类型安全的,他们的性质区别很明显的体现在了 task 的创建和配置上。详情可以参考Gradle官方迁移教程
1 | // groovy |
迁移步骤
- 字符串单引号变双引号
- 重命名所有
xx.gradle
脚本文件,加上.kts
后缀 - 插件声明 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
2fun PluginDependenciesSpec.kotlin(module: String): PluginDependencySpec =
id("org.jetbrains.kotlin.$module") - 按前面提到的基本原则改写所有语句
示例
apply
除了apple 插件外,apply 还可以用来应用其他的gradle
文件:
1 | //groovy |
依赖
依赖管理作为 gradle 的基本功能,gradle 提供了非常丰富的方法,下面以分三种情形进行说明。
基本情形
1 | // groovy |
fileTree
使用 groovy 添加本地目录 jar 包依赖:
1 | // groovy |
查看 fileTree 函数源码:
1 | //org.gradle.api.Project |
需要传入的是Map
,因此迁移到 kotlin-dsl 可以这样写:
1 | implementation(fileTree(mapOf("include" to listOf("*.jar"), "dir" to "libs"))) |
特别类型库依赖
之前在groovy
中添加特别类型库依赖:
1 | implementation(name: 'splibrary', ext: 'aar') |
按前面提到的基本原则,我们会这样改写:
1 | // wrong |
但实际上会导致失败。跳转到 kotlin-dsl implementation
的源码找原因:
1 | fun DependencyHandler.`implementation`(dependencyNotation: Any)... |
可以看到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 | productFlavors { |
在 kotlin-dsl 中:
1 | productFlavors { |
说明:
create()
和getByName()
是哪儿来的?demo
,full
,release
等为什么作为字符串变成函数的参数了?在Groovy中,
buildTypes
和productFlavors
以及signingConfigs
这几个函数都接收一个Action<? super NamedDomainObjectContainer<T>>
的接口作为参数,Action
是一个函数式接口,其唯一的方法接收一个NamedDomainObjectContainer
接口的实现类对象作为参数,而这个接口的主要方法就包含了create()
和getByName()
,这俩方法有一些重载方法,但都包含一个字符串参数作为T
的对象标识name
。在 Groovy 中的full
,release
等值都会作为这个参数的值传入。以
signingConfigs
为例,用 Java 代码来演示可以更详细的展示这个调用层级:1
2
3
4
5
6
7signingConfigs(signingConfigContainer -> {
signingConfigContainer.create("release",signingConfig ->{
signingConfig.setStorePassword("xxxx");
signingConfig.setKeyAlias("xx.yy.zz");
});
});*
signingConfigContainer
就是接口NamedDomainObjectContainer<SigningConfig>
的实现类。create()
和getByName()
在什么时候使用?在
NamedDomainObjectContainer
和其子接口NamedDomainObjectCollection
的Api 文档中已详细说明,简而言之,当其中的元素(i.e.productFlavor/buildType)已经存在则调用getByName()
,否则使用create()
创建新对象。而
buildTypes
中的debug
和release
以及 signingConfigs 中的debug
都是默认存在的,通过查看Android Gradle插件的源码可以发现这一点:1
2
3
4
5// com.android.build.gradle.AppPlugin
signingConfigContainer.create(DEBUG)
buildTypeContainer.create(DEBUG)
buildTypeContainer.create(RELEASE)因此这三者是调用
getByName()
方法进行配置的。在属性配置的代码块中,boolean 类型的赋值为什么有的在原来的表示(指 Groovy 中的表示)前加了is (e.g. minifyEnabled),有的不加 (e.g. multiDexEnabled)?
这其实就是 kotlin 和 java 的互调用规则决定的。 我们进行属性配置的这些类,即 SigningConfig BuildType 等,其源码都是 Java。
从 kotlin 中调用 Java,遵循 getterter 和 setter 的 Java 约定的方法(名称以
get
开头的无参数方法和名称以set
开头的单参数方法)在 Kotlin 中表示为属性,对boolean类型属性有两种情况:- 当 Java 类中的某boolean类型属性的
getter/setter
方法形如setXxx()
isXxx()
的时候,kotlin进行取值或赋值调用都是isXxx
:
1
2
3if(!isXxx){
isXxx = true
}- 形如通常的
getXxx()
和setXxx()
的时候,kotlin的调用则不带前缀is
。
跳转到BuildType
等的源码可以发现,minifyEnabled
属于第一种情况,而multiDexEnabled
属于第二种情况,因此转为kotlin
时前者需要带前缀is
。
- 当 Java 类中的某boolean类型属性的
访问gradle.properties
中的配置
我们通常会把签名信息、版本信息等配置写在gradle.properties
中,groovy
访问的时候直接引用即可:
1 | # gradle.properties |
而在kotlin-dsl的早期版本 (v0.16.3, Gradle 4.7 之前) 却无法直接访问,现在可以通过委托属性来访问:
1 | # build.gradle.kts |
关于 ext
Google 官方推荐的一个 Gradle 配置最佳实践是在项目最外层 build.gradle 文件的ext
代码块中定义项目范围的属性,然后在所有模块间共享这些属性,比如我们通常会这样存放依赖的版本号。
1 | // build.gradle |
但是由于缺乏IDE的辅助(跳转查看、全局重构等都不支持),实际使用体验欠佳。
随着 Gradle 5.0的发布,Gradle官方现在推荐将这些内容声明在一个专用文件夹buildSrc中。App目录结构如下:
1 | . |
依赖和版本号等值存放在了Depends.kt
和Versions.kt
文件中。
具体操作可以参考 Sam Edwards 的视频教程,以及 DroidKaigi 的2018开发者大会的 App 源码
尾巴
如文章开头所述,Kotiln-dsl 是一种更友好的 DSL语言,它是类型安全的,具有完善的IDE辅助支持(代码自动提示补全,快速跳转等),语法也非常灵活,同时也有很好的可读性,虽然目前与Groovy的兼容还没有100%完成,但对功能基本没有什么影响。
但目前的kotlin-dsl也有一些问题,如 ProAndroidDev 的文章中提到的,因为额外的类型安全检查,kotlin-dsl在性能上会有所损失,大型项目在迁移之前做个性能测试会更稳妥。
References
- Android Gradle Kotlin DSL 迁移
- Apache Groovy - 字符串表示
- ProAndroidDev - Migrating Android build scripts from Groovy to Kotlin DSL
- Gradle Guides - Migrating build logic from Groovy to Kotlin
- Gradle Docs - Organizing Gradle Projects
- kotlin - Type-Safe Builders
- kotlin-dsl Github issues - accessing gradle properties
- kotlin-dsl samples