Jetpack 成员 Paging3 网络实践及原理分析(二)

9,239 阅读12分钟

前言

Google 最近更新了几个 Jetpack 新成员 Hilt、Paging 3、App Startup 等等。

在之前的文章里面分别分析 App Startup 实践以及原理Paging3 加载本地数据(一)实践以及原理,如果没有看过可以点击下方地址前去查看:

今天这边文章主要来分析 Paging3 加载网络数据及其原理,利用周末的时间参考 Google 文档实现了 Paging3 期间也遇到一些坑,会在文中详细分析,代码已经上传到了 GitHub:Paging3SimpleWithNetWork

通过这篇文章你将学习到以下内容:

  • Paging3 是什么?
  • Paging3 相对之前版本 (Paging1、Paging2) 核心的变化?
  • 关于 Paging 支持的分页策略?
  • 在项目中如何使用 Paging3 去加载网络数据?
  • Paging3 网络异常如何处理?
  • Paging3 如何监听网络请求状态?
  • Paging3 如何进行刷新和重试?

在项目 Paging3SimpleWithNetWork 中用到了 Coil(Kotlin 图片加载库)、Databinding(数据绑定)、Anko(主要用来替换替代 XML 使用的方式)、Koin(Kotlin 依赖注入库)、JDatabinding(基于 Databinding 封装的组件)、Data Mapper(数据映射)、使用 Composing builds 作为依赖库的版本管理、Repository 设计模式、MVVM 架构等等,关于这里一些技术之前没有了解过,可以点击下面连接前往查看。

Paging3 是什么?

Paging 是一个分页库,它可以帮助您从本地存储或通过网络加载显示数据。这种方法使你的 App 更有效地使用网络带宽和系统资源。

Google 推荐使用 Paging 作为 App 架构的一部分,它可以很方便的和 Jetpack 组件集成,Paging3 包含了以下功能:

  • 在内存中缓存分页数据,确保您的 App 在使用分页数据时有效地使用系统资源。
  • 内置删除重复数据的请求,确保您的 App 有效地使用网络带宽和系统资源。
  • 可配置 RecyclerView 的 adapters,当用户滚动到加载数据的末尾时自动请求数据。
  • 支持 Kotlin 协程和 Flow, 以及 LiveData 和 RxJava。
  • 内置的错误处理支持,包括刷新和重试等功能。

Paging3 相对于之前类的职能变化

在 Paging3 之前提供了 ItemKeyedDataSource、PageKeyedDataSource、PositionalDataSource 这三个类,在这三个类中进行数据获取的操作。

  • PositionalDataSource:主要用于加载数据有限的数据(加载本地数据库)
  • ItemKeyedDataSource:主要用来请求网络数据,它适用于通过当前页面最后一条数据的 id,作为下一页的数据的开始的位置,例如 Github 的 API。
    • 例如地址 https://api.github.com/users?since=0?per_page=30 当 since = 0 时获取第一页数据,当前页面最后一条数据的 ID 是 46。
    • 将 46 作为开始位置,此时 since = 46,地址变成:https://api.github.com/users?since=46?per_page=30
  • PageKeyedDataSource:也是用来请求网络数据,它适用于通过页码分页来请求数据。

在 Paging3 之后 ItemKeyedDataSource、PageKeyedDataSource、PositionalDataSource 合并为一个 PagingSource,所有旧 API 加载方法被合并到 PagingSource 中的单个 load() 方法中。

abstract suspend fun load(params: LoadParams<Key>): LoadResult<Key, Value>

这是一个挂起函数,实现这个方法来触发异步加载,具体实现见下文,另外在 Paging3 中还有以下变化

  • LivePagedListBuilder 和 RxPagedListBuilder 合并为了 Pager。
  • 使用 PagedList.Config 替换 PagingConfig。
  • 使用 RemoteMediator 替换了 PagedList.BoundaryCallback 去加载网络和本地数据库的数据。

四步实现 Paging3 加载网络数据

Google 推荐我们使用 Paging3 时,在应用程序的三层中操作,以及它们如何协同工作加载和显示分页数据,如下图所示:

我们接下来按照 Google 推荐的方式开始实现,只需要四步即可实现 Paging3 加载网络数据,文中只贴出核心代码,具体实现可以看 GitHub 上的 Paging3SimpleWithNetWork 项目。

1. 网络请求部分

这里选择使用的是 GitHub API

interface GitHubService {

    @GET("users")
    suspend fun getGithubAccount(@Query("since") id: Int, @Query("per_page") perPage: Int):
            List<GithubAccountModel>

    companion object {
        fun create(): GitHubService {
            val client = OkHttpClient.Builder()
                .build()

            val retrofit = Retrofit.Builder()
                .client(client)
                .baseUrl("https://api.github.com/")
                .addConverterFactory(GsonConverterFactory.create())
                .build()

            return retrofit.create(GitHubService::class.java)
        }
    }
}

