1 DSL是什么?
Kotlin
是一门对 DSL
友好的语言,它的许多语法特性有助于 DSL
的打造,提升特定场景下代码的可读性和安全性。本文将带你了解 Kotlin DSL
的一般实现步骤,以及如何通过 @DslMarker
, Context Receivers
等特性提升 DSL
的易用性。
DSL
全称是 Domain Specific Language
,即领域特定语言
。顾名思义 DSL
是用来专门解决某一特定问题的语言,比如我们常见的 SQL
或者正则表达式等,DSL
没有通用编程语言(Java、Kotlin等)那么万能,但是在特定问题的解决上更高效。
2 Gradle Kotlin DSL的优点和使用
Gradle Kotlin DSL
是Gradle 5.0
引入的一种新型的Gradle
脚本语言,作为Groovy
语言的替代方案。
官方文档中提到,Kotlin DSL
具有如下的优点:
- 类型安全:编写
Gradle
脚本时,可以进行静态类型检查,这样可以保证更高的代码质量和更好的可维护性; - 代码提示:
Kotlin
语言具有良好的编码体验,比如IDE
可以提示代码补全、语法错误等,这些在Groovy
语言中不易得到; - 使用简单:
Kotlin
是一种现代化的语言,语法易懂,学习成本低; - 高效性:
Gradle
使用Kotlin
编写的DSL
脚本会比同样的Groovy
脚本快2~10倍。
创作一套全新新语言的成本很高,所以很多时候我们可以基于已有的通用编程语言打造自己的 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
开发效率。
3.1 一个简单DSL例子
在Kotlin
中实现DSL
构建要依靠这几样东西:
扩展函数;
带接收者的 Lambda 表达式;
在方法括号外使用Lambda
我们先来看一下一个DSL例子:
val person = person {
name = "John"
age = 25
address {
street = "Main Street"
number = 42
city = "London"
}
}
// 数据模型
data class Person(var name: String? = null,
var age: Int? = null,
var address: Address? = null)
data class Address(var street: String? = null,
var number: Int? = null,
var city: String? = null)
要实现上面的语法糖,现在要做的第一件事就是创建一个新文件,将保持DSL
与模型中的实际类分离。首先为Person
类创建一些构造函数。看看我们想要的结果,看到Person
的属性是在代码块中定义的。这些花括号实际上是定义一个lambda
。这就是使用上面提到的三种Kotlin语言特征中的第一种语言特征的地方:在方法括号外使用Lambda
。
如果一个函数的最后一个参数是一个lambda
,可以把它放在方法括号之外。而当你只有一个lambda
作为参数时,你可以省略整个括号。person {…}
实际上与person({…})
相同。这在我们的DSL
中变得更简洁。现在来编写person
函数的第一个版本。
// 数据模型
fun person(block: (Person) -> Unit): Person {
val p = Person()
block(p)
return p
}
所以在这里我们有一个创建一个Person
对象的函数。它需要一个带有我们在第2行创建的对象的lambda
。当在第3行执行这个lambda
时,我们期望在返回第4行的对象之前,该对象获得它所需要的属性。下面展示如何使用这个函数:
val person = person {
it.name = "John"
it.age = 25
}
由于这个lambda
只接收一个参数,可以用它来调用person
对象。这看起来不错,但还不够完美,如果在我们的DSL
看到的东西。特别是当我们要在那里添加额外的对象层。这带来了我们接下来提到的Kotlin
功能:带接受者的Lambda
。
在person
函数的定义中,可以给lambda
添加一个接收者。这样只能在lambda
中访问那个接收者的函数。由于lambda
中的函数在接收者的范围内,则可以简单地在接收者上执行lambda
,而不是将其作为参数提供。
fun person(block: Person.() -> Unit): Person {
val p = Person()
p.block()
return p
}
// 这实际上可以通过使用Kotlin提供的apply函数在一个简单的单行程中重写。
fun person(block: Person.() -> Unit): Person = Person().apply(block)
现在可以将其从DSL
中删除:
val person = person {
name = "John"
age = 25
}
到目前为止,还差一个Address
类,在我们想要的结果中,它看起来很像刚刚创建的person
函数。唯一的区别是必须将它分配给Person
对象的Address
属性。为此,可以使用上面提到的三个Kotlin
语言功能中的最后一个:扩展函数
。
扩展函数能够向类中添加函数,而无需访问类本身的源代码。这是创建Address
对象的完美选择,并直接将其分配给Person
的地址属性。这是DSL
文件的最终版本:
fun person(block: Person.() -> Unit): Person = Person().apply(block)
fun Person.address(block: Address.() -> Unit) {
address = Address().apply(block)
}
现在为Person
添加一个地址函数,它接受一个Address
作为接收者的lambda
表达式,就像对person
构造函数所做的那样。然后它将创建的Address
对象设置为Person
的属性:
val person = person {
name = "John"
age = 25
address {
street = "Main Street"
number = 42
city = "London"
}
}
3.2 实现简单的UI布局
我们先来看下这个布局
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent">
<TextView
android:id="@+id/tv"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:textSize="16sp"
android:paddingTop="10dp" />
</FrameLayout>
上面XML
使用DSL
写法如下:
context.FrameLayout {
layout_width = match_parent
layout_height = wrap_content
TextView {
layout_id = "tv"
layout_width = match_parent
layout_height = match_parent
textSize = 16f
padding_top = 10
}
}
首先要定义一种声明方式来初始化对象,所以可以写一个基于Context
的扩展函数
inline fun Context.FrameLayout(
style: Int? = null,
init: FrameLayout.() -> Unit
): FrameLayout {
val frameLayout =
if (style != null) FrameLayout(
ContextThemeWrapper(this, style)
) else FrameLayout(this)
return frameLayout.apply(init)
}
// 扩展View的layout_width、layout_height等属性,
// 其他属性这里不做详解,写法同layout_width、layout_height
inline var View.layout_width: Number
get() {
return 0
}
set(value) {
val w = if (value.dp > 0) value.dp else value.toInt()
val h = layoutParams?.height ?: 0
updateLayoutParams<ViewGroup.LayoutParams> {
width = w
height = h
}
}
inline var View.layout_height: Number
get() {
return 0
}
set(value) {
val w = layoutParams?.width ?: 0
val h = if (value.dp > 0) value.dp else value.toInt()
updateLayoutParams<ViewGroup.LayoutParams> {
width = w
height = h
}
}
这里的init就是上面说的带接受者的lamba
表达式拉,所以代码里去实现一个FrameLayout
布局就可以这样子拉
context.FrameLayout {
layout_width = match_parent
layout_height = wrap_content
}
而对于子控件,TextView举个栗子:
inline fun ViewGroup.TextView(
style: Int? = null,
init: AppCompatTextView.() -> Unit
): TextView {
val textView =
if (style != null) AppCompatTextView(
ContextThemeWrapper(context, style)
) else AppCompatTextView(context)
return textView.apply(init).also { addView(it) }
}
这样一个简单的动态布局就出来了,没想象中那么高级,其实就是对扩展函数
、高阶函数
的运用。
3.3 小结
Kotlin DSL
的好处,尤其是对View
进行特定领域的处理的时候 很有用。
- 有着近似
XML
的结构化表现力 - 较少的字符串,更多的强类型,更安全
- 可提取
linearLayoutParams
这样的对象方便复用 - 在布局中同步嵌入
onClick
等事件处理 - 如需要还可以嵌入
if
,for
这样的控制语句
4 DSL实现的原理
4.1 扩展函数(扩展属性)
package strings
fun String.lastChar(): Char = this.get(this.length - 1)
4.2 lambda使用
lambda
表达式定义:
高阶函数:高阶函数就是以另一个函数作为参数或返回值的函数。
Kotlin 的 lambda 有个规约:如果 lambda 表达式是函数的最后一个实参,则可以放在括号外面,并且可以省略括号
person.maxBy({ p:Person -> p.age })
// 可以写成
person.maxBy(){
p:Person -> p.age
}
// 更简洁的风格:
person.maxBy{
p:Person -> p.age
}
带接收者的 lambda
:
想一想 File
就是带接受者,说明这个lambda
的对象是File
。
4.3 中缀调用
中缀调用
是实现类似英语句子结构 DSL
的核心。
4.4 invoke 约定
invoke
约定的作用:它的作用就是让对象像函数一样调用方法。
class DependencyHandler{
//编译库
fun compile(libString: String){
Logger.d("add $libString")
}
//定义invoke方法
operator fun invoke(body: DependencyHandler.() -> Unit){
body()
}
}
//我们有下面的3种调用方式:
val dependency = DependencyHandler()
//调用invoke
dependency.invoke {
compile("androidx.core:core-ktx:1.6.0")
}
//直接调用
dependency.compile("androidx.core:core-ktx:1.6.0")
//带接受者lambda方式
dependency{
compile("androidx.core:core-ktx:1.6.0")
}
5 总结
Kotlin DSL
是一种强大的工具,可以帮助我们编写更简洁、优雅的代码。通过使用 Kotlin DSL
,我们可以提高代码的可读性、灵活性和类型安全性。当然 Android
中 DSL
远不止这些使用场景 ,但是实现思路都是相近的,最后再来一起回顾一下:
- DSL 是什么?
DSL 是一种针对特殊编程场景的语言或范式,它处理效率更高,且表达式更为专业。
例如 SQL、HTML、正则表达式等。 - Kotlin 如何支持 DSL
通过 扩展函数、带接收者的函数类型等来支持使用 DSL。 - Kotlin 自定义 DSL 的优势
提供一套编程风格,可以简化构建一些复杂对象的代码,提高简洁程度的同时,具备很高的可读性。 - Kotlin 自定义 DSL 的缺点
构造代码较为复杂,有一定上手难度,非必要不使用。
Tips: 对于顶级的Android发烧友,或者是Kotlin学习爱好者可以深度去挖掘DSL,或者是高级的Kotlin语法糖。注意对于在职场打拼的各位朋友们,还是那句话:学值得变现的知识点,并且要等机会来变现,从这个角度,Kotlin会用就可以了,不一定要非要死磕语法糖。切记。职场和自由职业free style 学习的东西是不一样的。