這篇看完,Paging3還不會你捶我!
作者:黃林晴
https://huanglinqing.blog.csdn.net/
Paging是什么?
想想我們之前的業(yè)務(wù)中,實現(xiàn)分頁加載需要怎么處理?一般我們都是自己封裝RecycleView或者使用XRecycleView這種第三方庫去做,而Paging 就是Google為我們提供的分頁功能的標(biāo)準(zhǔn)庫,這樣我們就無須自己去基于RecycleView實現(xiàn)分頁功能,并且Paging為我們提供了許多可配置選項,使得分頁功能更加靈活。而Paging3是Paging庫當(dāng)前的最新版本,仍處于測試版本,相比較于Paging2的使用就簡潔多了。
Paging的使用
項目搭建
首先我們新建項目,在gradle中引用paging庫如下:
def paging_version = "3.0.0-alpha07"
implementation "androidx.paging:paging-runtime:$paging_version"
testImplementation "androidx.paging:paging-common:$paging_version"
項目示例,我們使用Kotlin語言并且使用了協(xié)程和Flow,所以也需要添加協(xié)程的庫如下:
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.3.7-mpp-dev-11'
項目示例
在官方文檔中也給出了我們Paging在架構(gòu)中的使用圖。

通過上圖我們也可以清晰的看出來,Paging在倉庫層、ViewModel和UI層都有具體的表現(xiàn),接下來我們通過一個示例來逐步講解Paging是如何在項目架構(gòu)中工作的。
API接口準(zhǔn)備
這里我們已經(jīng)寫好了RetrofitService類用于創(chuàng)建網(wǎng)絡(luò)請求的service代碼如下所示:
object RetrofitService {
/**
* okhttp client
*/
lateinit var okHttpClient: OkHttpClient
/**
* 主Url地址
*/
private const val BASEAPI = "https://www.wanandroid.com/";
/**
* 創(chuàng)建service對象
*/
fun <T> createService(mClass: Class<T>): T {
val builder: OkHttpClient.Builder = OkHttpClient.Builder();
okHttpClient = builder.build()
val retrofit: Retrofit = Retrofit.Builder()
.baseUrl(BASEAPI)
.client(okHttpClient)
.addConverterFactory(GsonConverterFactory.create())
.build()
return retrofit.create(mClass) as T
}
}
和 DataApi接口,這里我們將方法聲明為掛起函數(shù),便于在協(xié)程中調(diào)用。
interface DataApi {
/**
* 獲取數(shù)據(jù)
*/
@GET("wenda/list/{pageId}/json")
suspend fun getData(@Path("pageId") pageId:Int): DemoReqData
}
定義數(shù)據(jù)源
首先我們來定義數(shù)據(jù)源DataSource繼承自PagingSource,代碼如下所示:
class DataSource():PagingSource<Int,DemoReqData.DataBean.DatasBean>(){
override suspend fun load(params: LoadParams<Int>): LoadResult<Int, DemoReqData.DataBean.DatasBean> {
TODO("Not yet implemented")
}
}
我們可以看到PagingSource中有兩個參數(shù)Key 和 Value,這里Key我們定義為Int類型Value DemoReqData 是接口返回數(shù)據(jù)對應(yīng)的實體類,這里的意思就是我們傳Int類型的值(如頁碼)得到返回的數(shù)據(jù)信息DemoReqData對象。
這里需要提醒的是如果你使用的不是Kotlin 協(xié)程而是Java,則需要繼承對應(yīng)的PagingSource如RxPagingSource或ListenableFuturePagingSource。
DataSource為我們自動生成了load方法,我們主要的請求操作就在load方法中完成。主要代碼如下所示:
override suspend fun load(params: LoadParams<Int>): LoadResult<Int, DemoReqData.DataBean.DatasBean> {
return try {
//頁碼未定義置為1
var currentPage = params.key ?: 1
//倉庫層請求數(shù)據(jù)
var demoReqData = DataRespority().loadData(currentPage)
//當(dāng)前頁碼 小于 總頁碼 頁面加1
var nextPage = if (currentPage < demoReqData?.data?.pageCount ?: 0) {
currentPage + 1
} else {
//沒有更多數(shù)據(jù)
null
}
if (demoReqData != null) {
LoadResult.Page(
data = demoReqData.data.datas,
prevKey = null,
nextKey = nextPage
)
} else {
LoadResult.Error(throwable = Throwable())
}
} catch (e: Exception) {
LoadResult.Error(throwable = e)
}
}
上面代碼我們可以看到在datasource中我們通過DataRespority()倉庫層,去請求數(shù)據(jù),如果沒有更多數(shù)據(jù)就返回null,最后使用 LoadResult.Page將結(jié)果返回,如果加載失敗則用LoadResult.Error返回,由于 LoadResult.Page中的data 必須是非空類型的,所以我們需要判斷返回是否為null。
接下來我們看下DataRespority倉庫層的代碼,代碼比較簡單,如下所示:
class DataRespority {
private var netWork = RetrofitService.createService(
DataApi::class.java
)
/**
* 查詢護理數(shù)據(jù)
*/
suspend fun loadData(
pageId: Int
): DemoReqData? {
return try {
netWork.getData(pageId)
} catch (e: Exception) {
//在這里處理或捕獲異常
null
}
}
}
Load調(diào)用官方給出的流程如下所示:

