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 是怎么使用的
-
导入 Room 和 Kotlin Flow 依赖
-
定义数据库实体 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 )
-
书写 Dao 层的 query 方法。返回值改成
Flow<User?>
即可,其他的和普通的 Dao 层方法没有差别kotlin@Query("SELECT * FROM user WHERE userId = :userId") // 这里的返回值得是可空的 User,因为: // 只要有数据库的增删改查,Flow 一定会返回,若此时没有 query 到数据,返回值就是 null fun queryByUserId(userId: Int): Flow<User?>
-
书写 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) } } } }
-
在 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
- StateFlow 的初始数据,即:
User()
- 由于初始数据库没有 userId 为 2 的数据,所以在建立管道时,会发送
null
- 插入 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