Kotlin DSL 實戰:像 Compose 一樣寫程式碼!

語言: CN / TW / HK

 BATcoder技術 群,讓一部分人先進大廠

大家好,我是劉望舒,騰訊最具價值專家,著有三本業內知名暢銷書,連續五年蟬聯電子工業出版社年度優秀作者,百度百科收錄的資深技術專家。

前華為面試官、獨角獸公司技術總監。

想要 加入  BATcoder技術群,公號回覆 BAT  即可。

1. 前言

Kotlin 是一門對 DSL 友好的語言,它的許多語法特性有助於 DSL 的打造,提升特定場景下程式碼的可讀性和安全性。本文將帶你瞭解 Kotlin DSL 的一般實現步驟,以及如何通過 @DslMarkerContext Receivers 等特性提升 DSL 的易用性。

2. 什麼是 DSL?

DSL 全稱是 Domain Specific Language ,即領域特定語言。顧名思義 DSL 是用來專門解決某一特定問題的語言,比如我們常見的 SQL 或者正則表示式等,DSL 沒有通用程式語言(Java、Kotlin等)那麼萬能,但是在特定問題的解決上更高效。

創作一套全新新語言的成本很高,所以很多時候我們可以基於已有的通用程式語言打造自己的 DSL,比如日常開發中我們將常見到 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 優秀的 DSL 特性。

3. Kotlin DSL 及其優勢

Kotlin 是 Android 的主要程式語言,因此我們可以在 Android 開發中發揮其 DSL 優勢,提升特定場景下的開發效率。例如 Compose 的 UI 程式碼就是一個很好的示範,它藉助 DSL 讓 Kotlin 程式碼具有了不輸於 XML 的表現力,同時還兼顧了型別安全,提升了 UI 開發效率。

普通的 Android View 也可以使用 DSL 進行描述。下面是一個簡單的 UI 佈局,左邊是其對應的 XML 程式碼,右邊是我們為其設計的 Kotlin DSL 程式碼

XML DSL

通過對比可以看到 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 等作用域函式進行了優化,但寫起來仍然很繁瑣,這樣的程式碼是完全無法替代 XML 的。

接下來,本文帶大家看看上述 DSL 是如何實現的,以及更進一步的優化技巧

4. Kotlin 如何實現 DSL

4.1 高階函式實現大括號層級

常見的 DSL 都會用大括號來表現層級。Kotlin 的高階函式允許指定一個 lambda 型別的引數,且當 lambda 位於引數列表的最後位置時可以脫離圓括號,滿足 DSL 中的大括號語法要求。

我們知道了實現大括號語法的核心就是將物件建立及初始化邏輯封裝成帶有尾 lambda 的高階函式中,我們按照這個思路改造下面程式碼

LinearLayout(context).apply {
orientation = LinearLayout.HORIZONTAL
addView(ImageView(context))
}

我們為 LinearLayout 的建立定義一個高階函式,根據預設的 orientation 命名為 HorizontalLayout 以提高可讀性。另外我們模仿 Compose 的風格使用首字母大寫,讓 DSL 節點更具辨識度

fun HorizontalLayout(context: Context, init: (LinearLayout) -> Unit) : LinearLayout {
return LinearLayout(context).apply {
orientation = LinearLayout.HORIZONTAL
init(this)
}
}

引數 init 是一個尾 lambda,傳入剛建立的 LinearLayout 物件,便於我們在大括號中為其進行初始化。我們為 ImageView 也定義類似的高階函式後,呼叫效果如下:

HorizontalLayout(context) {
...
it.addView(ImageView(context) {
...
})
}

雖然避免了 apply 的出現,但是效果仍然差強人意。

4.2 通過 Receiver 傳遞上下文

前面經高階函式轉化後的 DSL 中大括號內必須藉助 it 進行初始化,而且 addView 的出現也難言優雅。首先,我們可以將 lambda 的引數改為 Receiver,大括號中對 it 的引用可以變為 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 的思路,我們可以進一步上 ImageViewparent 引數提到 Receiver 的位置,實際就是改成 ViewGroup 的擴充套件函式:

fun ViewGroup.ImageView(init: ImageView.() -> Unit) {
addView(ImageView(context).apply(init))
}

經過上面優化,DSL 中寫 ImageView 時無需再傳遞引數 context ,而且大括號中也不會出現 it

HorizontalLayout {
...
ImageView {
...
}
}

