这是《通关Jetpack》系列的第1篇文章

有些人沦为平庸,有的人金玉其外、败絮其中。可未来某一天,不经意间你会遇到一个彩虹般绚丽的人,从此以后,其他人就不过是匆匆浮云。

Data Binding是什么

在学习一项新知识、新技能之前,必须问的问题是这门技术是什么、将为我们带来什么有益的改变。我们来看一下Data Binding。

Data Binding是什么

Data Binding是由Google推出的一个库(Library),用来解决数据变化与UI显示同步的问题。

Data Binding常常与MVVM一同出现,这并不意味着它们是同一类东西。MVVM是设计模式/编程思想,ViewModel是其中一个组成部分。而Data Binding则是实现这门思想的一种工具。

Data Binding将为我们带来怎样的改变

做应用开发,很大一部分精力都用于处理UI和数据的同步问题。在传统实现方式里,数据从服务器返回了,此时要手动调用updateView一类的方法更新UI;同样,当用户操作UI上的元素时,会通过Listener等监听,更新到内存的数据中——这是逻辑式写法,而Data Binding则是通过声明式的写法,将数据-UI解耦,从而可以简化控制流,让我们把注意力集中在复杂的业务逻辑上,而非UI同步。

我们首先从codelab的实例入手,学习如何使用Data Binding。然后会深入介绍Data Binding的API,最后则是从原理角度阐述它是如何工作的。预计分成3篇文章。

假想一种需求场景

以这样一个App为例,它需要显示一段静态的文本(左侧),以及可点击的LIKE按钮(右侧),点击该按钮时会提升欢迎度数值并实时显示在UI上,最后,欢迎度数值每达到一个阶段,会在屏幕右侧显示不同阶段的图片。

sample app
sample app

没有Data Binding时的写法

首先是没有使用Data Binding时,传统的MVVM写法,已省略无关代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
// SimpleViewModel.kt
// ViewModel,对应页面上的静态部分、动态部分以及点击监听函数
class SimpleViewModel : ViewModel() {
val name = "Grace"
val lastName = "Hopper"
var likes = 0

// 点击监听函数
fun onLike() {
likes++
}

// 不同阶段的欢迎度,基于like数计算
val popularity: Popularity
get() {
return when {
likes > 9 -> Popularity.STAR
likes > 4 -> Popularity.POPULAR
else -> Popularity.NORMAL
}
}
}

// PlainOldActivity.kt,UI层
class PlainOldActivity : AppCompatActivity() {

// Activity当中维护一个成员变量
private val viewModel by lazy { ViewModelProviders.of(this).get(SimpleViewModel::class.java) }

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

setContentView(R.layout.plain_activity)

// 缺点1:当数据发生变化时,需要手动更新UI
updateName()
updateLikes()
}

// 缺点2:在UI类中包含业务逻辑
fun onLike(view: View) {
viewModel.onLike()
updateLikes()
}

// 缺点3:太多的findViewById
private fun updateName() {
findViewById<TextView>(R.id.plain_name).text = viewModel.name
findViewById<TextView>(R.id.plain_lastname).text = viewModel.lastName
}

// 缺点4:多次调用findViewById
// 缺点5:包含未经测试的逻辑
// 缺点6:即使数据未发生变化,也会更新View
private fun updateLikes() {
findViewById<TextView>(R.id.likes).text = viewModel.likes.toString()
findViewById<ProgressBar>(R.id.progressBar).progress =
(viewModel.likes * 100 / 5).coerceAtMost(100)
val image = findViewById<ImageView>(R.id.imageView)

val color = getAssociatedColor(viewModel.popularity, this)

ImageViewCompat.setImageTintList(image, ColorStateList.valueOf(color))

image.setImageDrawable(getDrawablePopularity(viewModel.popularity, this))
}

private fun getAssociatedColor(popularity: Popularity, context: Context): Int {
// ...
}

private fun getDrawablePopularity(popularity: Popularity, context: Context): Drawable? {
// ...
}
}

这么简单一个页面,光是列出来的缺点就有6条之多,无法想象当业务逻辑复杂起来后,代码将会增长到何种程度。接下来,让我们看看Data Binding如何解决上述问题的。

静态数据绑定UI

gradle从1.5.0版本开始支持data binding首先需要在project的build.gradle文件里打开开关,

1
2
3
4
5
6
android {
...
dataBinding {
enabled true
}
}

相比于普通布局,data binding布局文件在最外层增加了<layout>标签,<layout>标签内部则由<data>标签和原布局组成。对于需要重构成data binding的布局文件,将鼠标选中最外层布局,会自动提示 Convert to data binding layout。一个data binding布局如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
<layout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools">
<data>

