Room 🔗 Flow 最佳实践

UDF 架构背景

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

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

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

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

kotlin 复制代码
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 即名和姓

    kotlin 复制代码
    @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 层方法没有差别

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

    kotlin 复制代码
    @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 中对数据管道进行监听:

    kotlin 复制代码
    @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 管道如下:

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

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

开始时,管道会观察 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() 方法的源码。

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

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

多行数据的场景

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

这里不再过多赘述

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

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

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

kotlin 复制代码
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 方法只打印了两条数据,非常清爽! 值得注意的是,distinctUntilChanged() 是通过判断前后数据是否相等来实现的,默认的相等条件是 ==:

kotlin 复制代码
private val defaultAreEquivalent: (Any?, Any?) -> Boolean = { old, new -> old == new }

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

多次调用 initSelectedUserData() 的情况

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

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

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

kotlin 复制代码
initSelectedUserData(2)
// 一段时间之后
initSelectedUserData(3)

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

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

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

取消多条管道的建立

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

我们取消之前的 Job 即可:

kotlin 复制代码
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)
                }
        }
    }
}

总结

本文从 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

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

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

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

相关推荐
SelectDB5 分钟前
Apache Doris 4.0 AI 能力揭秘(一):AI 函数之 LLM 函数介绍
数据库·人工智能·数据分析
我是哈哈hh19 分钟前
【MySQL】在UBuntu环境安装以及免密码登录入门
linux·数据库·mysql·ubuntu
喪彪1 小时前
MySQL新手教学
数据库·mysql·adb
auxor2 小时前
Android 开机动画音频播放优化方案
android
whysqwhw2 小时前
安卓实现屏幕共享
android
深盾科技3 小时前
Kotlin Data Classes 快速上手
android·开发语言·kotlin
一条上岸小咸鱼3 小时前
Kotlin 基本数据类型(五):Array
android·前端·kotlin
whysqwhw3 小时前
Room&Paging
android
whysqwhw3 小时前
RecyclerView超长列表优化
android