從上圖可以知道,load的方法 是我們通過Paging的配置自動觸發(fā)的,不需要我們每次去調(diào)用,那么我們?nèi)绾蝸硎褂肈ataSource呢?
調(diào)用PagingSource
The Pager object calls the load() method from the PagingSource object, providing it with the LoadParams object and receiving the LoadResult object in return.
這句話翻譯過來的意思就是:Pager對象從PagingSource對象調(diào)用load()方法,為它提供LoadParams對象,并作為回報接收LoadResult對象。
所以我們在創(chuàng)建viewModel對象,并創(chuàng)建pager對象從而調(diào)用PagingSource方法 ,代碼如下所示:
class MainActivityViewModel : ViewModel() {
/**
* 獲取數(shù)據(jù)
*/
fun getData() = Pager(PagingConfig(pageSize = 1)) {
DataSource()
}.flow
}
在viewmodel中我們定義了一個getData的方法,Pager中通過配置PagingConfig來實現(xiàn)特殊的定制,我們來看下PagingConfig中的參數(shù)如下:
pageSize:定義從PagingSource一次加載的項目數(shù)。
prefetchDistance:預(yù)取距離,簡單解釋就是 當(dāng)距離底部還有多遠(yuǎn)的時候自動加載下一頁,即自動調(diào)用load方法,默認(rèn)值和pageSize相等。
enablePlaceholders:是否顯示占位符,當(dāng)網(wǎng)絡(luò)不好的時候,可以考到頁面的框架,從而提升用戶體驗。
還有一些其他參數(shù)這里就不一一介紹了,從構(gòu)造方法的源碼中可以看出pageSize這個參數(shù)是必填的,其他的是可選項,所以我們這里傳了1。
定義RecycleViewAdapter
這一步,和我們平時定義普通的RecycleViewAdapter沒有太大的區(qū)別,只是我們繼承的是PagingDataAdapter,主要代碼如下所示:
class DataRecycleViewAdapter :
PagingDataAdapter<DemoReqData.DataBean.DatasBean, RecyclerView.ViewHolder>(object :
DiffUtil.ItemCallback<DemoReqData.DataBean.DatasBean>() {
override fun areItemsTheSame(
oldItem: DemoReqData.DataBean.DatasBean,
newItem: DemoReqData.DataBean.DatasBean
): Boolean {
return oldItem.id == newItem.id
}
@SuppressLint("DiffUtilEquals")
override fun areContentsTheSame(
oldItem: DemoReqData.DataBean.DatasBean,
newItem: DemoReqData.DataBean.DatasBean
): Boolean {
return oldItem == newItem
}
}) {
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
var dataBean = getItem(position)
(holder as DataViewHolder).binding.demoReaData = dataBean
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): TestViewHolder {
return TestViewHolder(
DataBindingUtil.inflate(
LayoutInflater.from(parent.context),
R.layout.health_item_test,
parent,
false
)
)
}
inner class DataViewHolder(private val dataBindingUtil: ItemDataBinding) :
RecyclerView.ViewHolder(dataBindingUtil.root) {
var binding = dataBindingUtil
}
}
這里我們要提醒的是DiffUtil這個參數(shù),用于計算列表中兩個非空項目之間的差異的回調(diào)。無特殊情況一般都是固定寫法。
View層數(shù)據(jù)請求并將結(jié)果顯示在View上
到這里,基本工作已經(jīng)差不多了,當(dāng)然我們說的差不多了只是快能看到成果了,其中需要講解的地方還有很多,最后一步我們在view中請求數(shù)據(jù),并將結(jié)果綁定在adapter上。我們在View代碼中調(diào)用viewModel中的getData方法,代碼如下所示:
val manager = LinearLayoutManager(this)
rv_data.layoutManager = manager
rv_data.adapter = dataRecycleViewAdapter
btn_get.setOnClickListener {
lifecycleScope.launch {
mainActivityViewModel.getData().collectLatest {
dataRecycleViewAdapter.submitData(it)
}
}
}
我們在協(xié)程中調(diào)用getData方法,接收最新的數(shù)據(jù),通過PagingAdapter的submitData方法為adapter提供數(shù)據(jù),運行結(jié)果如下所示(忽略丑陋的UI.jpg)。

