Kotlin DSL 的强类型安全设计与工程迁移实战
Kotlin DSL 不是 Groovy DSL 的“语法皮肤”,而是 Gradle 把动态构建模型重新暴露给静态类型系统的一套编译机制。
前一篇已经拆过 Groovy DSL:build.gradle 会被编译成 Groovy 脚本类,未限定的方法、属性、闭包委托会沿着 Script、Binding、Project、扩展容器一路动态查找。它的优势是宽松,插件运行后随时塞进来的名字都能在运行期尝试解析;它的代价是错误发现晚、IDE 推理弱、重构边界模糊。
Kotlin DSL 换了一种思路:脚本仍然配置同一个 Gradle 模型,但 Gradle 会在脚本编译前尽量把 Project、Settings、插件扩展、依赖配置、任务容器这些动态模型整理成 Kotlin 编译器能看懂的类型。这样,android {}、implementation(...)、tasks.test {} 不是靠“运行时猜名字”,而是尽量变成真实的 Kotlin 函数、属性和扩展函数。
可以把两者想象成两种控制室:
- Groovy DSL 像一个可以口头下达指令的控制室,操作员说“打开 implementation 通道”,系统运行时再去查有没有这个通道。
- Kotlin DSL 像一张带接口定义的控制面板,只有已经接入图纸的按钮才会出现在面板上,按错名字在通电前就会被发现。
这就是 Kotlin DSL 的核心收益,也是迁移时最容易踩坑的根源:它不是更“啰嗦”的 Gradle,而是把原本运行期的构建脚本错误前移到了编译期。
从脚本文件到 Kotlin 程序
Gradle Kotlin DSL 脚本的后缀是 .gradle.kts。kts 的意思不是“配置文件”,而是 Kotlin Script。官方文档明确说明:Kotlin DSL 和 Groovy DSL 一样构建在 Gradle Java API 之上,脚本里的对象、函数、属性主要来自 Gradle API 和已应用插件的 API。
所以,一个 Android 模块脚本:
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
}
android {
namespace = "club.zerobug.app"
compileSdk = 36
}
dependencies {
implementation("androidx.core:core-ktx:1.17.0")
}
底层仍然是在配置 :app 这个 Project:
build.gradle.kts
|
| 1. 解析 plugins{} 等早期块
v
插件请求 + 脚本 classpath
|
| 2. 应用插件,锁定可见模型
v
Project + extensions + configurations + tasks
|
| 3. 生成 Kotlin DSL 类型安全访问器
v
accessors classpath
|
| 4. 编译脚本主体
v
Kotlin 字节码
|
| 5. 执行脚本,配置 Gradle 模型
v
Project 模型完成配置
这里最关键的是第 2 步和第 3 步:Gradle 必须先知道插件提供了什么模型,才能生成 android {}、implementation(...) 这类访问器。也就是说,Kotlin DSL 的类型安全不是 Kotlin 编译器凭空知道 Gradle 的世界,而是 Gradle 在编译脚本前把模型扫描一遍,临时生成一批 Kotlin 代码,再把这些代码放进脚本编译 classpath。
Gradle 源码中的 StandardKotlinScriptEvaluator 可以看到这条链路:它会为脚本准备编译 classpath、插件块访问器、项目模型访问器,并把编译产物放入 Kotlin DSL 工作目录缓存。源码里的 ProjectAccessorsClassPathGenerator 进一步揭示了访问器生成的输入:目标脚本对象、项目 schema、脚本 classpath、Gradle 执行引擎和缓存工作目录。
用伪代码压缩一下:
val pluginRequests = parsePluginsBlock(script)
applyPluginsTo(project, pluginRequests)
val schema = projectSchemaProvider.schemaFor(project, lockedClassLoaderScope)
val accessorsClasspath = generateProjectAccessors(schema, scriptClasspath)
compileKotlinScript(
source = buildGradleKts,
classpath = gradleApi + pluginClasspath + accessorsClasspath,
)
这解释了一个重要事实:Kotlin DSL 的类型安全是一种“构建时生成的类型安全”。它不是静态到脱离 Gradle,也不是动态到像 Groovy 那样运行期兜底。它站在中间:先让 Gradle 构建出一份当前脚本可见的模型快照,再让 Kotlin 编译器检查脚本是否正确使用这份模型。
类型安全访问器:动态模型如何变成可补全的代码
Groovy DSL 可以写:
dependencies {
implementation 'androidx.core:core-ktx:1.17.0'
}
这里的 implementation 不一定是 DependencyHandler 真实声明的方法。它可能来自 Java、Android 或 Kotlin 插件创建的一个依赖配置,然后由 Gradle 动态 DSL 把“配置名 + 参数”转换成“往这个配置里添加依赖”。
Kotlin 不能这样玩。Kotlin 编译器在编译脚本时必须知道 implementation 是什么,否则就会报:
Unresolved reference: implementation
Gradle 的解决方式是生成类型安全访问器。概念上,它会为插件贡献的模型生成类似这样的 Kotlin 扩展:
// 概念示意:真实生成代码更复杂。
fun DependencyHandler.implementation(dependencyNotation: Any): Dependency? {
return add("implementation", dependencyNotation)
}
val Project.android: CommonExtension<*, *, *, *, *, *>
get() = extensions.getByName("android") as CommonExtension<*, *, *, *, *, *>
fun Project.android(configure: CommonExtension<*, *, *, *, *, *>.() -> Unit) {
extensions.configure("android", configure)
}
这样,脚本中的:
android {
namespace = "club.zerobug.app"
}
dependencies {
implementation("androidx.core:core-ktx:1.17.0")
}
就可以被 Kotlin 编译器当作普通 Kotlin 代码检查。namespace 拼错、implementation 不存在、传参类型明显错误,都会在脚本编译阶段暴露。
这层机制有几个非常具体的边界。
插件块之后的模型才有稳定访问器
Gradle 官方文档说明,主项目构建脚本和预编译项目脚本插件拥有类型安全模型访问器;这些访问器可覆盖插件贡献的依赖配置、扩展、任务、容器元素等。但可用访问器集合是在脚本主体执行前、plugins {} 块之后确定的。
因此,这样可以工作:
plugins {
id("com.android.application")
}
android {
namespace = "club.zerobug.app"
}
而这种写法不能期待生成访问器:
plugins {
id("java")
}
configurations.create("benchmarkRuntime")
dependencies {
// benchmarkRuntime 是脚本主体运行时才创建的名字。
// Kotlin DSL 不会为它提前生成类型安全访问器。
"benchmarkRuntime"("androidx.benchmark:benchmark-junit4:1.4.1")
}
这里要用字符串形式:
dependencies {
add("benchmarkRuntime", "androidx.benchmark:benchmark-junit4:1.4.1")
}
底层原因非常朴素:访问器生成发生在脚本主体执行之前,而 configurations.create("benchmarkRuntime") 是脚本主体执行时才发生的副作用。编译器不能引用一个未来才被脚本自己创建出来的名字。
apply(plugin = ...) 会削弱访问器能力
Gradle 仍然支持命令式应用插件:
apply(plugin = "com.android.application")
android {
namespace = "club.zerobug.app"
}
但这类写法不适合作为迁移后的主路径。原因不是它“过时难看”,而是它打破了 Kotlin DSL 最重要的前提:Gradle 需要在脚本主体编译前知道插件会贡献哪些模型。
plugins {} 是早期声明,Gradle 可以先解析它、应用插件、生成访问器,再编译脚本主体。apply(plugin = ...) 是普通脚本代码,它本身就在脚本主体里。等它运行时,脚本已经编译完成,类型安全访问器生成窗口已经过去。
所以,Kotlin DSL 迁移的第一原则是:
能放进 plugins{} 的插件,就不要留在 apply(plugin = ...)
少数无法通过插件门户元数据正常解析的插件,可以通过 pluginManagement、resolutionStrategy、includeBuild("build-logic") 或版本目录里的插件别名解决,而不是回退到散落的 buildscript {} 和 apply。
Kotlin DSL 脚本模板:为什么顶层 android {} 能直接出现
普通 Kotlin 文件里不能平白出现 repositories {}、dependencies {}、tasks {}。在 Kotlin DSL 里能写,是因为 Gradle 给不同脚本类型提供了不同的脚本模板和隐式接收者。
脚本文件与背后对象的关系大致如下:
| 文件 | Kotlin 脚本目标 | 典型顶层能力 |
|---|---|---|
settings.gradle.kts |
Settings |
pluginManagement、dependencyResolutionManagement、include |
build.gradle.kts |
Project |
plugins、repositories、dependencies、tasks、插件扩展 |
init.gradle.kts |
Gradle |
初始化脚本能力 |
Gradle 源码中的 KotlinBuildScript、KotlinSettingsScript 这类脚本基类会把脚本目标对象挂进 KotlinScriptHost。顶层未限定调用可以落到当前脚本目标、Gradle API、Kotlin DSL API、隐式导入以及生成访问器上。
这和 Groovy DSL 的动态委托看起来相似,但性质不同:
Groovy DSL:
名字出现 -> 运行时动态查找 -> 找不到才报错
Kotlin DSL:
名字出现 -> 编译期类型解析 -> 编译不过就不会进入执行期
对 Android 工程来说,这个差异很关键。大量构建错误不再等到 CI 执行某个任务才暴露,而是在 IDE 同步或脚本编译时就被指出。例如:
android {
compileSdk = "36"
}
这类类型错误不会变成运行期的迷惑异常;Kotlin 编译器会直接指出需要的是 Int 或对应属性类型。
Android 迁移不是机械改后缀
Android 官方迁移文档给出的第一步很务实:先在 Groovy 文件里补括号和赋值号,再改成 Kotlin 文件。这不是表面格式要求,而是在消除 Groovy 语法糖。
Groovy:
android {
namespace 'club.zerobug.app'
compileSdk 36
defaultConfig {
minSdk 26
targetSdk 36
versionCode 1
versionName '1.0'
}
}
Kotlin:
android {
namespace = "club.zerobug.app"
compileSdk = 36
defaultConfig {
minSdk = 26
targetSdk = 36
versionCode = 1
versionName = "1.0"
}
}
这一层只是入口。真正的迁移难点在于:Groovy 允许大量“运行时再说”的写法,而 Kotlin DSL 要你明确对象边界。
字符串字面量要变成显式调用
Groovy 的依赖声明:
dependencies {
implementation 'androidx.core:core-ktx:1.17.0'
testImplementation 'junit:junit:4.13.2'
}
Kotlin DSL:
dependencies {
implementation("androidx.core:core-ktx:1.17.0")
testImplementation("junit:junit:4.13.2")
}
这不是 Kotlin 喜欢括号,而是 implementation(...) 已经是一个真实函数调用。括号把“往 implementation 配置添加依赖”这个动作交给了编译器能检查的函数。
布尔属性通常要从 minifyEnabled 变成 isMinifyEnabled
Groovy 可以直接写:
buildTypes {
release {
minifyEnabled true
shrinkResources true
}
}
Kotlin DSL 要遵循 Kotlin 对 JavaBean 布尔属性的命名规则:
buildTypes {
release {
isMinifyEnabled = true
isShrinkResources = true
}
}
这里的 isMinifyEnabled 不是 Android 插件突然换概念,而是 Kotlin 把 Java 的 isMinifyEnabled() / setMinifyEnabled(Boolean) 暴露成属性时使用的名字。迁移时如果只靠搜索替换,很容易卡在这些 Bean 属性映射上。
容器元素要用 named、register、creating 表达生命周期
Groovy DSL 常见写法:
tasks.register('printVariant') {
doLast {
println 'variant'
}
}
Kotlin DSL:
tasks.register("printVariant") {
doLast {
println("variant")
}
}
如果是配置已存在任务,不要急着 getByName:
tasks.named<Test>("test") {
useJUnitPlatform()
}
tasks.named<T>() 返回的是 TaskProvider<T>,它和前面总览文章里的 Provider API、Task Configuration Avoidance 是同一条设计线:拿到的是“未来任务的惰性引用”,不是立刻把任务对象创建出来。Gradle 文档也明确指出,Kotlin DSL 的任务访问器会利用配置规避 API。
换句话说,Kotlin DSL 不只是让脚本可补全,也在语法层面把工程引向更懒、更可缓存的配置方式。
版本目录:把字符串依赖变成可治理的输入
Kotlin DSL 的类型安全访问器解决了“配置名、扩展名、任务名”的类型问题,但依赖坐标如果仍然散落在每个模块里,工程依然很脆:
dependencies {
implementation("androidx.core:core-ktx:1.17.0")
implementation("androidx.activity:activity-compose:1.12.1")
}
Version Catalogs 把依赖坐标集中到 gradle/libs.versions.toml:
[versions]
androidx-core = "1.17.0"
activity-compose = "1.12.1"
[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "androidx-core" }
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activity-compose" }
[plugins]
android-application = { id = "com.android.application", version = "8.13.1" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version = "2.2.21" }
模块里就可以写:
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
}
dependencies {
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.activity.compose)
}
官方文档强调,版本目录会为 TOML 里的别名生成类型安全访问器,IDE 可以补全、检查拼写、发现缺失依赖。但也要记住一条边界:版本目录不是依赖解析引擎。它只是声明依赖坐标和版本请求,真正选中哪个版本仍然由 Gradle 依赖解析、约束、平台/BOM、冲突解决规则决定。
所以版本目录最适合承担这几个职责:
- 统一依赖坐标和插件版本,减少模块脚本里的硬编码。
- 让依赖别名在 Kotlin DSL 中可补全、可重构。
- 配合 BOM 或 Gradle platform 管理版本对齐,但不替代冲突解决。
- 给多模块工程提供一份可审查的依赖入口。
不要把版本目录当成“全局变量仓库”。它管理依赖坐标,不管理业务配置、签名密钥、构建开关和任务逻辑。
预编译脚本插件:多模块迁移的真正落点
大型 Android 工程迁移 Kotlin DSL 时,最糟糕的策略是把每个模块都改成一份巨大的 build.gradle.kts,然后在里面重复同样的 Android、Kotlin、Compose、测试、Lint、发布配置。
重复配置会带来三个问题:
- 一处规则变更要改几十个模块。
- 模块之间看似一致,实际细节慢慢漂移。
- 脚本越写越长,类型安全只剩语法收益,架构收益消失。
Gradle 给出的更稳健路径是预编译脚本插件。它本质上是放在 buildSrc 或独立 build-logic included build 里的 .gradle.kts 文件,Gradle 会把它编译成真正的插件类,并按文件名推导插件 id。
目录可以这样组织:
settings.gradle.kts
build-logic/
build.gradle.kts
src/main/kotlin/
zerobug.android.application.gradle.kts
zerobug.android.library.gradle.kts
zerobug.android.compose.gradle.kts
app/
build.gradle.kts
feature/feed/
build.gradle.kts
build-logic/build.gradle.kts:
plugins {
`kotlin-dsl`
}
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
dependencies {
implementation("com.android.tools.build:gradle:<agp-version>")
implementation("org.jetbrains.kotlin:kotlin-gradle-plugin:<kotlin-version>")
}
如果 build-logic 是独立 included build,它有自己的 settings 和依赖解析边界。可以把主构建的 libs.versions.toml 显式导入到 build-logic/settings.gradle.kts,也可以在 build-logic 内维护专门给构建逻辑使用的版本目录;不要默认认为主构建脚本里的 libs 会自动穿透到另一个构建里。
build-logic/src/main/kotlin/zerobug.android.library.gradle.kts:
plugins {
id("com.android.library")
id("org.jetbrains.kotlin.android")
}
android {
compileSdk = 36
defaultConfig {
minSdk = 26
}
}
业务模块就变得很薄:
plugins {
id("zerobug.android.library")
}
android {
namespace = "club.zerobug.feature.feed"
}
dependencies {
implementation(projects.core.model)
implementation(libs.androidx.core.ktx)
}
这才是 Kotlin DSL 对多模块 Android 工程最有价值的部分:模块脚本只描述模块差异,公共构建规则进入可编译、可测试、可复用的构建逻辑。
如果把每个模块想象成一条生产线,预编译脚本插件就是“标准设备安装包”。模块不用每次手工安装一遍工具链,只需要声明自己采用哪套标准设备,再补充本模块特有的参数。
迁移顺序:从边界最清晰的文件开始
一个成熟 Android 工程不应该一次性把所有 Gradle 文件改后缀。更合理的迁移路径是逐层收紧动态边界。
第一步:升级 Wrapper 与 AGP/Kotlin 组合
先确认 Gradle Wrapper、Android Gradle Plugin、Kotlin Gradle Plugin、Android Studio 之间的兼容矩阵。Android 官方文档提到,Android Studio Giraffe 起新项目默认使用 Kotlin DSL;如果使用 AGP 8.1 和 Kotlin DSL,Gradle 8.1 是更好的体验组合。当前版本还要以实际 AGP 发布说明为准。
这一步不是追新,而是避免一上来就在旧工具链上调试 Kotlin DSL 的历史缺陷。
第二步:迁移 settings.gradle
settings.gradle(.kts) 的职责最清晰:插件仓库、依赖仓库策略、版本目录、模块 include。它通常比模块脚本少很多 Android 插件细节,适合作为第一块迁移区域。
Groovy:
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = 'ZeroBugAndroid'
include ':app'
Kotlin:
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "ZeroBugAndroid"
include(":app")
第三步:建立版本目录和插件别名
把插件版本、库版本集中到 gradle/libs.versions.toml,让后续模块迁移可以直接使用 alias(libs.plugins...) 和 libs...。
这一步能提前暴露命名问题。比如 androidx-core-ktx 会生成 libs.androidx.core.ktx,过度嵌套的别名会让调用链变长。版本目录的别名应该服务于可读性,而不是照搬 Maven 坐标。
第四步:把公共构建逻辑抽到 build-logic
在多模块工程里,不要先逐个模块美化脚本,而是先识别重复模式:
Android application 模块
Android library 模块
Compose 模块
纯 Kotlin/JVM 模块
测试夹具模块
发布模块
每类模式对应一个 convention plugin。这样,迁移单个模块时只需要替换成:
plugins {
id("zerobug.android.library")
}
然后保留模块独有的 namespace、依赖和少量变体配置。
第五步:逐模块迁移,先叶子后核心
优先迁移依赖少、脚本简单的叶子模块。每迁移一个模块,执行:
./gradlew :module:help
./gradlew :module:assembleDebug
./gradlew :module:testDebugUnitTest
help 任务用于验证配置期,assembleDebug 验证 Android 构建链路,单元测试验证测试任务和依赖配置。不要等全部模块迁移完再第一次运行构建,那会把几十个独立错误压成一团。
常见迁移故障的底层原因
Kotlin DSL 迁移报错通常不是“语法不熟”,而是旧脚本里隐藏的动态假设被编译器照出来了。
Unresolved reference: android
典型原因:
apply(plugin = "com.android.library")
android {
namespace = "club.zerobug.core"
}
android 访问器没有生成,因为插件不是通过 plugins {} 早期声明应用的。优先改成:
plugins {
id("com.android.library")
}
android {
namespace = "club.zerobug.core"
}
如果是在自定义二进制插件里配置 Android 扩展,不能指望脚本访问器可用,应显式使用类型:
class AndroidLibraryConventionPlugin : Plugin<Project> {
override fun apply(project: Project) {
project.pluginManager.apply("com.android.library")
project.extensions.configure<LibraryExtension>("android") {
compileSdk = 36
}
}
}
脚本访问器服务的是脚本编译体验;二进制插件是独立编译的普通 Kotlin/Java 代码,要依赖明确的 Gradle/AGP API。
Cannot get property ... on extra properties extension
Groovy 里常见:
ext {
compileSdk = 36
}
android {
compileSdk rootProject.ext.compileSdk
}
Kotlin DSL 可以勉强这样读:
val compileSdk: Int by rootProject.extra
但这不是推荐迁移方向。extra 是运行时键值表,类型安全很弱,来源也容易失控。更稳健的做法是:
- SDK 版本进入 convention plugin。
- 依赖版本进入 Version Catalog。
- 环境变量和本地配置通过 Provider API 读取。
- 模块特有值留在模块脚本中。
Type mismatch 暴露了旧脚本的隐式转换
Groovy 经常允许这些模糊写法:
compileSdk "36"
minSdk "26"
Kotlin DSL 会要求真实类型:
compileSdk = 36
minSdk = 26
这不是迁移噪音,而是在修复构建模型的类型污染。构建脚本越依赖隐式转换,未来插件升级时越容易在运行期爆炸。
tasks.test {} 和 tasks.named<Test>("test") 的差异
Kotlin DSL 对核心插件任务可能生成访问器:
tasks.test {
useJUnitPlatform()
}
但在复杂工程里,更通用、更可控的写法是:
tasks.named<Test>("test") {
useJUnitPlatform()
}
named 不会为了配置一个可能不参与本次构建的任务而急着创建它。对于 Android 多变体任务尤其重要,因为 AGP 会按变体创建大量任务,配置期提前实现任务会直接拖慢同步和 CI。
性能权衡:编译脚本不是免费的
Android 官方博客和迁移文档都提醒过一个现实:Kotlin DSL 的脚本编译通常比 Groovy DSL 更慢,尤其是首次同步或脚本 classpath 改变时。
这不是偶然。Groovy DSL 更多依赖运行期动态分派;Kotlin DSL 则要做脚本编译、类型检查、访问器生成、字节码加载。它把很多错误提前发现,代价就是前面多了一段编译工作。
但 Kotlin DSL 也有对应的工程补偿:
- 脚本编译结果可以进入 Gradle 本地/远程缓存。
- 访问器让 IDE 更准确地补全、导航和重构。
- 类型错误更早暴露,减少 CI 后段失败。
- 配合 convention plugin,可以减少大量重复脚本,从源头降低脚本数量和脚本 classpath 变化。
因此,性能判断不能只比较“单个脚本第一次编译谁快”。更应该看长期工程成本:
首次迁移成本
+ 首次脚本编译成本
- 动态脚本运行期错误
- 重复配置漂移
- IDE 无法可靠重构的成本
- 多模块构建逻辑不可测试的成本
如果项目只有两个模块、脚本很稳定,迁移收益可能不急迫。若项目有几十个模块、多人维护、构建逻辑不断演进,Kotlin DSL + 版本目录 + convention plugin 的组合会明显降低长期复杂度。
一套可落地的 Android 迁移模板
下面是一套适合中大型 Android 工程的目标形态。
根 settings.gradle.kts:
pluginManagement {
includeBuild("build-logic")
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS")
rootProject.name = "ZeroBugAndroid"
include(":app")
include(":core:model")
include(":feature:feed")
根 build.gradle.kts:
plugins {
alias(libs.plugins.android.application) apply false
alias(libs.plugins.android.library) apply false
alias(libs.plugins.kotlin.android) apply false
}
build-logic/src/main/kotlin/zerobug.android.application.gradle.kts:
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
}
android {
compileSdk = 36
defaultConfig {
minSdk = 26
targetSdk = 36
}
}
app/build.gradle.kts:
plugins {
id("zerobug.android.application")
}
android {
namespace = "club.zerobug.app"
defaultConfig {
applicationId = "club.zerobug.app"
versionCode = 1
versionName = "1.0.0"
}
}
dependencies {
implementation(projects.core.model)
implementation(projects.feature.feed)
implementation(libs.androidx.core.ktx)
}
这套结构有几个工程性质上的好处:
settings.gradle.kts决定项目边界和仓库策略。- 根脚本只声明插件版本,不承载业务构建逻辑。
build-logic承载公共构建规则。- 模块脚本只保留模块身份、依赖和少量差异。
projects.core.model、libs.androidx.core.ktx、id("zerobug.android.application")都是可推理的输入,而不是散落字符串。
迁移完成后,构建脚本会从“每个模块各自写一套配置”变成“模块声明自己采用哪套构建约定”。这才是 Kotlin DSL 在 Android 工程里的架构价值。
迁移自查清单
迁移时可以按这张表逐项检查:
| 检查项 | 推荐状态 | 底层原因 |
|---|---|---|
| 插件应用 | 优先 plugins {} / alias(libs.plugins...) |
让 Gradle 在脚本主体编译前生成访问器 |
| 公共配置 | 进入 build-logic convention plugin |
避免多模块脚本重复和漂移 |
| 依赖坐标 | 进入 libs.versions.toml |
生成依赖访问器,集中治理版本 |
| 自定义配置 | 运行时创建后用 add("name", ...) |
访问器生成早于脚本主体执行 |
| 任务配置 | 优先 tasks.named<T>() / tasks.register<T>() |
保持配置规避,不提前实现任务 |
| 外部命令/文件读取 | 用 Provider API 延迟读取 | 避免配置期副作用破坏缓存和同步速度 |
extra 属性 |
尽量消除 | 运行时键值表削弱类型安全和来源追踪 |
| Android 布尔属性 | 使用 isMinifyEnabled 等 Kotlin 属性名 |
Kotlin JavaBean 映射规则 |
| Groovy 闭包插件 | 显式 configure<T>() 或暂留 Groovy 脚本 |
Kotlin 不能自动模拟所有 Groovy 动态语义 |
| 验证命令 | 每迁移一个模块就跑 help、构建、测试 |
避免错误堆叠到最后统一爆炸 |
总结:迁移的目标是让构建模型可被编译器审计
Kotlin DSL 的真正价值不是“Android Studio 补全更舒服”,而是把 Gradle 构建模型从动态脚本世界推进到可编译、可导航、可重构的工程世界。
它的底层逻辑可以浓缩成三句话:
plugins{} 先声明插件,让 Gradle 知道模型边界。
Gradle 根据已知模型生成 Kotlin 类型安全访问器。
Kotlin 编译器在脚本执行前审计构建逻辑。
一旦理解这条链路,迁移策略就不再是机械替换语法,而是一次构建架构整理:把插件版本集中,把依赖坐标集中,把公共规则下沉到 convention plugin,把模块脚本压薄,把配置期副作用迁移到 Provider 和任务模型里。
这样的构建脚本更像工业图纸,而不是个人手写流程。它要求你把边界说清楚,也因此能让 Gradle、IDE 和 CI 更早发现错误。
参考资料
- Gradle User Manual: Kotlin DSL Primer
- Gradle User Manual: Migrating build logic from Groovy to Kotlin
- Android Developers: Migrate your build configuration from Groovy to Kotlin
- Android Developers Blog: Kotlin DSL is Now the Default for New Gradle Builds
- Gradle User Manual: Pre-compiled Script Plugins
- Gradle User Manual: Version Catalogs
- Gradle source: KotlinScriptEvaluator
- Gradle source: ProjectAccessorsClassPathGenerator