Google 推荐在项目中使用 sealed 和 RemoteMediator

10,389 阅读15分钟

之前分享过一篇 Jetpack + MVVM 综合实战应用 神奇宝贝(PokemonGo) 眼前一亮的 Jetpack + MVVM 极简实战 主要包了以下功能:

  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. 在 Flow 基础上封装成功或者失败处理

这篇文章是对 神奇宝贝(PokemonGo) 的部分功能做全面的分析,主要包含以下内容:

  • 如何在 Flow 基础上封装成功或者失败处理?
  • 如何自定义 RemoteMediator 实现 数据库 和 网络 加载数据?
  • Paging3 当中的 RemoteMediator 和 PagingSource 的区别?
  • Paging3 中的 cachedIn 是什么?它为我们解决了什么问题?

在开始阅读本文之前,建议更新 PokemonGo 最新的代码,对照着代码一起看,为了节省篇幅,文中只会列出核心代码。

如何在 Flow 基础上封装成功或者失败处理

之前有小伙们问过我,如何在 Flow 基础上封装成功或者失败处理逻辑,关于这个问题,其实 Google Android 团队的工程师在 medium 上发表过一篇文章 Sealed with a class 建议我们使用 sealed,在 Paging3 源码里面也大量用到了 sealed。

在分析封装逻辑之前,我们先来看一下 Paging3 源码是如何处理的,在 Paging3 中有个很重要的类 RemoteMediator,在 RemoteMediator 中有个重要的方法 load()

abstract suspend fun load(loadType: LoadType, state: PagingState<Key, Value>): MediatorResult

load() 方法返回值是 MediatorResult,我们来看一下 MediatorResult 源码的实现。

sealed class MediatorResult {
    class Error(val throwable: Throwable) : MediatorResult()

    class Success(
        @get:JvmName("endOfPaginationReached") val endOfPaginationReached: Boolean
    ) : MediatorResult()
}

其实 MediatorResult 是一个密封类,密封类有两个子类分别为 ErrorSuccess 封装了成功和失败处理逻辑。

我们在来看一下另外一个类 LoadState,在 Jetpack 新成员 Paging3 网络实践及原理分析(二)- 监听网路请求状态 文章中也提到 refresh、prepend 和 append 都是 LoadState 的对象,我们来看一下 LoadState 源码实现。

sealed class LoadState( val endOfPaginationReached: Boolean) {
    class NotLoading( endOfPaginationReached: Boolean) :LoadState(endOfPaginationReached) {
        ......
    }

    object Loading : LoadState(false) {
        ......
    }

    class Error(val error: Throwable) : LoadState(false) {
        ......
    }
}

LoadState 是一个密封类,它有三个子类 NotLoadingLoadingError 代表网络请求状态。

变量作用
Error表示加载失败
Loading表示正在加载
NotLoading表示当前未加载

正如你所见在 Paging3 源码中对于成功和失败处理都用到了 sealed,我们可以仿照 Paging3 源码,使用 sealed 在 Flow 基础上封装成功或者失败处理。
PokemonGo/app/src/main/java/com/hi/dhl/pokemon/data/remote/PokemonResult.kt

sealed class PokemonResult<out T> {
    data class Success<out T>(val value: T) : PokemonResult<T>()

    data class Failure(val throwable: Throwable?) : PokemonResult<Nothing>()
}

PokemonResult 是一个密封类,同样它也有两个子类 SuccessFailure 分别表示成功和失败,我们来看一下如何使用。
PokemonGo/app/src/main/java/com/hi/dhl/pokemon/data/repository/PokemonRepositoryImpl.kt

override suspend fun featchPokemonInfo(name: String): Flow<PokemonResult<PokemonInfoModel>> {
    return flow {
        try {
            
            emit(PokemonResult.Success(model)) // 成功
        } catch (e: Exception) {
            emit(PokemonResult.Failure(e.cause)) // 失败
        }
    }.flowOn(Dispatchers.IO) // 通过 flowOn 切换到 io 线程
}
  • 如果请求成功返回 PokemonResult.Success(model)
  • 如果出现错误返回 PokemonResult.Failure(e.cause)

