Android paging3 使用和踩坑经验分享

前言

Android 列表分页加载组件 paging3 alpha版本已经出来很久了。目前到了alpha7;
分享一下在项目中使用的经验和坑;不讲原理和源码,纯使用经验分享!
(不要问我为啥把alpha版本用在项目中,问就是任性,问就是paging2太难用了)

准备工作

1.依赖:

本文撰写日期:2020-10-21;最新版为3.0.0-alpha07

//java
implementation 'androidx.paging:paging-runtime:3.0.0-alpha07'
//kotlin
implementation 'androidx.paging:paging-runtime-ktx:3.0.0-alpha07'

根据语言二选一即可,我使用的是kotlin;

使用:

1.adapter

使用paging3 ,RecyclerView的adapter 必须继承 PagingDataAdapter
因为后续分页的UI和操作都归于 adapter 管理;

adpater 构造必须传参数 DiffUtil.ItemCallback ;
用过 AsyncListDiffer 的小伙伴应该明白它的作用;
不明白的可以参考一下这篇文章:Android AsyncListDiffer-RecyclerView最好的伙伴

DiffUtil.ItemCallback 简单介绍:

DiffUtil.ItemCallback的作用就是取代notifyDataSetChanged粗暴刷新列表的;
毕竟粗暴刷新比较消耗性能;

主要介绍三个方法:

override fun areItemsTheSame(oldItem: T, newItem: T): Boolean {}

override fun areContentsTheSame(oldItem: T, newItem: T): Boolean {}

override fun getChangePayload(oldItem: T, newItem: T): Any? {}

paging3的设计理念是不建议对列表数据直接修改;而是对数据源进行操作,数据源的变化会自动更新到列表;
DiffUtil.ItemCallback 就是用来比对数据变化,从而决定更新对应UI;并执行条目动画;

  • areItemsTheSame
    比对新旧条目是否是同一个条目;
    一般比对条目的唯一标示id即可,谨慎对待,如果条目不同则可能不会更新UI;

  • areContentsTheSame
    当上面的方法确定是同一个条目之后,这里比对条目的内容是否一样,不一样则会更新条目UI
    建议这里的比对把UI展示的数据都写上,写漏了会导致UI不更新对应字段;

  • getChangePayload (可选)
    这个方法对应 RcyclerView的 adapter的 第三个参数;用于条目内部的局部刷新;

override fun onBindViewHolder(
        holder: RecyclerView.ViewHolder,
        position: Int,
        payloads: MutableList<Any>
    )

2.数据请求处理

这里利用知乎日报的接口作为范例:
没有使用到paging3的数据库缓存方案 remoteMediator;因为参数被注解为
@OptIn(ExperimentalPagingApi::class)还在测试中;这里讲解纯网络请求分页方案;
实际项目中,不可能每个列表接口都做数据库缓存的,工作量太大;

paging3 数据请求主要用到3个类:

  1. Pager
  2. PagingConfig
  3. PagingSource
  • Pager 分页数据的主要入口,这是它的构造:
class Pager<Key : Any, Value : Any>
@JvmOverloads constructor(
    config: PagingConfig,
    initialKey: Key? = null,
    @OptIn(ExperimentalPagingApi::class)
    remoteMediator: RemoteMediator<Key, Value>? = null,
    pagingSourceFactory: () -> PagingSource<Key, Value>
)

它的泛型
Key -> 分页标志 ,类似于页码,或者其它告诉后端我要哪一页的参数;
Value -> 列表数据的单个数据类型,就是每个条目的类型;

参数解释:
config :分页配置,见下面介绍
initialKey : 初始页的页码 (可选)
remoteMediator :远程数据解调员;网络请求数据后处理的类,可以做数据缓存
pagingSourceFactory:数据源工厂(每次刷新数据都会生产新的数据源)

  • PagingConfig 介绍
    Pager第一个参数:config: PagingConfig 分页逻辑:每页多少条之类的设置;
    构造:
