Android开发(Kotlin):使用DSL进一步简化业务代码

什么是DSL?

       DSL (domain specific language),即“特定领域语言”,与它对应的一个概念叫“通用编程语言”,通用编程语言有一系列完善的能力来解决几乎所有能被计算机解决的问题,像 Java 就属于这种类型。而特定领域语言只专注于特定的任务,比如 SQL 只专注于操纵数据库,HTML 只专注于表述超文本。
       既然通用编程语言能够解决所有的问题,那为啥还需要特定领域语言?因为它可以使用比通用编程语言中等价代码更紧凑的语法来表达特定领域的操作。比如当执行一条 SQL 语句时,不需要从声明一个类及其方法开始。
       更紧凑的语法意味着更简洁的 API。应用程序中每个类都提供了其他类与之交互的可能性,确保这些交互易于理解并可以简洁地表达,对于软件的可维护性至关重要。

内部 DSL

       但是,如果为解决某一特定领域问题就创建一套独立的语言,开发成本和学习成本都很高,因此便有了内部 DSL 的概念。所谓内部 DSL,便是使用通用编程语言来构建 DSL。本文讨论的DSL 均使用Kotlin实现,称为Kotlin DSL ,在这里做一个简单的定义:
       “使用 Kotlin 语言开发的,解决特定领域问题,具备独特代码结构的 API 。”
       下面,一起来领略下千变万化的 Kotlin DSL 。

Kotlin DSL实例

一、日期 / 时间
一般写法
//使用Java8 的日期时间Api
var yesterday = LocalDate.now() - Duration.ofDay(1L)
var twoHourLater = LocalDate.now() + Duration.ofHour(2L)
使用Kotlin DSL
var yesterday = 1.day.ago //也可以这样写 var yesterday = 1 day ago
var twoHourLater = 2.hour.fromNow //var twoHourLater = 2 hour fromNow

以上日期处理的代码,特别整洁直观,具体实现细节可参考此库:kxdate

不考虑代码规范的话,以该库的设计思路甚至可以实现如下Api:

var yesterday = 1 天 前
var twoHourLater = 2 小时 后
二、网络请求

在kotlin DSL的世界里面 一个网络请求可以长成这样:

        http {

            url = "http://www.163.com/"
            method = "get"

            onSuccess {
                string -> Log.i("Network",string) 
           }

            onFail {
                e -> Log.e("Network", e.message)
            }
        }

如果你有接触过Web前端,上述代码会特别眼熟,在JQuery里面使用ajax来进行网络请求的代码是这样的:

        var json = JSONObject()
        json.put("xxx","yyyy")
        ....

        val postBody = RequestBody.create(MediaType.parse("application/json; charset=utf-8"),json.toString())

        http {

            url = "https://......"
            method = "post"
            body = postBody

            onSuccess {
                string -> L.json(string)
            }

            onFail {
                e -> L.i(e.message)
            }
        }

有兴趣封装比较完成可以复用的Kotlin DSL 网络请求框架可以参考
EasyHttp

三、动画
原生动画代码(Java)

假设需求如下:“缩放 textView 的同时平移 button ,然后拉长 imageView,动画结束后 toast 提示”。用系统原生接口构建如下:

PropertyValuesHolder scaleX = PropertyValuesHolder.ofFloat("scaleX", 1.0f, 1.3f);
PropertyValuesHolder scaleY = PropertyValuesHolder.ofFloat("scaleY", 1.0f, 1.3f);
ObjectAnimator tvAnimator = ObjectAnimator.ofPropertyValuesHolder(textView, scaleX, scaleY);
tvAnimator.setDuration(300);
tvAnimator.setInterpolator(new LinearInterpolator());

PropertyValuesHolder translationX = PropertyValuesHolder.ofFloat("translationX", 0f, 100f);
ObjectAnimator btnAnimator = ObjectAnimator.ofPropertyValuesHolder(button, translationX);
btnAnimator.setDuration(300);
btnAnimator.setInterpolator(new LinearInterpolator());

