(译)Android Jetpack 学习笔记之关于 DataBinding 的争论

数据绑定在 Android 应用开发中到底是不是一个好的实践?你肯定说话好,因为是 Google 出的嘛,Google 都说它好。不过这里有篇奇怪的文章,它说 Android 数据绑定技术不好,存在不少问题。到底什么问题,它说得对还是不对?我翻译一下,当作学习。

原文

注:随意 Android 数据绑定库的更新,这篇文章中提到的一些问题已经被修复了。

从一个比较高的角度来说,Android 的数据绑定库是一个非常不错的概念。更新 ViewModel 中的数据就可以让界面自动更新 (是不是很爽?)。但在本文中我会说说为什么我觉得这个库其实是在鼓励写糟糕的代码,以至于必须非常小心地管理这些糟糕的代码,以及这个库带来的其他的问题。这里的讨论基于我在实际项目中使用 Android 数据绑定库的体验。

关注点分离

使用数据绑定最大的问题是它鼓励将展示逻辑写在 XML 文件中。布局文件应当只定义跟屏幕展示内容相关的布局,这个道理是不言自明的。

看这个简单的例子:

1
android:visibility="@{viewModel.showTitle ? View.VISIBLE : View.GONE}"

为了更改一个 View 的可见性,你必须在 XML 中实现一个基于 boolean 值的三元操作,或者在 ViewModel 中设置正确的 integer 值。

考虑到这里的代码非常简单,所以你并不介意在 XML 中写类似代码。但问题是一旦你允许在布局中写逻辑,它就可能变得一团糟。某天你可能看到这样的代码:

1
2
android:visibility="@{!viewModel.hideFragmentLayout && !viewModel.showError && viewModel.vehiclesViewModel.showFullScreenProgress ? View.VISIBLE : View.GONE}"
android:visibility="@{!viewModel.isError && !viewModel.hideFragmentLayout ? View.VISIBLE : View.GONE}"

以上是一个比较极端的例子。解决办法是为每种组合引入一个变量,然后基于这个变量来恰当地更新 visibility。

如果使用数据绑定的布局有 include 其他布局文件,问题会变得更加明显。你可能会在子布局中定义变量,然后在父布局中为这些变量进行绑定。

1
2
3
4
5
6
7
<include
layout="@layout/layout_full_screen_error"
app:errorMessage="@{viewModel.errorMessage}"
app:errorTitle="@{viewModel.errorTitle}"
app:imageResource="@{viewModel.errorImage}"
app:onClick="@{() -> viewModel.onErrorRetryClicked()}"
app:visibility="@{viewModel.showError ? View.VISIBLE : View.GONE}" />

被包含的布局如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<data>
<import type="android.graphics.drawable.Drawable"/>
<import type="android.view.View"/>
<import type="android.text.TextUtils" />
<variable
name="visibility"
type="java.lang.Integer"/>
<variable
name="errorTitle"
type="java.lang.CharSequence"/>
<variable
name="errorMessage"
type="java.lang.CharSequence" />
<variable
name="onClick"
type="android.view.View.OnClickListener"/>
<variable
name="imageResource"
type="Drawable"/>
</data>

这个包含子布局的例子看起来非常乱。我甚至都不想讨论这个例子中的 onClick 绑定。我想我已经通过这个例子表明了我的观点。

不一致/不清晰的用法

到目前为止上面的例子主要是说将展示逻辑引入到 XMl 布局文件引起的问题。另一个问题是你如何进行绑定的方法并不清晰。ViewModel 有多种方式来更新数据:

  • 如果 ViewModel 是继承自 BaseObservable,你可以使用 @Bindable 注解
1
2
val showError: Boolean
@Bindable get() = !hideFragmentLayout.get() && isError.get()
  • 也可以使用 ObservableField
1
val errorMessage = ObservableField<CharSequence>()
  • 或者让 Activity/Fragment 监听 ObservableCommand
1
val showLoading = ObservableCommand<Boolean>()
1
2
3
viewModel.showLoading.subscribe {
progressBar?.showLoadingAnimation(it)
}

(译者注:我在最新的 support library 中找不到 ObservableCommand 类,去掉了?)

关于哪种场景下应该使用哪个方法我们有一些宽泛的规则,但由于这些方法太过相似,什么时候该用哪种方法还是非常容易弄混,所以给看代码和调代码都造成了困难。

更新:写这篇文章时 Google 推出了 Architecture Components library,它通过 LiveData 提供了另外一种类似 ObservableField 和 ObservableCommand 数据绑定方式。也许 LiveData 是数据绑定事实上的标准方式。

将 ViewModel 作为 POJO

我之前聊过这个话题,最好是将 ViewModel 作为 POJO。既有语义上的原因,另一个原因是它有助于单元测试。但是,由于 ViewModel 负责设置 text/drawables 等资源,为了实现这个目的我们最终不得不对 Android SDK 进行部分封装,最后会写出这样的代码:

1
uiService.hideKeyboard()

Which is a method on a UiService interface that is implemented as either an ActivityUiService or FragmentUiService an injected into the view model using dagger. This should be obvious, but a view model should not be responsible for showing and hiding the keyboard.

译者注:这一节我没有完全明白,感觉是原作者对 ViewModel 的用法理解有误,所以干脆略过没有翻译。我的理解是 ViewModel 只负责管理数据状态,不负责管理 UI 状态,所以不存在设置 text/drawables 一说。

构架

我跟一个非常热衷于数据绑定库的同事聊天时,他的回应是我提出的大部分问题其实是可以避免的,前提是你要小心地管理你的代码。但我认为就算他是正确的,一个好的架构应该可以限制你写出糟糕的代码,而不是鼓励你写出糟糕的代码。

测试

我之前也聊过这个话题,但值得再说一次:使用了数据绑定之后,展示层变得很难单元测试,有时甚至是无法测试的。

在 ViewModel 中引入 Android 类后意味着你可能需要使用生成的 R.java 文件,以及模块一些 Android 类,而其中某些类是 final 类。

更糟糕的是,如果你将展示逻辑放在 XML 文件中或者 Activity/Fragment 中(这个库推荐这种设计方式),也会让单元测试变得困难。如果有谁知道有什么好的测试办法,也请告诉我。

编译

使用了数据绑定库之后,kotlin 和 dagger 都会在编译期生成代码,所以项目的编译耗时成了恶梦。另外,由于多个库在编译期生成代码,kotlin 增量编译无法正常运行,instant run 也无法正常运行。我参与的另一个项目也在使用 kotlin 和 dagger,但是没有使用数据绑定库,这个项目就不存在这些问题。

总结

我问了一些使用过数据绑定库的同事以上这些问题,他们的回答通常是”我也不知道哪里去找代码逻辑”,所以我决定写这篇文章,文章中使用几个例子展示了逻辑是如何分散在多个地方并且没有清晰的界限的。考虑到开发过程中很多时间是在读代码,所以代码放在容易被找到的位置很重要。加上不易测试,以及过长的编译时间,所以很容易解释为什么新的开发者不愿意使用数据绑定库。我也不愿意在后续项目中使用这个库。