Kotlin DSL 实战

news2024/12/19 8:06:21

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

DSL代码

通过对比可以看到 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 的方法会报错如下:

@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 中使用效果如下:

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 这一种使用场景 ,但是实现思路都是相近的,最后再来一起回顾一下基本步骤:

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

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/371776.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

GORM-GEN快速上手

目录 1.什么是 GEN 2.GEN特性 3.快速使用GEN 3.1. 下载 3.2. 生成 4. 基础查询 5. 自定义 SQL 查询 6.demo源码 1.什么是 GEN 官方文档&#xff1a;Gen Guides | GORM - The fantastic ORM library for Golang, aims to be developer friendly. GEN 项目地址&#xf…

「mysql是怎样运行的」第19章 从猫爷被杀说起---事务简介

「mysql是怎样运行的」第19章 从猫爷被杀说起—事务简介 文章目录「mysql是怎样运行的」第19章 从猫爷被杀说起---事务简介[toc]一、事务的起源概述原子性(Atomicity)隔离性(Isolation)一致性(Consistency)持久性(Durability)二、事务的概念一、事务的起源 概述 对于大部分程…

android UI优化的基本原理和实战方法

任何Android应用都需要UI跟用户交互.UI是否好坏更是直接影响到用户的体验.如今UI的优化视乎是应用开发中一个绕不过去的话题。所以本篇文章小编带大家全面了解Android ui优化的主要知识和优化方法。 一、UI优化 UI优化知识点主要分为三部分&#xff1a; 第一部分&#xff0c…

Linux简单介绍(基本涵盖日常使用到的各种shell知识点)

文章目录shell基础认知1. shell语言2. 脚本执行方式3. 快捷键4. 通配符5. 命令后跟的选项6. 逻辑运算 && ||7. 算术运算&#xff08;equal&#xff0c;great&#xff0c;less&#xff09;8. 目录或文件意义9. 规则10. vimshell脚本常规内容解释1. set -ex2. set -o pip…

Google Brain新提出的优化器“Lion”,效果要比Adam(W)更好

Google Brain新提出的优化器“Lion”&#xff0c;效果要比Adam(W)更好 论文地址&#xff1a;https://arxiv.org/abs/2302.06675代码地址&#xff1a;https://github.com/google/automl/blob/master/lion/lion_pytorch.py 1 简单、内存高效、运行速度更快 与 AdamW 和各种自适…

量子计算对网络安全的影响

量子计算的快速发展&#xff0c;例如 IBM 的 Quantum Condor 处理器具有 1000 个量子比特的容量&#xff0c;促使专家们宣称第四次工业革命即将实现“量子飞跃”。 量子计算机的指数处理能力已经受到政府和企业的欢迎。 由于从学术和物理原理到商业可用解决方案的不断转变&am…

Spark Explain:查看执行计划

Spark SQL explain 方法有 simple、extended、codegen、cost、formatted 参数&#xff0c;具体如下 目录一、基本语法二、执行计划处理流程三、具体案例一、基本语法 从 3.0 开始&#xff0c;explain 方法有一个新的 mode 参数&#xff0c;指定执行计划展示格式 只展示物理执…

都2023年了,竟然还有人问网络安全怎么入门?

工作一直忙碌&#xff0c;偶然翻了一下知乎&#xff0c;都2022年了&#xff0c;相关网课这么多了&#xff0c;还有人不知道怎么学习网络安全&#xff0c;不了解也就算了&#xff0c;竟然还有一批神仙也真敢回答&#xff0c;对这个行业了解各一知半解就当做这些萌新的启蒙老师了…

UDP与TCP协议

目录 UDP协议 协议报头 UDP协议特点&#xff1a; 应用场景&#xff1a; TCP TCP协议报头 确认应答机制 理解可靠性 超时重传机制 连接管理机制 三次握手&#xff1a; 四次挥手&#xff1a; 滑动窗口 如何理解缓冲区和滑动窗口&#xff1f; 倘若出现丢包&#xf…

05 DC-AC逆变器(DCAC Converter / Inverter)简介