當(dāng)我們往下滑動時,當(dāng)?shù)撞窟€剩1個(pageSize)數(shù)據(jù)的時候會自動加載下一頁。
當(dāng)然對于這個接口不需要傳pageSize,所以返回的數(shù)據(jù)大小并不會受pageSize的影響,如此一來,我們就使用Paging3 完成了簡單的數(shù)據(jù)分頁請求。
/ Paging的加載狀態(tài) /
Paging3 為我們提供了獲取Paging加載狀態(tài)的方法,其中包含添加監(jiān)聽事件的方式以及在adapter中直接顯示的方式,首先我們來看監(jiān)聽事件的方式。
使用監(jiān)聽事件方式獲取加載狀態(tài)
上面我們在Activity中創(chuàng)建了dataRecycleViewAdapter來顯示頁面數(shù)據(jù),我們可以使用addLoadStateListener方法添加加載狀態(tài)的監(jiān)聽事件,如下所示:
dataRecycleViewAdapter.addLoadStateListener {
when (it.refresh) {
is LoadState.NotLoading -> {
Log.d(TAG, "is NotLoading")
}
is LoadState.Loading -> {
Log.d(TAG, "is Loading")
}
is LoadState.Error -> {
Log.d(TAG, "is Error")
}
}
}
這里的it是CombinedLoadStates數(shù)據(jù)類,有refresh、Append、Prepend 區(qū)別如下表格所示:
refresh | 在初始化刷新的使用 |
append | 在加載更多的時候使用 |
prepend | 在當(dāng)前列表頭部添加數(shù)據(jù)的時候使用 |
也就是說如果監(jiān)測的是it.refresh,當(dāng)加載第二頁第三頁的時候,狀態(tài)是監(jiān)聽不到的,這里只以it.refresh為例。
LoadState的值有三種,分別是NotLoading:當(dāng)沒有加載動作并且沒有錯誤的時候。Loading和Error顧名思義即對應(yīng)為正在加載 和加載錯誤的時候,監(jiān)聽方式除了addLoadStateListener外,還可以直接使用loadStateFlow的方式,由于flow內(nèi)部是一個掛起函數(shù) 所以我們需要在協(xié)程中執(zhí)行,代碼如下所示:
lifecycleScope.launch {
dataRecycleViewAdapter.loadStateFlow.collectLatest {
when (it.refresh) {
is LoadState.NotLoading -> {
}
is LoadState.Loading -> {
}
is LoadState.Error -> {
}
}
}
}
接下來我們運行上節(jié)的示例,運行成功后,點擊查詢按鈕,將數(shù)據(jù)顯示出來,我們看打印如下:
2020-11-14 16:39:19.841 23729-23729/com.example.pagingdatademo D/MainActivity: is NotLoading
2020-11-14 16:39:24.529 23729-23729/com.example.pagingdatademo D/MainActivity: 點擊了查詢按鈕
2020-11-14 16:39:24.651 23729-23729/com.example.pagingdatademo D/MainActivity: is Loading
2020-11-14 16:39:25.292 23729-23729/com.example.pagingdatademo D/MainActivity: is NotLoading
首先是NotLoading 狀態(tài),因為我們什么都沒有操作,點擊了查詢按鈕后變成Loading狀態(tài)因為正在加載數(shù)據(jù),查詢結(jié)束后再次回到了NotLoading的狀態(tài),符合我們的預(yù)期,那這個狀態(tài)有什么用呢? 我們在Loading狀態(tài)顯示一個progressBar過渡提升用戶體驗等,當(dāng)然最重要的還是Error狀態(tài),因為我們需要Error狀態(tài)下告知用戶。
我們重新打開App,斷開網(wǎng)絡(luò)連接,再次點擊查詢按鈕,打印日志如下:
2020-11-14 16:48:25.943 26846-26846/com.example.pagingdatademo D/MainActivity: is NotLoading
2020-11-14 16:48:27.218 26846-26846/com.example.pagingdatademo D/MainActivity: 點擊了查詢按鈕
2020-11-14 16:48:27.315 26846-26846/com.example.pagingdatademo D/MainActivity: is Loading
2020-11-14 16:48:27.322 26846-26846/com.example.pagingdatademo D/MainActivity: is Error
這里要注意的是什么呢,就是這個Error的狀態(tài),不是Paging為我們自動返回的,而是我們在DataSource中捕獲異常后,使用LoadResult.Error方法告知的。
我們也需要在Error狀態(tài)下監(jiān)聽具體的錯誤,無網(wǎng)絡(luò)的話就顯示無網(wǎng)絡(luò)UI 服務(wù)器異常的話 就提示服務(wù)器異常,代碼如下所示:
is LoadState.Error -> {
Log.d(TAG, "is Error:")
when ((it.refresh as LoadState.Error).error) {
is IOException -> {
Log.d(TAG, "IOException")
}
else -> {
Log.d(TAG, "others exception")
}
}
}
我們在斷網(wǎng)狀態(tài)下,點擊查詢,日志如下所示:
2020-11-14 17:29:46.234 12512-12512/com.example.pagingdatademo D/MainActivity: 點擊了查詢按鈕
2020-11-14 17:29:46.264 12512-12512/com.example.pagingdatademo D/MainActivity: 請求第1頁
2020-11-14 17:29:46.330 12512-12512/com.example.pagingdatademo D/MainActivity: is Loading
2020-11-14 17:29:46.339 12512-12512/com.example.pagingdatademo D/MainActivity: is Error:
2020-11-14 17:29:46.339 12512-12512/com.example.pagingdatademo D/MainActivity: IOException
在adapter中顯示
Paging3為我們提供了添加底部、頭部adapter的方法,分別為withLoadStateFooter、withLoadStateHeader以及同時添加頭部和尾部方法withLoadStateHeaderAndFooter,這里我們以添加尾部方法為例。
首先我們創(chuàng)建viewHolder LoadStateViewHolder綁定布局是底部顯示的布局,一個正在加載的顯示以及一個重試按鈕,xml布局如下所以:
<layout>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent">
<LinearLayout
android:id="@+id/ll_loading"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center"
android:orientation="horizontal"
android:visibility="gone"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="正在加載數(shù)據(jù)... ..."
android:textSize="18sp" />
<ProgressBar
android:layout_width="20dp"
android:layout_height="20dp" />
</LinearLayout>
<Button
android:id="@+id/btn_retry"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="加載失敗,重新請求"
android:visibility="gone"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/ll_loading" />
</androidx.constraintlayout.widget.ConstraintLayout>
</layout>
正在加載提示和重新請求的布局默認(rèn)都是隱藏,LoadStateViewHolder代碼如下所示:
class LoadStateViewHolder(parent: ViewGroup, var retry: () -> Void) : RecyclerView.ViewHolder(
LayoutInflater.from(parent.context)
.inflate(R.layout.item_loadstate, parent, false)
) {
var itemLoadStateBindingUtil: ItemLoadstateBinding = ItemLoadstateBinding.bind(itemView)
fun bindState(loadState: LoadState) {
if (loadState is LoadState.Error) {
itemLoadStateBindingUtil.btnRetry.visibility = View.VISIBLE
itemLoadStateBindingUtil.btnRetry.setOnClickListener {
retry()
}
} else if (loadState is LoadState.Loading) {
itemLoadStateBindingUtil.llLoading.visibility = View.VISIBLE
}
}
}
我們這里是和Adapter分為兩個類中的,所以我們要將adapter中的parent當(dāng)做參數(shù)傳過來,retry()是一個高階函數(shù),便于點擊重試后,在adapter中做重試邏輯。
bindState即為設(shè)置數(shù)據(jù),根據(jù)State的狀態(tài)來顯示不同的UI。
接著我們來創(chuàng)建LoadStateFooterAdapter 繼承自LoadStateAdapter,對應(yīng)的viewHolder即為LoadStateViewHolder,代碼如下所示:
class LoadStateFooterAdapter(private val retry: () -> Void) :
LoadStateAdapter<LoadStateViewHolder>() {
override fun onBindViewHolder(holder: LoadStateViewHolder, loadState: LoadState) {
(holder as LoadStateViewHolder).bindState(loadState)
}
override fun onCreateViewHolder(parent: ViewGroup, loadState: LoadState): LoadStateViewHolder {
return LoadStateViewHolder(parent, retry)
}
}
這里的代碼比較簡單,就不作講解了,最后我們來添加這個adapter.
rv_data.adapter =
dataRecycleViewAdapter.withLoadStateFooter(footer = LoadStateFooterAdapter(retry = {
dataRecycleViewAdapter.retry()
}))
這里要注意的是,應(yīng)該把withLoadStateFooter返回的adapter設(shè)置給recyclerview,如果你是這樣寫:dataRecycleViewAdapter.withLoadStateFooter后 在單獨設(shè)置recycleView的adapter,則會是沒有效果的。
這里我們點擊重試dataRecycleViewAdapter的retry()方法即可,我們運行程序求救第一頁后,斷開網(wǎng)絡(luò),然后往下滾動,效果如下所示:

