JetPack系列 Paging 3.0学习

上个周末晚上看到了鸿洋大神的公众号推送文章<<Jetpack重磅更新>>,于是乎点开文章看了一下具体内容,在翻阅的过程中发现Paging 3.0的信息,因为以前写过旧版Paging的demo,但是当时觉得Paging并不是很好用就放弃了,所以这次更新了Paging 3.0所以第一时间到官网看一下介绍然后写了个简单的小Demo来熟悉一下这个新的Paging


介绍

官方文档:https://developer.android.com/topic/libraries/architecture/paging/v3-overview

作为一个RecyclerView相关的库,Paging 3.0处理了关于数据加载,loadmore,refresh等功能,具体有以下这些功能和优点,可以减少代码中的逻辑处理和封装

  • 记录上一页和下一页的key,也就是我们常用的分页加载中的page
  • 滚动到底部的时候自动开启请求下一页的数据
  • 确保不会同时触发多个请求
  • 允许缓存数据:如果使用的是Kotlin,则可以通过CoroutineScope 来完成;如果使用的是Java,则可以用LiveData
  • 跟踪加载数据的状态,比如重试 刷新
  • 可以用map filter等操作符处理数据
  • 提供方法实现简单的分割线

实现步骤

1.创建数据源PagingSource

首先我们需要创建一个数据源PagingSource来给Paging提供源数据,这里用wanAndroid的接口来做测试,网络请求用Retrofit和协程来处理,具体相关代码就不贴在下面了

class MainSource : PagingSource<Int, ArticleEntity>() {
    override suspend fun load(params: LoadParams<Int>): LoadResult<Int, ArticleEntity> {
        // 如果key是null,那就加载第0页的数据
        val page = params.key ?: 0
        return try {
            // 这里获取文章列表的response
            val response = Api.getHomeArticles(page)
            LogUtils.d("加载第$page 页")
            // 如果成功加载,那么返回一个LoadResult.Page,如果失败就返回一个Error
            // Page里传进列表数据,以及上一页和下一页的页数,具体的是否最后一页或者其他逻辑就自行判断
            // 需要注意的是,如果是第一页,prevKey就传null,如果是最后一页那么nextKey也传null
            // 其他情况prevKey就是page-1,nextKey就是page+1
            LoadResult.Page(
                data = response.pageData(),
                prevKey = if (page == 0) null else page - 1,
                nextKey = if (response.isLastPage()) null else page + 1
            )
        } catch (e: Exception) {
            // 捕获异常,返回一个Error
            LoadResult.Error(e.toKError())
        }
    }
}

2. PagingDataPager

PagingData是数据源和RecyclerView Adapter之间的一个桥梁,每一页数据就是一个PagingData
首先创建Flow<PagingData<XXX>>,可以通过Pager().flow()去实现,可以传入PagingConfigPager中去设置一些分页的参数,比如:

  • pageSize : 每一页的数据量
  • prefetchDistance : 预取数据的距离,也就是距离最后一个item多远时开始加载下一页数据,默认是一页的数据量ppagesize,也就是说你获取到N页数据之后会自动开始获取第N+1页的数据,如果设置为0那么loadmore的效果就会消失
  • initialLoadSize : 初始化加载的数量,默认为pagesize * 3
 Pager(
    config = PagingConfig(pageSize = 20, prefetchDistance = 1),
    // 这里的source就是上面的MainSource对象
    pagingSourceFactory = { source }).flow.cachedIn(viewModelScope)

Flow<PagingData>有一个方便的cachedIn()方法,使我们可以将内容缓存Flow<PagingData>CoroutineScope中,如果我们放在viewmodel中,这样我们就可以在配置被更改之后,activity能够接收到原有的数据而不用重新开始获取,如果你要在flow上用map等操作符的时候,要确保cachedIn()是在最后面的,如果没有缓存,那么在切换横竖屏的时候就会重新开始请求,具体效果可以修改demo里的代码测试

3. 接收数据流

    // 在activity中订阅pager的数据流,并且通过submitData方法把数据传给adapter
    // 这里的PagingItemView是经过封装的recyclerview item,是自己封装的,下面会说到
    // 正常的逻辑下可以在这里做一些过滤转换的操作,然后把数据传给adapter就可以了
    lifecycleScope.launch {
        viewmodel.pager.collect {
        // 把数据转化成itemview然后让adapter刷新ui
        adapter.submitData(it.map { ItemArticleView(it) as PagingItemView<Any> })
        }
    }

4. PagingDataAdapter

Paging库时需要recyclerview的adapter继承PagingDataAdapter,代码和原本的自定义Adapter差别不大

这一部分加入了一点自己封装的代码,每个人写法不同,也可以直接跳过一些部分

  • PagingAdapter 这里我简单的封装了一下,配合PagingItemView去使用,这样项目里就只需要一个Adapter,每个列表不同的Item逻辑可以放到PagingItmeView里去写,也方便复用一些一样的item