这只是一个简单的封装,可以在这个基础上,针对于不同的场景进行二次封装,接下来看一下在 ViewModel 中如何处理。
PokemonGo/app/src/main/java/com/hi/dhl/pokemon/ui/detail/DetailViewModel.kt

when (result) {
    is PokemonResult.Failure -> {
        _failure.value = result.throwable?.message ?: "failure"
    }
    is PokemonResult.Success -> {
        _pokemon.postValue(result.value)
    }
}

使用强大的 when 表达式,针对于成功或者失败进行不同的处理,在 Pokemon 项目中,如果没有网,进入详情页,会弹出一个失败的 toast。

when 表达式虽然强大,但是有一个问题,在一个项目中进行网络请求的地方会有很多,如果每次都要写 when 表达式,就会出现很多重复的代码,那么如何减少这样的模板代码呢,可以利用 Kotlin 提供的强大的扩展函数,代码如下所示:

inline fun <reified T> PokemonResult<T>.doSuccess(success: (T) -> Unit) {
    if (this is PokemonResult.Success) {
        success(value)
    }
}

inline fun <reified T> PokemonResult<T>.doFailure(failure: (Throwable?) -> Unit) {
    if (this is PokemonResult.Failure) {
        failure(throwable)
    }
}

使用扩展函数进一步封装的目的是减少模板代码,我们重新修改一下之前使用 when 表达式的地方。

result.doFailure { throwable ->
    _failure.value = throwable?.message ?: "failure"
}
result.doSuccess { value ->
    _pokemon.postValue(value)
    emit(value)
}

如果在其他地方也需要进行成功 或者 失败处理,只需要调用对应的扩展函数即可,到这里关于如何在 Flow 基础上封装成功或者失败处理就分析完了。

接下来我们一起来分析一下今天的主角 如何自定义 RemoteMediator 实现 数据库 和 网络 加载数据,建议在了解这部分内容之前,先看一下之前的两篇文章,因为它们都是关联在一起的。

RemoteMediator 主要用来实现加载网络分页数据并更新到数据库中,在开始分析之前,我们先来了解一下基本概念。

Paging3 类的职能

  • PagingData :用于分页数据的容器,每次数据刷新都有一个单独的对应 PagingData
  • Pager :是 Paging3 的主要的入口,在其构造方法中接受 PagingConfiginitialKeyremoteMediatorpagingSourceFactory
  • Pager.flow :将会构建一个 Flow<PagingData>,在 PagingConfig 构造方法中定义了 pageSize、prefetchDistance、initialLoadSize 等等
  • PagingDataAdapter :是一个处理分页数据的可回收视图适配器,可以使用 AsyncPagingDataDiffer 组件来构建自己的自定义适配器
  • PagingSource :每个 PagingSource 对象定义一个数据源以及如何从该数据源查找数据
  • RemoteMediatorRemoteMediator 实现加载网络分页数据并更新到数据库中

到这里小伙伴们应该会有一个疑惑 RemoteMediator 和 PagingSource 都是用来加载数据源的数据,那么它们有什么区别?

RemoteMediator 和 PagingSource 的区别

  • RemoteMediator:实现加载网络分页数据并更新到数据库中,但是数据源的变动不能直接映射到 UI 上
  • PagingSource:实现单一数据源以及如何从该数据源中查找数据,例如 Room,数据源的变动会直接映射到 UI 上

使用分层数据源的分页实现

上图来自 Google 官网,正如你所见,使用 RemoteMediator 实现从网络加载分页数据更新到数据库中,使用 PagingSource 从数据库中查找数据并显示在 UI 上。

在项目中如何进行选择?

  • PagingSource:用于加载有限的数据集(本地数据库)例如手机通讯录等等 ,可以参考 Jetpack 成员 Paging3 数据库的实践以及源码分析(一) 这篇文章的实现
  • RemoteMediator:主要用来加载网络分页数据并更新到数据库中,当我们没有更多的数据时,我们向网络请求更多的数据,结合 PagingSource 当保存更多数据时会直接映射到 UI 上

注意:

  1. RemoteMediator 目前是实验性的 API ,所有实现 RemoteMediator 的类都需要添加 @OptIn(ExperimentalPagingApi::class) 注解。

  2. 当我们使用 OptIn 注解,需要在 App 模块下的 build.gradle 文件内添加以下代码

    android {
        kotlinOptions {
            freeCompilerArgs += ["-Xopt-in=kotlin.RequiresOptIn"]
        }
    }
    

