查看原文
其他

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

hi-dhl ByteCode 2022-12-14


这是 dhl 的第 22 篇原创文章


在之前的文章中分析了 Jetpack  综合实战应用  神奇宝贝 ,介绍了 Kotlin Flow 在项目中的使用,而这篇文章继续更深入的分析 Kotlin Flow,主要包含以下几个方面的内容:

  • 在 Repositories 或者 DataSource 中直接使用 LiveData 这种做法对吗?
  • Kotlin Flow 是什么?
  • Kotlin Flow 解决了什么问题?
  • Kotlin Flow 如何在 MVVM 中使用?
  • Kotlin Flow 如何与 Retrofit2 + Room 混合使用?

在 MVVM 中使用 Kotlin Flow

我相信如今几乎所有的 Android 开发者至少都听过 MVVM 架构,在 Google Android 团队宣布了 Jetpack 的视图模型之后,它已经成为了现代 Android 开发模式最流行的架构之一,如下图所示:

在官宣 Jetpack 的视图模型之后,同时 Google 在 Jetpack Guide 文章中的示例,也在 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 的成员 Paging3DataStore 等等,在其内部源码也大量的使用了 Flow

Paging3 分析及使用:

DataStore 分析及使用:

不仅仅是 Jetpack 成员支持 Flow,很多开源的项目也在逐渐切换到 Flow,那么 Flow 是什么?为什么 Google 推荐使用它?使用 Flow 在项目中为我们带来了那些好处?它为我们解决了什么问题?

Kotlin Flow 是什么?解决了什么问题?

Flow 库是在 Kotlin Coroutines 1.3.2 发布之后新增的库,也叫做异步流,类似 RxJava 的 ObservableFlowable 等等,所以很多人都用 Flow 与 RxJava 做对比。

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

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

  • LiveData 是一个生命周期感知组件,最好在 View 和 ViewModel 层中使用它,如果在 Repositories 或者 DataSource 中使用会有几个问题

    • 它不支持线程切换,其次不支持背压,也就是在一段时间内发送数据的速度 > 接受数据的速度,LiveData 无法正确的处理这些请求
    • 使用 LiveData 的最大问题是所有数据转换都将在主线程上完成
  • RxJava 入门的门槛很高,学习过的朋友们,我相信能够体会到从入门到放弃是什么感觉

  • RxJava 虽然支持线程切换和背压,但是 RxJava 那么多傻傻分不清楚的操作符,实际上在项目中常用的可能只有几个例如 ObservableFlowable  、 Single 等等

  • 如果我们不去了解背后的原理,很容易造成内存泄露,在 StackOverflow 上有很多因为 RxJava 造成内存泄露的例子

  • RxJava 的链式调用虽然方便,在复杂的业务逻辑里面,层层的  RxJava 的链式调用 ,让代码难易阅读

  • 成本很高,团队所有人都要了解 RxJava 原理以及用法,自然也出现了很多种不可思议的 RxJava 用法

  • 解决回调地狱的问题

而相对于以上的不足,Flow 有以下优点:

  • Flow 是对 Kotlin 协程的扩展,让我们可以像运行同步代码一样运行异步代码,使得代码更加简洁,提高了代码的可读性
  • Flow 支持线程切换、背压
  • Flow 入门的门槛很低,加上 Google 的支持,API 调用更加简单,没有那么多傻傻分不清楚的操作符,新的 Jetpack 源码也在大量使用 Flow
  • 简单的数据转换与操作符,如 map 等等
  • 易于做单元测试

Kotlin Flow 如何在 MVVM 中使用

Jetpack 的视图模型 MVVM 架构由 View + DataBinding + ViewModel + Model 组成,如下所示,我相信下面这张图大家非常熟悉了,

接下来我们一起来探究一下 Kotlin Flow 在 MVVM 当中每层是如何实现的。

Kotlin Flow 在数据源中的使用

