Jetpack成员Paging3获取网络分页数据并更新到数据库中(三)
Paging3 系列
这篇文章是 Paging3 系列的完结篇,主要来分析使用 Paging3 获取到的网络分页数据更新到数据库中。
文章中的示例代码,已经上传到 GitHub 欢迎前去查看
https://github.com/hi-dhl/PokemonGo
通过这篇文章你将学习到以下内容:
如何在 Flow 基础上封装成功或者失败处理? 如何自定义 RemoteMediator 实现 数据库 和 网络 加载数据? Paging3 当中的 RemoteMediator 和 PagingSource 的区别? Paging3 中的 cachedIn 是什么?它为我们解决了什么问题?
在开始阅读本文之前,建议 clone 一下项目,对照着代码一起看,为了节省篇幅,文章中只会列出核心代码。
如何在 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 是一个密封类,密封类有两个子类分别为 Error
和 Success
封装了成功和失败处理逻辑。
我们在来看一下另外一个类 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 是一个密封类,它有三个子类 NotLoading
、 Loading
、 Error
代表网络请求状态。
变量 | 作用 |
---|---|
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 是一个密封类,同样它也有两个子类 Success
和 Failure
分别表示成功和失败,我们来看一下如何使用。
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 的主要的入口,在其构造方法中接受PagingConfig
、initialKey
、remoteMediator
、pagingSourceFactory
Pager.flow
:将会构建一个Flow<PagingData>
,在PagingConfig
构造方法中定义了 pageSize、prefetchDistance、initialLoadSize 等等PagingDataAdapter
:是一个处理分页数据的可回收视图适配器,可以使用 AsyncPagingDataDiffer 组件来构建自己的自定义适配器PagingSource
:每个PagingSource
对象定义一个数据源以及如何从该数据源查找数据RemoteMediator
:RemoteMediator
实现加载网络分页数据并更新到数据库中
到这里小伙伴们应该会有一个疑惑 RemoteMediator 和 PagingSource 都是用来加载数据源的数据,那么它们有什么区别?
RemoteMediator 和 PagingSource 的区别
RemoteMediator:实现加载网络分页数据并更新到数据库中,但是数据源的变动不能直接映射到 UI 上 PagingSource:实现单一数据源以及如何从该数据源中查找数据,例如 Room,数据源的变动会直接映射到 UI 上
上图来自 Google 官网,正如你所见,使用 RemoteMediator 实现从网络加载分页数据更新到数据库中,使用 PagingSource 从数据库中查找数据并显示在 UI 上。
在项目中如何进行选择?
PagingSource
:用于加载有限的数据集(本地数据库)例如手机通讯录等等 ,可以参考 Jetpack成员Paging3 数据库实践及原理分析(一) 这篇文章的实现RemoteMediator
:主要用来加载网络分页数据并更新到数据库中,当我们没有更多的数据时,我们向网络请求更多的数据,结合PagingSource
当保存更多数据时会直接映射到 UI 上
注意:
RemoteMediator
目前是实验性的 API ,所有实现RemoteMediator
的类都需要添加@OptIn(ExperimentalPagingApi::class)
注解。当我们使用
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. 如何判断参数 LoadType 、2. 请问网络数据 、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()
方法内按照如上三步实现即可。
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 的实现类,这里是 PokemonRemoteMediatorpagingSourceFactory
:是一个 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 GitHub 地址:
https://github.com/hi-dhl/PokemonGo
推荐阅读:
再见 SharedPreferences 拥抱 Jetpack DataStore Jetpack 成员 App Startup 实践及原理分析 KKotlin StateFlow 搜索功能的实践 DB + NetWork
最后推荐我一直在更新维护的项目和网站:
最新的 AndroidX Jetpack 相关组件的实战项目 以及 原理分析的文章
https://github.com/hi-dhl/AndroidX-Jetpack-PracticeLeetCode / 剑指 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
致力于分享一系列最新技术原创文章
长按二维码即可关注
我知道你在看哟