当我们了解完基本概念之后,接下来一起来分析一下如何实现 RemoteMediator在这里建议更新 PokemonGo 最新代码,对照着项目中的代码一起看,为了节省篇幅文章中只会列出核心代码。

三步实现 RemoteMediator

使用分层数据源的分页实现

如上面图片所示在 Repository 中通过 RemoteMediator 获取网络分页数据并更新到数据库中,PagingSource 当保存更多数据时会直接映射到 UI 上。

其实实现一个 RemoteMediator 贯穿了数据源、Repository、ViewModel,接下来我们来分析一下如何在每层中,分三步实现一个 RemoteMediator。

1. 定义数据源

使用 Room 作为本地的数据源,将网络分页数据存储在本地数据库中。
PokemonGo/app/src/main/java/com/hi/dhl/pokemon/data/local/PokemonDao.kt

@Dao
interface PokemonDao {

    @Insert(onConflict = OnConflictStrategy.REPLACE)
    suspend fun insertPokemon(pokemonList: List<PokemonEntity>)

    @Query("SELECT * FROM PokemonEntity")
    fun getPokemon(): PagingSource<Int, PokemonEntity>

}
  • 在 Paging3 中使用的是 Flow,所以 insertPokemon 方法前需要添加 suspend 修饰符。
  • 需要注意的是 getPokemon() 方法返回了一个 PagingSource<Key, Value>,意味着数据源更新时会映射到 UI 上,其中 Key 和 Value 和实现 RemoteMediator 有很大关系,后面会提到。

2. 在 Repository 中实现 RemoteMediator

RemoteMediator 和 PagingSource 相似,都需要覆盖 load() 方法,但是不同的是 RemoteMediator 不是加载分页数据到 RecyclerView 列表上,而是获取网络分页数据并更新到数据库中。
PokemonGo/app/src/main/java/com/hi/dhl/pokemon/data/repository/PokemonRemoteMediator.kt

注意:

刚才我们在数据源中定义 getPokemon() 方法,其返回值是 PagingSource<Int, PokemonEntity>,那我们在实现 RemoteMediator<Key, Value> 的时候,其中 Key 和 Value,应该和 PagingSource<Int, PokemonEntity> Key 和 Value 相同,代码如下所示。

@OptIn(ExperimentalPagingApi::class)
class PokemonRemoteMediator(
    val api: PokemonService,
    val db: AppDataBase
) : RemoteMediator<Int, PokemonEntity>() {

    override suspend fun load(
        loadType: LoadType,
        state: PagingState<Int, PokemonEntity>
    ): MediatorResult {
       /**
         * 在这个方法内将会做三件事
         *
         * 1. 参数 LoadType 有个三个值,关于这三个值如何进行判断
         *      LoadType.REFRESH
         *      LoadType.PREPEND
         *      LoadType.APPEND
         *
         * 2. 请问网络数据
         *
         * 3. 将网络数据插入到本地数据库中
         */
    }
}

load() 方法有两个重要的参数,它们的意思如下所示:

  • PagingState:这个类当中有两个重要的变量

    • pages: List<Page<Key, Value>> 返回的上一页的数据,主要用来获取上一页最后一条数据作为下一页的开始位置
    • config: PagingConfig 返回的初始化设置的 PagingConfig 包含了 pageSize、prefetchDistance、initialLoadSize 等等
  • LoadType 是一个枚举类,里面定义了三个值,如下所示

    类名作用
    LoadType.Refresh在初始化刷新的使用
    LoadType.Append在加载更多的时候使用
    LoadType.Prepend在当前列表头部添加数据的时候使用

load() 的返回值 MediatorResult,MediatorResult 是一个密封类,根据不同的结果返回不同的值

  • 请求出现错误,返回 MediatorResult.Error(e)
  • 请求成功且有数据,返回 MediatorResult.Success(endOfPaginationReached = true)
  • 请求成功但是没有数据,返回 MediatorResult.Success(endOfPaginationReached = false)
  • 参数 endOfPaginationReached 表示是否还有更多数据

