稀有猿诉

十年磨一剑,历炼出锋芒,说话千百句,不如码二行。

Kotlin DSL深度解析:从Gradle脚本到构建你自己的DSL

本文译自「Kotlin DSL Deep Dive: From Gradle Scripts to Building Your Own」,原文链接https://proandroiddev.com/kotlin-dsl-deep-dive-from-gradle-scripts-to-building-your-own-8e08a1471467,由Ioannis Anifantakis发布于2026年7月2日。

引言

https://github.com/ioannisa/HtmlBuilder-Kotlin-DSL-Demo

重要提示:

本文中的所有代码都位于上面链接的配套项目中。

这是一个 Android 应用,它会在实时 WebView 中渲染 DSL 的输出,并提供一个交互式标签页,你可以在其中添加产品并查看第 5 部分中的循环重新渲染。

DSL 本身位于一个纯 Kotlin 文件中,没有任何 Android 导入,并且单元测试会逐字逐句地验证下面打印的每个输出。

第 1 部分:“DSL”的真正含义

有些语言是内部 DSL 的良好宿主;有些很糟糕。 Kotlin 恰好是最好的之一,这并非偶然 - JetBrains 在设计这些功能时明确考虑了 DSL。他们一共有五个,第五个是承担重任的人。

功能 1:尾随 lambda 语法

在 Kotlin 中,如果函数的最后一个参数是 lambda,你可以将其移到括号之外:

1
2
3
4
5
6
7
// These are the same call:
repeat(3, {
  println("Hi")
})
repeat(3) {
  println("Hi")
}

如果 lambda 是 only 参数,则可以完全删除括号:

1
2
3
4
5
6
7
// These are the same call:
run({
  println("Hi")
})
run {
  println("Hi")
}

暂停一下第二个例子。 run { ... } 看起来不像函数调用。它看起来像一个语言关键字。就像带有块的“if”或“while”。这种错觉是每个 Kotlin DSL 的基础。当你在 Gradle 文件中看到以下内容时:

1
2
3
dependencies {
    // ...
}

…你正在查看一个名为“dependency”的函数,该函数使用单个 lambda 参数进行调用。就是这样。 Gradle 语法之谜已经解开了一半。

特性2:扩展功能

Kotlin 允许你向你不拥有的类型添加函数:

1
2
fun String.shout() = this.uppercase() + "!!!"
"hello".shout()  // "HELLO!!!"

“String”是标准库中的最终类,但我们只是给了它一个新方法。 (在底层,它编译为一个以字符串作为参数的静态函数,因此实际上没有修改任何类。)对于 DSL,这意味着你可以将域词汇附加到任何类型,包括来自你不控制的库的类型。

如果你是一名 Android 开发人员,那么你已经拥有了一大堆这些库:KTX 库core-ktxfragment-ktx 等)本质上是此功能的产品化;将 Java Android API 封装在惯用的 Kotlin 中的扩展函数,因此你可以编写 view.isVisible = true 而不是 view.visibility = View.VISIBLE

KTX 和 DSL 是同一个工具箱中的近亲,但有着不同的目标:

  • KTX 使单次通话变得更好;
  • DSL 使用接收器(功能 5,即将推出)来构建嵌套结构

这条线在一些地方变得模糊——来自 core-ktx 的“sharedPreferences.edit { putString(“key”, value) }”是一个带有接收器的 lambda,一个单块迷你 DSL。

为了消除混淆,本文旨在解决:KTX 与 **.kts** 无关 — 一个是库命名约定,另一个是 Kotlin 脚本的文件扩展名,我们将在第 3 部分中介绍。

特性 3:中缀函数

可以在没有点和括号的情况下调用标记为“infix”的函数:

1
2
infix fun Int.times(str: String) = str.repeat(this)
3 times "Hey "  // "Hey Hey Hey "

你已经使用了它而没有注意到:mapOf("a" to 1) 中的to 不是语法。它是泛型类型的中缀扩展函数,返回“Pair”。

测试框架非常依赖于此。 Kotest 的“结果应该是 42”是一个中缀函数调用。

特性 4:运算符重载

Kotlin 允许你通过带有“operator”关键字的函数定义“+”、“-”、“[]”、“()”和友元对你的类型的含义:

1
operator fun File.plus(other: File): File = File(this, other.name)

Gradle 的 Kotlin DSL 在 sourceSets["main"] 等地方使用了它。该括号访问是对类似集合的容器的“operator fun get(name: String)”调用。

特征 5:带有接收器的 Lambda 函数——大接收器

这就是功能。如果你理解了本节,你就理解了 Kotlin DSL。如果需要,请阅读两遍;之后的一切都取决于它。

带有参数的普通 lambda 看起来像这样:

1
2
3
4
val greet: (StringBuilder) -> Unit = { sb ->
    sb.append("Hello")
    sb.append(", world")
}

在 lambda 内部,我们必须通过参数 sb 来执行任何操作(或者_it_如果你没有为参数指定名称)。现在看看带有receiver版本的lambda:

1
2
3
4
val greet: StringBuilder.() -> Unit = {
    append("Hello")
    append(", world")
}