class PagingConfig @JvmOverloads constructor(
    val pageSize: Int,
    @IntRange(from = 0)
    val prefetchDistance: Int = pageSize,
    val enablePlaceholders: Boolean = true,
    @IntRange(from = 1)
    val initialLoadSize: Int = pageSize*DEFAULT_INITIAL_PAGE_MULTIPLIER,
    val maxSize: Int = MAX_SIZE_UNBOUNDED,
    val jumpThreshold: Int = COUNT_UNDEFINED
)

参数解释:
pageSize:每页多少个条目;必填
prefetchDistance :预加载下一页的距离,滑动到倒数第几个条目就加载下一页,无缝加载(可选)默认值是pageSize
enablePlaceholders:是否启用条目占位,当条目总数量确定的时候;列表一次性展示所有条目,但是没有数据;在adapter的onBindViewHolder里面绑定数据时候,是空数据,判断是空数据展示对应的占位item;可选,默认开启。
initialLoadSize :第一页加载条目数量 ,可选,默认值是 3*pageSize (有时候需要第一页多点数据可用)
maxSize :定义列表最大数量;可选,默认值是:Int.MAX_VALUE
jumpThreshold:暂时还不知道用法,从文档注释上看,是滚动大距离导致加载失效的阈值;可选,默认值是:Int.MIN_VALUE (表示禁用此功能)

  • PagingSource 分页数据源
    pagingSourceFactory 工厂生产的产品;
abstract class PagingSource<Key : Any, Value : Any> {
    abstract suspend fun load(params: LoadParams<Key>): LoadResult<Key, Value>
}

泛型同 Pager 泛型,要实现的主要方法就一个:比paging2方便了不知道多少倍

参数解释:
params :请求列表需要的参数
返回值:
LoadResult :列表数据请求结果,包含下一页要请求的key

用法范例:

val allNews = Pager(PagingConfig(20), initialKey = initialKey) {
            object : PagingSource<Long, News.StoriesBean>() {
                override suspend fun load(params: LoadParams<Long>): LoadResult<Long, News.StoriesBean> {
                    val date = params.key ?: initialKey
                    return try {
                        val data = api.getNews(date).await() //网络请求数据
                        LoadResult.Page(data.stories, null, data.date.toLong())
                    } catch (e: Exception) {
                        LoadResult.Error(e)
                    }
                }
            }
        }
            .flow
            .cachedIn(viewModelScope)
            .asLiveData(viewModelScope.coroutineContext)

LoadResult.Page 解释:

constructor(
                data: List<Value>,
                prevKey: Key?,
                nextKey: Key?
            )

参数:
data :返回的数据列表
prevKey :上一页的key (传 null 表示没有上一页)
nextKey :下一页的key (传 null 表示没有下一页)

paging3 使用 flow 传递数据,不了解的可以搜索一下flow
cachedIn 绑定协程生命周期,必须加上,否则可能崩溃
asLiveData 熟悉livedata的都知道怎么用

绑定数据给adapter

model.allNews.observe(this@ZhiHuActivity, Observer {
            lifecycleScope.launchWhenCreated {
                adapter.submitData(it)
            }
        })

adapter.submitData 是一个协程挂起(suspend)操作,所以要放入协程赋值
lifecycleScope.launchWhenCreated 和 viewModelScope
需要依赖协程的生命周期辅助,见下面:

//生命周期辅助ktx
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.3.0-beta01'
implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.3.0-beta01'
implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.3.0-beta01'

3.UI状态处理和操作

下拉刷新

第一次请求不需要任何操作,订阅数据直接请求;
手动下拉刷新直接调用:

adapter.refresh()

就是这么简单,比paging2方便多了

上拉加载

paging3是无缝加载,实际没有手动上拉的操作
但是用户滑动过快的话还是会展示上拉的UI,下面会有UI的处理逻辑

失败重试

adapter.retry()

主要用于加载更多的重试。

