Google 推荐在 MVVM 架构中使用 Kotlin Flow

2020-12-16 11:46:42 浏览数 (1)

码个蛋(codeegg) 第 1035 次推文

作者:HiDhl

链接:https://juejin.im/post/6854573211930066951

前言

在之前分享过一篇 Jetpack 综合实战应用 [神奇宝贝(PokemonGo) 眼前一亮的 Jetpack MVVM 极简实战](https://juejin.im/post/6850037271253483534?utm_source=gold_browser_extension) ,这个项目主要包了以下功能:

  1. 自定义 RemoteMediator 实现 network db 的混合使用 ( RemoteMediator 是 Paging3 当中重要成员 )
  2. 使用 Data Mapper 分离数据源 和 UI
  3. Kotlin Flow 结合 Retrofit2 Room 的混合使用
  4. Kotlin Flow 与 LiveData 的使用
  5. 使用 Coil 加载图片
  6. 使用 ViewModel、LiveData、DataBinding 协同工作
  7. 使用 Motionlayout 做动画
  8. App Startup 与 Hilt 的使用
  9. 增加 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 的 ObservableFlowable 等等,所以很多人都用 Flow 与 RxJava 做对比。

Flow 相比于 RxJava 简单的太多了,你还记得那些 RxJava 傻傻分不清楚的操作符吗 ObservableFlowableSingleCompletableMaybe 等等。

那么 Flow 为我们解决了什么问题,我主要从以下几个方面思考:

  • LiveData 是一个生命周期感知组件,最好在 View 和 ViewModel 层中使用它,如果在 Repositories 或者 DataSource 中使用会有几个问题
    • 它不支持线程切换,其次不支持背压,也就是在一段时间内发送数据的速度 > 接受数据的速度,LiveData 无法正确的处理这些请求
    • 使用 LiveData 的最大问题是所有数据转换都将在主线程上完成
  • RxJava 虽然支持线程切换和背压,但是 RxJava 那么多傻傻分不清楚的操作符,实际上在项目中常用的可能只有几个例如 ObservableFlowableSingle 等等,如果我们不去了解背后的原理,造成内存泄露是很正常的事,大家可以从 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>

代码语言:javascript复制
@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 的 ObservableFlowableSingleCompletableMaybe 使用场景要简单太多了,我们来看一下在 Repositories 中是如何使用的。

Kotlin Flow 在 Repositories 中的使用

如果我们想在 Flow 中使用 Retrofit 或者 Room 进行网络请求或者查询数据库的操作,我们需要将使用 suspend 修饰符的操作放到 flow { ... } 中执行,最后使用 emit() 方法更新数据,将数据发送给 ViewModel,代码如下所示: PokemonGo/app/src/main/java/com/hi/dhl/pokemon/data/repository/PokemonRepositoryImpl.kt

代码语言:javascript复制
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 调用。

代码语言:javascript复制
@OptIn(ExperimentalCoroutinesApi::class)
suspend fun fectchPokemonInfo3(name: String) =
    polemonRepository.featchPokemonInfo(name)
        .onStart {
            // 在调用 flow 请求数据之前,做一些准备工作,例如显示正在加载数据的按钮
        }
        .catch {
            // 捕获上游出现的异常
        }
        .onCompletion {
            // 请求完成
        }.asLiveData()

因为 polemonRepository.featchPokemonInfo(name) 是一个用 suspend 修饰的方法,所以在 ViewModel 中调用也需要使用 suspend 来修饰。

为什么说调用 asLiveData() 方法会返回一个不可变的 LiveData,我们来看一下源码:

代码语言:javascript复制
fun <T> Flow<T>.asLiveData(
    context: CoroutineContext = EmptyCoroutineContext,
    timeoutInMs: Long = DEFAULT_TIMEOUT
): LiveData<T> = liveData(context, timeoutInMs) {
    collect {
        emit(it)
    }
}

asLiveData() 方法其实就是对 方法二 中的 liveData{ ... } 的封装

  • asLiveData 是 Flow 的扩展函数,返回值是一个 LiveData
  • liveData{ ... } 协程构造方法提供了一个协程代码块,在 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 即可,如下所示:

代码语言:javascript复制
// 方法三
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复制

0 人点赞