ValueAnimator rightAnimator = ValueAnimator.ofInt(ivRight, screenWidth);
rightAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
    @Override
    public void onAnimationUpdate(ValueAnimator animation) {
        int right = ((int) animation.getAnimatedValue());
        imageView.setRight(right);
    }
});
rightAnimator.setDuration(400);
rightAnimator.setInterpolator(new LinearInterpolator());

AnimatorSet animatorSet = new AnimatorSet();
animatorSet.play(tvAnimator).with(btnAnimator);
animatorSet.play(tvAnimator).before(rightAnimator);
animatorSet.addListener(new Animator.AnimatorListener() {
    @Override
    public void onAnimationStart(Animator animation) {}
    @Override
    public void onAnimationEnd(Animator animation) {
        Toast.makeText(activity,"animation end" ,Toast.LENGTH_SHORT).show();
    }
    @Override
    public void onAnimationCancel(Animator animation) {}
    @Override
    public void onAnimationRepeat(Animator animation) {}
});
animatorSet.start();

实现起来不难,但是可读性很差,要看完整段代码后,才能在脑海中构建出整个需求的样子。

但逐行看也很费劲,不信就试着从第一行开始读:

创建一个横向缩放属性
创建一个纵向缩放属性
创建一个动画,这个动画施加在 textView 上,并且包含缩放和透明度属性
动画时长300毫秒
动画使用线性插值器

原生 API 将“缩放 textView ”这短短的一句话拆分成一个个零散的逻辑单元,并以一种不符合自然语言的顺序排列,所以不得不读完所有单元,才能拼凑出整个语义。

如果有一种更符合自然语言的 API,就能更省力地构建动画,更快速地理解代码。

用 Kotlin 预定义扩展函数简化代码
AnimatorSet().apply {
    ObjectAnimator.ofPropertyValuesHolder(
            textView,
            PropertyValuesHolder.ofFloat("scaleX", 1.0f, 1.3f),
            PropertyValuesHolder.ofFloat("scaleY", 1.0f, 1.3f)
    ).apply {
        duration = 300L
        interpolator = LinearInterpolator()
    }.let {
        play(it).with(
                ObjectAnimator.ofPropertyValuesHolder(
                        button,
                        PropertyValuesHolder.ofFloat("translationX", 0f, 100f)
                ).apply {
                    duration = 300L
                    interpolator = LinearInterpolator()
                }
        )
        play(it).before(
                ValueAnimator.ofInt(ivRight,screenWidth).apply { 
                    addUpdateListener { animation -> imageView.right= animation.animatedValue as Int }
                    duration = 400L
                    interpolator = LinearInterpolator()
                }
        )
    }
    addListener(object : Animator.AnimatorListener {
        override fun onAnimationRepeat(animation: Animator?) {}
        override fun onAnimationEnd(animation: Animator?) {
            Toast.makeText(activity,"animation end",Toast.LENGTH_SHORT).show()
        }
        override fun onAnimationCancel(animation: Animator?) {}
        override fun onAnimationStart(animation: Animator?) {}
    })
    start() 
}

使用apply()和let()避免了重复对象名,缩减了代码量。更重要的是 Kotlin 的代码有一种结构,这种结构让代码更符合自然语言。试着读一下:

构建动画集,它包含{
动画1
将动画1和动画2一起播放
将动画3在动画1之后播放
。。。
}

虽然在语义上已经比较清晰,但结构还是显得啰嗦,此起彼伏的缩进看着有点乱。

用 DSL 进一步简化代码

animSet {
    objectAnim {
        target = textView
        scaleX = floatArrayOf(1.0f,1.3f)
        scaleY = scaleX
        duration = 300L
        interpolator = LinearInterpolator()
    } with objectAnim {
        target = button
        translationX = floatArrayOf(0f,100f)
        duration = 300
        interpolator = LinearInterpolator()
    } before anim {
        values = intArrayOf(ivRight,screenWidth)
        action = { value -> imageView.right = value as Int }
        duration = 400
        interpolator = LinearInterpolator()
    }
    onEnd = Toast.makeText(activity,"animation end",Toast.LENGTH_SHORT).show()
    start()
}

一目了然的语义和清晰的结构,就好像是一篇英语文章。具体实现可以参考KotlinAnimationDSL

