MENU

在 Kotlin 中运用 DSL

November 17, 2021 • 开发

从 Wiki 开始

领域特定语言(domain-specific language) 指的是专注于某个应用程序领域的计算机语言。不同于普通的跨领域通用计算机语言(GPL),领域特定语言只用在某些特定的领域。

Wiki:「我只想说懂得都懂,不懂的我也不多解释」
散了散了,读完 DSL 的 Wiki 发现解释了个寂寞。事实上网络上解释 DSL 定义的文章很多,但大多数表达的十分抽象。由于这部分不在我们的讨论范围以内,在此不多提及。有兴趣的同学可以自行了解。
DSL 很少有人能讲清楚、听明白,这也是其本身的特点决定的。事实上 DSL 离我们近得很,你可能没听过 DSL,但你一定用过 DSL:SQL、正则表达式。更近一点的:Gradle
让我们从每天都要打交道的 Gradle 开始看起:

plugins {
    id 'com.android.application'
    id 'org.jetbrains.kotlin.android'
}

android {
    compileSdk 31

    defaultConfig {
        applicationId "com.devmcry.kotlindsl"
        minSdk 23
        targetSdk 31
        versionCode 1
        versionName "1.0"

        testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
    }

    buildTypes {
        release {
            minifyEnabled false
            proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
        }
    }
    compileOptions {
        sourceCompatibility JavaVersion.VERSION_1_8
        targetCompatibility JavaVersion.VERSION_1_8
    }
    kotlinOptions {
        jvmTarget = '1.8'
    }
    buildFeatures {
        viewBinding true
    }
}

dependencies {

    implementation 'androidx.core:core-ktx:1.7.0'
    implementation 'androidx.appcompat:appcompat:1.3.1'
    implementation 'com.google.android.material:material:1.4.0'
    implementation 'androidx.constraintlayout:constraintlayout:2.1.1'
    implementation 'androidx.navigation:navigation-fragment-ktx:2.3.5'
    implementation 'androidx.navigation:navigation-ui-ktx:2.3.5'
    testImplementation 'junit:junit:4.13.2'
    androidTestImplementation 'androidx.test.ext:junit:1.1.3'
    androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0'
}

(一个普通的 build.gradle)
当我们在写 build.gradle 的时候,实际上就是在使用 Groovy 语言的内部 DSL 来完成一个 Android 项目的构建。
DSL 表达形式简洁、相对简单的上手、屏蔽了内部的具体实现、这些都大大降低了调用者的学习成本,以及后续人员的维护成本

目前在 Android Studio 创建新项目(Gradle Plugin Version 4.0+)你会发现用了好多年的 build.gradle 不见了,取而代之的是 build.gradle.kts。,你几乎不会感知到切换语言对你后续工作展开所带来的任何困扰。详细信息可查阅 将构建配置从 Groovy 迁移到 KTS 这篇官方文档。

这将是一个漫长的迁移过程,但可以证明 Kotlin DSL 不是花里花哨的小把戏,而是实实在在对开发有所帮助的。现在让 gradle 自己玩会儿,我们再去看看大佬们刚刚分析过的两大框架:CoilCompose。看过这两份重量级框架的部分源码,相信更能证明我们有必要了解、掌握 DSL,并学习写出这种风格的代码。

Coil、Compose 中的 DSL

imageView.load("https://www.example.com/image.jpg") {
    crossfade(true)
    placeholder(R.drawable.image)
    transformations(CircleCropTransformation())
}

(一段白嫖来的代码)
在加载图片的场景里往往配置项众多,就像上面提到的动画、占位、裁切等等。在 Glide 里使用了链式调用的风格:

Glide.with(context)
    .load("https://www.example.com/image.jpg")
    .placeholder(R.drawable.image))
    ...
    .into(imageView)

已经足够简便易懂了对吧,但和 Coil 相比还是稍差一筹。随着调用项数量的提升,这种差距也会更加明显。
现在让我们进入 load() 方法看看:

@JvmSynthetic
inline fun ImageView.load(
    uri: String?,
    imageLoader: ImageLoader = context.imageLoader,
    builder: ImageRequest.Builder.() -> Unit = {}
): Disposable = loadAny(uri, imageLoader, builder)

我们要关注的就是这个 builder。查看 ImageRequest.Builder 内部的代码可以得知这就是一个普通的 builder 模式的产物,那么到底是什么让 Coil 语法风格与 Glide 的不同呢?没错,关键就在于:ImageRequest.Builder.() -> Unit
这是一个 lambda,在它的作用域内以 ImageRequest.Builder 作为上下文,lambda 内的调用实际上都是对 ImageRequest.Builder 的内部成员方法、变量的调用。通过 lambda 完成了 builder 的配置后,接下来会在 loadAny() 的最后调用 builder.build(),这样便使用 DSL 构建了一个完整的图片加载请求。

@JvmSynthetic
inline fun ImageView.loadAny(
    data: Any?,
    imageLoader: ImageLoader = context.imageLoader,
    builder: ImageRequest.Builder.() -> Unit = {}
): Disposable {
    val request = ImageRequest.Builder(context)
        .data(data)
        .target(this)
        .apply(builder)
        .build()
    return imageLoader.enqueue(request)
}

回过头来再看看 Compose,你会发现它也用到了 DSL:

