Kotlin DSL 實戰:像 Compose 一樣寫程式碼
theme: vuepress highlight: androidstudio
1. 前言
Kotlin 是一門對 DSL 友好的語言,它的許多語法特性有助於 DSL 的打造,提升特定場景下程式碼的可讀性和安全性。本文將帶你瞭解 Kotlin DSL 的一般實現步驟,以及如何通過 @DslMarker
, Context Receivers
等特性提升 DSL 的易用性。
2. 什麼是 DSL?
DSL 全稱是 Domain Specific Language,即領域特定語言。顧名思義 DSL 是用來專門解決某一特定問題的語言,比如我們常見的 SQL 或者正則表示式等,DSL 沒有通用程式語言(Java、Kotlin等)那麼萬能,但是在特定問題的解決上更高效。
創作一套全新新語言的成本很高,所以很多時候我們可以基於已有的通用程式語言打造自己的 DSL,比如日常開發中我們將常見到 gradle 指令碼 ,其本質就是來自 Groovy 的一套 DSL:
groovy
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 語法則變成下面這樣,是下面這樣,在可讀性上的好壞立判:
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 達到上述好處,程式碼可能是下面這樣的: ```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 的高階函式中,我們按照這個思路改造下面程式碼
kotlin
LinearLayout(context).apply {
orientation = LinearLayout.HORIZONTAL
addView(ImageView(context))
}
我們為 LinearLayout 的建立定義一個高階函式,根據預設的 orientation
命名為 HorizontalLayout
以提高可讀性。另外我們模仿 Compose 的風格使用首字母大寫,讓 DSL 節點更具辨識度
kotlin
fun HorizontalLayout(context: Context, init: (LinearLayout) -> Unit) : LinearLayout {
return LinearLayout(context).apply {
orientation = LinearLayout.HORIZONTAL
init(this)
}
}
引數 init
是一個尾 lambda,傳入剛建立的 LinearLayout
物件,便於我們在大括號中為其進行初始化。我們為 ImageView
也定義類似的高階函式後,呼叫效果如下:
kotlin
HorizontalLayout(context) {
...
it.addView(ImageView(context) {
...
})
}
雖然避免了 apply
的出現,但是效果仍然差強人意。
4.2 通過 Receiver 傳遞上下文
前面經高階函式轉化後的 DSL 中大括號內必須藉助 it
進行初始化,而且 addView
的出現也難言優雅。
首先,我們可以將 lambda 的引數改為 Receiver,大括號中對 it
的引用可以變為 this
並直接省略:
kotlin
fun HorizontalLayout(context: Context, init: LinearLayout.() -> Unit) : LinearLayout {
return LinearLayout(context).apply {
orientation = LinearLayout.HORIZONTAL
init()
}
}
其次,我們如果能將 addView
隱藏到 ImageView
內部程式碼會更加簡潔,這需要 ImageView
持有它的父 View 的引用,我們可以將引數 context
換成 ViewGroup
kotlin
fun ImageView(parent: ViewGroup, init: ImageView.() -> Unit) {
parent.addView(ImageView(parent.context).apply(init))
}
由於不再需要返回例項給父 View,返回值也可以改為 Unit
了。
按照前面引數轉 Receiver 的思路,我們可以進一步上 ImageView
的 parent
引數提到 Receiver 的位置,實際就是改成 ViewGroup
的擴充套件函式:
kotlin
fun ViewGroup.ImageView(init: ImageView.() -> Unit) {
addView(ImageView(context).apply(init))
}
經過上面優化,DSL 中寫 ImageView
時無需再傳遞引數 context
,而且大括號中也不會出現 it
kotlin
HorizontalLayout {
...
ImageView {
...
}
}
4.3 擴充套件函式優化程式碼風格
View 的固有方法簽名都是為命令式語句設計的,不符合 DSL 的程式碼風格,此時可以藉助 Kotlin 的擴充套件函式進行重新定義。
那麼什麼是 DSL 應該有的程式碼風格? 雖然不同功能的 DSL 不能一概而論,但是它們大都是偏向於對結構的靜態描述,所以應該避免出現命令式的命名風格。
kotlin
fun View.onClick(l: (v: View) -> Unit) {
setOnClickListener(l)
}
比如上面這樣,通過擴充套件函式使用 onClick
優化 setOnClickListener
命名,而且引數中使用函式型別替代了原有的 OnClickListener
介面型別,在 DSL 寫起來更簡單。由於 OnClickListener
是一個 SAM 介面,所以優勢不夠明顯。下面的例子可能更能說明問題。
如果想在 DSL 中呼叫 TextView
的 addTextChangedListener
方法,寫法上將非常冗餘:
```kotlin 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 的擴充套件函式:
```kotlin 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 中使用的效果如下,清爽了不少
```kotlin Text { textChangedListener { beforeTextChanged { charSequence, i, i2, i3 -> //... }
onTextChanged { charSequence, i, i2, i3 ->
//...
}
afterTextChanged {
//...
}
}
} ```
5. 進一步優化你的 DSL
經過前面的優化我們的 DSL 基本達到了預期效果,接下來通過更多 Kotlin 的特性讓這套 DSL 更加好用。
5.1 infix 增強可讀性
Kotlin 的中綴函式可以讓函式省略圓點以及圓括號等程式符號,讓語句更自然,進一步提升可讀性。
比如所有的 View 都有 setTag
方法,正常使用如下:
kotlin
HorizontalLayout {
setTag(1,"a")
setTag(2,"b")
}
我們使用中綴函式來優化 setTag
的呼叫如下:
```kotlin class _Tag(val view: View) { infix fun Int.to(that: B) = view.setTag(this, that) }
fun View.tag(block: _Tag.() -> Unit) { _Tag(this).apply(block) } ```
DSL 中呼叫的效果如下:
kotlin
HorizontalLayout {
tag {
1 to "a"
2 to "b"
}
}
5.2 @DslMarker 限制作用域
```kotlin HorizontalLayout {// this: LinearLayout ... TextView {//this : TextView // 此處仍然可以呼叫 HorizontalLayout HorizontalLayout { ... } }
}
``
上述 DSL 程式碼,我們發現在
TextView {...}可以呼叫
HorizontalLayout {...},這顯示是不合邏輯的。由於
Text的作用域同時處於父
HorizontalLayout的作用域中,所以上面程式碼中,編譯器會認為其內部的
HorizontalLayout {...}是呼叫在
this@LinearLayout` 中不會報錯。缺少了編譯器的提醒,會增大出現 Bug 的機率
Kotlin 為 DSL 的使用場景提供了 @DslMarker
註解,可以對方法的作用域進行限制。添加註解的 lambda 中在省略 this
的隱式呼叫時只能訪問到最近的 Receiver 型別,當呼叫更外層的 Receiver 的方法會報錯如下:
@DslMarker
是一個元註解,我們需要基於它定義自己的註解
kotlin
@DslMarker
@Target(AnnotationTarget.TYPE)
annotation class ViewDslMarker
接著,在尾 lambda 的 Receiver 添加註解,如下:
kotlin
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 中釋出的新語法,它使函式定義時擁有多個 Receiver 成為可能。
```kotlin context(View) val Float.dp get() = this * [email protected]
class SomeView : View { val someDimension = 4f.dp } ```
上面程式碼是使用 Context Receivers 定義函式的例子,dp
是 Float
的擴充套件函式,所以已經有了一個 Receiver,在此基礎上,通過 context(View)
又增加了 View 作為 Receiver,可以通過 this@xxx
引用不同 Receiver 完成運算。
context 的新特性乍看起來好像沒啥用,但其實它對於 DSL 場景有很重要的意義,可以讓我們的程式碼變得更智慧。比如下面的例子
```kotlin 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
擴充套件函式:
```kotlin
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
型別,便於後續配置。
5.4 使用 inline 和 @PublishedApi 提高效能
DSL 的實現使用了大量高階函式,過多的 lambda 會產生過的匿名類,同時也會增加執行時物件建立的開銷,不少 DSL 選擇使用 inline
操作符,減少匿名類的產生,提高執行時效能。
比如為 ImageView
的定義新增 inline
:
kotlin
inline fun ViewGroup.ImageView(init: ImageView.() -> Unit) {
addView(ImageView(context).apply(init))
}
inline
函式內部呼叫的函式必須是 public
的,這會造成一些不必要的程式碼暴露,此時可以藉助 @PublishedApi
化解。
```kotlin //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 終於成型了,而且還經過了優化,看看最終的樣子: ```kotlin 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 傳遞上下文
- 通過擴充套件函式優化程式碼風格,DSL 中避免出現命令式的語義
- 使用 infix 減少點號圓括號等符號的出現,提高可讀性
- 使用 @DslMarker 限制 DSL 作用域,避免出錯
- 使用 Context Receivers 傳遞多個上下文,DSL 更聰明(非正式語法,未來有變動的可能)
- 使用 inline 提升效能,同時使用 @PublishedApi 避免不必要的程式碼暴露
- Android Studio Electric Eel 起支援手機投屏
- Compose 為什麼可以跨平臺?
- 一看就懂!圖解 Kotlin SharedFlow 快取系統
- 深入淺出 Compose Compiler(2) 編譯器前端檢查
- 深入淺出 Compose Compiler(1) Kotlin Compiler & KCP
- Jetpack MVVM七宗罪之三:在 onViewCreated 中載入資料
- 為什麼說 Compose 的宣告式程式碼最簡潔 ?Compose/React/Flutter/SwiftUI 語法對比
- Compose 型別穩定性註解:@Stable & @Immutable
- Fragment 這些 API 已廢棄,你還在使用嗎?
- 告別KAPT!使用 KSP 為 Kotlin 編譯提速
- 探索 Jetpack Compose 核心:深入 SlotTable 系統
- 盤點 Material Design 3 帶來的新變化
- Compose 動畫邊學邊做 - 夏日彩虹
- Google I/O :Android Jetpack 最新變化(二) Performance
- Google I/O :Android Jetpack 最新變化(一) Architecture
- Google I/O :Android Jetpack 最新變化(四)Compose
- Google I/O :Android Jetpack 最新變化(三)UI
- 一文看懂 Jetpack Compose 快照系統
- 聊聊 Kotlin 代理的“缺陷”與應對
- AAB 扶正!APK 再見!