</data>
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="match_parent">

<TextView
...

<data>标签中的变量,可以在布局文件里进行调用,也支持简单的表达式,比如if...else...、类型转换、字符串拼接。表达式的格式为@{...}。虽然表达式很强大,但是不要滥用,否则会使布局文件过于复杂,难以维护。

1
2
3
android:text="@{String.valueOf(index + 1)}"
android:visibility="@{age < 13 ? View.GONE : View.VISIBLE}"
android:transitionName='@{"image_" + id}'

基于上述知识,我们为属性name和last name声明变量。

1
2
3
4
<data>
<variable name="name" type="String"/>
<variable name="lastName" type="String"/>
</data>

从而可以在布局文件中直接使用它们。

1
2
3
<TextView
android:id="@+id/plain_name"
android:text="@{name}" />

目前为止已经完成了布局文件的编写,需要一个时机,将数据塞给布局文件,可以在onCreate里做这件事。

1
2
3
4
5
// onCreate
setConetntView(R.layout.plain_activity)
val binding: PlainActivityBinding = DataBindingUtil.setContgentView(this, R.layout.plain_activity)
binding.name = "Lei"
binding.lastName = "Li"

如此即可,不需要findViewByIdsetText,甚至不关心UI里到底是TextView还是EditText甚至自定义控件,要做的事情只是给成员变量赋值。处理完了静态文本,接下来看看如何响应UI的点击LIKE事件。要知道,这里我们同样也不用findViewById以及setOnClickListener的。

首先回忆一下最早的点击事件处理方式——在布局文件中,onClick=onLike,这样自动关联起来Activity中的onLike方法,虽然直观,但带来的后果就是绑死了布局文件与Activity,根本无法复用。其实Data Binding的处理方式与此类似,只不过它增加了一个ViewModel层。

在计算机软件领域,没有什么问题是增加一个中间层不能解决的,如果就,那就加两层。

1
2
3
4
5
<data>
<variable
name="viewmodel"
type="com.example.android.databinding.basicsample.data.SimpleViewModel"/>
</data>

<data>中不再是简单的String,而是一个SimpleViewModel类型对象,该类在上文中可以看到,它包含namelastName字段,以及一个处理LIKE++事件的onLike方法。有了这个viewmodel对象,就可以在接下来的布局里面这样使用了。且比上种写法更好的地方在于,编译器会检查onLike方法是否存在,如果不存在则编译时就会异常。

1
2
3
4
5
6
7
8
9
10
11
<TextView
android:id="@+id/plain_name"
android:text="@{viewmodel.name}"
... />
<TextView
android:id="@+id/plain_lastname"
android:text="@{viewmodel.lastName}"
... />
<Button
android:onClick="@{() -> viewmodel.onLike()}"
... />

对于viewmodel的赋值,则在onCreate里面直接binding.viewmodel = viewModel就可以。但是如果我们把likes属性绑定到布局文件的某个TextView上(注意这里不要直接让android:text="@{viewmodel.lines}",因为这会导致运行时读取id=0的字符串资源,从而异常),会发现即使通过onLike()增长了LIKE,但在UI上并没有体现,LIKE数始终显示为0。这是因为目前为止我们进行的绑定都是静态单向绑定,下一节我们将学习双向绑定,从而让LIKES的数目实时显示在UI上。

双向Data Binding的写法

所谓“双向”Data Binding,是指对于View而言,在初始化时可以自动从data里获取数据,对于data而言,当它们的值发生变化时,能自动通知UI显示相应数值。对于这种发生变化时能通知UI的数据,称之为具有“可观察性”,即observable。有多重途径生成一个可观察的对象,比如observable classesobservable fields,以及最通用也最好用的LiveData。声明可观察对象的方式如下。带有前置下划线_的变量为私有变量,是可变的,不对外进行暴露。每个私有变量都拥有一个LiveData<T>类型的接口变量,用于提供给布局文件读取。

1
2
3
4
5
6
7
private val _name = MutableLiveData("Ada")
private val _lastName = MutableLiveData("Lovelace")
private val _likes = MutableLiveData(0)

val name: LiveData<String> = _name
val lastName: LiveData<String> = _lastName
val likes: LiveData<Int> = _likes

同时需要在Activity的onCreate()方法中,为binding对象的lifecycleOwner赋值(为Activity)。如果不做这一步,数据的变化就无法被观测到。

1
2
3
4
5
override fun onCreate(SavedInstanceState: Bundle?) {
...
binding.lifecycleOwner = this
...
}