4.3 擴充套件函式優化程式碼風格

View 的固有方法簽名都是為命令式語句設計的,不符合 DSL 的程式碼風格,此時可以藉助 Kotlin 的擴充套件函式進行重新定義。

那麼什麼是 DSL 應該有的程式碼風格? 雖然不同功能的 DSL 不能一概而論,但是它們大都是偏向於對結構的靜態描述,所以應該避免出現命令式的命名風格。

fun View.onClick(l: (v: View) -> Unit) {
setOnClickListener(l)
}

比如上面這樣,通過擴充套件函式使用 onClick 優化 setOnClickListener 命名,而且引數中使用函式型別替代了原有的 OnClickListener 介面型別,在 DSL 寫起來更簡單。由於 OnClickListener 是一個 SAM 介面,所以優勢不夠明顯。下面的例子可能更能說明問題。

如果想在 DSL 中呼叫 TextViewaddTextChangedListener 方法,寫法上將非常冗餘:

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. 進一步優化你的 DSL

經過前面的優化我們的 DSL 基本達到了預期效果,接下來通過更多 Kotlin 的特性讓這套 DSL 更加好用。

5.1 infix 增強可讀性

Kotlin 的中綴函式可以讓函式省略圓點以及圓括號等程式符號,讓語句更自然,進一步提升可讀性。比如所有的 View 都有 setTag 方法,正常使用如下:

HorizontalLayout {
setTag(1,"a")
setTag(2,"b")
}

我們使用中綴函式來優化 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 程式碼,我們發現在 TextView {...} 可以呼叫 HorizontalLayout {...} ,這顯示是不合邏輯的。由於 Text 的作用域同時處於父 HorizontalLayout 的作用域中,所以上面程式碼中,編譯器會認為其內部的 HorizontalLayout {...} 是呼叫在 [email protected] 中不會報錯。缺少了編譯器的提醒,會增大出現 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 的方法,必須顯示的使用 [email protected] 呼叫

5.3 Context Receivers 傳遞多個上下文

Context Receivers 是剛剛在 Kotlin 1.6.20-M1 中釋出的新語法,它使函式定義時擁有多個 Receiver 成為可能。

context(View)
val Float.dp
get() = this * this@View.resources.displayMetrics.density

class SomeView : View {
val someDimension = 4f.dp
}

上面程式碼是使用 Context Receivers 定義函式的例子, dpFloat 的擴充套件函式,所以已經有了一個 Receiver,在此基礎上,通過 context(View) 又增加了 View 作為 Receiver,可以通過 [email protected] 引用不同 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 型別決定,上面程式碼中,我們在建立 LayoutParams 時必須時刻留意型別是否正確,心理負擔很大。

這個問題也可以用 context 很好的解決,如下我們為 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 中使用效果如下:

TextViewlayoutParams {...} 會根據父容器型別自動返回不同的 this 型別,便於後續配置。

5.4 使用 inline 和 @PublishedApi 提高效能

DSL 的實現使用了大量高階函式,過多的 lambda 會產生過的匿名類,同時也會增加執行時物件建立的開銷,不少 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 方法,分別用於 resIddrawable 的圖片設定。由於大部分程式碼可以複用,我們抽出了一個   _ImageView 方法。但是由於要在 inline 方法中使用,所以編譯器要求 _ImageView 必須是 public 型別。 _ImageView 只需在庫的內部服務,所以可以新增為 internal 的同時加 @PublishdApi 註解,它允許一個模組內部方法在 inline 中使用,且編譯器不會報錯。

6. 總結

經過上述幾個步驟,我們的 DSL 終於成型了,而且還經過了優化,看看最終的樣子:

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 這一種使用場景 ,但是實現思路都是相近的,最後再來一起回顧一下基本步驟:

  1. 使用帶有尾 lambda 的高階函式實現大括號的層級呼叫

  2. 為 lambda 新增 Receiver,通過 this 傳遞上下文

  3. 通過擴充套件函式優化程式碼風格,DSL 中避免出現命令式的語義

  4. infix 減少點號圓括號等符號的出現,提高可讀性

  5. @DslMarker 限制 DSL 作用域,避免錯誤呼叫

  6. Context Receivers 傳遞多個上下文程式碼更智慧(實驗語法未來有變動可能)

  7. inline 提升效能,同時使用 @PublishedApi 避免不必要的程式碼暴露