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

为什么要从 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
  1. 在 Gradle Kotlin DSL 脚本中的 Kotlin 语法高亮
  2. 在 Gradle Kotlin DSL 脚本中的代码补全, 源码跳转, 文档, 重构等等

当我们修改了构建逻辑时, IntelliJ IDEA 和 Android Studio 会检测到并提供两个建议:

  1. 再次导入整个构建任务;

  2. 当编辑构建脚本时重载脚本依赖.

官方推荐 关闭 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
  1. buildSrc 目录下的 build.gradle.kts 文件内容为: plugins { `kotlin-dsl` } repositories { jcenter() }
  2. 创建一个 maven 目录结构src/main/java/your/package, 也就是上面的例子中的 src/main/java/.
  3. 在上面创建好的目录下创建一个 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 代码.

  1. 将字符串由单引号引用改为双引号引用, 即将 ” 改为 “” . 在 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"
  1. 消除歧义. 在不了解上下文的情况下, “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 上, 没有任何反应, 则意味着这是一个属性.

  1. 重命名 .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.

https://plugins.gradle.org/search?term=kotlin+jetbrains

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