上一篇介绍了运用 Kotlin DSL 构建布局的方法,相较于 XML,可读性和性能都有显著提升。如果这套 DSL 还能数据绑定就更好了,这样就能去掉 findViewById 和 Activity 中的业务逻辑代码(findViewById 需要遍历 View 树,这是耗时的,而 Activity 中的业务逻辑让其变得越发臃肿)。这一篇就介绍一种实现思路。
这是该系列的第十篇,系列文章目录如下:
Kotlin基础 | 白话文转文言文般的Kotlin常识
Kotlin基础 | 望文生义的Kotlin集合操作
Kotlin实战 | 用实战代码更深入地理解预定义扩展函数
Kotlin实战 | 使用DSL构建结构化API去掉冗余的接口方法
Kotlin基础 | 属性也可以是抽象的
Kotlin进阶 | 动画代码太丑,用DSL动画库拯救,像说话一样写代码哟!
Kotlin基础 | 用约定简化相亲
Kotlin基础 | 2 = 12 ?泛型、类委托、重载运算符综合应用
Kotlin实战 | 语法糖,总有一颗甜到你(持续更新)
Kotlin 实战 | 干掉 findViewById 和 Activity 中的业务逻辑
在没有 Data Binding 之前,我们是这样为控件绑定数据的:
class MainActivity : AppCompatActivity() { private var tvName: TextView? = null override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_main) //'获取控件引用' tvName = findViewById<TextView>(R.id.tvName) } override fun onUserReturn(user: User){ //'在数据返回时设置控件' tvName?.text = user.name } } 复制代码
tvName
被静态地声明在 XML 中,程序动态地通过findViewById()
获取引用,在数据返回的地方调用设值 API。
静态的意味着可以预先定义,且保持不变。而动态的恰恰相反。
对于某个特定的业务场景,除了界面布局是静态的之外,布局中某个控件和哪个数据绑定也是静态的。这种绑定关系最初是通过“动态”代码实现的,直到出现了Data Binding
。
它是 Google 推出的一种将数据和控件相关联的方法。
如果把 XML 称为声明型的
,那 Kotlin 代码就是程序型的
,前者是静态的,后者是动态的。为了让它俩关联,Data Binding 的思路是把程序型的
变量引入到声明型的
布局中,比如下面把 User.name 绑定到 TextView 上( data_binding_activity.xml ):
<?xml version="1.0" encoding="utf-8"?> <layout xmlns:android="http://schemas.android.com/apk/res/android"> <data> <variable name="user" type="com.test.User"/> </data> <TextView android:layout_width="match_parent" android:layout_height="match_parent" android:text="@{user.name}"/> </layout> 复制代码
其中User
是程序型的实体类:
package com.test data class User(var name: ObservableField<String>, var age: ObservableField<Int>) 复制代码
ObservableField
用于将任何类型包装成可被观察的对象,当对象值发生变化时,观察者就会被通知。在 Data Binding 中,控件是观察者。
在 Activity 中,这样写代码就完成了数据绑定:
class MainActivity: AppCompatActivity() { //'声明在 Activity 中的数据源' private var user:User? = null override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) //'为 Activity 设置布局并绑定控件' val binding = DataBindingUtil.setContentView<DataBindingActivityBinding>(this, R.layout.data_binding_activity); //'绑定数据源' binding.user = this.user } override fun onUserReturn(user: User){ //'修改数据源' this.user.name.set( user.name ) } } 复制代码
这样写的好处是,Activity 中不会再出现findViewById()
和各种为控件设置属性的方法,而只需要观察数据源的变动。
回顾下上一篇构建布局的 DSL :
class MainActivity : AppCompatActivity() { //'构建布局' private val rootView by lazy { ConstraintLayout { layout_width = match_parent layout_height = match_parent TextView { layout_id = "tvName" layout_width = wrap_content layout_height = wrap_content textSize = 30f textStyle = bold align_vertical_to = parent_id } } } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) //'将布局设置为 Activity 的 content view' setContentView(rootView) } } 复制代码
Activity content view 的根布局是 ConstraintLayout,其中包含一个 TextView。为了让它的值和User.name
联动,新增扩展属性如下:
inline var TextView.bindText: LiveData<CharSequence>? get() { return null } set(value) { //'为 TextView 的 text 属性绑定数据源' observe(value) { text = it } } //'为控件绑定 LiveData 类型的数据源' fun <T> View.observe(liveData: LiveData<T>?, action: (T) -> Unit) { (context as? LifecycleOwner)?.let { owner -> liveData?.observe(owner, Observer { action(it) }) } } 复制代码
为 View 新增一个扩展方法,用于在 View 生命周期内观察数据源 LiveData 的变化。当数据源发生变化时执行action
。
然后就可以像这样为 TextView 绑定数据源了:
class MainActivity : AppCompatActivity() { private val nameLiveData = MutableLiveData<CharSequence>() private val rootView by lazy { ConstraintLayout { layout_width = match_parent layout_height = match_parent TextView { layout_id = "tvName" layout_width = wrap_content layout_height = wrap_content textSize = 30f textStyle = bold //'绑定数据源' bindText = nameLiveData align_vertical_to = parent_id } } } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(rootView) } override fun onUserReturn(user: User){ //'数据源变更' nameLiveData.value = user.name } } 复制代码
布局 DSL 和界面类定义在同一个kt
文件中,所以它能方便地访问到各种数据源。
把数据源都抽象为LiveData<T>
, 控件中每一个需要绑定数据的属性,都可以为其扩展一个bindXXX
属性,它的值是LiveData<T>
。
上面的例子虽然运用了数据绑定
和LiveData
,但还是沿用了MVP
的架构。在业务逻辑更复杂的场景,MVP
架构下的 Activity 类就会变得越来越臃肿。
再来看一个业务逻辑更复杂的例子。不同性别的用户名称有不同颜色:
class MainActivity : AppCompatActivity() { //'应该让 ViewModel 持有 LiveData' private val nameLiveData = MutableLiveData<CharSequence>() //'构建布局应该在 Activity 层完成' private val rootView by lazy { ConstraintLayout { layout_width = match_parent layout_height = match_parent TextView { layout_id = "tvName" layout_width = wrap_content layout_height = wrap_content textSize = 30f textStyle = bold //'数据源和控件的绑定应该在Activity层完成' bindText = nameLiveData align_vertical_to = parent_id } } } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(rootView) } override fun showUserName(user: User){ //'数据源变更(这段业务逻辑应该写在 ViewModel 里面)' nameLiveData.value = SpannableStringBuilder(user.name).apply { setSpan(ForegroundColorSpan(Color.RED), 0, it.name.indexOf(" "), Spannable.SPAN_INCLUSIVE_EXCLUSIVE) val color = if (user.gender == 1) "#b300ff00" else "#b3ff00ff" setSpan(ForegroundColorSpan(Color.parseColor(color)), user.name.indexOf(" "), user.name.lastIndex + 1, Spannable.SPAN_EXCLUSIVE_INCLUSIVE) } } } 复制代码
这里的业务逻辑是:用户的姓和名之间会以空格分隔,将用户的姓展示为红色,而名随性别的变化而变色。
demo 是按照 MVP 来写的,但没有展示所有架构的细节,用文字补充如下:
showUserName()
通知界面刷新(由 Activity 实现)。若有了数据绑定,则可以把 Presenter 和 Activity 间通信都去掉,这也正是 MVP 模式被诟病的地方,即 View 层接口会随着业务逻辑而膨胀。
若改用 MVVM 架构重新实现一边 demo,应该是这样的:
这样 Activity 里面就不再有 findViewById 和 业务逻辑代码。
想要扩展这套 “DSL + 数据绑定” 方案也极为方便,比如为 ImageView 添加一个绑定 url 的属性:
inline var ImageView.bindSrc: LiveData<Bitmap>? get() { return null } set(value) { observe(value) { setImageBitmap(it) } } 复制代码
先为 ImageView 控件扩展一个名为bindSrc
的属性,它是LiveData<Bitmap>?
类型的。
然后在 构建布局 DSL 中就可以这样使用该属性(为简单起见还是用 MVP):
class FirstFragment : Fragment() { //'数据源' val avatarLiveData = MutableLiveData<Bitmap>() //'数据源发生变更' private val target = object : SimpleTarget<Bitmap>() { override fun onResourceReady(resource: Bitmap?, glideAnimation: GlideAnimation<in Bitmap>?) { avatarLiveData.value = resource } } //构建布局 private val rootView by lazy { ConstraintLayout { layout_width = match_parent layout_height = match_parent ImageView { layout_width = 40 layout_height = 40 //'和数据源绑定' bindSrc = avatarLiveData } } override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View? { return rootView } override fun showUser(user: User){ //'触发加载数据源' Glide.with(context).load(user.url).asBitmap().into(target) } } 复制代码
相较于 DataBinding 中自定义 BindingAdapter 更简单一丢丢。