class PagingAdapter(context: Context) :
    PagingDataAdapter<PagingItemView<Any>, RecyclerView.ViewHolder>(
     // 首先构造方法里需要传入diffutil的callback,这是判断item是否需要更新的部分
    // 相信用过diffutil的都能明白这部分代码,就不多讲了
        object : DiffUtil.ItemCallback<PagingItemView<Any>>() {
            override fun areItemsTheSame(
                oldItem: PagingItemView<Any>,
                newItem: PagingItemView<Any>
            ): Boolean {
                return oldItem.areItemsTheSame(newItem)
            }

            override fun areContentsTheSame(
                oldItem: PagingItemView<Any>,
                newItem: PagingItemView<Any>
            ): Boolean {
                return oldItem.areContentsTheSame(newItem)
            }
        }
    ) {

    val layoutInflater by lazy {
        LayoutInflater.from(context)
    }
    
    // 获取到对应的itemview去调用onBindView方法设置UI
    override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
        if (position != RecyclerView.NO_POSITION) {
            getItem(position)?.onBindView(holder = holder as PagingVHolder, position = position)
        }
    }

    // 这里用itemView的layoutRes去作为viewtype,这样不同布局的itemview就可以区分开来
    override fun getItemViewType(position: Int): Int {
        return getItem(position)!!.layoutRes
    }

    // 因为上面是用layoutRes来作为itemType,所以创建viewholder的时候直接用viewType来创建
    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
        val holder = PagingVHolder(layoutInflater.inflate(viewType, parent, false))
        return holder
    }
}
  • 然后是PagingItemView 这里是把recyclerview的每一个item 都作为一个PagingItemView,这样对应item的逻辑可以写在各自的PagingItemView,adapter里就不需要写什么逻辑了,我一般会配合databinding一起使用,
abstract class PagingItemView<T : Any>(@LayoutRes val layoutRes: Int) {
    lateinit var holder: PagingVHolder
    
    // adapter执行到bindView的时候会调用,可以通过holder去获取view更新UI,也可以直接用databinding
    open fun onBindView(holder: PagingVHolder, position: Int) {
        this.holder = holder
    }

    abstract fun areItemsTheSame(data: T): Boolean

    abstract fun areContentsTheSame(data: T): Boolean
}

// 把每一篇文章每一个item作为一个PagingItemView,传入对应的文章entity,然后比较旧数据和新数据是否相同,这里简单比较一下id,具体项目中自己去决定
// 配合databinding用的时候,如果只是绑定一些简单的数据显示,代码量可以减少很多
class ItemArticle(val entity: ArticleEntity) :
    PagingItemView<ItemArticle>(R.layout.item_article) {

    val binding by lazy {
        DataBindingUtil.bind<ItemArticleBinding>(holder.itemView)
    }

    override fun areItemsTheSame(data: ItemArticle): Boolean {
        return entity.id == data.entity.id
    }

    override fun areContentsTheSame(data: ItemArticle): Boolean {
        return entity.id == data.entity.id
    }

    override fun onBindView(holder: PagingVHolder, position: Int) {
        super.onBindView(holder, position)
        // 设置文章标题
        binding?.tvTitle?.text = entity.title
    }
}

实际上在Paging 3.0Adapter的部分跟常规的Adapter几乎是一样的,只是改成继承PagingDataAdapter并且要传入一个DiffUtil的比较逻辑,在这里贴出了我自己简单封装的Adapter,大家可以参考一下,或者自己按照喜欢的方式去写都可以。

5. Loadmore Refresh Retry

用到RecyclerView的地方基本上都会有loadmore/refresh功能存在,Paging 3.0很好的支持了这两个功能

  • Loadmore只需要创建一个LoadStateAdapter 然后把loadmore样式的viewholdercreate和bind,最后调用adapter.withLoadStateFooter把footer这个adapter加到PagingDataAdapter里去,这样就完成了对原有recyclerview添加loadmore页脚的功能,具体代码如下
  • refresh自由发挥,demo里用了官方的下拉控件,直接调用PagingDataAdapterrefresh()就可以完成刷新了
  • retry 在demo里用在loadmore失败的时候,把进度条变成一个重试按钮,点击后尝试重新加载,只要调用PagingDataAdapterretry方法就可以,非常方便

Paging 3.0在这里还帮我们做了一些优化处理,比如上面说到的避免重复触发不同的请求,比如刷新和loadmore的交替请求,Paging最后只会处理后一个操作,这样可以避免列表的数据错乱


// 这部分只是创建一个简单的loadmoreadapter,并且传入一个重试的回调
class LoadmoreAdapter(val retrycallback: () -> Unit) : LoadStateAdapter<LoadmoreView>() {
    override fun onBindViewHolder(holder: LoadmoreView, loadState: LoadState) {
        holder.bindState(loadState)
    }

    override fun onCreateViewHolder(parent: ViewGroup, loadState: LoadState): LoadmoreView {
        return LoadmoreView(parent, loadState,retrycallback)
    }

}

