本文译自「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 | |
如果 lambda 是 only 参数,则可以完全删除括号:
1 2 3 4 5 6 7 | |
暂停一下第二个例子。 run { ... } 看起来不像函数调用。它看起来像一个语言关键字。就像带有块的“if”或“while”。这种错觉是每个 Kotlin DSL 的基础。当你在 Gradle 文件中看到以下内容时:
1 2 3 | |
…你正在查看一个名为“dependency”的函数,该函数使用单个 lambda 参数进行调用。就是这样。 Gradle 语法之谜已经解开了一半。
特性2:扩展功能
Kotlin 允许你向你不拥有的类型添加函数:
1 2 | |
“String”是标准库中的最终类,但我们只是给了它一个新方法。 (在底层,它编译为一个以字符串作为参数的静态函数,因此实际上没有修改任何类。)对于 DSL,这意味着你可以将域词汇附加到任何类型,包括来自你不控制的库的类型。
如果你是一名 Android 开发人员,那么你已经拥有了一大堆这些库:KTX 库(core-ktx、fragment-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 | |
你已经使用了它而没有注意到:mapOf("a" to 1) 中的to 不是语法。它是泛型类型的中缀扩展函数,返回“Pair”。
测试框架非常依赖于此。 Kotest 的“结果应该是 42”是一个中缀函数调用。
特性 4:运算符重载
Kotlin 允许你通过带有“operator”关键字的函数定义“+”、“-”、“[]”、“()”和友元对你的类型的含义:
1
| |
Gradle 的 Kotlin DSL 在 sourceSets["main"] 等地方使用了它。该括号访问是对类似集合的容器的“operator fun get(name: String)”调用。
特征 5:带有接收器的 Lambda 函数——大接收器
这就是功能。如果你理解了本节,你就理解了 Kotlin DSL。如果需要,请阅读两遍;之后的一切都取决于它。
带有参数的普通 lambda 看起来像这样:
1 2 3 4 | |
在 lambda 内部,我们必须通过参数 sb 来执行任何操作(或者_it_如果你没有为参数指定名称)。现在看看带有receiver版本的lambda:
1 2 3 4 | |
有两件事发生了变化:
- 类型从
(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 | |
查看调用站点。 一个块,你可以在其中“just say” append(...) 并且它可以工作。视线中没有物体,没有点,没有样板。感觉就像是一种用于字符串构建的特殊迷你语言。就是普通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 | |
每一层嵌套都会交换一个新的隐式接收器,从而交换一个新的词汇表。 这就是 DSL 获得结构的方式:类型系统本身决定在哪里允许什么。
Write
_body { body { } }_它不会编译。_Body_没有_body()_函数。你的“语言”有语法规则,由编译器强制执行,免费。
我们将在第 5 部分中准确构建这个 HTML DSL。但首先,让我们利用我们对带你来到这里的文件的新知识。
第 3 部分:“.kts”文件实际上是什么
构建 DSL 是一个设计练习,然后才是编码练习。食谱:
- 决定调用站点应该是什么样子。 首先编写梦想代码,就好像 DSL 已经存在一样。
- 识别接收者。 你梦想代码中的每个“{ }”块都需要一个类。该类的成员定义了块内的合法内容。
- 编写构建器函数来创建接收者对象,将用户的 lambda 应用于它,并向父级注册结果。
- 添加护栏(
@DslMarker- 即将推出),以便编译器强制执行你的语法。
我们梦想的代码:我们承诺的 HTML 构建器:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | |
我们需要发明三件事:
- 标签类别(接收者),
- 构建器函数(
html、body、p…)、 - 以及那个奇怪的
+"text"语法。
我们开始吧。
第 5 部分:逐步构建 HTML DSL
现在你可以了,更难的问题是:你应该吗?
**当你描述结构化、嵌套、声明性事物时,请使用 DSL:配置、UI 树、标记、路由表、测试场景、查询构建器。当代码的形状可以反映数据的形状时,当类型系统可以强制执行“什么去哪里”时,以及当许多人经常阅读或编写这些描述时,该模式就会发光。这正是 Gradle、Compose 和 Ktor 驻扎在这里的原因。
当逻辑是顺序的而不是结构化的(简单的函数更清晰)时,当只有一个调用站点时(抽象成本超过它节省的成本),或者当你纯粹受到美学的诱惑时,请跳过 DSL。 DSL 是一个带有意见的 API——每一个意见都是下一个开发人员必须学习的东西。问题“这个函数在哪里定义的?”以及“现在‘这个’是什么?”在深度嵌套的接收器范围内变得更难回答;好的 DSL 可以保持嵌套浅和词汇量小。与任何聪明的 API 一样,如果没有设计规范,DSL 的增长就会变成迷宫:调用站点的魔法意味着它下面的间接性。调试也继承了这种间接性——当 DSL 内部出现故障时,堆栈跟踪通常指向构建器内部而不是导致问题的调用站点,因此尽早投资于良好的错误消息。
一个合理的经验法则是:DSL 必须由很多人多次编写,或者阅读的次数远多于其实现 — 否则会编写出无聊的代码。
TL;DR——整篇文章六行
