Kotlin DSL

什么是DSL?

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 DSL

Kotlin 早几年已经被Google作为Android应用软件开发的主要编程语言,在很多场景下,我们已经看到了DSL在 Android 开发中发挥的优势,并且可以有效地提升开发效率。例如 Jetpack Compose 的 UI 代码就是一个很好的示范,它借助 DSL 让 Kotlin 代码具有了不输于 XML 的表现力,同时还兼顾了类型安全,提升了 UI 开发效率。

XML布局

xml View

Compose DSL布局

compose View

通过对比可以看到 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取代)。

Kotlin是怎么实现DSL?

常见的 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 {
            //...
        }
    }
}

Kotlin DSL 更进一步

经过前面的一步一步的优化,我们的 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 中使用效果如下:

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

  • 使用 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 中使用,且编译器不会报错。

总结

经过上面的步骤,我们已经基本能实现02 中Compose DSL view代码,但是kotlin DSL的运用场景远不止UI这一种,但是基本思路都是相通的,我们再回顾一下基本步骤:

使用带有尾 lambda 的高阶函数实现大括号的层级调用;
为 lambda 添加 Receiver,通过 this 传递上下文;
通过扩展函数优化代码风格,DSL 中避免出现命令式的语义;
使用 infix 减少点号圆括号等符号的出现,提高可读性;
使用 @DslMarker 限制 DSL 作用域,避免出错;
使用 Context Receivers 传递多个上下文,DSL 更聪明(非正式语法,未来有变动的可能);
使用 inline 提升性能,同时使用 @PublishedApi 避免不必要的代码暴露;

页面更新:2024-04-23

标签:圆括号   可读性   编译器   括号   注解   函数   定义   类型   代码   方法

1 2 3 4 5

上滑加载更多 ↓
更多:

本站资料均由网友自行发布提供,仅用于学习交流。如有版权问题,请与我联系,QQ:4156828  

© CopyRight 2020-2024 All Rights Reserved. Powered By 71396.com 闽ICP备11008920号-4
闽公网安备35020302034903号

Top