load() 方法里面将会做三件事 1. 如何判断参数 LoadType2. 请问网络数据3. 将网络数据插入到本地数据库中

1. 如何判断参数 LoadType

val pageKey = when (loadType) {
   // 首次访问 或者调用 PagingDataAdapter.refresh()
   LoadType.REFRESH -> null
   // 在当前加载的数据集的开头加载数据时
   LoadType.PREPEND -> return MediatorResult.Success(endOfPaginationReached = true)
   LoadType.APPEND -> { // 下来加载更多时触发
       /**
        * 方式一:这种方式比较简单,当前页面最后一条数据是下一页的开始位置
        * 通过 load 方法的参数 state 获取当页面最后一条数据
        */
//       val lastItem = state.lastItemOrNull()
//       if (lastItem == null) {
//           return MediatorResult.Success(
//             endOfPaginationReached = true
//          )
//         }
//        lastItem.page

       /**
        * 方式二:比较麻烦,当前分页数据没有对应的远程 key,这个时候需要我们自己建表
        */
       val remoteKey = db.withTransaction {
           db.remoteKeysDao().getRemoteKeys(remotePokemon)
       }
       if (remoteKey == null || remoteKey.nextKey == null) {
           return MediatorResult.Success(endOfPaginationReached = true)
       }
       remoteKey.nextKey
   }
}
  • LoadType.REFRESH首次访问 或者调用 PagingDataAdapter.refresh() 触发,加载第一页数据,这里不需要做任何操作,返回 null 就可以。
  • LoadType.PREPEND:在当前列表头部添加数据的时候时触发,需要注意的是当 LoadType.REFRESH 触发了,LoadType.PREPEND 也会触发,所以为了避免重复请求,直接返回 MediatorResult.Success(endOfPaginationReached = true) 即可
  • LoadType.APPEND:下拉加载更多时触发,这里获取下一页的 key,如果 key 不存在,直接返回 MediatorResult.Success(endOfPaginationReached = true) 不会在进行请求

2. 请问网络数据

val page = pageKey ?: 0
val result = api.fetchPokemonList(
    state.config.pageSize,
    page * state.config.pageSize
).results

这里不需要调用 withContext(Dispatcher.IO) { ... } 因为 Retrofit 的协程是发生在 worker thread 中的

3. 将网络分页数据并更新到数据库中

remoteKeysDao.insertAll(entity)
pokemonDao.insertPokemon(item) 

所有实现 RemoteMediator 的类都需要重写 load() 方法,在 load() 方法内按照如上三步实现即可,具体逻辑需要根据需求而定。

PokemonRemoteMediator 完整代码太长了,这里就不贴了,可以点击 PokemonRemoteMediator 前去查看。

3. 在 Repository 中构建 Pager

Pager 是 Paging3 的主要的入口,是从数据源获取数据的入口,其构造方法接受 pagingConfig 、initialKey 、remoteMediator 、pagingSourceFactory,其中 initialKey、remoteMediator 是可选的,pageConfig 和 pagingSourceFactory 必填的,代码如下所示。
PokemonGo/app/src/main/java/com/hi/dhl/pokemon/data/repository/PokemonRepositoryImpl.kt

Pager(
    config = pageConfig,
    remoteMediator = PokemonRemoteMediator(api, db)
) {
    db.pokemonDao().getPokemon()
}.flow.map { pagingData ->
    pagingData.map { mapper2ItemMolde.map(it) }
}
  • config :初始化 Pager 参数 pageSize、prefetchDistance、initialLoadSize 等等
  • remoteMediator :提供 RemoteMediator 的实现类,这里是 PokemonRemoteMediator
  • pagingSourceFactory :是一个 lambda 表达式,在 Kotlin 中可以直接用花括号表示,在花括号内执行加载分页数据,这里直接调用 db.pokemonDao().getPokemon()
  • 调用 getPokemon() 方法返回的是一个 PagingSource,在 PokemonRemoteMediator 中获取网络分页数据,更新数据库的时候,这里返回的是你请求的网络分页数据

到这里关于 如何自定义 RemoteMediator 实现 数据库 和 网络 加载数据 就分析完了,接下来就是在 ViewModel 中调用 Repository 获取数据。

