什么是DSL? DSL全称是DomainSpecificLanguage,即领域特定语言。顾名思义DSL是用来专门解决某一特定问题的语言,比如我们常见的SQL或者正则表达式等,DSL没有通用编程语言(Java、Kotlin等)那么万能,但是在特定问题的解决上更高效。 设想以下有这样一种场景,如果我们希望其他人跟随我们既定的规则,排除定义接口、类、方法这种传统方式,我们能怎么实现呢? 自己去开发一门语言,难度可想而知。其实可以转换一下思路,我们可以基于已有的通用编程语言打造自己的DSL,比如日常开发中我们将常见到gradle脚本,其本质就是来自Groovy的一套DSL:android{compileSdkVersion30defaultConfig{applicationIdx。xx。xxxminSdkVersion24targetSdkVersion30versionCode1versionName1。0testInstrumentationRunnerandroid。support。test。runner。AndroidJUnitRunner}buildTypes{release{minifyEnabledfalseproguardFilesgetDefaultProguardFile(proguardandroidoptimize。txt),proguardrules。pro}}} 使用大括号表现层级结构,使用键值对的形式设置参数,没有多余的程序符号,非常直观。还原成标准的Groovy语法则变成如下:Android(30,DefaultConfig(com。my。app,24,30,1,1。0,android。support。test。runner。AndroidJUnitRunner)),BuildTypes(Release(false,getDefaultProguardFile(proguardandroidoptimize。txt),proguardrules。pro)) 我们可以对比一下上下两段代码,很明显上面一段的代码可读性更高! 目前Gradle已经开始推荐使用kts替代gradle,其实就是利用了Kotlin优秀的DSL特性。KotlinDSL Kotlin早几年已经被Google作为Android应用软件开发的主要编程语言,在很多场景下,我们已经看到了DSL在Android开发中发挥的优势,并且可以有效地提升开发效率。例如JetpackCompose的UI代码就是一个很好的示范,它借助DSL让Kotlin代码具有了不输于XML的表现力,同时还兼顾了类型安全,提升了UI开发效率。 XML布局 xmlView ComposeDSL布局 composeView 通过对比可以看到KotinDSL有诸多好处:有着近似XML的结构化表现力;较少的字符串,更多的强类型,更安全;linearLayoutParams这样的对象可以多次复用,更方便组件化和维护;可以在定义布局的同时实现onClick等事件;还可以嵌入if,for这样的控制语句,用于根据状态动态控制和显示V 在没有DSL之前,如果我们想要实现这样的效果,代码可能会写成这样,如下:LinearLayout(context)。apply{addView(ImageView(context)。apply{imagecontext。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等作用域函数进行了优化,但写起来仍然很繁琐,同时阅读起来也比较困难。 小结: 通过上面的代码实例,我们已经能感受到KotlinDSL带来的冲击,并且伴随着JetpackCompose的快速迭代,所带来的性能提升、即时预览等等,在不远的未来传统的xml布局开发方式,也会被取代(Android平台Java基本已经被Kotlin取代)。Kotlin是怎么实现DSL? 常见的DSL都会用大括号来表现层级。Kotlin的高阶函数允许指定一个lambda类型的参数,且当lambda位于参数列表的最后位置时可以脱离圆括号,满足DSL中的大括号语法要求。 那么我们不妨先尝试去改造一下下面这段代码:LinearLayout(context)。apply{orientationLinearLayout。HORIZONTALaddView(ImageView(context))} 为LinearLayout定义一个高阶函数HorizontalLayout,用于表示水平布局,代码如下:函数定义funHorizontalLayout(context:Context,init:(LinearLayout)Unit):LinearLayout{returnLinearLayout(context)。apply{orientationLinearLayout。HORIZONTALinit(this)}}函数调用HorizontalLayout(context){。。。it。addView(ImageView(context))。。。} 通过上面函数调用,可以看出来虽然省略了apply,但是离我们想要的结果还是有很远差距。大括号内部,还是需要使用it来进行相关函数的调用,addView方法也带有浓重的传统写法味道。 我们再进一步对函数定义进行优化函数定义funHorizontalLayout(context:Context,init:LinearLayout。()Unit):LinearLayout{returnLinearLayout(context)。apply{orientationLinearLayout。HORIZONTALinit()}}隐藏addView方法到ImageView内部,同时ViewGroup添加拓展函数由于不用把ImageView实例返回给父View,直接返回UnitfunViewGroup。ImageView(init:ImageView。()Unit){addView(ImageView(context)。apply(init))}优化之后的,函数调用HorizontalLayout{。。。ImageView{。。。}。。。} 再看一下DSL改造view的setOnClickListener的代码,如下:函数定义funView。onClick(listener:(v:View)Unit){setOnClickListener(listener)}函数调用Button。onClick{。。。。。。} 当然这个例子中由于setOnClickListener是一个SAM接口,优势并不明显。下面我们用EdttText的addTextChangedListener方法,来进一步展示DSL的优势,对于EditText代码通常如下:EditText{addTextChangedListener(object:TextWatcher{overridefunbeforeTextChanged(。。。){。。。}overridefunonTextChanged(。。。){。。。。}overridefunafterTextChanged(。。。){。。。。}}} 使用DSL进行改造funEditText。textChangeListener(init:TextWatcher。()Unit){vallistenerTextWatcher()listener。init()addTextChangedListener(listener)}classTextWatcher:android。text。TextWatcher{privatevaronTextChanged:((Charsequence?,Int,Int,Int)Unit)?nulloverridefunonTextChanged(s:CharSequence?,start:Int,before:Int,count:int){onTextChanged?。invoke(s,start,before,count)}funonTextChanged(listener:(Charsequence?,Int,Int,Int)Unit){onTextChangedlistener}beforeTextChanged和afterTextChanged相关代码省略} 函数调用代码如下,同时我们在调用的过程中可以按需调用内部的三个方法,而不用每次都必须复写三个方法。EditText{textChangedListener{beforeTextChanged{charSequence,i,i2,i3。。。}onTextChanged{charSequence,i,i2,i3。。。}afterTextChanged{。。。}}}KotlinDSL更进一步 经过前面的一步一步的优化,我们的DSL基本达到了预期效果,接下来通过更多Kotlin的特性让这套DSL更加好用,并且语义更加清晰。infix增强可读性 Kotlin的中缀函数可以让函数省略圆点以及圆括号等程序符号,让语句更自然,进一步提升可读性。 比如所有的View都有setTag方法,正常使用如下:HorizontalLayout{setTag(1,tag1)setTag(2,tag2)} 我们使用中缀函数来优化setTag的调用如下:classTag(valview:View){infixfunBInt。to(that:B)View。setTag(this,that)}funView。tag(block:Tag。()Unit){Tag(this)。apply(block)} DSL中调用代码如下:HorizontalLayout{tag{1totag12totag2}}DslMarker限制作用域HorizontalLayout{this:LinearLayout。。。TextView{this:TextView此处仍然可以调用HorizontalLayoutHorizontalLayout{。。。}}} 上面这段代码,我们发现在TextView{。。。}内部可以调用HorizontalLayout{。。。},这明显是不符合逻辑的。由于TextView的作用域同时处于父HorizontalLayout的作用域内,所以上面代码编译器任务其内容的HorizontalLayout{。。。}是在调用thisLinearLayout不会报错。如果编译器不报错,那么将提升代码的bug,同时也不利于我们日常开发功能。 Kotlin为DSL的使用场景提供了DslMarker注解,可以对方法的作用域进行限制。添加注解的lambda中在省略this的隐式调用时只能访问到最近的类型,当调用更外层的的方法会报错。 DslMarker是一个元注解,我们需要基于它定义自己的注解,如下:DslMarkerTarget(AnnotationTarget。TYPE)annotationclassViewDslMarker 接着,在尾lambda的Receiver添加此注解,如下:funViewGroup。TextView(init:(ViewDslMarkerTextView)。()Unit){addView(TextView(context)。apply(init))} TextView{。。。}中如果不写this。则只能调用TextView的方法,如果想调用外层的方法,必须显示的使用thisxxx进行调用。ContextReceivers传递多个上下文 先看一段代码,如下:funView。dp(value:Int):Int(valuecontext。resources。displayMetrics。density)。toInt()HorizontalLayout{TextView{layoutParamsLinearLayout。LayoutParams(context,null)。apply{widthdp(60)height0weight1。0}}}RelativeLayout{TextView{layoutParamsRelativeLayout。LayoutParams(context,null)。apply{widthdp(60)heightViewGroup。LayoutParams。WRAPCONTENT}}} 上面的代码中有几点可以使用context帮助改善。 首先,代码中使用带参数的dp(60)进行dip转换。我们可以通过前面介绍的context语法替换为60f。dp这样的写法,避免括号的出现,写起来更加舒适。 此外,我们知道View的LayoutParams的类型由其父View类型决定,上面代码中,我们在创建LayoutParams时必须时刻留意类型是否正确,心理负担很大。 这个问题也可以用context很好的解决,如下我们为TextView针对不同的context定义layoutParams扩展函数:context(RelativeLayout)funTextView。layoutParams(block:RelativeLayout。LayoutParams。()Unit){layoutParamsRelativeLayout。LayoutParams(context,null)。apply(block)}context(LinearLayout)funTextView。layoutParams(block:LinearLayout。LayoutParams。()Unit){layoutParamsLinearLayout。LayoutParams(context,null)。apply(block)} 在DSL中使用效果如下: TextView的layoutParams{。。。}会根据父容器类型自动返回不同的this类型,便于后续配置。使用inline和PublishedApi提高性能 DSL的实现使用了大量高阶函数,过多的lambda会产生过的匿名类,同时也会增加运行时对象创建的开销,不少DSL选择使用inline操作符,减少匿名类的产生,提高运行时性能。 比如为ImageView的定义添加inline:inlinefunViewGroup。ImageView(init:ImageView。()Unit){addView(ImageView(context)。apply(init))} inline函数内部调用的函数必须是public的,这会造成一些不必要的代码暴露,此时可以借助PublishedApi化解。resInt指定图片inlinefunViewGroup。ImageView(resId:Int,init:ImageView。()Unit){ImageView(init)。apply{setImageResource(resId)}}drawable指定图片inlinefunViewGroup。ImageView(drawable:Drawable,init:ImageView。()Unit){ImageView(init)。apply{setImageDrawable(drawable)}}PublishedApiinternalinlinefunViewGroup。ImageView(init:ImageView。()Unit)ImageView(context)。apply{thisImageView。addView(this)init()} 如上,为了方便DSL中使用,我们定义了两个ImageView方法,分别用于resId和drawable的图片设置。由于大部分代码可以复用,我们抽出了一个ImageView方法。但是由于要在inline方法中使用,所以编译器要求ImageView必须是public类型。ImageView只需在库的内部服务,所以可以添加为internal的同时加PublishdApi注解,它允许一个模块内部方法在inline中使用,且编译器不会报错。总结 经过上面的步骤,我们已经基本能实现02中ComposeDSLview代码,但是kotlinDSL的运用场景远不止UI这一种,但是基本思路都是相通的,我们再回顾一下基本步骤:使用带有尾lambda的高阶函数实现大括号的层级调用;为lambda添加Receiver,通过this传递上下文;通过扩展函数优化代码风格,DSL中避免出现命令式的语义;使用infix减少点号圆括号等符号的出现,提高可读性;使用DslMarker限制DSL作用域,避免出错;使用ContextReceivers传递多个上下文,DSL更聪明(非正式语法,未来有变动的可能);使用inline提升性能,同时使用PublishedApi避免不必要的代码暴露;