DSL 全称是 Domain Specific Language,即领域特定语言。顾名思义 DSL 是用来专门解决某一特定问题的语言,比如我们常见的 SQL 或者正则表达式等,DSL 没有通用编程语言(Java、Kotlin等)那么万能,但是在特定问题的解决上更高效。
设想以下有这样一种场景,如果我们希望其他人跟随我们既定的规则,排除定义接口、类、方法这种传统方式,我们能怎么实现呢?
自己去开发一门语言,难度可想而知。其实可以转换一下思路,我们可以基于已有的通用编程语言打造自己的 DSL,比如日常开发中我们将常见到 gradle 脚本 ,其本质就是来自 Groovy 的一套 DSL:
android {
compileSdkVersion 30
defaultConfig {
applicationId "x.xx.xxx"
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'
}
}
}
使用大括号表现层级结构,使用键值对的形式设置参数,没有多余的程序符号,非常直观。还原成标准的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')
)
我们可以对比一下上下两段代码,很明显上面一段的代码可读性更高!
目前Gradle已经 开始推荐使用 kts 替代 gradle,其实就是利用了 Kotlin 优秀的 DSL 特性。
Kotlin 早几年已经被Google作为Android应用软件开发的主要编程语言,在很多场景下,我们已经看到了DSL在 Android 开发中发挥的优势,并且可以有效地提升开发效率。例如 Jetpack Compose 的 UI 代码就是一个很好的示范,它借助 DSL 让 Kotlin 代码具有了不输于 XML 的表现力,同时还兼顾了类型安全,提升了 UI 开发效率。
XML布局
Compose DSL布局
通过对比可以看到 Kotin DSL 有诸多好处:
在没有DSL之前,如果我们想要实现这样的效果,代码可能会写成这样,如下:
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 等作用域函数进行了优化,但写起来仍然很繁琐,同时阅读起来也比较困难。
小结:
通过上面的代码实例,我们已经能感受到Kotlin DSL带来的冲击,并且伴随着Jetpack Compose的快速迭代,所带来的性能提升、即时预览等等,在不远的未来传统的xml布局开发方式,也会被取代(Android平台Java基本已经被Kotlin取代)。
常见的 DSL 都会用大括号来表现层级。Kotlin 的高阶函数允许指定一个 lambda 类型的参数,且当 lambda 位于参数列表的最后位置时可以脱离圆括号,满足 DSL 中的大括号语法要求。
那么我们不妨先尝试去改造一下下面这段代码:
LinearLayout(context).apply {
orientation = LinearLayout.HORIZONTAL
addView(ImageView(context))
}
为LinearLayout定义一个高阶函数HorizontalLayout,用于表示水平布局,代码如下:
//函数定义
fun HorizontalLayout(context: Context, init: (LinearLayout) -> Unit) : LinearLayout {
return LinearLayout(context).apply {
orientation = LinearLayout.HORIZONTAL
init(this)
}
}
//函数调用
HorizontalLayout(context) {
...
it.addView(ImageView(context))
...
}
通过上面函数调用,可以看出来虽然省略了apply,但是离我们想要的结果还是有很远差距。大括号内部,还是需要使用it来进行相关函数的调用,addView 方法也带有浓重的传统写法味道。
我们再进一步对函数定义进行优化
//函数定义
fun HorizontalLayout(context: Context, init: LinearLayout.() -> Unit) : LinearLayout {
return LinearLayout(context).apply {
orientation = LinearLayout.HORIZONTAL
init()
}
}
//隐藏addView方法到ImageView内部,同时ViewGroup添加拓展函数
//由于不用把ImageView实例返回给父View,直接返回Unit
fun ViewGroup.ImageView(init: ImageView.() -> Unit) {
addView(ImageView(context).apply(init))
}
//优化之后的,函数调用
HorizontalLayout {
...
ImageView {
...
}
...
}
再看一下DSL改造view的setOnClickListener的代码,如下:
//函数定义
fun View.onClick(listener: (v: View) -> Unit) {
setOnClickListener(listener)
}
//函数调用
Button.onClick {
......
}
当然这个例子中由于setOnClickListener是一个SAM接口,优势并不明显。下面我们用EdttText的addTextChangedListener方法,来进一步展示DSL的优势,对于EditText代码通常如下:
EditText {
addTextChangedListener(object: TextWatcher {
override fun beforeTextChanged(...){
...
}
override fun onTextChanged(...){
....
}
override fun afterTextChanged(...){
....
}
}
}
使用DSL进行改造
fun EditText.textChangeListener(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相关代码省略
}
函数调用代码如下,同时我们在调用的过程中可以按需调用内部的三个方法,而不用每次都必须复写三个方法。
EditText {
textChangedListener {
beforeTextChanged { charSequence, i, i2, i3 ->
//...
}
onTextChanged { charSequence, i, i2, i3 ->
//...
}
afterTextChanged {
//...
}
}
}
经过前面的一步一步的优化,我们的 DSL 基本达到了预期效果,接下来通过更多 Kotlin 的特性让这套 DSL 更加好用,并且语义更加清晰。
Kotlin 的中缀函数可以让函数省略圆点以及圆括号等程序符号,让语句更自然,进一步提升可读性。
比如所有的 View 都有 setTag 方法,正常使用如下:
HorizontalLayout {
setTag(1, "tag1")
setTag(2, "tag2")
}
我们使用中缀函数来优化 setTag 的调用如下:
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中调用代码如下:
HorizontalLayout {
tag {
1 to "tag1"
2 to "tag2"
}
}
HorizontalLayout {// this: LinearLayout
...
TextView {//this : TextView
// 此处仍然可以调用 HorizontalLayout
HorizontalLayout {
...
}
}
}
上面这段代码,我们发现在TextView {...}内部可以调用HorizontalLayout{...},这明显是不符合逻辑的。由于TextView的作用域同时处于父HorizontalLayout的作用域内,所以上面代码编译器任务其内容的HorizontalLayout{...}是在调用this@LinearLayout不会报错。如果编译器不报错,那么将提升代码的bug,同时也不利于我们日常开发功能。
Kotlin为DSL的使用场景提供了@DslMarker注解,可以对方法的作用域进行限制。添加注解的 lambda 中在省略 this 的隐式调用时只能访问到最近的类型,当调用更外层的的方法会报错。
@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 的方法,如果想调用外层的方法,必须显示的使用 this@xxx 进行调用。
先看一段代码,如下:
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 类型,便于后续配置。
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 中使用,且编译器不会报错。
经过上面的步骤,我们已经基本能实现02 中Compose DSL view代码,但是kotlin DSL的运用场景远不止UI这一种,但是基本思路都是相通的,我们再回顾一下基本步骤:
使用带有尾 lambda 的高阶函数实现大括号的层级调用;
为 lambda 添加 Receiver,通过 this 传递上下文;
通过扩展函数优化代码风格,DSL 中避免出现命令式的语义;
使用 infix 减少点号圆括号等符号的出现,提高可读性;
使用 @DslMarker 限制 DSL 作用域,避免出错;
使用 Context Receivers 传递多个上下文,DSL 更聪明(非正式语法,未来有变动的可能);
使用 inline 提升性能,同时使用 @PublishedApi 避免不必要的代码暴露;
页面更新:2024-04-23
本站资料均由网友自行发布提供,仅用于学习交流。如有版权问题,请与我联系,QQ:4156828
© CopyRight 2020-2024 All Rights Reserved. Powered By 71396.com 闽ICP备11008920号-4
闽公网安备35020302034903号