13、DataBinding与Room:让数据活起来
说实话,前面几章我们一直在讲DataBinding怎么绑定UI,怎么处理事件。但有个问题你肯定想过——数据变了怎么办?总不能每次数据库更新都手动调一遍binding吧?
嗯,这一章就是来解决这个问题的。我们把Room和DataBinding串起来,让数据库变化自动反映到UI上。说白了,就是让数据自己"跑"到界面上去。
13.1 Room实体与Observable
先说说我个人的习惯。早期做项目时,我都是手动写ContentObserver监听数据库变化,然后刷新UI。那叫一个累。后来有了Room + LiveData,才算是解放了双手。
Room实体本身就是一个普通的Kotlin数据类,加上@Entity注解就行。但要让DataBinding感知到它的变化,我们需要把它包装成Observable。
最简单的做法——让实体类继承BaseObservable。来看代码:
@Entity(tableName = "users")
class User : BaseObservable() {
@PrimaryKey
var id: Long = 0
@ColumnInfo(name = "name")
var name: String = ""
set(value) {
field = value
notifyPropertyChanged(BR.name)
}
@ColumnInfo(name = "age")
var age: Int = 0
set(value) {
field = value
notifyPropertyChanged(BR.age)
}
}
注意这里有个小坑。我刚开始用的时候,忘了在setter里调用notifyPropertyChanged,结果数据改了UI纹丝不动。排查了半天才发现——嗯,这个通知必须手动触发。
BR类是DataBinding自动生成的,对应你在布局里用到的每个@{}表达式。所以name属性变了,BR.name就会通知绑定的UI更新。
13.2 LiveData与Room查询
LiveData是Google官方推荐的观察者模式实现。它和Room配合起来,简直是天作之合。
你想想看,DAO里返回LiveData,Room会自动在后台线程执行查询,数据变化时自动通知观察者。我们完全不用操心线程切换的问题。
@Dao
interface UserDao {
@Query("SELECT * FROM users WHERE age > :minAge")
fun getUsersOlderThan(minAge: Int): LiveData<List<User>>
@Insert
suspend fun insert(user: User)
@Update
suspend fun update(user: User)
@Delete
suspend fun delete(user: User)
}
看到没?返回值直接是LiveData>。这意味着只要users表有变化,这个LiveData就会自动发射新数据。
我记得有一次,产品经理要求列表页实时刷新。我直接用LiveData + Room,数据库一改UI就变,连刷新按钮都省了。产品经理还问我是不是偷偷做了轮询……
13.3 数据库变化自动更新UI
现在我们把DataBinding和Room的LiveData串起来。核心思路就三步:
- ViewModel里持有LiveData
- 布局文件绑定LiveData
- Activity/Fragment设置LifecycleOwner
先看ViewModel:
class UserViewModel(application: Application) : AndroidViewModel(application) {
private val userDao = AppDatabase.getInstance(application).userDao()
val allUsers: LiveData<List<User>> = userDao.getAllUsers()
fun addUser(name: String, age: Int) {
viewModelScope.launch {
userDao.insert(User(name = name, age = age))
}
}
}
布局文件里这样绑定:
<layout>
<data>
<variable name="viewModel" type="com.example.UserViewModel"/>
</data>
<LinearLayout>
<androidx.recyclerview.widget.RecyclerView
app:items="@{viewModel.allUsers}"/>
</LinearLayout>
</layout>
等等,RecyclerView怎么绑定LiveData?这里需要自定义一个BindingAdapter:
@BindingAdapter("app:items")
fun setItems(recyclerView: RecyclerView, users: List<User>?) {
users?.let {
(recyclerView.adapter as? UserAdapter)?.submitList(it)
}
}
最后在Activity里:
val binding = ActivityMainBinding.inflate(layoutInflater)
binding.viewModel = viewModel
binding.lifecycleOwner = this // 这行是关键!
binding.recyclerView.adapter = UserAdapter()
为什么一定要设置lifecycleOwner?因为LiveData需要知道Activity的生命周期。如果不设,LiveData不会自动观察,UI也就不会更新。我曾经见过有人漏了这一行,折腾了一下午……
13.4 复杂查询的绑定
实际项目中,查询不会总是select * from table这么简单。多表联查、聚合查询、带参数的动态查询,这些才是常态。
举个例子,我们要查某个年龄段内、名字包含特定关键字的用户,还要按注册时间排序:
@Dao
interface UserDao {
@Query("""
SELECT * FROM users
WHERE age BETWEEN :minAge AND :maxAge
AND name LIKE '%' || :keyword || '%'
ORDER BY registerTime DESC
""")
fun searchUsers(minAge: Int, maxAge: Int, keyword: String): LiveData<List<User>>
}
在ViewModel里,我们可以用MediatorLiveData或者Transformations来组合多个查询条件:
class UserViewModel(application: Application) : AndroidViewModel(application) {
private val userDao = AppDatabase.getInstance(application).userDao()
private val _minAge = MutableLiveData<Int>(0)
private val _maxAge = MutableLiveData<Int>(100)
private val _keyword = MutableLiveData<String>("")
val searchResult: LiveData<List<User>> = MediatorLiveData<List<User>>().apply {
fun update() {
val min = _minAge.value ?: 0
val max = _maxAge.value ?: 100
val kw = _keyword.value ?: ""
// 这里不能直接调用DAO,因为MediatorLiveData需要在主线程
// 实际项目中应该用Transformations.switchMap
}
}
}
其实更优雅的做法是用Transformations.switchMap:
val searchResult: LiveData<List<User>> = Transformations.switchMap(_keyword) { keyword ->
val min = _minAge.value ?: 0
val max = _maxAge.value ?: 100
userDao.searchUsers(min, max, keyword)
}
这样只要keyword变了,Room就会重新查询,LiveData自动发射新数据,UI自动刷新。一条龙服务。
13.5 一张图看懂整体流程
下面这张SVG图,把整个数据流动的过程画出来了。我建议你仔细看看,理解每个环节的职责。
从图里你能看到,数据是单向流动的。Room数据库变化后,DAO自动发出新数据,LiveData通知ViewModel,ViewModel通过DataBinding更新UI。整个过程不需要你写任何刷新代码。
13.6 避坑指南
最后分享几个我踩过的坑:
- LiveData在配置变更后丢失数据? 这是因为Activity重建后,LiveData重新订阅,但Room查询是异步的。解决方案是用Transformations.distinctUntilChanged()避免重复发射相同数据。
- 列表刷新闪烁? 如果你用DiffUtil配合RecyclerView,记得在BindingAdapter里判断数据是否真的变了。我遇到过因为对象引用不同导致全量刷新的情况。
- Room查询在主线程崩溃? 默认Room不允许在主线程查询。但LiveData的查询是自动在后台线程执行的,所以放心用。如果你非要手动查,记得加allowMainThreadQueries()——不过我不建议这么做。
嗯,这一章的内容就到这里。DataBinding和Room的配合,说白了就是让数据自己"流"到界面上。你只需要定义好数据结构和查询逻辑,剩下的交给框架。