在 PokemonGo 项目中,进入详情页,会检查本地是否有数据,如果没有会去请求详情页接口,获得最新的数据,然后存储在数据库中。

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

@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

@GET("pokemon/{name}")
suspend fun fetchPokemonInfo(@Path("name") name: String): NetWorkPokemonInfo

如上所见在方法前增加了用 suspend 进行了修饰,只有被  suspend 修饰的方法,才可以在协程中调用。

按照如上配置,在数据源的工作就完成了,相比于 RxJava 的 ObservableFlowable  、 SingleCompletableMaybe 使用场景要简单太多了,我们来看一下在 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 线程

将上面的代码简化如下所示:

flow {
    // 进行网络或者数据库操作
    emit(model)
}.flowOn(Dispatchers.IO) // 通过 flowOn 切换到 IO 线程

正如你所见,将耗时操作放到 flow { ... } 里面,通过 flowOn(Dispatchers.IO) 切换到 IO 线程,最后通过 emit() 方法将数据发送给 ViewModel,接下来我们来看一下如何在 ViewModel 中接受 Flow 发送的数据。

Kotlin Flow 在 ViewModel 中的使用

在 ViewModel 中接受 Flow 发送的数据有三种方法,根据实际情况去调用。
PokemonGo/app/src/main/java/com/hi/dhl/pokemon/ui/detail/DetailViewModel.kt

方法一

在 LifeCycle 2.2.0 之前使用的方法,使用两个 LiveData,一个是可变的,一个是不可变的,如下所示:

// 私有的 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 项目中用到的方法。

@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 的扩展函数,返回值是一个 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

<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 即可,如下所示:

// 方法一
mViewModel.pokemon.observe(this, Observer {
    // 将数据显示在页面上
})

方式二:

使用 LiveData 协程构造方法 (coroutine builder) 提供的协程代码块,产生的是一个不可变的 LiveData,处理方式 同方法一,在 Activity 或者 Fragment 中调用这个不可变的 LiveData 即可,如下所示:

// 方法二
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 的小伙伴们,入门的门槛也是非常低的,强烈建议至少体验一次,体验过之后,我认为你会跟我一样爱上它的

Jetpack 实战项目 PokemonGo(神奇宝贝)基于 MVVM 架构和 Repository 设计模式开发的一个小型的 App 项目,涉及到技术:Paging3(network + db),Dagger-Hilt,App Startup,DataBinding,Room,Motionlayout,Kotlin Flow,Coil 等等。

我正在为 PokemonGo 项目设计更多的场景,也会加入更多 Jetpack 成员,可以点击下方链接前往查看。

PokemonGo 仓库地址
https://github.com/hi-dhl/PokemonGo


全文到这里就结束了,如果有帮助欢迎 点赞 、分享 、在看 就是对我最大的鼓励!!!


推荐阅读



最后推荐我一直在更新维护的项目和网站:

  • 最新的 AndroidX Jetpack 相关组件的实战项目 以及 原理分析的文章
    https://github.com/hi-dhl/AndroidX-Jetpack-Practice

  • LeetCode / 剑指 offer / 国内外大厂面试题 / 多线程 题解,语言 Java 和 kotlin,包含多种解法、解题思路、时间复杂度、空间复杂度分析

    剑指 offer:https://offer.hi-dhl.com
    LeetCode:https://leetcode.hi-dhl.com

  • 最新 Android 10 源码分析系列文章
    https://github.com/hi-dhl/Android10-Source-Analysis

  • 一系列国外的技术文章,每篇文章都会有译者思考部分,对原文的更加深入的分析
    https://github.com/hi-dhl/Technical-Article-Translation

  • 「为互联网人而设计,国内国外名站导航」涵括新闻、体育、生活、娱乐、设计、产品、运营、前端开发、Android 开发等等网址
    https://site.51git.cn



致力于分享一系列最新技术原创文章

长按二维码即可关注


我知道你在看

您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存