其它
  • Android 布局
  • HTML代码构建
  • Gradle 构建
  • 单元测试
  • ......

终上所述:
DSL 所体现的代码结构有如下特点:链式调用,大括号嵌套,并且可以近似于英语句子。

实现原理

这些看似“不可理喻”的代码写法是如何实现的呢?让我们来揭开它的神秘面纱,其实是使用到了如下Kotlin的语言特性。

扩展函数 / 属性

对于同样作为静态语言的 Kotlin 来说,扩展函数(扩展属性)是让他拥有类似于动态语言能力的法宝,即我们可以为任意对象动态的增加函数或属性。

1. 扩展函数

Kotlin的扩展函数可以让你作为一个类成员进行调用的函数,但是是定义在这个类的外部。这样可以很方便的扩展一个已经存在的类,为它添加额外的方法。在Kotlin源码中,有大量的扩展函数来扩展java,这样使得Kotlin比java更方便使用,效率更高。通常在java中,我们是以各种XXXUtils的方式来对已经存在的类进行功能的扩展。但是有了扩展函数,我们就能丢弃让人讨厌的XXXUtils方法工具类。下面举个例子,假如我们需要为String类型添加一个返回这个字符串最后一个字符的方法:

package com.yzc.kotlindsldemo

fun String.lastChar(): Char = this.get(this.length - 1)

fun main(args: Array<String>) {
    println("Kotlin".lastChar())
}

你只需要在你添加的函数名字之前放置你想要扩展的类或者接口的类型。这个类名叫着接收器类型(receiver type),而你调用的扩展函数的值叫做接收器对象(receiver object)。如下图:

image

接收器类型是扩展定义的类型,而接收器对象是这个类型的实例。调用方式跟普通的函数调用方式一致:

println("Kotlin".lastChar())

在这个例子中,String是接收器类型,"Kotlin"接收器对象,在这个扩展函数中,你可以直接访问你扩展的类型的函数和属性,就像定义在这个类中的方法一样,但是扩展函数并不允许你打破封装。跟定义在类中方法不同,它不能访问那些私有的、受保护的方法和属性。

1.1 扩展函数的导入

大多数情况下,我们直接在包里定义扩展函数。这样我们就可以在整个包里面使用这些扩展,如果我们要使用其他包的扩展,我们就需要导入它。导入扩展函数跟导入类是一样的方式。

import  com.yzc.kotlindsldemo.lastChar

或者

import com.yzc.kotlindsldemo.*

有时候,可能你引入的第三方包都对同一个类型进行了相同函数名扩展,为了解决冲突问题,你可以使用下面的方式对扩展函数进行改名。

packagecom.yzc.kotlindsldemo.kotlintest2

import com.yzc.kotlindsldemo.kotlintest1.lastChar as last

fun main(args: Array<String>) {
    println("Kotlin".last())
}

1.2 范型化的扩展函数

我们也可以在对扩展函数进行范型化。

package com.yzc.kotlindsldemo.kotlintest1

fun String.lastChar(): Char = this.get(this.length - 1)

fun <T> Collection<T>.joinToString(
        separator: String = ",",
        prefix: String = "",
        postfix: String = ""
): String{
    val result = StringBuilder(prefix)
    for ((index, value) in this.withIndex()) {
        if (index > 0) {
            result.append(separator)
        }
        result.append(value)
    }
    result.append(postfix)
    return result.toString()
}

fun main(args: Array<String>) {
    println(listOf("a", "b", "c").joinToString(prefix = "[", postfix = "]"))
}

输出:

[a,b,c]

1.3 扩展函数不可覆盖(overriding)

方法的覆盖(overriding)对类中的成员函数是有效的,但是扩展函数不能被覆盖,请看下面这个例子:

package com.yzc.kotlindsldemo.kotlintest1

open class View{
    open fun click() {
        println("view clicked")
    }
}

open class Button: View() {
    override fun click(){
        println("button clicked")
    }
}

fun View.longClick() = println("view longClicked")
fun Button.longClick() = println("button longClicked")

