Kotlin DSL 實戰(zhàn):像 Compose 那樣寫代碼

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 代碼

image
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 的思路,我們可以進一步上 ImageViewparent 參數提到 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 中調用 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. 進一步優(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 的方法會報錯如下:

image

@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 定義函數的例子,dpFloat 的擴展函數,所以已經有了一個 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 中使用效果如下:

image

TextViewlayoutParams {...} 會根據父容器類型自動返回不同的 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 方法,分別用于 resIddrawable 的圖片設置。由于大部分代碼可以復用,我們抽出了一個 _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 這一種使用場景 ,但是實現思路都是相近的,最后再來一起回顧一下基本步驟:

  1. 使用帶有尾 lambda 的高階函數實現大括號的層級調用
  2. 為 lambda 添加 Receiver,通過 this 傳遞上下文
  3. 通過擴展函數優(yōu)化代碼風格,DSL 中避免出現命令式的語義
  4. infix 減少點號圓括號等符號的出現,提高可讀性
  5. @DslMarker 限制 DSL 作用域,避免錯誤調用
  6. Context Receivers 傳遞多個上下文,代碼更智能(實驗語法,未來有變動可能)
  7. inline 提升性能,同時使用 @PublishedApi 避免不必要的代碼暴露
?著作權歸作者所有,轉載或內容合作請聯系作者
【社區(qū)內容提示】社區(qū)部分內容疑似由AI輔助生成,瀏覽時請結合常識與多方信息審慎甄別。
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發(fā)布,文章內容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務。

相關閱讀更多精彩內容

友情鏈接更多精彩內容