码个蛋(codeegg) 第 1035 次推文
作者:HiDhl
链接:https://juejin.im/post/6854573211930066951
前言
在之前分享过一篇 Jetpack 综合实战应用 [神奇宝贝(PokemonGo) 眼前一亮的 Jetpack MVVM 极简实战](https://juejin.im/post/6850037271253483534?utm_source=gold_browser_extension) ,这个项目主要包了以下功能:
- 自定义 RemoteMediator 实现 network db 的混合使用 ( RemoteMediator 是 Paging3 当中重要成员 )
- 使用 Data Mapper 分离数据源 和 UI
- Kotlin Flow 结合 Retrofit2 Room 的混合使用
- Kotlin Flow 与 LiveData 的使用
- 使用 Coil 加载图片
- 使用 ViewModel、LiveData、DataBinding 协同工作
- 使用 Motionlayout 做动画
- App Startup 与 Hilt 的使用
- 增加 Fragment 1.2.0 上重要的更新:通过 Fragment 的构造函数传递参数,以及 FragmentFactory 和 FragmentContainerView 的使用
我近期也在开发另外一个 Jetpack MVVM 实战应用,和神奇宝贝(PokemonGo) 有很多不同之处,神奇宝贝(PokemonGo) 主要偏向于 Paging3 的分页处理,以及 Flow 在 MVVM 中的实战。
而今天这篇文章主要来分析一下 神奇宝贝(PokemonGo) 项目,主要包含以下几个方面的内容:
- 在 Repositories 或者 DataSource 中直接使用 LiveData 这种做法对吗?
- Kotlin Flow 是什么?
- Kotlin Flow 解决了什么问题?
- Kotlin Flow 如何在 MVVM 中使用?
- Kotlin Flow 如何与 Retrofit2 Room 混合使用?
Google 推荐在 MVVM 中
使用 Kotlin Flow
Google 推荐在 MVVM 中使用 Kotlin Flow我相信如今几乎所有的 Android 开发者至少都听过 MVVM 架构,在 Google Android 团队宣布了 Jetpack 的视图模型之后,它已经成为了现代 Android 开发模式最流行的架构之一,如下图所示:
在官宣 Jetpack 的视图模型之后,同时 Google 在 [Jetpack Guide](https://developer.android.com/jetpack/guide#fetch-data) 文章中的示例,也在 Repositories 或者 DataSource 中使用 LiveData,以至于在很多开源的 MVVM 项目中也是直接使用 LiveData,但是在 Repositories 或者 DataSource 中直接使用 LiveData 这种做法对吗?这是我一直以来的一个疑问?
直到我打开[ Android 架构组件 ](https://developer.android.com/topic/libraries/architecture/index.html)页面,看了在页面上增加了最新的文章,这几篇文章大概的内容是说如何在 MVVM 中使用 Flow 以及如何与 LiveData 一起使用,当我看完并通过实践之后大概明白了,LiveData 是一个生命周期感知组件,它并不属于 Repositories 或者 DataSource 层,下文会有详细的分析。
在 Google 发布的 Jetpack 的最新成员 Paging3,在其内部的源码实现也是使用的 Flow,关于 Paging3 的使用可以参考以下链接:
- Jetpack 成员 Paging3 实践以及源码分析(一)(https://juejin.im/post/6844904193468137486)
- Jetpack 新成员 Paging3 网络实践及原理分析(二)(https://juejin.im/post/6844904196207345672)
- 自定义 RemoteMediator 实现 network db 的混合使用(https://github.com/hi-dhl/PokemonGo)
不仅仅是 Jetpack 成员支持 Flow,在 Google 提供的 Demo 里面也都在使用 Flow,也有很多开源的 MVVM 项目也在逐渐切换到 Flow,为什么 Google 会推荐使用它呢,使用 Flow 能带来那些好处呢,为我们解决了什么问题
Kotlin Flow 是什么?
Kotlin Flow 解决了什么问题?
Flow 库是在 Kotlin Coroutines 1.3.2 发布之后新增的库,也叫做异步流,类似 RxJava 的 Observable
、 Flowable
等等,所以很多人都用 Flow 与 RxJava 做对比。
Flow 相比于 RxJava 简单的太多了,你还记得那些 RxJava 傻傻分不清楚的操作符吗 Observable
、 Flowable
、 Single
、 Completable
、 Maybe
等等。
那么 Flow 为我们解决了什么问题,我主要从以下几个方面思考:
- LiveData 是一个生命周期感知组件,最好在 View 和 ViewModel 层中使用它,如果在 Repositories 或者 DataSource 中使用会有几个问题
- 它不支持线程切换,其次不支持背压,也就是在一段时间内发送数据的速度 > 接受数据的速度,LiveData 无法正确的处理这些请求
- 使用 LiveData 的最大问题是所有数据转换都将在主线程上完成
- RxJava 虽然支持线程切换和背压,但是 RxJava 那么多傻傻分不清楚的操作符,实际上在项目中常用的可能只有几个例如
Observable
、Flowable
、Single
等等,如果我们不去了解背后的原理,造成内存泄露是很正常的事,大家可以从 StackOverflow 上查看一下,有很多因为 RxJava 造成内存泄露的例子 - RxJava 入门的门槛很高,学习过的朋友们,我相信能够体会到从入门到放弃是什么感觉
- 解决回调地狱的问题
而相对于以上的不足,Flow 有以下优点:
- Flow 支持线程切换、背压
- Flow 入门的门槛很低,没有那么多傻傻分不清楚的操作符
- 简单的数据转换与操作符,如 map 等等
- Flow 是对 Kotlin 协程的扩展,让我们可以像运行同步代码一样运行异步代码,使得代码更加简洁,提高了代码的可读性
- 易于做单元测试
Kotlin Flow 如何在 MVVM 中使用
Jetpack 的视图模型 MVVM 架构由 View DataBinding ViewModel Model 组成,如下所示,我相信下面这张图大家非常熟悉了,
接下来我们一起来探究一下 Kotlin Flow 在 MVVM 当中每层是如何实现的。
Kotlin Flow 在数据源中的使用
在 [PokemonGo](https://github.com/hi-dhl/PokemonGo) 项目中,进入详情页,会检查本地是否有数据,如果没有会去请求 [pokeapi] (https://pokeapi.co/)详情页接口,获得最新的数据,然后存储在数据库中。
Flow 是协程的扩展,如果要在 Room 和 Retrofit 中使用,Room 和 Retrofit 需要支持协程才可以,在 Retrofit >= 2.6.0 和 Room >= 2.1 版本都支持协程,我们来看一下 Room 和 Retrofit 数据源的配置。
Room: PokemonGo/app/src/main/java/com/hi/dhl/pokemon/data/local/PokemonInfoDao.kt
代码语言:javascript复制@Query("SELECT * FROM PokemonInfoEntity where name = :name")
suspend fun getPokemon(name: String): PokemonInfoEntity?
或者直接返回 Flow<PokemonInfoEntity>
@Query("SELECT * FROM PokemonInfoEntity where name = :name")
fun getPokemon(name: String): Flow<PokemonInfoEntity>
Retrofit: PokemonGo/app/src/main/java/com/hi/dhl/pokemon/data/remote/PokemonService.kt
代码语言:javascript复制@GET("pokemon/{name}")
suspend fun fetchPokemonInfo(@Path("name") name: String): NetWorkPokemonInfo
如上所见在方法前增加了用 suspend
进行了修饰,只有被 suspend
修饰的方法,才可以在协程中调用。
按照如上配置,在数据源的工作就完成了,相比于 RxJava 的 Observable
、 Flowable
、 Single
、 Completable
、 Maybe
使用场景要简单太多了,我们来看一下在 Repositories 中是如何使用的。
Kotlin Flow 在 Repositories 中的使用
如果我们想在 Flow 中使用 Retrofit 或者 Room 进行网络请求或者查询数据库的操作,我们需要将使用 suspend
修饰符的操作放到 flow { ... }
中执行,最后使用 emit()
方法更新数据,将数据发送给 ViewModel,代码如下所示:
PokemonGo/app/src/main/java/com/hi/dhl/pokemon/data/repository/PokemonRepositoryImpl.kt
flow {
val pokemonDao = db.pokemonInfoDao()
// 查询数据库是否存在,如果不存在请求网络
var infoModel = pokemonDao.getPokemon(name)
if (infoModel == null) {
// 网络请求
val netWorkPokemonInfo = api.fetchPokemonInfo(name)
// 将网路请求的数据,换转成的数据库的 model,之后插入数据库
infoModel = netWorkPokemonInfo.let {
PokemonInfoEntity(
name = it.name,
height = it.height,
weight = it.weight,
experience = it.experience
)
}
// 插入更新数据库
pokemonDao.insertPokemon(infoModel)
}
// 将数据源的 model 转换成上层用到的 model,
// ui 不能直接持有数据源,防止数据源的变化,影响上层的 ui
val model = mapper2InfoModel.map(infoModel)
// 更新数据,将数据发送给 ViewModel
emit(model)
}.flowOn(Dispatchers.IO) // 通过 flowOn 切换到 IO 线程
将上面的代码简化如下所示:
代码语言:javascript复制flow {
// 进行网络或者数据库操作
emit(model)
}.flowOn(Dispatchers.IO) // 通过 flowOn 切换到 IO 线程
正如你所见,将耗时操作放到 flow { ... }
里面,通过 flowOn(Dispatchers.IO)
切换到 IO 线程,最后通过 emit()
方法将数据发送给 ViewModel,接下来我们来看一下如何在 ViewModel 中接受 Flow 发送的数据。
Kotlin Flow 在 ViewModel 中的使用
在 ViewModel 中使用 Flow 之前在 Jetpack 成员 Paging3 实践以及源码分析(一) 文章也有提到, 这里我们在深入分析一下,在 ViewModel 中接受 Flow 发送的数据有三种方法,根据实际情况去调用。 PokemonGo/app/src/main/java/com/hi/dhl/pokemon/ui/detail/DetailViewModel.kt
方法一
在 LifeCycle 2.2.0 之前使用的方法,使用两个 LiveData,一个是可变的,一个是不可变的,如下所示:
代码语言:javascript复制// 私有的 MutableLiveData 可变的,对内访问
private val _pokemon = MutableLiveData<PokemonInfoModel>()
// 对外暴露不可变的 LiveData,只能查询
val pokemon: LiveData<PokemonInfoModel> = _pokemon
viewModelScope.launch {
polemonRepository.featchPokemonInfo(name)
.onStart {
// 在调用 flow 请求数据之前,做一些准备工作,例如显示正在加载数据的进度条
}
.catch {
// 捕获上游出现的异常
}
.onCompletion {
// 请求完成
}
.collectLatest {
// 将数据提供给 Activity 或者 Fragment
_pokemon.postValue(it)
}
}
- 准备一私有的 MutableLiveData,只对内访问
- 对外暴露不可变的 LiveData
- 在
viewModelScope.launch
方法中执行协程代码块 collectLatest
是末端操作符,收集 Flow 在 Repositories 层发射出来的数据,在一段时间内发送多次数据,只会接受最新的一次发射过来的数据- 调用
_pokemon.postValue
方法将数据提供给 Activity 或者 Fragment
方法二
在 LifeCycle 2.2.0 之后,可以用更精简的方法来完成,使用 LiveData 协程构造方法 (coroutine builder),这个方法也是在 PokemonGo 项目中用到的方法。
代码语言:javascript复制@OptIn(ExperimentalCoroutinesApi::class)
fun fectchPokemonInfo(name: String) = liveData<PokemonInfoModel> {
polemonRepository.featchPokemonInfo(name)
.onStart { // 在调用 flow 请求数据之前,做一些准备工作,例如显示正在加载数据的进度条 }
.catch { // 捕获上游出现的异常 }
.onCompletion { // 请求完成 }
.collectLatest {
// 更新 LiveData 的数据
emit(it)
}
}
liveData{ ... }
协程构造方法提供了一个协程代码块,产生的是一个不可变的 LiveData,emit()
方法则用来更新 LiveData 的数据collectLatest
是末端操作符,收集 Flow 在 Repositories 层发射出来的数据,在一段时间内发送多次数据,只会接受最新的一次发射过来的数据
PS:需要注意的是 flow { ... }
和 liveData{ ... }
内部都有一个 emit()
方法。
方法三:
调用 Flow 的扩展方法 asLiveData()
返回一个不可变的 LiveData,供 Activity 或者 Fragment 调用。
@OptIn(ExperimentalCoroutinesApi::class)
suspend fun fectchPokemonInfo3(name: String) =
polemonRepository.featchPokemonInfo(name)
.onStart {
// 在调用 flow 请求数据之前,做一些准备工作,例如显示正在加载数据的按钮
}
.catch {
// 捕获上游出现的异常
}
.onCompletion {
// 请求完成
}.asLiveData()
因为 polemonRepository.featchPokemonInfo(name)
是一个用 suspend
修饰的方法,所以在 ViewModel 中调用也需要使用 suspend
来修饰。
为什么说调用 asLiveData()
方法会返回一个不可变的 LiveData,我们来看一下源码:
fun <T> Flow<T>.asLiveData(
context: CoroutineContext = EmptyCoroutineContext,
timeoutInMs: Long = DEFAULT_TIMEOUT
): LiveData<T> = liveData(context, timeoutInMs) {
collect {
emit(it)
}
}
asLiveData()
方法其实就是对 方法二 中的 liveData{ ... }
的封装
asLiveData
是 Flow 的扩展函数,返回值是一个 LiveDataliveData{ ... }
协程构造方法提供了一个协程代码块,在liveData{ ... }
中执行协程代码collect
是末端操作符,收集 Flow 在 Repositories 层发射出来的数据- 最后调用 LiveData 中的
emit()
方法更新 LiveData 的数据
DataBinding(数据绑定)
在 PokemonGo 项目中使用了 DataBinding 进行的数据绑定。
DataBinding(数据绑定)实际上是 XML 布局中的另一个视图结构层次,视图 (XML) 通过数据绑定层不断地与 ViewModel 交互,如下所示: PokemonGo/app/src/main/res/layout/activity_details.xml
代码语言:javascript复制<layout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools">
<data>
<variable
name="viewModel"
type="com.hi.dhl.pokemon.ui.detail.DetailViewModel" />
</data>
......
<androidx.appcompat.widget.AppCompatTextView
android:id="@ id/weight"
android:text="@{viewModel.pokemon.getWeightString}"/>
......
</layout>
这是获取神奇宝贝的详细信息,通过 DataBinding 以声明方式将数据(神奇宝贝的体重)绑定到界面上,更多使用参考项目中的代码。
如何处理 ViewModel 的三种方式
如果不使用数据绑定,在 Activity 或者 Fragment 中如何处理 ViewModel 的三种方式。 PokemonGo/app/src/main/java/com/hi/dhl/pokemon/ui/detail/DetailsFragment.kt
方式一:
使用两个 LiveData,一个是可变的,一个是不可变的,在 Activity 或者 Fragment 中调用对外暴露不可变的 LiveData 即可,如下所示:
代码语言:javascript复制// 方法一
mViewModel.pokemon.observe(this, Observer {
// 将数据显示在页面上
})
方式二:
使用 LiveData 协程构造方法 (coroutine builder) 提供的协程代码块,产生的是一个不可变的 LiveData,处理方式 同方法一,在 Activity 或者 Fragment 中调用这个不可变的 LiveData 即可,如下所示:
代码语言:javascript复制// 方法二
mViewModel.fectchPokemonInfo2(mPokemonModel.name).observe(this, Observer {
// 将数据显示在页面上
})
方式三:
调用 Flow 的扩展方法 asLiveData()
返回一个不可变的 LiveData,在 Activity 或者 Fragment 调用这个不可变的 LiveData 即可,如下所示:
// 方法三
lifecycleScope.launch {
mViewModel.apply {
fectchPokemonInfo3(mPokemonModel.name).observe(this@DetailsFragment, Observer {
// 将数据显示在页面上
})
}
}
到这里关于 Kotlin Flow 在 MVVM 当中每层的实践就分析完了,如果使用过 RxJava 的小伙伴们应该会非常熟悉,对于没有使用过 RxJava 的小伙伴们,入门的门槛也是非常低的,强烈建议至少体验一次,体验过之后,我认为你会跟我一样爱上它的。
神奇宝贝 (PokemonGo) 基于 Jetpack MVVM Repository Data Mapper Kotlin Flow 的实战项目,我也正在为 PokemonGo 项目设计更多的场景,也会加入更多的 Jetpack 成员,可以点击下方链接前往查看。
PokemonGo GitHub 地址:https://github.com/hi-dhl/PokemonGo
结语
致力于分享一系列 Android 系统源码、逆向分析、算法、翻译、Jetpack 源码相关的文章,正在努力写出更好的文章,如果这篇文章对你有帮助给个 star,文章中有什么没有写明白的地方,或者有什么更好的建议欢迎留言,欢迎一起来学习,在技术的道路上一起前进。
代码语言:javascript复制