
1. 前言
Kotlin 是一門對 DSL 友好的語言,它的許多語法特性有助于 DSL 的打造,提升特定場景下代碼的可讀性和安全性。本文將帶你了解 Kotlin DSL 的一般實現步驟,以及如何通過 @DslMarker , Context Receivers 等特性提升 DSL 的易用性。
2. 什么是 DSL?
DSL 全稱是 Domain Specific Language,即領域特定語言。顧名思義 DSL 是用來專門解決某一特定問題的語言,比如我們常見的 SQL 或者正則表達式等,DSL 沒有通用編程語言(Java、Kotlin等)那么萬能,但是在特定問題的解決上更高效。
創(chuàng)作一套全新新語言的成本很高,所以很多時候我們可以基于已有的通用編程語言打造自己的 DSL,比如日常開發(fā)中我們將常見到 gradle 腳本 ,其本質就是來自 Groovy 的一套 DSL:
android {
compileSdkVersion 28
defaultConfig {
applicationId "com.my.app"
minSdkVersion 24
targetSdkVersion 30
versionCode 1
versionName "1.0"
testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner"
}
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
}
}
build.gradle 中我們可以用大括號表現層級結構,使用鍵值對的形式設置參數,沒有多余的程序符號,非常直觀。如果將其還原成標準的 Groovy 語法則變成下面這樣,是下面這樣,在可讀性上的好壞立判:
Android(30,
DefaultConfig("com.my.app",
24,
30,
1,
"1.0",
"android.support.test.runner.AndroidJUnitRunner"
)
),
BuildTypes(
Release(false,
getDefaultProguardFile('proguard-android-optimize.txt'),
'proguard-rules.pro'
)
)
除了 Groovy,Kotlin 也非常適合 DSL 的書寫,正因如此 Gradle 開始推薦使用 kts 替代 gradle,其實就是利用了 Kotlin 優(yōu)秀的 DSL 特性。
3. Kotlin DSL 及其優(yōu)勢
Kotlin 是 Android 的主要編程語言,因此我們可以在 Android 開發(fā)中發(fā)揮其 DSL 優(yōu)勢,提升特定場景下的開發(fā)效率。例如 Compose 的 UI 代碼就是一個很好的示范,它借助 DSL 讓 Kotlin 代碼具有了不輸于 XML 的表現力,同時還兼顧了類型安全,提升了 UI 開發(fā)效率。
普通的 Android View 也可以使用 DSL 進行描述。下面是一個簡單的 UI 布局,左邊是其對應的 XML 代碼,右邊是我們?yōu)槠湓O計的 Kotlin DSL 代碼