有两件事发生了变化:

  • 类型从 (StringBuilder) -> Unit 变为 StringBuilder.() -> Unit。将其理解为“StringBuilder 上的函数”而不是“采用 StringBuilder 的函数”。匿名扩展函数。
  • 在 lambda 内部,参数消失了。我们直接调用 append(...) 而不是 sb.append(...)

为什么这样有效?因为在带有接收器的 lambda 中,**this** 是接收器对象。 lambda 的主体就像“StringBuilder”的方法一样执行。就像你不在类自己的方法中编写 this.append() 一样,你也不会在这里编写它。接收者是隐式的。

现在将其与尾随 lambda 语法结合起来。

这是一个微小但完整的 DSL 风格的函数。这是标准库的“buildString”使用的模式:

1
2
3
4
5
6
7
8
9
10
11
// DSL Definition
fun buildString(builderAction: StringBuilder.() -> Unit): String {
    val sb = StringBuilder()
    sb.builderAction()   // run the lambda with sb as the receiver
    return sb.toString()
}
// Call site
val message = buildString {
    append("Hello")
    append(", world")
}

查看调用站点。 一个块,你可以在其中“just sayappend(...) 并且它可以工作。视线中没有物体,没有点,没有样板。感觉就像是一种用于字符串构建的特殊迷你语言。就是普通Kotlin的三行。

这就是整个技巧。_ 你见过的每一个 Kotlin DSL(Gradle、Ktor 的路由、kotlinx.html)是接受带有接收器的 lambda 表达式的函数的某种排列,因此每个 _{ }_ block 会改变 _this_ 是什么,从而改变你可以“直接说”的函数。

Jetpack Compose 层位于顶部。一个编译器插件,重组,_@Composable_machinery。但它的作用域块,比如_RowScope_决定_weight()_合法,正是这个接收器技巧。

并且你一直在没有 DSL 标签的情况下使用它:

标准库的作用域函数(“apply”、“run”、“with”)是建立在其他任何东西之上的微型 DSL。 person.apply { name = "Ioannis" } 只是一个以 person 作为接收者的 lambda。

嵌套块只是嵌套接收器:

1
2
3
4
5
6
7
html {            // 'this' is an Html object  → you can call body()
    body {        // 'this' is a Body object   → you can call p()
        p {       // 'this' is a P object      → you can call text fns
            +"Hello"
        }
    }
}

每一层嵌套都会交换一个新的隐式接收器,从而交换一个新的词汇表。 这就是 DSL 获得结构的方式:类型系统本身决定在哪里允许什么。

Write _body { body { } }_ 它不会编译。 _Body_ 没有 _body()_ 函数。你的“语言”有语法规则,由编译器强制执行,免费。

我们将在第 5 部分中准确构建这个 HTML DSL。但首先,让我们利用我们对带你来到这里的文件的新知识。

第 3 部分:“.kts”文件实际上是什么

构建 DSL 是一个设计练习,然后才是编码练习。食谱:

  1. 决定调用站点应该是什么样子。 首先编写梦想代码,就好像 DSL 已经存在一样。
  2. 识别接收者。 你梦想代码中的每个“{ }”块都需要一个类。该类的成员定义了块内的合法内容。
  3. 编写构建器函数来创建接收者对象,将用户的 lambda 应用于它,并向父级注册结果。
  4. 添加护栏@DslMarker - 即将推出),以便编译器强制执行你的语法。

我们梦想的代码:我们承诺的 HTML 构建器:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
val page = html {
    head {
        title { +"My Page" }
    }
    body {
        h1 { +"Welcome" }
        p {
            +"This entire page is "
            b { +"plain Kotlin" }
            +"."
        }
    }
}
println(page.render())

我们需要发明三件事:

  • 标签类别(接收者),
  • 构建器函数(htmlbodyp…)、
  • 以及那个奇怪的 +"text" 语法。

我们开始吧。

第 5 部分:逐步构建 HTML DSL

现在你可以了,更难的问题是:你应该吗?

**当你描述结构化、嵌套、声明性事物时,请使用 DSL:配置、UI 树、标记、路由表、测试场景、查询构建器。当代码的形状可以反映数据的形状时,当类型系统可以强制执行“什么去哪里”时,以及当许多人经常阅读或编写这些描述时,该模式就会发光。这正是 Gradle、Compose 和 Ktor 驻扎在这里的原因。

逻辑是顺序的而不是结构化的(简单的函数更清晰)时,当只有一个调用站点时(抽象成本超过它节省的成本),或者当你纯粹受到美学的诱惑时,请跳过 DSL。 DSL 是一个带有意见的 API——每一个意见都是下一个开发人员必须学习的东西。问题“这个函数在哪里定义的?”以及“现在‘这个’是什么?”在深度嵌套的接收器范围内变得更难回答;好的 DSL 可以保持嵌套浅和词汇量小。与任何聪明的 API 一样,如果没有设计规范,DSL 的增长就会变成迷宫:调用站点的魔法意味着它下面的间接性。调试也继承了这种间接性——当 DSL 内部出现故障时,堆栈跟踪通常指向构建器内部而不是导致问题的调用站点,因此尽早投资于良好的错误消息。

一个合理的经验法则是:DSL 必须由很多人多次编写,或者阅读的次数远多于其实现 — 否则会编写出无聊的代码。

TL;DR——整篇文章六行

Comments