Kotlin DSL 实战:像 Compose 一样写代码

简介: Kotlin DSL 实战:像 Compose 一样写代码

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

image.png

image.png

通过对比可以看到 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 {...} 是调用在 this@LinearLayout 中不会报错。缺少了编译器的提醒,会增大出现 Bug 的几率

Kotlin 为 DSL 的使用场景提供了 @DslMarker 注解,可以对方法的作用域进行限制。添加注解的 lambda 中在省略 this 的隐式调用时只能访问到最近的 Receiver 类型,当调用更外层的 Receiver 的方法会报错如下:

image.png

@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 中发布的新语法,它使函数定义时拥有多个 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 类型决定,上面代码中,我们在创建 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 中使用效果如下:

image.png

TextViewlayoutParams {...} 会根据父容器类型自动返回不同的 this 类型,便于后续配置。

5.4 使用 inline 和 @PublishedApi 提高性能

DSL 的实现使用了大量高阶函数,过多的 lambda 会产生过的匿名类,同时也会增加运行时对象创建的开销,不少 DSL 选择使用 inline 操作符,减少匿名类的产生,提高运行时性能。 比如为 ImageView 的定义添加 inline

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

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 传递多个上下文,DSL 更聪明(非正式语法,未来有变动的可能)
  7. 使用 inline 提升性能,同时使用 @PublishedApi 避免不必要的代码暴露
目录
相关文章
|
4月前
|
设计模式 Android开发 Kotlin
Android经典实战之Kotlin委托模式和by关键字
本文介绍了Kotlin中`by`关键字在类及属性委托中的运用,通过实例展示了如何利用类委托简化接口实现,以及如何借助标准与自定义属性委托管理属性的读写操作。通过`by`关键字的支持,Kotlin使得委托模式的实现更为直观且高效。
91 4
|
4月前
|
缓存 安全 Android开发
Android经典实战之用Kotlin泛型实现键值对缓存
本文介绍了Kotlin中泛型的基础知识与实际应用。泛型能提升代码的重用性、类型安全及可读性。文中详细解释了泛型的基本语法、泛型函数、泛型约束以及协变和逆变的概念,并通过一个数据缓存系统的实例展示了泛型的强大功能。
44 2
|
4月前
|
编译器 API Android开发
Android经典实战之Kotlin Multiplatform 中,如何处理不同平台的 API 调用
本文介绍Kotlin Multiplatform (KMP) 中使用 `expect` 和 `actual` 关键字处理多平台API调用的方法。通过共通代码集定义预期API,各平台提供具体实现,编译器确保正确匹配,支持依赖注入、枚举类处理等,实现跨平台代码重用与原生性能。附带示例展示如何定义跨平台函数与类。
116 0
|
3月前
|
安全 Java Android开发
探索安卓应用开发的新趋势:Kotlin和Jetpack Compose
在安卓应用开发领域,随着技术的不断进步,新的编程语言和框架层出不穷。Kotlin作为一种现代的编程语言,因其简洁性和高效性正逐渐取代Java成为安卓开发的首选语言。同时,Jetpack Compose作为一个新的UI工具包,提供了一种声明式的UI设计方法,使得界面编写更加直观和灵活。本文将深入探讨Kotlin和Jetpack Compose的特点、优势以及如何结合使用它们来构建现代化的安卓应用。
73 4
|
3月前
|
自然语言处理 Java 网络架构
解锁跨平台微服务新纪元:Micronaut与Kotlin联袂打造的多语言兼容服务——代码、教程、实战一次打包奉送!
【9月更文挑战第6天】Micronaut是一款轻量级、高性能的Java框架,适用于微服务开发。它支持Java、Groovy和Kotlin等多种语言,提供灵活的多语言开发环境。本文通过创建一个简单的多语言兼容服务,展示如何使用Micronaut及其注解驱动特性实现REST接口,并引入国际化支持。无论是个人项目还是企业应用,Micronaut都能提供高效、一致的开发体验,成为跨平台开发的利器。通过简单的配置和代码编写,即可实现多语言支持,展现其强大的跨平台优势。
54 3
|
4月前
|
算法 安全 数据安全/隐私保护
Android经典实战之常见的移动端加密算法和用kotlin进行AES-256加密和解密
本文介绍了移动端开发中常用的数据加密算法,包括对称加密(如 AES 和 DES)、非对称加密(如 RSA)、散列算法(如 SHA-256 和 MD5)及消息认证码(如 HMAC)。重点讲解了如何使用 Kotlin 实现 AES-256 的加密和解密,并提供了详细的代码示例。通过生成密钥、加密和解密数据等步骤,展示了如何在 Kotlin 项目中实现数据的安全加密。
148 1
|
4月前
|
算法 安全 数据安全/隐私保护
Android经典实战之常见的移动端加密算法和用kotlin进行AES-256加密和解密
本文介绍了移动端开发中常用的数据加密算法,包括对称加密(如 AES 和 DES)、非对称加密(如 RSA)、散列算法(如 SHA-256 和 MD5)及消息认证码(如 HMAC)。重点展示了如何使用 Kotlin 实现 AES-256 的加密和解密,提供了详细的代码示例。
79 2
|
4月前
|
Java 调度 Android开发
Android经典实战之Kotlin的delay函数和Java中的Thread.sleep有什么不同?
本文介绍了 Kotlin 中的 `delay` 函数与 Java 中 `Thread.sleep` 方法的区别。两者均可暂停代码执行,但 `delay` 适用于协程,非阻塞且高效;`Thread.sleep` 则阻塞当前线程。理解这些差异有助于提高程序效率与可读性。
82 1
|
3月前
|
API 数据处理 数据库
掌握 Kotlin Flow 的艺术:让无限数据流处理变得优雅且高效 —— 实战教程揭秘如何在数据洪流中保持代码的健壮与灵活
Kotlin Flow 是一个强大的协程 API,专为处理异步数据流设计。它适合处理网络请求数据、监听数据库变化等场景。本文通过示例代码展示如何使用 Kotlin Flow 管理无限流,如实时数据流。首先定义了一个生成无限整数的流 `infiniteNumbers()`,然后结合多种操作符(如 `buffer`、`onEach`、`scan`、`filter`、`takeWhile` 和 `collectLatest`),实现对无限流的优雅处理,例如计算随机数的平均值并在超过阈值时停止接收新数据。这展示了 Flow 在资源管理和逻辑清晰性方面的优势。
68 0
|
4月前
|
Android开发 C++ 开发者
Android经典实战之跨平台开发方案:Kotlin Multiplatform vs Flutter
本文对比了Kotlin Multiplatform与Flutter两大跨平台开发框架,从技术特性、性能、开发效率、UI体验、可扩展性及适用场景等维度进行了详尽分析,帮助开发者根据项目需求和技术背景选择最优方案。
149 2