阅读 2924

Kotlin 实战 | 用数据绑定技术干掉 findViewById 和 Activity 中的业务逻辑

上一篇介绍了运用 Kotlin DSL 构建布局的方法,相较于 XML,可读性和性能都有显著提升。如果这套 DSL 还能数据绑定就更好了,这样就能去掉 findViewById 和 Activity 中的业务逻辑代码(findViewById 需要遍历 View 树,这是耗时的,而 Activity 中的业务逻辑让其变得越发臃肿)。这一篇就介绍一种实现思路。

这是该系列的第十篇,系列文章目录如下:

  1. Kotlin基础 | 白话文转文言文般的Kotlin常识

  2. Kotlin基础 | 望文生义的Kotlin集合操作

  3. Kotlin实战 | 用实战代码更深入地理解预定义扩展函数

  4. Kotlin实战 | 使用DSL构建结构化API去掉冗余的接口方法

  5. Kotlin基础 | 抽象属性的应用场景

  6. Kotlin进阶 | 动画代码太丑,用DSL动画库拯救,像说话一样写代码哟!

  7. Kotlin基础 | 用约定简化相亲

  8. Kotlin基础 | 2 = 12 ?泛型、类委托、重载运算符综合应用

  9. Kotlin实战 | 语法糖,总有一颗甜到你(持续更新)

  10. Kotlin 实战 | 干掉 findViewById 和 Activity 中的业务逻辑

  11. Kotlin基础 | 为什么要这样用协程?

  12. Kotlin 应用 | 用协程控制多个并行异步结果的优先级

  13. Kotlin实战 | 干掉 xml 再也不用为各种形状写一堆资源文件了

  14. Kotlin 进阶 | 不变型、协变、逆变

数据绑定原始方式

在没有 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

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 添加数据绑定

回顾下上一篇构建布局的 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

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
    }
}
复制代码

然后就可以像这样为 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 来写的,但没有展示所有架构的细节,用文字补充如下:

  1. Activity 通过 Presenter 请求用户数据。
  2. Presenter 将服务器返回的 Json 转换成 User 类,并调用 View 层接口showUserName()通知界面刷新(由 Activity 实现)。
  3. Activity 在 View 层接口中拿到数据并调用控件设值 API 展示数据。

若有了数据绑定,则可以把 Presenter 和 Activity 间通信都去掉,这也正是 MVP 模式被诟病的地方,即 View 层接口会随着业务逻辑而膨胀。

若改用 MVVM 架构重新实现一边 demo,应该是这样的:

  1. LiveData 实例应该被 ViewModel 持有。
  2. “数据源变更”这种业务逻辑都应该写到 ViewModel 中。
  3. ViewModel 通过 LiveData 将数据源的变更通知出去,而 LiveData 和 控件在 Activity 层完成了绑定。

这样 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更简单一丢丢。

talk is cheap, show me the code

推荐阅读

  1. Kotlin基础:白话文转文言文般的Kotlin常识
  2. Kotlin实战:使用DSL构建结构化API去掉冗余的接口方法
  3. Kotlin进阶:动画代码太丑,用DSL动画库拯救,像说话一样写代码哟!
  4. Android性能优化 | 把构建布局用时缩短 20 倍(上)
  5. Android性能优化 | 把构建布局用时缩短 20 倍(下)