对于popularity,同样将其声明为LiveData类型。这里注意,并没有私有的_popularity,因为它的值实际上是根据likes计算得到的。因此通过Transformations.map()操作,从一个MutableLiveData类型的_likes对象计算出LiveData类型的popularity。关于Transformation的使用,可以看这篇文章

1
2
3
4
5
6
7
8
// popularity is exposed as LiveData using a Transformation instead of a @Bindable property.
val popularity: LiveData<Popularity> = Transformations.map(_likes) {
when {
it > 9 -> Popularity.STAR
it > 4 -> Popularity.POPULAR
else -> Popularity.NORMAL
}
}

此时,在onLike()中我们直接修改_likes的值,就会 通知到UI自动发生调整了。

1
2
3
fun onLike() {
_likes.value = (_likes.value ?: 0) + 1
}

此处简单介绍下其实现原理,Data Binding库里面包含众多Adapters(适配器),见源码。对于xml布局文件里的UI组件,需要设置的属性均声明了相应的静态设值方法,比如对于TextViewandroid:text属性,就有如下setText(TextView, CharSequence)方法。当text值发生变更时,就会调用到这个方法。

1
2
3
4
5
@BindingAdapter("android:text")
public static void setText(TextView view, CharSequence text) {
// Some checks removed for clarity
view.setText(text);
}

此时还剩下两点功能没有完成,分别是LIKE按钮下方的进度条没有实时增加,以及LIKE图片没有更新。先看进度条相关的事项。对于进度条有3点需求,分别是:

  1. 当LIKES=0时,进度条隐藏
  2. 当LIKES增加时,进度条实时增长,且每5个LIKE一个循环,进度条归零
  3. 当进度条拉满时,颜色变深

这些需求都可以通过自定义BindingAdapter来实现,它可以为xml元素创建任意的自定义属性,并通过代码读取该属性,进而对xml元素原生的属性进行修改。在任意包下创建一个BindingAdapters.kt文件(不要担心路径,因为编译时会自动识别注解),借助于Kotlin的顶层函数,可以不声明BindingAdapter类而直接写函数。我们为所有的View创建一个app:hideIfZero属性,控制当该属性为0时隐藏UI元素。

当LIKES=0时,进度条隐藏

1
2
3
4
@BindingAdapter("app:hideIfZero")
fun hideIfZero(view: View, number: Int) {
view.visibility = if (number == 0) View.GONE else View.VISIBLE
}

随后在xml布局文件中将该属性与viewmodel.likes进行绑定,这样当likes=0时,进度条就会自动隐藏。

1
2
3
4
<ProgressBar
android:id="@+id/progressBar"
app:hideIfZero="@{viewmodel.likes}"
...

当LIKES增加时,进度条实时增长,且每5个LIKE一个循环,进度条归零

参考上面的hideIfZero属性,我们这次同样用自定义BindingAdapter来实现。不同之处在于,这次我们需要同时读取两个属性,在@BindingAdapter注解里面,通过字符串数组["app:progressScaled", "android:max"]来声明,同时requireAll = true表示必须两个属性都在xml得到声明时,才会调用该方法进行处理。如果有任一个没有赋值,则不进行方法。与之相对应的requiredAll = false则对至少一个属性进行相应,对于没有声明的属性会使用其默认值。

coerceAtMost是Kotlin提供的扩展函数,表示“最大不超过max”。

1
2
3
4
@BindingAdapter(value = ["app:progressScaled", "android:max"], requireAll = true)
fun setProgress(progressBar: ProgressBar, likes: Int, max: Int) {
progressBar.progress = (likes * max / 5).coerceAtMost(max)
}

相应地,在布局文件中声明app:progressScaledandroid:max两个属性。

1
2
3
4
5
6
<ProgressBar
android:id="@+id/progressBar"
android:hideIfZero="@{viewmodel.likes}"
android:progressScaled="@{viewmodel.likes}"
android:max="@{100}"
/>

当进度条拉满时,颜色变深

如果前面的知识都已经掌握,这里也就不难了。我们声明一个自定义属性app:progressTint,表示会影响到progressBar的外显颜色,输入为popularity,方法内部先根据popularity计算出color(注意这里使用到的Context为View的Context),然后再将其设置到progressBar的progressTintList属性上。

1
2
3
4
5
6
7
@BindingAdapter("app:progressTint")
fun tintPopularity(view: ProgressBar, popularity: Popularity) {
val color = getAssociatedColor(popularity, view.context)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
view.progressTintList = ColorStateList.valueOf(color)
}
}

随后还有一个功能,是根据不同的popularity显示不同的图片,可以把它当做课后作业,参考答案在这里

参考资料