UI状态处理

adapter.addLoadStateListener :添加状态监听:

adapter.addLoadStateListener {
            when (it.refresh) {
                is LoadState.Loading -> {}
                is LoadState.NotLoading -> {}
                is LoadState.Error -> {}
            }
        }

状态返回的参数 CombinedLoadStates,包含了
refresh,prepend,append,source,mediator 五种行为的状态
分别是:
刷新,向前加载更多,向后加载更多,数据源,调解员

每个行为分为3中状态:

  • LoadState.Loading 加载中 (加载数据时候回调)
  • LoadState.NotLoading 没有加载中 (加载数据前和加载数据完成后回调)
  • LoadState.Error 加载失败 (加载数据失败回调)

我们一般业务只关注 刷新和向后加载更多;

以SmartRefreshLayout为例:

下拉刷新状态处理:

//因为刷新前也会调用LoadState.NotLoading,所以用一个外部变量判断是否是刷新后
var hasRefreshing = false
adapter.addLoadStateListener {
    when (it.refresh) {
        is LoadState.Loading -> {
            hasRefreshing = true
            //如果是手动下拉刷新,则不展示loading页
            if (srl_refresh.state != RefreshState.Refreshing) {
                statePager.showLoading()
            }
        }
        is LoadState.NotLoading -> {
            if (hasRefreshing) {
                hasRefreshing= false
                statePager.showContent()
                srl_refresh.finishRefresh(true)
                //如果第一页数据就没有更多了,第一页不会触发append
                if (it.source.append.endOfPaginationReached){
                    //没有更多了(只能用source的append)
                    srl_refresh.finishLoadMoreWithNoMoreData()
                }
            }
        }
        is LoadState.Error -> {
            statePager.showError()
            srl_refresh.finishRefresh(false)
        }
    }
}

上拉加载更多状态处理:

//因为刷新前也会调用LoadState.NotLoading,所以用一个外部变量判断是否是加载更多后
var hasLoadingMore = false
adapter.addLoadStateListener {
    when (it.append) {
        is LoadState.Loading -> {
            hasLoadingMore = true
            //重置上拉加载状态,显示加载loading
            srl_refresh.resetNoMoreData()
        }
        is LoadState.NotLoading -> {
            if (hasLoadingMore) {
                hasLoadingMore = false
                if (it.source.append.endOfPaginationReached){
                    //没有更多了(只能用source的append)
                    srl_refresh.finishLoadMoreWithNoMoreData()
                }else{
                    srl_refresh.finishLoadMore(true)
                }
            }
        }
        is LoadState.Error -> {
            srl_refresh.finishLoadMore(false)
        }
    }
}

上面代码就是刷新和加载更多状态监听了,有一个问题:
第一页数据如果没有更多了,是不会触发 append 的 LoadState.Loading 状态,所以得在refresh里面判断一下;

刷新失败处理:

直接调用刷新即可

adapter.refresh()

加载更多失败处理:

srl_refresh.setOnLoadMoreListener { 
    adapter.retry()
}

为什么是重试?
因为paging是无缝加载,所以没有手动上拉加载逻辑
retry()虽然是重试,但是paging已处理,只有失败后会重试,所以这里上拉加载调用重试没问题

关于Header和 Footer

PagingDataAdapter 是支持 添加Header和Footer 的

adapter.withLoadStateHeader(header: LoadStateAdapter<*>)
adapter.withLoadStateFooter(header: LoadStateAdapter<*>)
adapter.withLoadStateHeaderAndFooter(header: LoadStateAdapter<*>,
        footer: LoadStateAdapter<*>)

LoadStateAdapter : 也是一个 RecyclerView.Adapter ;
类似于多条目布局,只是分成多个adapter
谷歌出过一个 MergeAdapter,就是把多个RecyclerView.Adapter 合并成一个,
有兴趣的小伙伴可以搜索一下。这里就不介绍了;

本文范例地址:

github

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