fun main(args: Array<String>) {
    val button:View = Button()
    button.click()
    button.longClick()
}

输出:

button clicked
view longClicked

可以看到扩展函数并不能被覆盖,我们把变量定义成View,longClick()使用的是View.longClick()扩展函数。扩展函数并不是类的一部分,他们申明在类的外部。尽管你可以为某个基类和它的之类用同样的名字和参数来定义扩展函数,被调用的函数依赖已被申明的静态类型,而不是运行时的变量类型。

2. 扩展属性

扩展属性提供了一种方法用能通过属性语法进行访问的API来扩展。尽管它们被叫做属性,但是它们不能拥有任何状态,它不能添加额外的字段到现有的java对象实例。不过可以有更简短的语法在某些时候还是更方便的。

package com.yzc.kotlindsldemo.kotlintest1

val String.lastChar: Char
    get() = get(length - 1)

var StringBuilder.lastChar: Char
    get() =  get(length - 1)
    set(value) {
        this.setCharAt(length -1, value)
    }

fun main(args: Array<String>) {
    println("Kotlin".lastChar)

    val sb = StringBuilder("Kotlin")
    sb.lastChar = 'g'
    println(sb)
}

可以看到扩展属性也可以通过val或者var定义,然后也是接你需要扩展的类型,然后属性名称,最后是属性的类型。var的话可以有set方法定义。你访问扩展属性和访问成员属性完全一样。

lambda

lambda 为 Java8 提供的新特性,于2014年3月18日发布。在2019年的今天我们依然无法使用或者要花很大的代价才能在 Android 编程中使用,而 Kotlin 则帮助我们解决了这一瓶颈,这也是我们拥抱 Kotlin 的原因之一。

lambda 是构建整洁代码的一大利器。

1. lambda 表达式

下图是 lambda 表达式,他总是用一对大括号包装起来,可以作为值传递给下节要提到的高阶函数。

图片来自于 Kotlin in Action

2. 高阶函数

关于高阶函数的定义,参考《Kotlin 实战》:

高阶函数就是以另一个函数作为参数或返回值的函数

如果用 lamba 来作为高价函数的参数(此时为形参),就必须先了解如何声明一个函数的形参类型,如下:

图片来自于 Kotlin in Action

相对于上一小节,我们应该弄清楚 lambda 作为实参和形参时的表现形式:

// printSum 为高阶函数,定义了 lambda 形参
fun printSum(sum:(Int,Int)->Int){
        val result = sum(1, 2)
        println(result)
}

// 以下 lambda 为实参,传递给高阶函数 printSum
val sum = {x:Int,y:Int->x+y}
printSum(sum)

有了高阶函数,我们可以很轻易地做到一个 lambda 嵌套另一个 lambda 的代码结构

3. 大括号放在最后

Kotlin 的 lambda 有个规约:如果 lambda 表达式是函数的最后一个实参,则可以放在括号外面,并且可以省略括号,如:

person.maxBy({ p:Person -> p.age })

// 可以写成
person.maxBy(){
    p:Person -> p.age
}

// 更简洁的风格:
person.maxBy{
    p:Person -> p.age
}

这个规约是 Kotlin DSL 实现嵌套结构的本质原因,比如上文提到的 anko Layout:

verticalLayout {
            val name = editText()
            button("Say Hello") {
                onClick { toast("Hello, ${name.text}!") }
            }
        }

这里 verticalLayout 中 嵌套了 button,想必该库定义了如下函数:

fun verticalLayout( ()->Unit ){

}

fun button( text:String,()->Unit ){

}

verticalLayout 和 button 均是高阶函数,结合大括号放在最后的规约,就形成了 lambda 嵌套的语法结构。

4. 带接收者的 lambda

lambda 作为形参函数声明时,可以携带接收者,如下图:

图片来自于 Kotlin in Action

带接收者的 lambda 丰富了函数声明的信息,当传递该 lambda值时,将携带该接收者,比如:

// 声明接收者
fun kotlinDSL(block:StringBuilder.()->Unit){
  block(StringBuilder("Kotlin"))
}

// 调用高阶函数
kotlinDSL {
  // 这个 lambda 的接收者类型为StringBuilder
  append(" DSL")
  println(this)
}