注意: 这里需要在 getGithubAccount 方法前添加 suspend 关键字,否则调用的时候,会抛出以下异常。

Unable to create call adapter for XXXXX

2. 在 Repository 层创建 PagingSource 数据源

class GitHubItemPagingSource(
    private val api: GitHubService
) : PagingSource<Int, GithubAccountModel>(), AnkoLogger {
    override suspend fun load(params: LoadParams<Int>): LoadResult<Int, GithubAccountModel> {

        return try {
            // key 相当于 id
            val key = params.key ?: 0
            // 获取网络数据
            val items = api.getGithubAccount(key, params.loadSize)
            // 请求失败或者出现异常,会跳转到 case 语句返回 LoadResult.Error(e)
            // 请求成功,构造一个 LoadResult.Page 返回
            LoadResult.Page(
                data = items, // 返回获取到的数据
                prevKey = null, // 上一页,设置为空就没有上一页的效果,这需要注意的是,如果是第一页需要返回 null,否则会出现多次请求
                nextKey = items.lastOrNull()?.id// 下一页,设置为空就没有加载更多效果,如果后面没有更多数据设置为空,即滑动到最后不会在加载数据
            )
        } catch (e: Exception) {
            e.printStackTrace()
            LoadResult.Error(e)
        }
    }
}
  • PagingSource 是一个抽象类,主要用来向 Paging 提供源数据,需要重写 load 方法,在这个方法进行网络请求的处理。需要注意的是 LoadResult.Page 里面的两个参数 prevKey 和 nextKey,这里有个坑

    • prevKey:上一页,设置为空就没有上一页的效果,这需要注意的是,如果是第一页需要返回 null,否则会出现多次请求,我刚开始忽略了,导致首次加载的时候,出现了两次请求。
    • nextKey:下一页,设置为空就没有加载更多效果,如果后面没有更多数据设置为空,即滑动到最后不会在加载数据。
  • load 方法的参数 LoadParams,它是一个密封类,里面有三个内部类 Refresh、Append、Prepend。

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

3. 在 Repository 层创建 Pager 和 PagingData

  • Pager:是主要的入口页面,在其构造方法中接受 PagingConfig、initialKey、remoteMediator、pagingSourceFactory。
  • PagingData:是分页数据的容器,它查询一个 PagingSource 对象并存储结果。
class GitHubRepositoryImpl(
    val pageConfig: PagingConfig,
    val gitHubApi: GitHubService,
    val mapper2Person: Mapper<GithubAccountModel, GitHubAccount>
) : Repository {

    override fun postOfData(id: Int): Flow<PagingData<GitHubAccount>> {
        return Pager(pageConfig) {
            // 加载数据库的数据
            GitHubItemPagingSource(gitHubApi, 0)
        }.flow.map { pagingData ->
            // 数据映射,数据源 GithubAccountModel ——>  上层用到的 GitHubAccount
            pagingData.map { mapper2Person.map(it) }
        }
    }
}

在 postOfData 方法中构建了一个 Pager, 其构造方法中接受 PagingConfig、initialKey、remoteMediator、pagingSourceFactory,其中 initialKey、remoteMediator 是可选的,pageConfig 和 pagingSourceFactory 必填的。

pagingSourceFactory 是一个 lambda 表达式,在 Kotlin 中可以直接用花括号表示,在花括号内,执行执行网络请求 GitHubItemPagingSource(gitHubApi, 0)

最后调用 flow 返回 Flow<PagingData<Value>>,然后通过 Flow 的 map 方法将数据源 GithubAccountModel 转换成上层用到的 GithubAccount。

关于 flow 在上一篇 Jetpack 成员 Paging3 实践以及源码分析(一) 已经分析过了.

4. 最后一步,接受数据,并绑定 UI

在 ViewModel 接受数据,并传递给 Adapter.

val gitHubLiveData: LiveData<PagingData<GitHubAccount>> =
        repository.postOfData(0).asLiveData()

LiveData 有三种使用方式,这里演示的是其中一种,其余的在之前的文章 Jetpack 成员 Paging3 实践以及源码分析(一) 已经分析过了。

 mMainViewModel.gitHubLiveData.observe(this, Observer { data ->
            mAdapter.submitData(lifecycle, data)
        })

到这里请求网络数据并显示的在 UI 上就结束了,最后我们来分析一下 Paging3 内置的错误处理支持,包括刷新和重试等功能。

5. 网络状态异常的处理

Paging3 提供了内置的错误处理支持,包括刷新和重试等功能,说到这里 Google 对于 Paging3 的设计相比于之前的设计真的好,基本上进行网络请求地方用 RecyclerView 去展示数据,都需要用到刷新、重试、错误处理等等功能。