如此,我們就在adapter中完成了數(shù)據(jù)加載狀態(tài)的顯示。
除此之外,Paging3中還有一個比較重要的RemoteMediator,用來更好的加載網(wǎng)絡(luò)數(shù)據(jù)庫和本地數(shù)據(jù)庫,我們后續(xù)有機會再為大家單獨分享吧~
2020年11月21日更新
paging3的設(shè)計理念是不建議對列表數(shù)據(jù)直接修改;而是對數(shù)據(jù)源進(jìn)行操作,數(shù)據(jù)源的變化會自動更新到列表,看到評論區(qū)中很多朋友說如何操作item的刪除和修改,這里我們使用最簡單的方式即可。
對單個item的修改
我們都知道RecycleView中是沒有直接監(jiān)聽item的Api的,一般都是在onBindViewHolder中去操作,或者通過回調(diào)在View層操作,在這里回調(diào)也可以寫為一個高階函數(shù),我們這里回調(diào)到View層的原因是評論區(qū)中有伙伴評論說要操作viewModel,所以避免在將viewModel注入到adapter,我們直接使用一個高階函數(shù)回調(diào)即可。修改DataRecycleViewAdapter代碼如下所示:
class DataRecycleViewAdapter(
val itemUpdate: (Int, DemoReqData.DataBean.DatasBean?,DataRecycleViewAdapter) -> Unit
) :
PagingDataAdapter<DemoReqData.DataBean.DatasBean, RecyclerView.ViewHolder>(object :
DiffUtil.ItemCallback<DemoReqData.DataBean.DatasBean>() {
override fun areItemsTheSame(
oldItem: DemoReqData.DataBean.DatasBean,
newItem: DemoReqData.DataBean.DatasBean
): Boolean {
return oldItem.id == newItem.id
}
@SuppressLint("DiffUtilEquals")
override fun areContentsTheSame(
oldItem: DemoReqData.DataBean.DatasBean,
newItem: DemoReqData.DataBean.DatasBean
): Boolean {
return oldItem == newItem
}
}) {
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
val dataBean = getItem(position)
(holder as DataViewHolder).binding.demoReaData = dataBean
holder.binding.btnUpdate.setOnClickListener {
itemUpdate(position, dataBean,this)
}
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
val binding: ItemDataBinding =
DataBindingUtil.inflate(
LayoutInflater.from(parent.context),
R.layout.item_data,
parent,
false
)
return DataViewHolder(binding)
}
inner class DataViewHolder(private val dataBindingUtil: ItemDataBinding) :
RecyclerView.ViewHolder(dataBindingUtil.root) {
var binding = dataBindingUtil
}
}
為了便于演示我們這里在數(shù)據(jù)列表中新增了一個更新數(shù)據(jù)的按鈕,在Activity中聲明adapter的代碼修改如下:
private var dataRecycleViewAdapter = DataRecycleViewAdapter { position, it, adapter ->
it?.author = "黃林晴${position}"
adapter.notifyDataSetChanged()
}
我們通過執(zhí)行高階函數(shù) 將作者的名字修改為黃林晴和當(dāng)前點擊的序號,然后調(diào)用notifyDataSetChanged即可,演示效果如下所示:

對數(shù)據(jù)的刪除、新增
我們都知道,在之前,我們給adapter設(shè)置一個List,如果需要刪除或者新增,我們只要改變List即可,但是在Paging3中好像沒有辦法,因為數(shù)據(jù)源是PagingSource ,看了下官網(wǎng)的介紹。
A PagingSource / PagingData pair is a snapshot of the data set. A new PagingData / PagingData must be created if an update occurs, such as a reorder, insert, delete, or content update occurs. A PagingSource must detect that it cannot continue loading its snapshot (for instance, when Database query notices a table being invalidated), and call invalidate. Then a new PagingSource / PagingData pair would be created to represent data from the new state of the database query.
大致意思就是如果數(shù)據(jù)發(fā)生變化 必須創(chuàng)建新的PagingData ,所以暫時我也不知道如何可以在不重新請求的情況下,在數(shù)據(jù)刪除、新增后來刷新,如果你有好的方案,歡迎賜教!
推薦閱讀
? 耗時2年,Android進(jìn)階三部曲第三部《Android進(jìn)階指北》出版!
推薦我的技術(shù)博客
推薦一下我的獨立博客: liuwangshu.cn ,內(nèi)含Android最強原創(chuàng)知識體系,一直在更新,歡迎體驗和收藏!
BATcoder技術(shù)群,讓一部分人先進(jìn)大廠
你好,我是劉望舒,百度百科收錄的騰訊云TVP,著有暢銷書《Android進(jìn)階之光》《Android進(jìn)階解密》《Android進(jìn)階指北》,蟬聯(lián)四屆電子工業(yè)出版社年度優(yōu)秀作者,谷歌開發(fā)者社區(qū)特邀講師。
前華為面試官,現(xiàn)大廠技術(shù)負(fù)責(zé)人。
歡迎添加我的微信 henglimogan ,備注:BATcoder,加入BATcoder技術(shù)群。
