DSL In Action

Kotlin2018-12-04 07:45:22

伴随着Kotlin的发展,有一个神奇的框架 anko-layout,一直存在于我们的视野却又一直因为各种原因无法用于生产环境中。最近在写项目时,再次拿出anko这个框架,思考它在UI小组件上的可用性。

PS: Anko != Anko_Layouts ,但是为了表述方便,文中一部分Anko是代指这Anko Layouts框架,大家自己理解一下~

概述

关于 Anko-Layouts框架的好处和局限性,网上已经有大部分文章在讲,它好在用DSL的方式来描述View,而缺点在于无法即时预览,在这方面导致Anko DSL的开发效率不及XML传统方式。经过大家的一些踩坑,以及开发上的试用,一致表示,Anko Layouts无法用在成熟的项目之中,还是老老实实用XML吧…

Anko Layouts的DSL设计那么棒… 就要这么放弃了吗

大家眼里的Anko Layouts DSL

受官方文档的“诱导”,大家对于Anko Layouts DSL的印象大概是这样子的:

  1. override fun onCreate(savedInstanceState: Bundle?) {

  2.    super.onCreate(savedInstanceState)

  3.    verticalLayout {

  4.        padding = dip(30)

  5.        editText {

  6.            hint = "Name"

  7.            textSize = 24f

  8.        }

  9.        editText {

  10.            hint = "Password"

  11.            textSize = 24f

  12.        }

  13.        button("Login") {

  14.            textSize = 26f

  15.        }

  16.    }

  17. }

  18. val name: EditText = with(ankoContext) {

  19.    editText {

  20.        hint = "Name"

  21.    }

  22. }

官方的Demo中,将Activity的布局方式从 setContentView()中传入Layout ID换到了直接的DSL,嗯… 看起来还不错,官方文档也提供了一个Anko View 组件化的方案:

  1. class MyActivity : AppCompatActivity() {

  2.    override fun onCreate(savedInstanceState: Bundle?, persistentState: PersistableBundle?) {

  3.        super.onCreate(savedInstanceState, persistentState)

  4.        MyActivityUI().setContentView(this)

  5.    }

  6. }

  7. class MyActivityUI : AnkoComponent<MyActivity> {

  8.    override fun createView(ui: AnkoContext<MyActivity>) = with(ui) {

  9.        verticalLayout {

  10.            val name = editText()

  11.            button("Say Hello") {

  12.                onClick { ctx.toast("Hello, ${name.text}!") }

  13.            }

  14.        }

  15.    }

  16. }

直戳XML的痛点,XML作为传统的View构建方式,复用的方式极其有限(比如说蛋疼的 include),而Anko可以在编程语言的层面来做View组件的复用,实在是棒…

但是它背后做了啥?这些View是怎么被构造的?这些View是怎么被添加进去的?如果是复杂的参数又应该怎么办? 这些问题在你计划把Anko Layouts DSL 作为构建View的方式后,逐个浮出水面,然后开始劝退… QAQ

Anko Layout DSL 到底在干什么

为什么我们可以用DSL来写界面?

Kotlin DSL本身就是语法糖而已,所以DSL背后就是使用Kotlin代码来自己初始化View,初始化LayoutParams,进行addView之类…

而其实LayoutInflater它本身也只是在做相似的事情而已,LayoutInflater是根据XML文件里面的配置来通过反射初始化View,根据其他字段来填充View属性以及LayoutParams什么的。所以没有什么神秘的东西…

我们梳理一下,其实在非XML代码中构建View的时候,无非就是 newView(context)->addView

那么我们瞅瞅Anko的代码,是不是也有相似的逻辑(不用想也是啊)

  1. inline fun ViewManager.textView(init: (@AnkoViewDslMarker android.widget.TextView).() -> Unit): android.widget.TextView {

  2.    return ankoView(`$$Anko$Factories$Sdk25View`.TEXT_VIEW, theme = 0) { init() }

  3. }

  4. inline fun <T : View> ViewManager.ankoView(factory: (ctx: Context) -> T, theme: Int, init: T.() -> Unit): T {

  5.    val ctx = AnkoInternals.wrapContextIfNeeded(AnkoInternals.getContext(this), theme)

  6.    val view = factory(ctx)

  7.    view.init()

  8.    AnkoInternals.addView(this, view) // this.addView(view)

  9.    return view

  10. }

  11. //自定义的View添加DSL支持的话 (ColorCircleView是我的一个自定义View)

  12. //这里的代码比ViewManager.textView更容易理解

  13. inline fun ViewManager.colorCircleView() = colorCircleView {}

  14. inline fun ViewManager.colorCircleView(init: ColorCircleView.() -> Unit): ColorCircleView {

  15.    return ankoView({ ColorCircleView(it) }, theme = 0, init = init)

  16. }