1. 错误处理

Paging3 的组件 PagingDataAdapter,PagingDataAdapter 是一个处理分页数据的可回收视图适配器,PagingDataAdapter 提供了三个方法,如下图所示:

方法名作用
withLoadStateFooter添加列表底部(类似于加载更多)
withLoadStateHeader添加列表的头部
withLoadStateHeaderAndFooter添加头部和底部

Paging3 提供了 LoadStateAdapter 用于实现列表底部和头部样式,只需要继承 LoadStateAdapter 做对应的网络状态处理即可,例如这里实现的 FooterAdapter 加载更多样式。

class FooterAdapter(val adapter: GitHubAdapter) : LoadStateAdapter<NetworkStateItemViewHolder>() {
    override fun onBindViewHolder(holder: NetworkStateItemViewHolder, loadState: LoadState) {
        holder.bindData(loadState, 0)
    }

    override fun onCreateViewHolder(
        parent: ViewGroup,
        loadState: LoadState
    ): NetworkStateItemViewHolder {
        val view = inflateView(parent, R.layout.recycie_item_network_state)
        return NetworkStateItemViewHolder(view) { adapter.retry() }
    }

    private fun inflateView(viewGroup: ViewGroup, @LayoutRes viewType: Int): View {
        val layoutInflater = LayoutInflater.from(viewGroup.context)
        return layoutInflater.inflate(viewType, viewGroup, false)
    }
}

class NetworkStateItemViewHolder(view: View, private val retryCallback: () -> Unit) :
    DataBindingViewHolder<LoadState>(view) {
    val mBinding: RecycieItemNetworkStateBinding by viewHolderBinding(view)

    override fun bindData(data: LoadState, position: Int) {
        mBinding.apply {
            // 正在加载,显示进度条
            progressBar.isVisible = data is LoadState.Loading
            // 加载失败,显示并点击重试按钮
            retryButton.isVisible = data is LoadState.Error
            retryButton.setOnClickListener { retryCallback() }
            // 加载失败显示错误原因
            errorMsg.isVisible = !(data as? LoadState.Error)?.error?.message.isNullOrBlank()
            errorMsg.text = (data as? LoadState.Error)?.error?.message

            executePendingBindings()
        }
    }
}

在上面分别处理了,正在加载、加载失败并提供重试按钮等等状态。

2. Paging3 同时提供了刷新、重试等等方法,如下图所示:

  • refresh:常用用于下拉更新数据。
  • retry:常用于底部更多样式,当请求网络失败的时候,显示重试按钮,点击调用 retry。

3. Paging3 还帮我处理了如果出现多次网络请求,只会处理最后一次请求,例如由于网络慢,用户频繁的刷新数据等等

6. 监听网路请求状态

刚才分析过 PagingDataAdapter 是一个处理分页数据的可回收视图适配器,并且还提供了两个监听数据状态的方法。

这两个方法的区别是:

  • addDataRefreshListener:当一个新的 PagingData 提交并显示的时候调用。
  • addLoadStateListener:这个方法同 addDataRefreshListener 方法,它们之间的区别是 addLoadStateListener 方法返回了一个 CombinedLoadStates 的对象,如上图所示。

CombinedLoadStates 是一个数据类,里面有三个成员变量 refresh、prepend 和 append。

val refresh: LoadState = (mediator ?: source).refresh
val prepend: LoadState = (mediator ?: source).prepend
val append: LoadState = (mediator ?: source).append
变量作用
refresh在初始化刷新的使用
append在加载更多的时候使用
prepend在当前列表头部添加数据的时候使用

refresh、prepend 和 append 都是 LoadState 的对象,LoadState 也是一个密封类,每一个 refresh、prepend 和 append 都对应着三种状态。

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

到这里不得不佩服 Google 什么都替我们想好了,这里需要结合自己的项目实际情况,去定制不同的状态处理。

到这里 Paging3 算是完结了,最后贴一下本文案例 Paging3SimpleWithNetWork 已经上传到 GitHub,最后祝大家周末愉快呀。

计划建立一个最全、最新的 AndroidX Jetpack 相关组件的实战项目 以及 相关组件原理分析文章,正在逐渐增加 Jetpack 新成员,仓库持续更新,可以前去查看:AndroidX-Jetpack-Practice, 如果这个仓库对你有帮助,请帮我点个赞,我会陆续完成更多 Jetpack 新成员的项目实践。

结语

致力于分享一系列 Android 系统源码、逆向分析、算法、翻译、Jetpack 源码相关的文章,正在努力写出更好的文章,如果这篇文章对你有帮助给个 star,一起来学习,期待与你一起成长。

算法

由于 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,文章都会同步到这个仓库。

工具系列