4. 在 ViewModel 获取数据

在 ViewModel 中调用 Repository 请求数据,通过构建 Pager 加载网络分页数据并更新到数据库中,当数据库更新时,会映射到 UI 上。
PokemonGo/app/src/main/java/com/hi/dhl/pokemon/ui/main/MainViewModel.kt

fun postOfData(): LiveData<PagingData<PokemonItemModel>> =
    polemonRepository.featchPokemonList().cachedIn(viewModelScope).asLiveData()

正如你所见在 ViewModel 中就两行代码,结合着 DataBinding 一起使用,在 Activity 或者 Fragment 只需要不到 20 行代码甚至更少。

注意: 在 ViewModel 中的 postOfData 方法中调用了 cachedIn() 方法

Paging3 中的 cachedIn 是什么?它为我们解决了什么问题?

cachedIn()Flow<PagingData> 的扩展方法,主要用来缓存 Flow<PagingData> 返回的内容,当我们在使用 Flow 进行 map 或者 filter 操作后调用 cachedIn() 是为了确保不需要再次触发它们,我们来看一下 cachedIn() 方法的源码。

fun <T : Any> Flow<PagingData<T>>.cachedIn(
    scope: CoroutineScope
) 

正如你所见 cachedIn()Flow<PagingData> 的扩展方法,cachedIn() 方法接受一个 CoroutineScope,CoroutineScope 表示协程的作用域,在 ViewModel 中对应的是 androidx.lifecycle.viewModelScope.,也就意味在作用域内防止不需要再次触发它们,在屏幕旋转的时候也可以复用。

全文到这里就结束了,在这里强烈建议至少体验一次,结合 Kotlin Flow + DataBinding + Jetpack + MVVM

神奇宝贝 (PokemonGo) 基于 Jetpack + MVVM + Repository + Data Mapper + Kotlin Flow 的实战项目,我也正在为 PokemonGo 项目设计更多的场景,也会加入更多的 Jetpack 成员,在 PokemonGo 项目首页增加了更新记录,可以点击下方链接前往查看 PokemonGo 项目的更新记录。

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

PokemonGo

结语

公众号开通了:ByteCode , 欢迎小伙伴们前去查看 Android 10 系列源码,Jetpack ,Kotlin ,译文,LeetCode / 剑指 Offer / 国内外大厂算法题 等等一系列文章,如果对你有帮助,请帮我点个 star,感谢!!!,欢迎一起来学习,在技术的道路上一起前进。

正在建立一个最全、最新的 AndroidX Jetpack 相关组件的实战项目 以及 相关组件原理分析文章,目前已经包含了 App Startup、Paging3、Hilt 等等,正在逐渐增加其他 Jetpack 新成员,仓库持续更新,可以前去查看:AndroidX-Jetpack-Practice, 如果这个仓库对你有帮助,请仓库右上角帮我点个赞。

算法

由于 LeetCode 的题库庞大,每个分类都能筛选出数百道题,由于每个人的精力有限,不可能刷完所有题目,因此我按照经典类型题目去分类、和题目的难易程度去排序。

  • 数据结构: 数组、栈、队列、字符串、链表、树……
  • 算法: 查找算法、搜索算法、位运算、排序、数学、……

每道题目都会用 Java 和 kotlin 去实现,并且每道题目都有解题思路、时间复杂度和空间复杂度,如果你同我一样喜欢算法、LeetCode,可以关注我 GitHub 上的 LeetCode 题解:Leetcode-Solutions-with-Java-And-Kotlin,一起来学习,期待与你一起成长。

Android 10 源码系列

正在写一系列的 Android 10 源码分析的文章,了解系统源码,不仅有助于分析问题,在面试过程中,对我们也是非常有帮助的,如果你同我一样喜欢研究 Android 源码,可以关注我 GitHub 上的 Android10-Source-Analysis,文章都会同步到这个仓库。

Android 应用系列

精选译文

目前正在整理和翻译一系列精选国外的技术文章,不仅仅是翻译,很多优秀的英文技术文章提供了很好思路和方法,每篇文章都会有译者思考部分,对原文的更加深入的解读,可以关注我 GitHub 上的 Technical-Article-Translation,文章都会同步到这个仓库。

工具系列