Kotlin DSL 實戰:像 Compose 一樣寫代碼!
BATcoder技術 羣,讓一部分人先進大廠
大家好,我是劉望舒,騰訊最具價值專家,著有三本業內知名暢銷書,連續五年蟬聯電子工業出版社年度優秀作者,百度百科收錄的資深技術專家。
前華為面試官、獨角獸公司技術總監。
想要
加入
BATcoder技術羣,公號回覆
BAT
即可。
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:
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 的思路,我們可以進一步上 ImageView
的 parent
參數提到 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 中調用 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. 進一步優化你的 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 定義函數的例子, dp
是 Float
的擴展函數,所以已經有了一個 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 中使用效果如下:

TextView
的 layoutParams {...}
會根據父容器類型自動返回不同的 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
方法,分別用於 resId
和 drawable
的圖片設置。由於大部分代碼可以複用,我們抽出了一個 _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 這一種使用場景 ,但是實現思路都是相近的,最後再來一起回顧一下基本步驟:
-
使用帶有尾 lambda 的高階函數實現大括號的層級調用
-
為 lambda 添加 Receiver,通過 this 傳遞上下文
-
通過擴展函數優化代碼風格,DSL 中避免出現命令式的語義
-
infix 減少點號圓括號等符號的出現,提高可讀性
-
@DslMarker 限制 DSL 作用域,避免錯誤調用
-
Context Receivers 傳遞多個上下文代碼更智能(實驗語法未來有變動可能)
-
inline 提升性能,同時使用 @PublishedApi 避免不必要的代碼暴露
為了防止失聯,歡迎關注我的小號
微信改了推送機制,真愛請星標本公號 :point_down:
- 厲害了!自己寫個App 啟動任務框架
- 一個解決滑動衝突的新思路,做到視圖之間無縫地嵌套滑動!
- 谷歌官方改了兩次的知識點,你一定要知道!
- Android 最新架構詳解 | MVI = 響應式編程 單向數據流 唯一可信數據源 !
- 説兩件事~
- 最新的動畫布局來了,一文帶你瞭解!
- Gradle:你必須掌握的開發常見技巧~
- Kotlin DSL 實戰:像 Compose 一樣寫代碼!
- 厲害了,Android自定義樹狀圖控件來了!
- 一文帶你全面掌握Android組件化核心!
- 為什麼大廠開始全面轉向Compose?
- 谷歌限制俄羅斯使用Android系統,俄或將轉用 HarmonyOS!
- 鴻蒙OS、安卓、iOS測試對比,結果出乎意料!
- 最詳細的Android圖片壓縮攻略,讓你一次過足癮(建議收藏)
- Android字體漸變效果實戰!
- 攔截控件點擊 - 巧用ASM處理防抖!
- Android正確的保活方案,拒絕陷入需求死循環!
- 再見 MMKV,自己擼一個FastKV,快的一批
- 白嫖一個Android項目的類圖生成工具!(建議收藏)
- 日常需求做的挺好,面試就被底層原理放倒