>>> 输出 Kotlin DSL

总而言之,lambda 在 Kotlin 和 Kotlin DSL 中扮演着很重要的角色,是实现整洁代码的必备语法糖。

中缀调用

Kotlin 中有种特殊的函数可以使用中缀调用,代码风格如下:

"key" to "value"

// 等价于
"key.to("value")

而 to() 的实现源码如下:

infix fun Any.to(that:Any) = Pair(this,that)

这段源码理解起来不难,infix 修饰符代表该函数支持中缀调用,然后为任意对象提供扩展函数 to,接受任意对象作为参数,最终返回键值对。

再举个栗子:

"kotlin" should start with "kot"

// 等价于
"kotlin".should(start).with("kot")

使用两个中缀调用便可实现,以下是伪代码:

object start
infix fun String.should(start:start):String = ""
infix fun String.with(str:String):String = ""

所以,中缀调用是实现类似英语句子结构 DSL 的核心。

invoke 约定

Kotlin 提供了 invoke 约定,可以让对象向函数一样直接调用,比如:

class Person(val name:String){
    operator fun invoke(){
        println("my name is $name")
    }
}

>>>val person = Person("geniusmart")
>>> person()
my name is geniusmart

invoke 约定让对象调用函数的语法结构更加简洁。

小试牛刀

日期 / 时间 DSL 实现:

下面来实际讲解实例里面 日期的 DSL的实现过程:

val yesterday = 1.days.ago

为配合扩展函数,我们先降低 api 的整洁程度,先实现一个扩展函数的版本:

val yesterday = 1.days().ago()

1 为 Int 类型,显然 Int 并没有 days() 函数,因此days() 为扩展函数,伪代码如下:

fun Int.days() = {//逻辑实现}

结合 Java8 的 Time api,此处将会涉及到两个扩展函数,完整实现如下:

fun Int.days() = Period.ofDays(this)
fun Period.ago() = LocalDate.now() - this

若要实现最终的效果,实际上就是将扩展函数修改为扩展属性的方式即可(扩展属性需提供getter或setter,本质上等同于扩展函数):

val Int.days:Period
    get() = Period.ofDays(this)

val Period.ago:LocalDate
    get() = LocalDate.now() - this

上面提到的不太规范的中文 api:

val yesteraty = 1 天 前

使用扩展函数和中缀调用便可实现:

object 前
infix fun Int.天(ago:前) = LocalDate.now() - Period.ofDays(this)

使用DSL实现 自定义对话框

定义一个CustomDialogFragment,实现了项目中规定的Dialog样式,并提供相应的接口用于设置Title,Message,LeftButton和RightButton。

要实现的Kotlin DSL API 效果如下:

showDialog {
    title = "title"
    message = "message"
    rightClicks {
        toast("clicked!")
    }
}

实现代码如下:

fun AppCompatActivity.showDialog(settings: CustomDialogFragment.() -> Unit) : CustomDialogFragment {

    val dialog = CustomDialogFragment.newInstance()

    dialog.apply(settings)

    val ft = this.supportFragmentManager.beginTransaction()
    val prev = this.supportFragmentManager.findFragmentByTag("dialog")

    if (prev != null) {
        ft.remove(prev)
    }

    ft.addToBackStack(null)
    dialog.show(ft, "dialog")

    return dialog

}

参考文献:
Kotlin之美——DSL篇
用 kotlin 来实现 dsl 风格的编程
Kotlin扩展函数和扩展属性笔记
Kotlin进阶:动画代码太丑,用DSL动画库拯救,像说话一样写代码哟!

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 156,069评论 4 358
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 66,212评论 1 287
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 105,912评论 0 237
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,424评论 0 202
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 51,741评论 3 285
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,194评论 1 206
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,553评论 2 307
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,289评论 0 194
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 33,923评论 1 237
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,251评论 2 240
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 31,775评论 1 255
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,144评论 2 249
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 32,698评论 3 228
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 25,936评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,658评论 0 192
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,214评论 2 267
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,159评论 2 258

推荐阅读更多精彩内容