为什么要从 Groovy 迁移到 Kotlin DSL?
Gradle 从 5.0 开始, 正式加入了对 Kotlin DSL 的支持, 相比于传统的 Groovy, Kotlin DSL 带来的好处包括:
- 更好的 IDE 支持, 包括自动补全, 代码提示, 源码跳转, 错误高亮, 重构和 Debug 等(得益于 Kotlin 本身是一种静态强类型的语言);
- 支持自动检测导包;
- Kotlin 语言层级的特性如顶级函数, 扩展方法等;
- 更加熟悉的语法;
- 支持与 Groovy 混编等(得益于 Kotlin for JVM).
什么是 DSL?
DSL, 领域特定语言(domain-specific language), 指的是专注于某个应用程序领域的计算机语言; 与 DSL 相对的是 GPL(general-purpose language), 普通的跨领域通用计算机语言, 注意这里的 GPL 不是那一款开源许可证(GNU General Public License). 二者最大的不同是表达能力, 我们熟悉的 C 语言, Java, Python 等 GPL 语言可以用来编写任意计算机程序, 并且能表达任何的可被计算的逻辑(同时也是 图灵完备 的, 关于图灵完备, 可以参考本文末尾的参考链接), 而 HTML, SQL, Shell, make 等语言和正则表达式等 DSL 通过表达能力上的妥协换取某一领域内的高效(不是图灵完备的).
作为一门 GPL 语言, Kotlin 具备的一些很酷的特性如 带有接收者的函数字面值, 传递末尾的 lambda 表达式, 中缀表示法 既能够让我们用简洁的语法写出非常干净的代码, 也允许我们用非常小的代价写出类 DSL 的代码.
用 Anko 举个例子:
verticalLayout {
val name = editText()
button("Say Hello") {
onClick { toast("Hello, ${name.text}!") }
}
}
如何迁移?
选择合适的 IDE
Intellij IDEA 和 Android Studio 都全面支持 Kotlin DSL. 其他的 IDE 还没有提供能高效地编辑 Kotlin DSL 文件的工具, 不过我们还是可以引入基于 Kotlin DSL 的构建任务并正常运行.
构建任务导入 | 语法高亮 1 | 语义编辑器 2 | |
---|---|---|---|
IntelliJ IDEA | ✅ | ✅ | ✅ |
Android Studio | ✅ | ✅ | ✅ |
Eclipse IDE | ✅ | ✅ | ❌ |
CLion | ✅ | ✅ | ❌ |
Apache Netbeans | ✅ | ✅ | ❌ |
Visual Studio Code (LSP) | ✅ | ✅ | ❌ |
Visual Studio | ✅ | ❌ | ❌ |
- 在 Gradle Kotlin DSL 脚本中的 Kotlin 语法高亮
- 在 Gradle Kotlin DSL 脚本中的代码补全, 源码跳转, 文档, 重构等等
当我们修改了构建逻辑时, IntelliJ IDEA 和 Android Studio 会检测到并提供两个建议:
- 再次导入整个构建任务;
- 当编辑构建脚本时重载脚本依赖.
官方推荐 关闭 auto-import 但 开启 auto-reload,
升级 Gradle Wrapper 至 5.0 或以上
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-6.5.1-all.zip
注意⚠️:
- Android 项目使用 Gradle 5.0 的最低要求为 Gradle Plugin 3.4;
- Gradle 5.0 仅支持 Java 8 及以上;
- 使用
gradle-6.5.1-all
而不是gradle-6.5.1-bin
, Gradle 各个版本的区别为:gradle-x.y.z-all
是完整版, 包含了各种二进制文件(提供给 IDE 的 Gradle API), 源代码文件(Groovy DSL/Kotlin DSL)和离线的文档;gradle-x.y.z-bin
是二进制版, 只包含了二进制文件(可执行文件);gradle-x.y.z-src
是源码版, 只包含了 Gradle 源代码, 没有可执行文件, 不能用于构建项目;gradle-x.y.z-doc
是文档版, 只包含了 Gradle 的文档及示例项目, 同样没有可执行文件, 也不能用于构建项目.
创建 buildSrc
目录
在 Gradle 5.0 之前, Google 推荐的一种管理依赖及其版本的方法是将其放置在 顶层 build.gradle
文件内的 ext
代码块中:
buildscript {...}
allprojects {...}
ext {
compileSdkVersion = 28
supportLibVersion = "28.0.0"
}
访问时用 rootProject.ext.xxx
的方式:
android {
compileSdkVersion rootProject.ext.compileSdkVersion
}
dependencies {
implementation "com.android.support:appcompat-v7:${rootProject.ext.supportLibVersion}"
}
但是, 理想很丰满, 现实很骨感. 这种看起来很美好的方式实际体验并不佳, 因为她既不支持代码跳转, 也不支持全局重构.
在 Gradle 5.0 之后, Gradle 官方推荐在项目根目录下创建一个 buildSrc
目录, 并将各种依赖及其版本信息归拢于此.
当 Gradle 运行时, 她会自动检查 buildSrc
目录是否存在, 然后自动编译并测试这个目录下的构建脚本代码并将他们存放到 classpath 中. 这种方式允许我们用一种非常干净的方法管理依赖及其版本, 并且这种方式符合 单一真值来源(Single source of truth) 原则.
对于有多个 module 的项目, 只能有一个 buildSrc
目录, 并且必须在项目根目录下. 目录结构如下:
.
├── app
├── build.gradle.kts
└── src
├── main
└── test
├── build.gradle.kts
├── buildSrc
├── build.gradle.kts
└── src
└── main
└── java
└── Dependencies.kt
├── build.gradle.kts
└── settings.gradle
buildSrc
目录下的build.gradle.kts
文件内容为:plugins { `kotlin-dsl` } repositories { jcenter() }
- 创建一个 maven 目录结构:
src/main/java/your/package
, 也就是上面的例子中的src/main/java/
. - 在上面创建好的目录下创建一个
Dependencies.kt
文件, 定义一些单例用于声明依赖:
object Versions {
val compileSdk = 30
val targetSdk = 30
val minSdk = 23
val androidGradle = "4.2.0-alpha07"
val kotlin = "1.4.0-rc"
val coroutines = "1.3.8-1.4.0-rc"
// ...
}
object Deps {
object GradlePlugin {
val android = "com.android.tools.build:gradle:${Versions.androidGradle}"
val kotlin = "org.jetbrains.kotlin:kotlin-gradle-plugin:${Versions.kotlin}"
// ...
}
object Kotlin {
val stdlib = "org.jetbrains.kotlin:kotlin-stdlib-jdk7:${Versions.kotlin}"
val coroutinesCore = "org.jetbrains.kotlinx:kotlinx-coroutines-core:${Versions.coroutines}"
val coroutinesAndroid =
"org.jetbrains.kotlinx:kotlinx-coroutines-android:${Versions.coroutines}"
}
// ...
}
迁移 Groovy 代码为 Kotlin 代码
Groovy 和 Kotlin 在语法上有一些相似之处, 只是 Kotlin 的语法更为严格. 我们先行修改一部分已经存在的 Groovy 代码, 防止直接将 Groovy 代码转换为 Kotlin 代码之后出现数量可观的语法错误. 当然你也可以选择直接用 Kotlin 重写, 而不是修改原有的 Groovy 代码.
- 将字符串由单引号引用改为双引号引用, 即将 ” 改为 “” . 在 Groovy 中, 字符串使用单双引号均可, 而在 Kotlin 中只允许双引号.
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
// 改为
proguardFiles getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro"
implementation 'androidx.lifecycle:lifecycle-viewmodel:2.0.0'
// 改为
implementation "androidx.lifecycle:lifecycle-viewmodel:2.0.0"
include ':app'
// 改为
include ":app"
- 消除歧义. 在不了解上下文的情况下, “xx zzzz” 类型的表达式在 Groovy 中既可以表示将 xx 赋值 zzzz (即 Kotlin 中的 xx = zzzz), 也可以表示方法调用(即 Kotlin 中的 xx(zzzz)), 即调用方法 xx, 参数为 zzzz. 而在 Kotlin 中, 赋值和函数调用有不同的语法, 因此我们需要更加明确地区分.
消除歧义前:
// 这是属性赋值
applicationId "io.github.tonnyl.moka"
// 这是方法调用
implementation "androidx.lifecycle:lifecycle-viewmodel:2.0.0"
消除歧义后:
// 这是属性赋值
applicationId = "io.github.tonnyl.moka"
// 这是方法调用
implementation("androidx.lifecycle:lifecycle-viewmodel:2.0.0")
小技巧: 如何识别变量赋值和方法调用: cmd(macOS)/ctrl(Windows) + 鼠标点击 左边的单词如 implementation, 如果 IDE 打开了一个源文件则意味着这是一个方法, 但当你 cmd(macOS)/ctrl(Windows) + 鼠标点击 在 applicationId 上, 没有任何反应, 则意味着这是一个属性.
- 重命名
.gradle
文件为.gradle.kts
. 重命名后, IDE 会有很多的语法错误提示, 下面我们一步步的从 Groovy 语法迁移到 Kotlin 语法.
3.1 配置插件:
apply plugin: 'com.android.application'
apply plugin: 'kotlin-android'
apply plugin: 'kotlin-android-extensions'
apply plugin: 'kotlin-kapt'
apply plugin: 'com.apollographql.android'
apply plugin: 'androidx.navigation.safeargs'
plugins {
id("com.android.application")
id("com.apollographql.apollo").version(Versions.apollo)
id("kotlin-android")
id("kotlin-android-extensions")
id("kotlin-kapt")
id("androidx.navigation.safeargs.kotlin")
}
id()
方法用于应用常规插件, 参数为插件 id, 并可以用version()
指定插件版本;id("kotlin-android")
还可以替换为kotlin("android")
,kotlin()
方法用于应用 Kotlin 插件, 其实际封装了id()
方法, 其内部硬编码了 Kotlin 的 artifact groupID, 然后通过拼接传入的 module ID 实现.
fun PluginDependenciesSpec.kotlin(module: String): PluginDependencySpec = id(“org.jetbrains.kotlin.$module”)
下面的网站罗列出了 Kotlin 插件清单, 可以帮助我们查询插件 id.
3.2 配置 android
代码块
下面继续我们的常规配置, 但是用一种非常规的方式 — android
是一个 Kotlin 扩展方法. 代码块内部我们会用到之前定义在 buildSrc
目录下的 Dependencies.kt
文件.
android {
compileSdkVersion(Versions.compileSdk)
defaultConfig {
applicationId = "io.github.tonnyl.moka"
minSdkVersion(Versions.minSdk)
targetSdkVersion(Versions.targetSdk)
versionCode = 1
versionName = "1.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
buildTypes(Action {
getByName("release") {
isMinifyEnabled = true
isShrinkResources = true
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
getByName("debug") {
isMinifyEnabled = false
isShrinkResources = false
versionNameSuffix = "-debug"
isTestCoverageEnabled = true
}
})
compileOptions {
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = JavaVersion.VERSION_1_8.toString()
}
buildFeatures {
dataBinding = true
compose = true
}
composeOptions {
kotlinCompilerVersion = Versions.composeKotlinCompilerVersion
kotlinCompilerExtensionVersion = Versions.ui
}
}
有一点值得注意的, 我们需要通过 getByName(name: String)
方法获取 BuildType
, 其中 name 参数即构建类型的名称, 如 release
, debug
. 与之前的方式有所不同:
buildTypes {
debug {
...
}
release {
...
}
}
3.3 声明依赖
dependencies {
implementation(fileTree(Pair("dir", "libs"), Pair("include", listOf("*.jar"))))
// Kotlin
implementation(Deps.Kotlin.coroutinesCore)
implementation(Deps.Kotlin.coroutinesAndroid)
// AndroidX
implementation(Deps.AndroidX.constraintLayout)
...
}
和以前的方式有一些不同的是 fileTree
. 之前使用 Groovy 添加本地 jar 包依赖的方法:
dependencies {
implementation fileTree(dir: 'libs', include: ['*.jar'])
...
}
fileTree
有一些重载方法和 Kotlin 扩展方法, 借助 IDE 查看源码即可:
ConfigurableFileTree fileTree(Object baseDir);
ConfigurableFileTree fileTree(Object baseDir, Closure configureClosure);
ConfigurableFileTree fileTree(Object baseDir, Action<? super ConfigurableFileTree> configureAction);
ConfigurableFileTree fileTree(Map<String, ?> args);
inline fun org.gradle.api.Project.`fileTree`(vararg `args`: Pair<String, Any?>): org.gradle.api.file.ConfigurableFileTree =
`fileTree`(mapOf(*`args`))
选择合适的即可.
3.4 task
task clean(type: Delete) {
delete rootProject.buildDir
}
task<Delete>("clean") {
delete(rootProject.buildDir)
}
改动比较简单, 主要是语法层面的改动. 详细的迁移教程可以参考 https://guides.gradle.org/migrating-build-logic-from-groovy-to-kotlin/#configuring-tasks
3.5 读取项目配置
# gradle.properties
CLIENT_ID=your_id
groovy 访问 .properties
文件中的内容十分简单:
# build.gradle
clientId CLIENT_ID
Kotlin 则可以通过委托属性访问:
val CLIENT_ID: String by project
module 目录下的 build.gradle 迁移方法与上面类似, 这里就不再赘述了.
回顾
值得一提的是, 因为 Kotlin 额外的类型安全检查, Kotlin DSL 会带来一些性能问题而影响构建时间. 而 Kotlin 1.3.61 之后有了很大的性能提升, 不过我仍然建议你在迁移大型生产项目之前做一些性能测试.
相比于性能上的损失, Kotlin DSL 带来的好处是显而易见的. 感谢 Gradle 和 Kotlin 团队, 干得漂亮!
参考:
- Gradle 官方迁移指南: https://guides.gradle.org/migrating-build-logic-from-groovy-to-kotlin/
- Gradle 官方 Kotlin DSL 示例项目: https://github.com/gradle/kotlin-dsl-samples
- Gradle 官方 Kotlin DSL 的 Android 示例项目: https://github.com/gradle/kotlin-dsl-samples/tree/master/samples/hello-android
- ProAndroidDev 的文章 Migrating Android build scripts from Groovy to Kotlin DSL: https://proandroiddev.com/migrating-android-build-scripts-from-groovy-to-kotlin-dsl-f8db79dd6737
- 什么是 图灵完备: https://www.jianshu.com/p/a04b16c5bbda