| XML | DSL |
|---|---|
![]() image
|
![]() image
|
通過對比可以看到 Kotin DSL 有諸多好處:
- 有著近似 XML 的結構化表現力
- 較少的字符串,更多的強類型,更安全
- linearLayoutParams 這樣的對象可以多次復用
- 可以在定義布局的同時實現 onClick 等
- 如果需要,還可以嵌入 if ,for 這樣的控制語句
倘若沒有 DSL ,我們想借助 Kotlin 達到上述好處,代碼可能是下面這樣的:
LinearLayout(context).apply {
addView(ImageView(context).apply {
image = context.getDrawable(R.drawable.avatar)
}, LinearLayout.LayoutParams(context, null).apply {...})
addView(LinearLayout(context).apply {
...
}, LinearLayout.LayoutParams(context,null).apply {...})
addView(Button(context).apply {
setOnClickListener {
...
}
}, LinearLayout.LayoutParams(0,0).apply {...})
}
雖然代碼已經借助 apply 等作用域函數進行了優(yōu)化,但寫起來仍然很繁瑣,這樣的代碼是完全無法替代 XML 的。
接下來,本文帶大家看看上述 DSL 是如何實現的,以及更進一步的優(yōu)化技巧
4. Kotlin 如何實現 DSL
4.1 高階函數實現大括號層級
常見的 DSL 都會用大括號來表現層級。Kotlin 的高階函數允許指定一個 lambda 類型的參數,且當 lambda 位于參數列表的最后位置時可以脫離圓括號,滿足 DSL 中的大括號語法要求。
我們知道了實現大括號語法的核心就是將對象創(chuàng)建及初始化邏輯封裝成帶有尾 lambda 的高階函數中,我們按照這個思路改造下面代碼
LinearLayout(context).apply {
orientation = LinearLayout.HORIZONTAL
addView(ImageView(context))
}
我們?yōu)?LinearLayout 的創(chuàng)建定義一個高階函數,根據預設的 orientation 命名為 HorizontalLayout 以提高可讀性。另外我們模仿 Compose 的風格使用首字母大寫,讓 DSL 節(jié)點更具辨識度
fun HorizontalLayout(context: Context, init: (LinearLayout) -> Unit) : LinearLayout {
return LinearLayout(context).apply {
orientation = LinearLayout.HORIZONTAL
init(this)
}
}
參數 init 是一個尾 lambda,傳入剛創(chuàng)建的 LinearLayout 對象,便于我們在大括號中為其進行初始化。我們?yōu)?ImageView 也定義類似的高階函數后,調用效果如下:
HorizontalLayout(context) {
...
it.addView(ImageView(context) {
...
})
}
雖然避免了 apply 的出現,但是效果仍然差強人意。
4.2 通過 Receiver 傳遞上下文
前面經高階函數轉化后的 DSL 中大括號內必須借助 it 進行初始化,而且 addView 的出現也難言優(yōu)雅。
首先,我們可以將 lambda 的參數改為 Receiver,大括號中對 it 的引用可以變?yōu)?this 并直接省略:
fun HorizontalLayout(context: Context, init: LinearLayout.() -> Unit) : LinearLayout {
return LinearLayout(context).apply {
orientation = LinearLayout.HORIZONTAL
init()
}
}
其次,我們如果能將 addView 隱藏到 ImageView 內部代碼會更加簡潔,這需要 ImageView 持有它的父 View 的引用,我們可以將參數 context 換成 ViewGroup
fun ImageView(parent: ViewGroup, init: ImageView.() -> Unit) {
parent.addView(ImageView(parent.context).apply(init))
}
由于不再需要返回實例給父 View,返回值也可以改為 Unit 了。
按照前面參數轉 Receiver 的思路,我們可以進一步上 ImageView 的 parent 參數提到 Receiver 的位置,實際就是改成 ViewGroup 的擴展函數:
fun ViewGroup.ImageView(init: ImageView.() -> Unit) {
addView(ImageView(context).apply(init))
}
經過上面優(yōu)化,DSL 中寫 ImageView 時無需再傳遞參數 context,而且大括號中也不會出現 it
HorizontalLayout {
...
ImageView {
...
}
}
4.3 擴展函數優(yōu)化代碼風格
View 的固有方法簽名都是為命令式語句設計的,不符合 DSL 的代碼風格,此時可以借助 Kotlin 的擴展函數進行重新定義。
那么什么是 DSL 應該有的代碼風格? 雖然不同功能的 DSL 不能一概而論,但是它們大都是偏向于對結構的靜態(tài)描述,所以應該避免出現命令式的命名風格。
fun View.onClick(l: (v: View) -> Unit) {
setOnClickListener(l)
}
比如上面這樣,通過擴展函數使用 onClick 優(yōu)化 setOnClickListener 命名,而且參數中使用函數類型替代了原有的 OnClickListener 接口類型,在 DSL 寫起來更簡單。由于 OnClickListener 是一個 SAM 接口,所以優(yōu)勢不夠明顯。下面的例子可能更能說明問題。
如果想在 DSL 中調用 TextView 的 addTextChangedListener 方法,寫法上將非常冗余:
TextView {
addTextChangedListener( object : TextWatcher {
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {
...
}
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {
...
}
override fun afterTextChanged(s: Editable?) {
...
}
})
為 TextView 新增適合 DSL 的擴展函數:
fun TextView.textChangedListener(init: _TextWatcher.() -> Unit) {
val listener = _TextWatcher()
listener.init()
addTextChangedListener(listener)
}
class _TextWatcher : android.text.TextWatcher {
private var _onTextChanged: ((CharSequence?, Int, Int, Int) -> Unit)? = null
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {
_onTextChanged?.invoke(s, start, before, count)
}
fun onTextChanged(listener: (CharSequence?, Int, Int, Int) -> Unit) {
_onTextChanged = listener
}
// beforeTextChanged 和 afterTextChanged 的相關代碼省略
}
DSL 中使用的效果如下,清爽了不少
Text {
textChangedListener {
beforeTextChanged { charSequence, i, i2, i3 ->
//...
}
onTextChanged { charSequence, i, i2, i3 ->
//...
}
afterTextChanged {
//...
}
}
}
5. 進一步優(yōu)化你的 DSL
經過前面的優(yōu)化我們的 DSL 基本達到了預期效果,接下來通過更多 Kotlin 的特性讓這套 DSL 更加好用。
5.1 infix 增強可讀性
Kotlin 的中綴函數可以讓函數省略圓點以及圓括號等程序符號,讓語句更自然,進一步提升可讀性。
比如所有的 View 都有 setTag 方法,正常使用如下:
HorizontalLayout {
setTag(1,"a")
setTag(2,"b")
}
我們使用中綴函數來優(yōu)化 setTag 的調用如下:
class _Tag(val view: View) {
infix fun <B> Int.to(that: B) = view.setTag(this, that)
}
fun View.tag(block: _Tag.() -> Unit) {
_Tag(this).apply(block)
}
DSL 中調用的效果如下:
HorizontalLayout {
tag {
1 to "a"
2 to "b"
}
}
5.2 @DslMarker 限制作用域
HorizontalLayout {// this: LinearLayout
...
TextView {//this : TextView
// 此處仍然可以調用 HorizontalLayout
HorizontalLayout {
...
}
}
}
上述 DSL 代碼,我們發(fā)現在 TextView {...} 可以調用 HorizontalLayout {...} ,這顯示是不合邏輯的。由于 Text 的作用域同時處于父 HorizontalLayout 的作用域中,所以上面代碼中,編譯器會認為其內部的 HorizontalLayout {...} 是調用在 this@LinearLayout 中不會報錯。缺少了編譯器的提醒,會增大出現 Bug 的幾率
Kotlin 為 DSL 的使用場景提供了 @DslMarker 注解,可以對方法的作用域進行限制。添加注解的 lambda 中在省略 this 的隱式調用時只能訪問到最近的 Receiver 類型,當調用更外層的 Receiver 的方法會報錯如下:

@DslMarker 是一個元注解,我們需要基于它定義自己的注解
@DslMarker
@Target(AnnotationTarget.TYPE)
annotation class ViewDslMarker
接著,在尾 lambda 的 Receiver 添加注解,如下:
fun ViewGroup.TextView(init: (@ViewDslMarker TextView).() -> Unit) {
addView(TextView(context).apply(init))
}
TextView {...} 中如果不寫 this. 則只能調用 TextView 的方法,如果想調用外層 Receiver 的方法,必須顯示的使用 this@xxx 調用
5.3 Context Receivers 傳遞多個上下文
Context Receivers 是剛剛在 Kotlin 1.6.20-M1 中發(fā)布的新語法,它使函數定義時擁有多個 Receiver 成為可能。
context(View)
val Float.dp
get() = this * this@View.resources.displayMetrics.density
class SomeView : View {
val someDimension = 4f.dp
}
上面代碼是使用 Context Receivers 定義函數的例子,dp 是 Float 的擴展函數,所以已經有了一個 Receiver,在此基礎上,通過 context(View) 又增加了 View 作為 Receiver,可以通過 this@xxx 引用不同 Receiver 完成運算。
context 的新特性乍看起來好像沒啥用,但其實它對于 DSL 場景有很重要的意義,可以讓我們的代碼變得更智能。比如下面的例子
fun View.dp(value: Int): Int = (value * context.resources.displayMetrics.density).toInt()
HorizontalLayout {
TextView {
layoutParams = LinearLayout.LayoutParams(context, null).apply {
width = dp(60)
height = 0
weight = 1.0
}
}
}
RelativeLayout {
TextView {
layoutParams = RelativeLayout.LayoutParams(context, null).apply {
width = dp(60)
height = ViewGroup.LayoutParams.WRAP_CONTENT
}
}
}
上面的代碼中有幾點可以使用 context 幫助改善。
首先,代碼中使用帶參數的 dp(60) 進行 dip 轉換。我們可以通過前面介紹的 context 語法替換為 60f.dp 這樣的寫法 ,避免括號的出現,寫起來更加舒適。
此外,我們知道 View 的 LayoutParams 的類型由其父 View 類型決定,上面代碼中,我們在創(chuàng)建 LayoutParams 時必須時刻留意類型是否正確,心理負擔很大。
這個問題也可以用 context 很好的解決,如下我們?yōu)?TextView 針對不同的 context 定義 layoutParams 擴展函數:
context(RelativeLayout)
fun TextView.layoutParams(block: RelativeLayout.LayoutParams.() -> Unit) {
layoutParams = RelativeLayout.LayoutParams(context, null).apply(block)
}
context(LinearLayout)
fun TextView.layoutParams(block: LinearLayout.LayoutParams.() -> Unit) {
layoutParams = LinearLayout.LayoutParams(context, null).apply(block)
}
在 DSL 中使用效果如下:

TextView 的 layoutParams {...} 會根據父容器類型自動返回不同的 this 類型,便于后續(xù)配置。
5.4 使用 inline 和 @PublishedApi 提高性能
DSL 的實現使用了大量高階函數,過多的 lambda 會產生過的匿名類,同時也會增加運行時對象創(chuàng)建的開銷,不少 DSL 選擇使用 inline 操作符,減少匿名類的產生,提高運行時性能。
比如為 ImageView 的定義添加 inline :
inline fun ViewGroup.ImageView(init: ImageView.() -> Unit) {
addView(ImageView(context).apply(init))
}
inline 函數內部調用的函數必須是 public 的,這會造成一些不必要的代碼暴露,此時可以借助 @PublishedApi 化解。
//resInt 指定圖片
inline fun ViewGroup.ImageView(resId: Int, init: ImageView.() -> Unit) {
_ImageView(init).apply { setImageResource(resId) }
}
//drawable 指定圖片
inline fun ViewGroup.ImageView(drawable: Drawable, init: ImageView.() -> Unit) {
_ImageView(init).apply { setImageDrawable(drawable) }
}
@PublishedApi
internal inline fun ViewGroup._ImageView(init: ImageView.() -> Unit) =
ImageView(context).apply {
this@_ImageView.addView(this)
init()
}
如上,為了方便 DSL 中使用,我們定義了兩個 ImageView 方法,分別用于 resId 和 drawable 的圖片設置。由于大部分代碼可以復用,我們抽出了一個 _ImageView 方法。但是由于要在 inline 方法中使用,所以編譯器要求 _ImageView 必須是 public 類型。_ImageView 只需在庫的內部服務,所以可以添加為 internal 的同時加 @PublishdApi 注解,它允許一個模塊內部方法在 inline 中使用,且編譯器不會報錯。
6. 總結
經過上述幾個步驟,我們的 DSL 終于成型了,而且還經過了優(yōu)化,看看最終的樣子:
val linearLayoutParams = LinearLayout.LayoutParams(context, null).apply {
width = MATCH_PARENT
height = WRAP_CONTENT
}
HorizontalLayout {
ImageView(R.drawable.avatar) {
layoutParams {
width = 60f.dp
height = MATCH_PARENT
}
}
VerticalLayout {
Text("Andy Rubin") {
textSize = 18.dp
layoutParams = linearLayoutParams
}
Text("American computer programmer") {
textSize = 14f.dp
layoutParams = linearLayoutParams
}
layoutParams {
width = dip(0)
height = MATCH_PARENT
weight = 1f
gravity = Grivaty.CENTER
}
}
Button("Follow") {
onClick {
//...
}
layoutParams {
width = 120f.dp
height = MATCH_PARENT
}
}
layoutParams = linearLayoutParams
}
當然 Android 中 DSL 遠不止 UI 這一種使用場景 ,但是實現思路都是相近的,最后再來一起回顧一下基本步驟:
- 使用帶有尾 lambda 的高階函數實現大括號的層級調用
- 為 lambda 添加 Receiver,通過 this 傳遞上下文
- 通過擴展函數優(yōu)化代碼風格,DSL 中避免出現命令式的語義
- infix 減少點號圓括號等符號的出現,提高可讀性
- @DslMarker 限制 DSL 作用域,避免錯誤調用
- Context Receivers 傳遞多個上下文,代碼更智能(實驗語法,未來有變動可能)
- inline 提升性能,同時使用 @PublishedApi 避免不必要的代碼暴露