// 这部分是一个底部loadmore的item,包含一个进度条以及重试按钮
class LoadmoreView(
    parent: ViewGroup,
    loadState: LoadState,
    val retrycallback: () -> Unit
) :
    RecyclerView.ViewHolder(
        LayoutInflater.from(parent.context)!!.inflate(R.layout.item_loadmore, parent, false)
    ) {

    val loading = ObservableBoolean()


    // 通过databinding来控制UI,具体可以查看demo的xml
    init {
        DataBindingUtil.bind<ItemLoadmoreBinding>(itemView)?.itemview = this
        bindState(loadState)
    }

    fun bindState(loadState: LoadState) {
        loading.set(loadState is LoadState.Loading)
    }
}

PagingDataAdapter中有三个添加页头页脚的方法

  • withLoadStateHeader 添加页脚,可以用于loadmore
  • withLoadStateHeaderAndFooter 可以添加页头/页脚
  • withLoadStateFooter 添加页头

这里需要注意的是,具体页头页脚的实现方式也是创建一个ViewHolder然后放到LoadStateAdapter中去,我们常见的底部loadmore就是添加页脚,但是这里的Header不是我们项目中在列表最顶部添加一个item的意思,而是和loadmore类似的概念。
也就是说如果我们添加了一个页头,那么只有在PagingSource中返回LoadResult.Page的时候prevKey不为null才会显示出来,所以如果我们从第一页开始加载是看不到这个Header的,如果我们一开始加载的页数是第5页,那么我们在往上滑动的时候,才能看到我们的Header

6.监听状态

想要监听数据获取的状态在PagingDataAdapter里有两个方法

  • addDataRefreshListener 这个方法是当新的PagingData被提交并且显示的回调
  • addLoadStateListener这个相较于上面那个比较复杂,listener中的回调会返回一个CombinedLoadStates对象
data class CombinedLoadStates(
    /**
     * [LoadStates] corresponding to loads from a [PagingSource].
     */
    val source: LoadStates,

    /**
     * [LoadStates] corresponding to loads from a [RemoteMediator], or `null` if RemoteMediator
     * not present.
     */
    val mediator: LoadStates? = null
) {
    val refresh: LoadState = (mediator ?: source).refresh
    val prepend: LoadState = (mediator ?: source).prepend
    val append: LoadState = (mediator ?: source).append
...
}
  • refresh: 对应LoadType.REFRESH也就是我们初始化/刷新数据的类型
  • prepend: 对应LoadType.PREPEND也就是上面提到的要往当前列表的头部添加数据
  • append: 对应LoadType.APPEND 往列表底部添加数据,也就是loadmore

上面三种加载类型对应的加载状态用LoadState来表示,分别有 NotLoading Loading Error 看类名就很好理解,然后我们需要结合起来去判断当前的加载类型以及状态,这里我做一些简单的判断处理,大家具体在项目中可以自由发挥

adapter.addLoadStateListener { loadState ->
    // 这里的逻辑可以自由发挥,我这里用了自己写的statuslayout库去管理页面状态
    // 错误和loading的时候对应去切换状态的UI,还可以做全局设置,这样不同页面也只需要设置一次就够了
    when (loadState.refresh) {
        is LoadState.Error -> statuslayout.switchLayout(StatusLayout.STATUS_ERROR)
        is LoadState.Loading -> {
            statuslayout.showDefaultContent()
            refreshlayout.isRefreshing = true
        }
        is LoadState.NotLoading -> {
            statuslayout.showDefaultContent()
            refreshlayout.isRefreshing = false
        }
    }
}

这里一般用来做RecyclerView的初始化loading,失败,成功这几种状态的页面切换。在项目里我会用自己之前写的StatusLayout来处理,这样项目里不同页面的列表状态UI都可以很方便的统一处理

这是StatusLayout的github地址,https://github.com/Colaman0/StatusLayout
大家可以试试看配合使用,如果觉得有用的话可以给个star

7.总结

  • PagingSource负责提供源数据,一般是网络请求或者数据库查询,
  • Flow<PagingData<Value>> 或者说是 Pager负责一些分页的参数设定和订阅源数据流
  • PagingDataAdapter 跟常规的RecyclerivewAdapter一样把数据转换成UI
  • LoadStateAdapter 可以添加页头/页脚,方便实现loadmore的样式

Paging 3.0的基本用法介绍到这里就完成了,在学习的过程中能明显感觉到Paging对于列表处理是很友好的,为我们处理了很多业务上的逻辑,代码实现起来也相对优雅友好很多。还有一些实验性的Api这次就先不写了,等以后有机会再写一篇文章来介绍。最后附上自己的Demo地址作为参考

https://github.com/Colaman0/PagingDemo

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 160,706评论 4 366
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 68,002评论 1 301
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 110,462评论 0 250
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 44,375评论 0 216
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,763评论 3 294
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,849评论 1 224
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 32,033评论 2 317
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,768评论 0 204
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,490评论 1 246
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,734评论 2 253
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,204评论 1 264
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,566评论 3 260
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,227评论 3 241
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,137评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,934评论 0 201
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,926评论 2 283
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,774评论 2 274