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

相关推荐
猿小喵9 分钟前
DBA之路,始于足下
数据库·dba
tyler_download18 分钟前
golang 实现比特币内核:实现基于椭圆曲线的数字签名和验证
开发语言·数据库·golang
weixin_4493108444 分钟前
高效集成:聚水潭采购数据同步到MySQL
android·数据库·mysql
Zender Han1 小时前
Flutter自定义矩形进度条实现详解
android·flutter·ios
Cachel wood2 小时前
Github配置ssh key原理及操作步骤
运维·开发语言·数据库·windows·postgresql·ssh·github
standxy2 小时前
如何将钉钉新收款单数据高效集成到MySQL
数据库·mysql·钉钉
Narutolxy3 小时前
MySQL 权限困境:从权限丢失到权限重生的完整解决方案20241108
数据库·mysql
Venchill3 小时前
安装和卸载Mysql(压缩版)
数据库·mysql
白乐天_n3 小时前
adb:Android调试桥
android·adb
Humbunklung3 小时前
一种EF(EntityFramework) MySQL修改表名去掉dbo前缀的方法
数据库·mysql·c#