文章目录0、概述逆变原理方波变换阶梯波变换斩控调制方式逆变器分类逆变器波形指标1、方波变换器A 单相单相全桥对称单脉冲调制移相单脉冲调制单相半桥2、方波变换器B 三相180度导通120度导通&#xff08;线、相的关系与180度相反&#xff09;3、阶梯波逆变器独立直流源二极管钳…

Esxi NAT网络搭建

前言 本文主要讲述如何在Esxi上只有一个公网IP情况下,实现内部虚拟机上网,以及外部对内部服务的访问,以及外网通过vpn访问内网; 环境 Esxi 6.7iKuai8 3.6.13OpenVPN 2.6一、创建虚拟路由 1.1 目的 虚拟路由,也就是常说的软路由;只有一个外网IP情况下,其他虚拟机需要上…

LeetCode刷题系列 -- 429. N 叉树的层序遍历

给定一个 N 叉树&#xff0c;返回其节点值的层序遍历。&#xff08;即从左到右&#xff0c;逐层遍历&#xff09;。树的序列化输入是用层序遍历&#xff0c;每组子节点都由 null 值分隔&#xff08;参见示例&#xff09;。示例 1&#xff1a;输入&#xff1a;root [1,null,3,2…

【每日阅读】JS知识(三)

var声明提升 js是一个解释性语言类型&#xff0c;预解析就是在执行代码之前对代码进行通读 var关键字是&#xff0c;在内存中声明一个变量名 js在代码执行之前 会经历两个环节 解释代码 和执行代码 声明式函数 内存中 先声明一个变量名是函数 这个名代表的是函数 乘法表 // for…

IP、ICMP、TCP和UDP校验和计算

一. 前言 计算网络数据包的校验和是机器自动完成&#xff0c;不需要手动计算。但是正因为如此&#xff0c;我们往往不会去深究校验和到底是怎么计算的&#xff0c;留下这一块盲区。虽然书上有大致介绍计算的方法&#xff0c;但是&#xff0c;“纸上得来终觉浅&#xff0c;绝知此…

二叉树——验证二叉搜索树

验证二叉搜索树 链接 给你一个二叉树的根节点 root &#xff0c;判断其是否是一个有效的二叉搜索树。 有效 二叉搜索树定义如下&#xff1a; 节点的左子树只包含 小于 当前节点的数。 节点的右子树只包含 大于 当前节点的数。 所有左子树和右子树自身必须也是二叉搜索树。 …

【Proteus仿真】【51单片机】粮仓温湿度控制系统设计

文章目录一、功能简介二、软件设计三、实验现象联系作者一、功能简介 本项目使用Proteus8仿真51单片机控制器&#xff0c;使用声光报警模块、LCD1602显示模块、DHT11温湿度模块、继电器模块、加热加湿除湿风扇等。 主要功能&#xff1a; 系统运行后&#xff0c;LCD1602显示传…

LeetCode 144. 二叉树的前序遍历

144. 二叉树的前序遍历 难度&#xff1a;easy\color{Green}{easy}easy 题目描述 给你二叉树的根节点 rootrootroot &#xff0c;返回它节点值的 前序 遍历。 示例 1&#xff1a; 输入&#xff1a;root [1,null,2,3] 输出&#xff1a;[1,2,3]示例 2&#xff1a; 输入&#…

Web前端学习:三 - 练习

三六&#xff1a;风筝效果 <!DOCTYPE html> <html><head><meta charset"utf-8"><title></title><style type"text/css">*{margin: 0;padding: 0;}.d1{width: 200px;height: 200px;background: yellow;position…

链表的排序:插入排序和归并排序

文章目录链表的排序&#xff1a;插入排序和归并排序147. 对链表进行插入排序148. 排序链表链表的排序&#xff1a;插入排序和归并排序 两道例题进行记录。 147. 对链表进行插入排序 题目链接&#xff1a;https://leetcode.cn/problems/insertion-sort-list/ 题目大意&#x…

计算机网络笔记 | 第一章:计算机网络概述(1.1-1.4小节知识点整理)

从专栏将讲述有关于计算机网络相关知识点&#xff0c;如果有想学习Java的小伙伴可以点击下方连接查看专栏&#xff0c;还有JavaEE部分 本专栏地址&#xff08;持续更新中&#xff09;&#xff1a;&#x1f525;计算机网络 MyBatis&#xff1a;✍️MyBatis Java入门篇&#xff1…