之前分享过一篇 Jetpack + MVVM 综合实战应用 神奇宝贝(PokemonGo) 眼前一亮的 Jetpack + MVVM 极简实战 主要包了以下功能:
这篇文章是对 神奇宝贝(PokemonGo) 的部分功能做全面的分析,主要包含以下内容:
在开始阅读本文之前,建议更新 PokemonGo 最新的代码,对照着代码一起看,为了节省篇幅,文中只会列出核心代码。
之前有小伙们问过我,如何在 Flow 基础上封装成功或者失败处理逻辑,关于这个问题,其实 Google Android 团队的工程师在 medium 上发表过一篇文章 Sealed with a class 建议我们使用 sealed,在 Paging3 源码里面也大量用到了 sealed。
在分析封装逻辑之前,我们先来看一下 Paging3 源码是如何处理的,在 Paging3 中有个很重要的类 RemoteMediator,在 RemoteMediator 中有个重要的方法 load()
abstract suspend fun load(loadType: LoadType, state: PagingState): 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 {
data class Success(val value: T) : PokemonResult()
data class Failure(val throwable: Throwable?) : PokemonResult()
}
PokemonResult 是一个密封类,同样它也有两个子类 Success
和 Failure
分别表示成功和失败,我们来看一下如何使用。
PokemonGo/app/src/main/java/com/hi/dhl/pokemon/data/repository/PokemonRepositoryImpl.kt
override suspend fun featchPokemonInfo(name: String): Flow> {
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 PokemonResult.doSuccess(success: (T) -> Unit) {
if (this is PokemonResult.Success) {
success(value)
}
}
inline fun PokemonResult.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
,在 PagingConfig
构造方法中定义了 pageSize、prefetchDistance、initialLoadSize 等等PagingDataAdapter
:是一个处理分页数据的可回收视图适配器,可以使用 AsyncPagingDataDiffer 组件来构建自己的自定义适配器PagingSource
:每个 PagingSource
对象定义一个数据源以及如何从该数据源查找数据RemoteMediator
:RemoteMediator
实现加载网络分页数据并更新到数据库中到这里小伙伴们应该会有一个疑惑 RemoteMediator 和 PagingSource 都是用来加载数据源的数据,那么它们有什么区别?
上图来自 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 最新代码,对照着项目中的代码一起看,为了节省篇幅文章中只会列出核心代码。
如上面图片所示在 Repository 中通过 RemoteMediator 获取网络分页数据并更新到数据库中,PagingSource
当保存更多数据时会直接映射到 UI 上。
其实实现一个 RemoteMediator 贯穿了数据源、Repository、ViewModel,接下来我们来分析一下如何在每层中,分三步实现一个 RemoteMediator。
使用 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)
@Query("SELECT * FROM PokemonEntity")
fun getPokemon(): PagingSource
}
insertPokemon
方法前需要添加 suspend 修饰符。getPokemon()
方法返回了一个 PagingSource
,意味着数据源更新时会映射到 UI 上,其中 Key 和 Value 和实现 RemoteMediator 有很大关系,后面会提到。RemoteMediator 和 PagingSource 相似,都需要覆盖 load() 方法,但是不同的是 RemoteMediator 不是加载分页数据到 RecyclerView 列表上,而是获取网络分页数据并更新到数据库中。
PokemonGo/app/src/main/java/com/hi/dhl/pokemon/data/repository/PokemonRemoteMediator.kt
注意:
刚才我们在数据源中定义 getPokemon()
方法,其返回值是 PagingSource
,那我们在实现 RemoteMediator
的时候,其中 Key 和 Value,应该和 PagingSource
Key 和 Value 相同,代码如下所示。
@OptIn(ExperimentalPagingApi::class)
class PokemonRemoteMediator(
val api: PokemonService,
val db: AppDataBase
) : RemoteMediator() {
override suspend fun load(
loadType: LoadType,
state: PagingState
): MediatorResult {
/**
* 在这个方法内将会做三件事
*
* 1. 参数 LoadType 有个三个值,关于这三个值如何进行判断
* LoadType.REFRESH
* LoadType.PREPEND
* LoadType.APPEND
*
* 2. 请问网络数据
*
* 3. 将网络数据插入到本地数据库中
*/
}
}
load()
方法有两个重要的参数,它们的意思如下所示:
PagingState:这个类当中有两个重要的变量
pages: List>
返回的上一页的数据,主要用来获取上一页最后一条数据作为下一页的开始位置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)
在 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()
方法内按照如上三步实现即可,具体逻辑需要根据需求而定。
PokemonRemoteMediator 完整代码太长了,这里就不贴了,可以点击 PokemonRemoteMediator 前去查看。
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 获取数据。
在 ViewModel 中调用 Repository 请求数据,通过构建 Pager 加载网络分页数据并更新到数据库中,当数据库更新时,会映射到 UI 上。
PokemonGo/app/src/main/java/com/hi/dhl/pokemon/ui/main/MainViewModel.kt
fun postOfData(): LiveData> =
polemonRepository.featchPokemonList().cachedIn(viewModelScope).asLiveData()
正如你所见在 ViewModel 中就两行代码,结合着 DataBinding 一起使用,在 Activity 或者 Fragment 只需要不到 20 行代码甚至更少。
注意: 在 ViewModel 中的 postOfData 方法中调用了 cachedIn()
方法
Paging3 中的 cachedIn 是什么?它为我们解决了什么问题?
cachedIn()
是 Flow
的扩展方法,主要用来缓存 Flow
返回的内容,当我们在使用 Flow 进行 map
或者 filter
操作后调用 cachedIn()
是为了确保不需要再次触发它们,我们来看一下 cachedIn()
方法的源码。
fun Flow>.cachedIn(
scope: CoroutineScope
)
正如你所见 cachedIn()
是 Flow
的扩展方法,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
致力于分享一系列 Android 系统源码、逆向分析、算法、翻译、Jetpack 源码相关的文章,正在努力写出更好的文章,如果这篇文章对你有帮助给个 star,文章中有什么没有写明白的地方,或者有什么更好的建议欢迎留言,欢迎一起来学习,在技术的道路上一起前进。
正在建立一个最全、最新的 AndroidX Jetpack 相关组件的实战项目 以及 相关组件原理分析文章,目前已经包含了 App Startup、Paging3、Hilt 等等,正在逐渐增加其他 Jetpack 新成员,仓库持续更新,可以前去查看:AndroidX-Jetpack-Practice, 如果这个仓库对你有帮助,请仓库右上角帮我点个赞。
正在写一系列的 Android 10 源码分析的文章,了解系统源码,不仅有助于分析问题,在面试过程中,对我们也是非常有帮助的,如果你同我一样喜欢研究 Android 源码,可以关注我 GitHub 上的 Android10-Source-Analysis。
由于 LeetCode 的题库庞大,每个分类都能筛选出数百道题,由于每个人的精力有限,不可能刷完所有题目,因此我按照经典类型题目去分类、和题目的难易程度去排序。
每道题目都会用 Java 和 kotlin 去实现,并且每道题目都有解题思路,如果你同我一样喜欢算法、LeetCode,可以关注我 GitHub 上的 LeetCode 题解:Leetcode-Solutions-with-Java-And-Kotlin。
目前正在整理和翻译一系列精选国外的技术文章,不仅仅是翻译,很多优秀的英文技术文章提供了很好思路和方法,每篇文章都会有译者思考部分,对原文的更加深入的解读,可以关注我 GitHub 上的 Technical-Article-Translation。