之前分享过一篇 Jetpack + MVVM 综合实战应用 神奇宝贝(PokemonGo) 眼前一亮的 Jetpack + MVVM 极简实战 主要包了以下功能:
- 自定义 RemoteMediator 实现 network + db 的混合使用 ( RemoteMediator 是 Paging3 当中重要成员 )
- 使用 Data Mapper 分离数据源 和 UI
- Kotlin Flow 结合 Retrofit2 + Room 的混合使用
- Kotlin Flow 与 LiveData 的使用
- 使用 Coil 加载图片
- 使用 ViewModel、LiveData、DataBinding 协同工作
- 使用 Motionlayout 做动画
- App Startup 与 Hilt 的使用
- 在 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 是一个密封类,密封类有两个子类分别为 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()
方法内按照如上三步实现即可,具体逻辑需要根据需求而定。
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 的实现类,这里是 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 项目首页增加了更新记录,可以点击下方链接前往查看 PokemonGo 项目的更新记录。
PokemonGo GitHub 地址:https://github.com/hi-dhl/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,文章都会同步到这个仓库。
- 0xA01 Android 10 源码分析:APK 是如何生成的
- 0xA02 Android 10 源码分析:APK 的安装流程
- 0xA03 Android 10 源码分析:APK 加载流程之资源加载
- 0xA04 Android 10 源码分析:APK 加载流程之资源加载(二)
- 0xA05 Android 10 源码分析:Dialog 加载绘制流程以及在 Kotlin、DataBinding 中的使用
- 0xA06 Android 10 源码分析:WindowManager 视图绑定以及体系结构
- 0xA07 Android 10 源码分析:Window 的类型 以及 三维视图层级分析
- 更多......
Android 应用系列
- 为数不多的人知道的 Kotlin 技巧以及 原理解析
- Jetpack 最新成员 AndroidX App Startup 实践以及原理分析
- Jetpack 成员 Paging3 实践以及源码分析(一)
- Jetpack 新成员 Paging3 网络实践及原理分析(二)
- Jetpack 新成员 Hilt 实践(一)启程过坑记
- Jetpack 新成员 Hilt 实践之 App Startup(二)进阶篇
- Jetpack 新成员 Hilt 与 Dagger 大不同(三)落地篇
- 全方面分析 Hilt 和 Koin 性能
- 神奇宝贝(PokemonGo) 眼前一亮的 Jetpack + MVVM 极简实战
- Google 推荐在 MVVM 架构中使用 Kotlin Flow
精选译文
目前正在整理和翻译一系列精选国外的技术文章,不仅仅是翻译,很多优秀的英文技术文章提供了很好思路和方法,每篇文章都会有译者思考部分,对原文的更加深入的解读,可以关注我 GitHub 上的 Technical-Article-Translation,文章都会同步到这个仓库。
- [译][Google工程师] 刚刚发布了 Fragment 的新特性 “Fragment 间传递数据的新方式” 以及源码分析
- [译][Google工程师] 详解 FragmentFactory 如何优雅使用 Koin 以及部分源码分析
- [译][2.4K Start] 放弃 Dagger 拥抱 Koin
- [译][5k+] Kotlin 的性能优化那些事
- [译] 解密 RxJava 的异常处理机制
- [译][1.4K+ Star] Kotlin 新秀 Coil VS Glide and Picasso
- 更多......
工具系列
- 为数不多的人知道的 AndroidStudio 快捷键(一)
- 为数不多的人知道的 AndroidStudio 快捷键(二)
- 关于 adb 命令你所需要知道的
- 10分钟入门 Shell 脚本编程
- 基于 Smali 文件 Android Studio 动态调试 APP
- 解决在 Android Studio 3.2 找不到 Android Device Monitor 工具