@Composable
fun PhotographerCard() {
    Row {
        Surface(
            modifier = Modifier.size(50.dp),
            shape = CircleShape,
            color = MaterialTheme.colors.onSurface.copy(alpha = 0.2f)
        ) {
            // Image goes here
        }
        Column {
            Text("Alfred Sisley", fontWeight = FontWeight.Bold)
            CompositionLocalProvider(LocalContentAlpha provides ContentAlpha.medium) {
                Text("3 minutes ago", style = MaterialTheme.typography.body2)
            }
        }
    }
}

(Compose 编写 UI)

@Composable
inline fun Column(
    modifier: Modifier = Modifier,
    verticalArrangement: Arrangement.Vertical = Arrangement.Top,
    horizontalAlignment: Alignment.Horizontal = Alignment.Start,
    content: @Composable ColumnScope.() -> Unit
) {
    val measurePolicy = columnMeasurePolicy(verticalArrangement, horizontalAlignment)
    Layout(
        content = { ColumnScopeInstance.content() },
        measurePolicy = measurePolicy,
        modifier = modifier
    )
}

(Compose Column 布局代码)

动手

将你的回调改为 DSL 风格

假设现在有这样一个需求需要将 View 的 TouchEvent 封装,最后对外提供一个各种手势的回调,按照以往的思路应该写这样的一个接口:

interface ViewGestureListener {
    // 点击
    fun onClick()
    // 长按
    fun onLongPress()
    // 双击
    fun onDoubleTap()
    // 双指缩放
    fun onScale()
    // 双指旋转
    fun onRotate()
    // 拖动
    fun onDrag()
}

然后 View 提供一个 setViewGestureListener(listenr: ViewGestureListener) 方法。在识别手势时分别调用以上方法。
然而不同场景下响应的手势大多不同,调用者完全没必要实现每种手势,随着后续手势的不断增加,这酸爽简直了。
这时我们就可以将 ViewGestureListener 改造为 DSL 风格:

inner class ViewGestureAction {
    internal var onClickAction: (() -> Unit)? = null
    internal var onLongPressAction: (() -> Unit)? = null
    internal var onDoubleTapAction: (() -> Unit)? = null
    internal var onScaleAction: (() -> Unit)? = null
    internal var onRotateAction: (() -> Unit)? = null
    internal var onDragAction: (() -> Unit)? = null

    fun onClick(action: (() -> Unit)?) {
        onClickAction = action
    }

    fun onLongPress(action: (() -> Unit)?) {
        onLongPressAction = action
    }

    fun onDoubleTap(action: (() -> Unit)?) {
        onDoubleTapAction = action
    }

    fun onScale(action: (() -> Unit)?) {
        onScaleAction = action
    }

    fun onRotate(action: (() -> Unit)?) {
        onRotateAction = action
    }

    fun onDrag(action: (() -> Unit)?) {
        onDragAction = action
    }
}

View 同样需要提供一个 setViewGestureAction(action: ViewGestureAction.() -> Unit) 方法

fun setViewGestureAction(action: ViewGestureAction.() -> Unit) {
    gestureAction = ViewGestureAction().apply(action)
}

最后让我们对比一下调用代码:

// Interface
setViewGestureListener(object : CustomGestureView.ViewGestureListener {
    override fun onClick() {
        // do nothing
    }

    override fun onLongPress() {
        // do nothing
    }

    override fun onDoubleTap() {
        // do nothing
    }

    override fun onScale() {
        Log.d(TAG, "onScale")
    }

    override fun onRotate() {
        Log.d(TAG, "onRotate")
    }

    override fun onDrag() {
        Log.d(TAG, "onDrag")
    }
})
// DSL
setViewGestureAction {
    onClick {
        Log.d(TAG, "onClick")
    }
    onLongPress {
        Log.d(TAG, "onLongPress")
    }
    onDoubleTap {
        Log.d(TAG, "onDoubleTap")
    }
}

更优雅的 DSL

infix 中缀调用
利用 infix 修饰符,可以写出更加 DSL 的代码。

NetDsl init {
    okHttp {
        cookieJar { TODO() }
        applicationInterceptor { TODO() }
        applicationInterceptor { TODO() }
        networkInterceptor { TODO() }
        dns { TODO() }
        custom {
            connectTimeout(30, TimeUnit.SECONDS)
            retryOnConnectionFailure(true)
        }
    }
    retrofit {
        baseUrl { "https://www.timeapi.io" }
        // callAdapter { TODO() }
        converter { GsonConverterFactory.create() }
    }
}

infix fun NetDsl.init(block: (NetDsl.() -> Unit)?) {
    block?.invoke(this)
}

invoke约定
如果一个类中定义了使用 operator 修饰符的 invoke 方法,就可以被当作函数一样调用该类的实例。
setViewGestureAction(action: ViewGestureAction.() -> Unit) 为例:

inner class ViewGestureAction {
    // new added
    operator fun invoke(action: ViewGestureAction.() -> Unit): ViewGestureAction {
        return apply(action)
    }
}
fun setViewGestureAction(action: ViewGestureAction.() -> Unit) {
    // gestureAction = ViewGestureAction().apply(action)
    // 可以改写为
    gestureAction = ViewGestureAction()(action)
}    

乍看起来意义不大,但在复杂的 DSL 中是很有必要的。不然就等着被遍地都是的 invoke 教育吧。

尾巴

我们介绍了 DSL 风格的赋值以及回调写法,可以看出核心思路与 builder 基本一致。本文讲述的不深,给人一种都是些语法糖级别的内容,后面我应该会写几个 DSL 风格封装的库。真正展现 DSL 写法迷人的地方。