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更新。

小提示: 如果实体字段很多,每个都写setter很烦。你可以用@Bindable注解配合data class的copy()方法,但那样会失去实时通知能力。我个人建议还是老老实实写BaseObservable,一劳永逸。

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串起来。核心思路就三步:

  1. ViewModel里持有LiveData
  2. 布局文件绑定LiveData
  3. 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也就不会更新。我曾经见过有人漏了这一行,折腾了一下午……

注意: lifecycleOwner必须和LiveData的观察者生命周期一致。如果你在Fragment里用,记得传fragment.viewLifecycleOwner,而不是fragment本身。否则可能造成内存泄漏。

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自动刷新。一条龙服务。

核心要点: Room + LiveData + DataBinding 这套组合拳,核心就是"数据驱动UI"。你只管改数据库,界面会自动跟着变。不需要写任何刷新代码。

13.5 一张图看懂整体流程

下面这张SVG图,把整个数据流动的过程画出来了。我建议你仔细看看,理解每个环节的职责。

Room + LiveData + DataBinding 数据流 Room 数据库 SQLite 存储层 DAO 数据访问对象 LiveData 观察者模式 ViewModel 持有 LiveData DataBinding 自动绑定 UI UI 界面 自动刷新 数据从 Room 流出,经过 DAO → LiveData → ViewModel → DataBinding → UI,全程自动

从图里你能看到,数据是单向流动的。Room数据库变化后,DAO自动发出新数据,LiveData通知ViewModel,ViewModel通过DataBinding更新UI。整个过程不需要你写任何刷新代码。

13.6 避坑指南

最后分享几个我踩过的坑:

  • LiveData在配置变更后丢失数据? 这是因为Activity重建后,LiveData重新订阅,但Room查询是异步的。解决方案是用Transformations.distinctUntilChanged()避免重复发射相同数据。
  • 列表刷新闪烁? 如果你用DiffUtil配合RecyclerView,记得在BindingAdapter里判断数据是否真的变了。我遇到过因为对象引用不同导致全量刷新的情况。
  • Room查询在主线程崩溃? 默认Room不允许在主线程查询。但LiveData的查询是自动在后台线程执行的,所以放心用。如果你非要手动查,记得加allowMainThreadQueries()——不过我不建议这么做。

嗯,这一章的内容就到这里。DataBinding和Room的配合,说白了就是让数据自己"流"到界面上。你只需要定义好数据结构和查询逻辑,剩下的交给框架。

公众号:蓝海资料掘金营,微信deep3321