Room 🔗 Flow 最佳实践

1,963 阅读3分钟

UDF 架构背景

Android 官方推荐使用 UDF(Unidirectional Data Flow),即单向数据流架构。UDF 的示意图如下:

Untitled.png

如此组织代码可以保证唯一可信的数据源,同时提高代码的可维护性。

那么,我们在需求开发中,一般怎么简单实现单向数据流架构呢?

以用户点击,刷新数据的操作为例,我们可以有如下实现:

class MainActivity : ComponentActivity() {
    private val viewModel: MyViewModel

    view.setOnClickListener { // 1️⃣ 用户点击
        viewModel.updateSomething() // 2️⃣ 调用 ViewModel 方法
    }
}

class MyViewModel: ViewModel() {
		
    fun updateSomething() {
        updateDatabase() // 3️⃣ 更新数据库
        updateUI() // 4️⃣ 手动更新 UI
    }
		
}

1️⃣ 用户点击 → 2️⃣ 调用 ViewModel 方法(updateSomething) → 3️⃣ 更新数据库 → 4️⃣ 手动更新 UI。

这么看没有任何问题,但是有没有更加简便的方法呢?答案是有的,那就是使用 Room + Flow,把步骤 4️⃣ 去除,实现更新数据库后自动更新 UI

简单尝试

接下来我们将写个 Demo 简单尝试一下 Flow + Room 是怎么使用的

  1. 导入 Room 和 Kotlin Flow 依赖

  2. 定义数据库实体 User:

    其包含三个字段 userId 即用户 id,firstName 和 lastName 即名和姓

    @Entity
    data class User(
        @PrimaryKey val userId: Int = -1,
        @ColumnInfo(name = "first_name") val firstName: String? = null,
        @ColumnInfo(name = "last_name") val lastName: String? = null
    )
    
  3. 书写 Dao 层的 query 方法。返回值改成 Flow<User?> 即可,其他的和普通的 Dao 层方法没有差别

    @Query("SELECT * FROM user WHERE userId = :userId")
    // 这里的返回值得是可空的 User,因为:
    // 只要有数据库的增删改查,Flow 一定会返回,若此时没有 query 到数据,返回值就是 null
    fun queryByUserId(userId: Int): Flow<User?>
    
  4. 书写 ViewModel 的逻辑

    @HiltViewModel
    class RoomStarterViewModel @Inject constructor(
        private val userDao: UserDao
    ): ViewModel() {
    
        private val _selectedUserData = MutableStateFlow<User?>(User())
        val selectedUserData: Flow<User?>
            get() = _selectedUserData
    
        fun initSelectedUserData(userId: Int) {
            Log.d(logTag, "start subscribe userId: $userId")
            viewModelScope.launch {
                userDao.queryByUserId(userId) // 调用 Dao 层方法
                    .flowOn(Dispatchers.IO)   // 如上逻辑在 IO Dispatcher 上运行
                    .collect {
                        Log.d(logTag, "collect in ViewModel: $it")
                        _selectedUserData.emit(it)
                    }
            }
        }
    }
    
  5. 在 Activity 中对数据管道进行监听:

    @AndroidEntryPoint
    class MainActivity : ComponentActivity() {
    	
        override fun onCreate(savedInstanceState: Bundle?) {
            super.onCreate(savedInstanceState)
            viewModel.initSelectedUserData(2)
    
            lifecycleScope.launch {
                viewModel.selectedUserData.collect {
                    Log.d(TAG, "collect in Activity: $it")
                }
            }
        }
    }
    

建立的 Flow 管道如下:

SimpleExample.png

运行之后会发生什么呢?来看看日志:

其中 insertData 是向数据库中插入数据

Untitled 1.png

开始时,管道会观察 userId 为 2 的数据

Activity 层总共收到了 3 条数据,截图中编号分别为 1、2 和 3

  1. StateFlow 的初始数据,即:User()
  2. 由于初始数据库没有 userId 为 2 的数据,所以在建立管道时,会发送 null
  3. 插入 userId 为 2 的数据之后,发送了相对应的数据

插入其他 userId 的数据时,Activity 没有收到,表现符合预期!

但是,每次插入新的数据, ViewModel 都会收到,为什么呢?

StateFlow collect() 的代码逻辑

通过如上代码和图示,我们可以了解到 Activity 中拿到的是 StateFlow,ViewModel 中拿到的是 Flow 接口,类型暂时未知,区别就在这。

简单查看一下 StateFlow collect() 方法的源码。

Untitled 2.png

可以看到里边有这么一行逻辑 oldState == null || oldState != newState ,StateFlow 只有是 null 或者和旧数据不同时,才会 emit。真相大白!

Activity 和 ViewModel 之间的 StateFlow,对管道进行了一次数据过滤的操作,重复数据就不会 emit 了。

多行数据的场景

和单行数据的逻辑类似,不过 Dao 层 query 方法 return 的数据类型有修改 Flow<User?>Flow<List<User>>

这里不再过多赘述

解决 ViewModel 层收到重复数据的问题

根据上节的描述:每次插入新的数据,ViewModel 层的 collect 方法都会收到。如下图中,灰色标识的部分。 SimpleFlowViewModelLayer.png 插入一条 userId 为 3 的数据,ViewModel 层的 collect 还是会返回一条 userId 为 2 的数据,插入一条 userId 为 4 的数据也是如此。

有没有办法解决 ViewModel 层 Flow 的重复刷新问题呢?我们可以使用 distinctUntilChanged() API,该 API 会返回一个 DistinctFlowImpl() ,ViewModel 层初始化 Flow 的代码修改成如此即可:

fun initSelectedUserData(userId: Int) {
    Log.d(logTag, "start subscribe userId: $userId")
    viewModelScope.launch {
        userDao.queryByUserId(userId)
            .flowOn(Dispatchers.IO)
            .distinctUntilChanged()  // 修改点
            .collect {
                Log.d(logTag, "collect in ViewModel: $it")
                _selectedUserData.emit(it)
            }
    }
}

重新运行一下,发现 ViewModel 层的 collect 方法只打印了两条数据,非常清爽! Untitled 3.png 值得注意的是,distinctUntilChanged() 是通过判断前后数据是否相等来实现的,默认的相等条件是 ==:

private val defaultAreEquivalent: (Any?, Any?) -> Boolean = { old, new -> old == new }

如果数据结构较为复杂,可以传入自定义的 areEquivalent 参数解决。

多次调用 initSelectedUserData() 的情况

initSelectedUserData() 的代码片段可以在之前的小节找到

接下来讨论的这个问题仅仅和 Flow 的管道有关,但也是挺有意思的!

如果我在 Activity 中多次调用 initSelectedUserData() 会发生什么呢?比如:

initSelectedUserData(2)
// 一段时间之后
initSelectedUserData(3)

Untitled 4.png

可以看到,初始时,调用了 initSelectedUserData() 两次,分别传入 2 和 3

之后,在 Activity 和 ViewModel 层分别收到了 userId 2 和 userId 3 的推送,这时的 Flow 管道结构是怎样的呢?答案如下图所示:

MultiFlowTubes.png

从 Room 数据库中建立了两条管道至 ViewModel,这两条管道分别发送 userId 为 2 和 userId 为 3 的数据。最后,再在 ViewModel 中将这两条管道的数据聚合发送至 Activity。

取消多条管道的建立

如果需要在建立 userId 为 3 的管道时,取消 userId 为 2 的管道,类似于下图这种,要如何操作呢?

M

我们取消之前的 Job 即可:

class RoomStarterViewModel @Inject constructor(
    private val userDao: UserDao
): ViewModel() {

    private var userDataJob: Job? = null // 定义一个 Job 变量
		
    fun initSelectedUserData(userId: Int) {
        Log.d(logTag, "start subscribe userId: $userId")
        userDataJob?.cancel() // 取消之前的 Job
        userDataJob = viewModelScope.launch { // 保存新的 Job 供下次取消
            userDao.queryByUserId(userId)
                .flowOn(Dispatchers.IO)
                .distinctUntilChanged()
                .collect {
                    Log.d(logTag, "collect in ViewModel: $it")
                    _selectedUserData.emit(it)
                }
        }
    }
}

相关技术原理

如果不打算了解原理的话,本小节可以直接跳过,相关的 commit 记录

我们每次调用 Dao 层的 query 方法,会调用到 CoroutinesRoom.kt 的 createFlow ,通过其获取到一个 Flow 接口的对象实例,之后再在 Repository/ViewModel 层去 collect Flow 发送的数据,来实现对数据库数据变化的监听。这里简单分析一下 createFlow 方法的相关逻辑:

@JvmStatic
fun <R> createFlow(
    db: RoomDatabase,
    inTransaction: Boolean,
    tableNames: Array<String>,
    callable: Callable<R>
): Flow<@JvmSuppressWildcards R> = flow {
    // 声明一个 Channel,当数据变化时,让 Flow emit data
    val observerChannel = Channel<Unit>(Channel.CONFLATED)
    val observer = object : InvalidationTracker.Observer(tableNames) {
        override fun onInvalidated(tables: MutableSet<String>) {
            // 数据库数据发生变化
            observerChannel.offer(Unit)
        }
    }
    // 首次创建 flow,一定会返回一次 query 的数据
    observerChannel.offer(Unit)
    val flowContext = coroutineContext
    val queryContext = if (inTransaction) db.transactionDispatcher else db.queryDispatcher
    withContext(queryContext) {
        db.invalidationTracker.addObserver(observer)
        try {
            // 这里会有 suspend 标记,等调用 observerChannel.offer() 会 resume
            // 之后再 suspend,如此往复
            for (signal in observerChannel) {
                // query 一次数据库
                val result = callable.call()
                withContext(flowContext) { emit(result) }
            }
        } finally {
            db.invalidationTracker.removeObserver(observer)
        }
    }
}

而 Dao 层相关代码的生成是通过 CoroutineFlowResultBinder 这个类来实现的,可以简单看看,这里就不过多介绍了。

总结

本文从 UDF 出发,介绍了 Room + Flow 的一些基本使用。并讨论了在使用过程中,会遇到的收到重复数据和多管道建立的问题。在项目的开发过程中,针对 UDF 的构建,我们一般会采用手动刷新、回调或者 EventBus 的方式来实现。但目前来看,Room + Flow 也是一个优雅的选择,值得一试!

RESOURCES

该 Blog 的测试代码:Github Source Code

Flow 管道示意图的 Figma 资源:Android Flow Tubes

REFERENCE

可以学习到一点点 UDF(Unidirectional Data Flow): UI layer  |  Android Developers

Google Flow + Room 的 IssueTracker: Google Issue Tracker

Flow + Room 的 commit 内容: Support Kotlin Coroutines Flow as query return types.

一篇 Room + Flow 的文章: Room 🔗 Flow

文中 Flow 管道示意图的画图参考: Testing Kotlin flows on Android  |  Android Developers

画图工具: Figma: The Collaborative Interface Design Tool