我们可以大概看到,在AnkoView中构造了一个View然后通过ViewManager添加到ViewGroup里面去。 那么,ViewManager是什么呢?

  1. package android.view;

  2. /** Interface to let you add and remove child views to an Activity. To get an instance

  3.  * of this class, call {@link android.content.Context#getSystemService(java.lang.String) Context.getSystemService()}.

  4.  */

  5. public interface ViewManager

  6. {

  7.    public void addView(View view, ViewGroup.LayoutParams params);

  8.    public void updateViewLayout(View view, ViewGroup.LayoutParams params);

  9.    public void removeView(View view);

  10. }

所以ViewManager是管理着View的添加,修改以及删除的接口,不出意料,ViewGroup就实现了ViewManager

  1. public abstract class ViewGroup extends View implements ViewParent, ViewManager {

然后我们梳理一下, textView是一个拓展方法,拓展到了ViewManager接口里面,因此所有实现ViewManager接口的类都可以调用这个 textView方法,而调用这个方法的结果就是把 textView加入到此ViewGroup里面,比如说:

  1. val frameLayout = findViewById<FrameLayout>(R.id.fl_container)

  2. val view = frameLayout.textView {

  3.    text = "辣鸡办公网???????"

  4.    textColor = Color.BLACK

  5.    textSize = 16f

  6. }.apply {

  7.    layoutParams = FrameLayout.LayoutParams(matchParent, wrapContent)

  8.    visibility = View.GONE

  9. }

效果就是,在FrameLayout里面添加了一个TextView,Textview拥有着DSL闭包里面的配置。

另外,我们构造View的方式还有,传入一个Context就可以构建出一个View,我们可以瞅瞅相关的代码:

  1. inline fun Context.constraintLayout(): android.support.constraint.ConstraintLayout = constraintLayout() {}

  2. inline fun Context.constraintLayout(init: (@AnkoViewDslMarker _ConstraintLayout).() -> Unit): android.support.constraint.ConstraintLayout {

  3.    return ankoView(`$$Anko$Factories$ConstraintLayoutViewGroup`.CONSTRAINT_LAYOUT, theme = 0) { init() }

  4. }

背后的实现我们不做深究,大概就是用Context来构建出一个View,然后拿到了View,我们就可以为所欲为了。

怎么把Anko灵活用起来

简单回顾一下上面一节的内容: 如果我们拥有一个ViewGroup或者拥有一个Context,就可以用来创建View

因此Anko的用法远要比你想象中的灵活 -> 可以拿到Context/ViewGroup的地方就可以使用Anko,而Anko的作用也就是简化初始化View + AddView的流程。

举个栗子?

比如说我已经用XML写好了页面的布局,然后我们需要根据代码在其中一个FrameLayout中动态添加一些东西。我们就可以拿到这个FrameLayout的引用,然后就可以用anko大展拳脚了。

  1. val frameLayout = findViewById<FrameLayout>(R.id.fl_container)

  2. val view = frameLayout.textView {

  3.    text = "辣鸡办公网???????"

  4.    textColor = Color.BLACK

  5.    textSize = 16f

  6. }.apply {

  7.    layoutParams = FrameLayout.LayoutParams(matchParent, wrapContent)

  8.    visibility = View.GONE

  9. }

  10. frameLayout.verticalLayout {

  11. }

摸着良心说,是不是比自己创建View(不管是从Inflater还是java code方式)都要简单太多。

再举一个例子,在BottomSheetDialogFragment中,我们拿到Dialog后,需要通过setContView的方式来给它设置有个View进去,而我们一般会在XML写好然后Inflater获得View加载进去,或者自己一个一个new。有了Anko后,你可以随手写起DSL。

  1.    override fun setupDialog(dialog: Dialog?, style: Int) {

  2.        if (dialog == null) return

  3.        val context = dialog.context

  4.        val view = context.nestedScrollView {

  5.            verticalLayout {

  6.                constraintLayout {

  7.                    backgroundColor = getColorCompat(R.color.colorPrimary)

  8.                    val titleText = textView {

  9.                        text = "课程表设置"

  10.                        id = View.generateViewId()

  11.                        textSize = 20f

  12.                        textColor = Color.WHITE

  13.                    }.lparams(width = wrapContent, height = wrapContent) {

  14.                        startToStart = ConstraintLayout.LayoutParams.PARENT_ID

  15.                        topToTop = ConstraintLayout.LayoutParams.PARENT_ID

  16.                        bottomToBottom = ConstraintLayout.LayoutParams.PARENT_ID

  17.                        margin = dip(16)

  18.                    }

  19.                }

  20.                indicator("课程表界面设置")

  21.                constraintLayout {

  22.                    backgroundColor = Color.WHITE

  23.                    textView {

  24.                        text = "自动隐藏周六日"

  25.                        textSize = 14f

  26.                        textColor = Color.BLACK

  27.                    }.lparams(width = wrapContent, height = wrapContent) {

  28.                        startToStart = PARENT_ID

  29.                        topToTop = PARENT_ID

  30.                        bottomToBottom = PARENT_ID

  31.                        leftMargin = dip(16)

  32.                    }

  33.                    switch {

  34.                        isChecked = SchedulePref.autoCollapseSchedule

  35.                        onCheckedChange { _, isChecked ->

  36.                            SchedulePref.autoCollapseSchedule = isChecked

  37.                        }

  38.                    }.lparams {

  39.                        topToTop = PARENT_ID

  40.                        bottomToBottom = PARENT_ID

  41.                        endToEnd = PARENT_ID

  42.                        rightMargin = dip(16)

  43.                    }

  44.                }.lparams(width = matchParent, height = dip(48))

  45.                indicator("主题设置(课程表试点)")

  46.            }

  47.        }

  48.        dialog.setContentView(view)

  49.    }

你甚至可以像函数一样去封装,给LinearLayout做拓展后,就可以包装添加固定风格TextView的操作了(这个封装是不是就很好写 就贼tm方便)

  1. fun _LinearLayout.indicator(indicatorText: String) = frameLayout {

  2.        textView {

  3.            text = indicatorText

  4.            textColor = getColorCompat(R.color.colorPrimary)

  5.            textSize = 12f

  6.            typeface = Typeface.create("sans-serif-medium", Typeface.NORMAL)

  7.        }.lparams(width = matchParent, height = wrapContent) {

  8.            leftMargin = dip(8)

  9.            topMargin = dip(8)

  10.        }

  11.    }.lparams(width = matchParent, height = wrapContent)

  12.    fun lollipop(block: () -> Unit) {

  13.        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {

  14.            block()

  15.        }

  16.    }

你甚至可以用for循环来做类似于适配器的事情(当然缓存是不会有缓存的 这辈子都没有做的)

  1. spreadChainLayout {

  2.                    listOf(

  3.                            "佩奇粉" to Color.parseColor("#EDC6CD"),

  4.                            "乔治蓝" to Color.parseColor("#6595D9"),

  5.                            "猪妈黄" to Color.parseColor("#F4B17F"),

  6.                            "猪爸绿" to Color.parseColor("#6FC6C5"),

  7.                            "基佬紫" to Color.parseColor("#9C26B0")

  8.                    ).forEachIndexed { index, (name, color) ->

  9.                        verticalLayout {

  10.                            colorCircleView {

  11.                                this.color = color

  12.                            }.lparams {

  13.                                width = dip(24)

  14.                                height = dip(24)

  15.                                gravity = Gravity.CENTER_HORIZONTAL

  16.                            }

  17.                            textView {

  18.                                text = name

  19.                                textColor = color

  20.                                textSize = 12f

  21.                                typeface = Typeface.create("sans-serif-medium", Typeface.NORMAL)

  22.                            }.lparams(width = wrapContent, height = wrapContent) {

  23.                                topMargin = dip(6)

  24.                            }

  25.                        }.lparams {

  26.                            width = wrapContent

  27.                            height = wrapContent

  28.                            horizontalPadding = dip(16)

  29.                        }.setOnClickListener {

  30.                            val theme = CustomTheme.themeList[index]

  31.                            Log.e(TAG, "custom theme: $theme")

  32.                            val activity = this@CustomSettingBottomFragment.activity

  33.                            Colorful().edit()

  34.                                    .setPrimaryColor(theme)

  35.                                    .setAccentColor(theme)

  36.                                    .apply(context) {

  37.                                        activity?.recreate()

  38.                                    }

  39.                        }

  40.                    }

  41.                }.lparams(width = matchParent, height = wrapContent) {

  42.                    topMargin = dip(12)

  43.                    bottomMargin = dip(12)

  44.                }

在一个Layout的闭包里面写循环,填充数据,然后addView,有了Kotlin的语法糖 + Anko变得很舒服。

你甚至可以在Recyclerview里面写Anko

  1. class Item(var text: String, var builder: (TextView.() -> Unit)? = null)

  2. class ViewHolder(itemView: View?, val textView: TextView) : RecyclerView.ViewHolder(itemView)

  3. override fun onCreateViewHolder(parent: ViewGroup): RecyclerView.ViewHolder {

  4.    lateinit var textView: TextView

  5.    val view = parent.context.constraintLayout {

  6.         textView = textView {

  7.            text = "课程表设置"

  8.            id = View.generateViewId()

  9.            textSize = 16f

  10.            textColor = Color.BLACK

  11.        }.lparams(width = wrapContent, height = wrapContent) {

  12.            startToStart = PARENT_ID

  13.            topToTop = PARENT_ID

  14.            bottomToBottom = PARENT_ID

  15.            margin = dip(16)

  16.        }

  17.        imageView {

  18.            backgroundColor = getColorCompat(R.color.common_lv4_divider)

  19.        }.lparams(width = matchParent, height = dip(1)) {

  20.            bottomToBottom = PARENT_ID

  21.        }

  22.    }.apply {

  23.        layoutParams = RecyclerView.LayoutParams(matchParent, wrapContent)

  24.    }

  25.    return ViewHolder(view,textView)

  26. }

  27. override fun onBindViewHolder(holder: RecyclerView.ViewHolder, item: Item) {

  28.    item as SingleTextItem

  29.    holder as ViewHolder

  30.    holder.textView.text = item.text

  31.    item.builder?.invoke(holder.textView)

  32. }

在数据里面附着上一个闭包,便可以实现TextView的自定义(把逻辑从onBindViewHolder里面抽离出来),我们的项目中Recyclerview Adapter做了DSL风格的二次封装,目前处于测试阶段,稳定了之后会分享在博客里面。

DSL大概是这样子的:

  1.        recyclerView.withItems {

  2.            courseInfo(course = course)

  3.            indicatorText("上课信息")

  4.            val week = course.week

  5.            course.arrangeBackup.forEach {

  6.                iconLabel(CourseDetailViewModel(R.drawable.ic_schedule_location, "${week.start}-${week.end}周,${it.week}上课,每周${getChineseCharacter(it.day)}第${it.start}-${it.end}节\n${it.room}"))

  7.            }

  8.            indicatorText("其他信息")

  9.            iconLabel(CourseDetailViewModel(R.drawable.ic_schedule_other, "逻辑班号:${course.classid}\n课程编号:${course.courseid}"))

  10.            indicatorText("自定义(开发中 敬请期待)")

  11.            iconLabel(CourseDetailViewModel(R.drawable.ic_schedule_search, "在蹭课功能中搜索相似课程"))

  12.            iconLabel(CourseDetailViewModel(R.drawable.ic_schedule_event, "添加自定义课程/事件"))

  13.            iconLabel(CourseDetailViewModel(R.drawable.ic_schedule_homework, "添加课程作业/考试"))

  14.            indicatorText("帮助")

  15.        }

做个总结?

DSL最吸引人的地方就在于,它可以在布局上加入逻辑,对于布局过程,它有着编程语言级别的控制,比如说封装成类,封装成函数什么的。这些东西在XML里面都是无法做到的,因为aapt工具的局限性,XML只能按照固定的格式写布局 + 代码控制来提供动态性,反正就很蛋疼。

而DSL可以解决很多问题,比如说用一个for循环来取代Adapter填充View功能,避免了很多无用的操作。比如说在布局里面加一个if就可以来操作一个控件的布局与否,而不是在findView之后控制Visibility,可以用Kotlin的闭包来封装一个View的初始化操作什么的,重复的操作就可以封装起来,再比如XML只能设置paddingLeft/paddingRight,在Anko DSL / 自定义DSL里面就可以很轻易的封装出一个horizontalPadding。当然Anko因为避免了反射,提高了大量的性能。

DSL和XML并不是冲突的,DSL用于解决布局中细碎和动态的部分,而XML用于单页布局,复杂布局。同时DSL和XML也可以无缝嵌合在一起,所以两者并不是冲突的关系,也没有必要去选择“我到底该用DSL写还是XML写”,两者各有优点,了解Anko DSL并且与XML活用起来才是最优解。XML可以拿到ViewGroup的应用然后用DSL做骚操作,DSL也可以动态添加Inflate出来的XML来实现复杂页面布局的添加

DSL和XML各有所长,DSL更适合用于页面模块的解耦,XML更多用于单页构建 / 复杂布局,两者相互结合相互服务。

还想说的

Anko DSL让人望而却步的部分就是它不能支持即时预览,所以这个局限性也就导致Anko无法构建大型复杂的页面。而当你的设计图可以精确到dp的时候,完全可以用DSL来描述UI的各个小组件,因此DSL在这里不应该被一棒子打死,DSL在目前的项目中,可以很好的替代手工 newView,add view的部分,以及小规模的View控制。

如果你认真看了上面的内容,并且有自己的体会,可以在已有的UI构架中很快的用上Anko Layout来解决一些轻量级UI的构建。比如说List中的一个Item,或者一个小Dialog之类。

没有所谓的“最佳实践”,对于业务与技术的一步步探索才是最重要的。


转载请注明出处:微信公众号 Kotlin


Copyright © 温县电话机虚拟社区@2017