Paging3 (一) 入門
前言:
官方分頁工具, 確實香. 但數據源不開放, 無法隨意增刪改操作; 只能借助 Room; 但列表數據不一定都要用 Room吧;
如果偏查詢的分頁數據用 Paging3 ; 其他一概用 老Adapter; 這倒也算個方案. [苦笑]
目錄:
- 簡單使用 - 數據源,Viewmodel,Adapter 等
- LoadResult - Error, Page. Error 用法等
- PagingConfig
- 監聽列表加載狀態
- LoadStateAdapter - loading, 加載失敗, 沒有更多等
- Map - 數據預處理
官方 Pagings 優勢:
- 分頁數據的內存中緩存。該功能可確保您的應用在處理分頁數據時高效利用系統資源。
- 內置的請求重復信息刪除功能,可確保您的應用高效利用網絡帶寬和系統資源。
- 可配置的
RecyclerView
適配器,會在用戶滾動到已加載數據的末尾時自動請求數據。 - 對 Kotlin 協程和 Flow 以及
LiveData
和 RxJava 的一流支持。 - 內置對錯誤處理功能的支持,包括刷新和重試功能。
導包:
dependencies { val paging_version = "3.0.0" //唯一必導包 implementation("androidx.paging:paging-runtime:$paging_version") // 測試用 testImplementation("androidx.paging:paging-common:$paging_version") // optional - RxJava2 support implementation("androidx.paging:paging-rxjava2:$paging_version") // optional - RxJava3 support implementation("androidx.paging:paging-rxjava3:$paging_version") // 適配 Guava 庫 - 高效java擴展庫 implementation("androidx.paging:paging-guava:$paging_version") // 適配 Jetpack Compose - 代碼構建View; 干掉 layout implementation("androidx.paging:paging-compose:1.0.0-alpha09") }
1.1 數據源 PagingSource
自定義數據源, 繼承 PagingSource
它有兩個泛型參數, 1. 頁碼key, 沒有特殊需求的話一般就是 Int 類型; 2.集合實體類型
重寫兩個方法: 1.load() 加載數據的方法; 2.getRefreshKey 初始加載的頁碼; 暫且返回 1 或 null
LoadResult.Page 后面再講;
class DynamicDataSource: PagingSource<Int, DynamicTwo>() { //模擬最大頁碼 private var maxPage = 2 //模擬數據 private fun fetchItems(startPosition: Int, pageSize: Int): MutableList<DynamicTwo> { Log.d("ppppppppppppppppppppp", "startPosition=${startPosition};;;pageSize=${pageSize}") val list: MutableList<DynamicTwo> = ArrayList() for (i in startPosition until startPosition + pageSize) { val concert = DynamicTwo() concert.title = "我是標題${i}" concert.newsInfo = "我是內容${i}" concert.nickName = "小王${i}" list.add(concert) } return list } override fun getRefreshKey(state: PagingState<Int, DynamicTwo>): Int? = null override suspend fun load(params: LoadParams<Int>): LoadResult<Int, DynamicTwo> { val nextPageNumber = params.key ?: 1 val size = params.loadSize Log.d("ppppppppppppppppppppp", "nextPageNumber=${nextPageNumber};;;size=${size}") val response = fetchItems((nextPageNumber-1) * size, size) return LoadResult.Page( data = response, prevKey = null, // Only paging forward. 只向后加載就給 null //nextKey 下一頁頁碼; 尾頁給 null; 否則當前頁碼加1 nextKey = if(nextPageNumber >= maxPage) null else (nextPageNumber + 1) ) } }
1.2 ViewModel
代碼比較簡單. 內容我們一會再講
class DynamicPagingModel(application: Application) : AndroidViewModel(application) { val flow = Pager( //配置 PagingConfig(pageSize = 10, prefetchDistance = 2,initialLoadSize = 10) ) { //我們自定義的數據源 DynamicDataSource() }.flow .cachedIn(viewModelScope) }
1.3 前台使用:
初始化 Adapter 及 RecycleView
mViewModel?.flow?.collectLatest 綁定監聽, 然后通過 submitData() 刷新列表;
mAdapter = SimplePagingAdapter(R.layout.item_dynamic_img_two, null) mDataBind.rvRecycle.let { it.layoutManager = LinearLayoutManager(mActivity) it.adapter = mAdapter } //Activity 用 lifecycleScope //Fragments 用 viewLifecycleOwner.lifecycleScope viewLifecycleOwner.lifecycleScope.launchWhenCreated { mViewModel?.flow?.collectLatest { mAdapter.submitData(it) } }
1.4 Adapter
必須繼承 paging 的 PagingDataAdapter
DiffCallback() 或 handler NewViewHolder 不了解的可以看我的 ListAdapter 封裝系列
open class SimplePagingAdapter( private val layout: Int, protected val handler: BaseHandler? = null ) : PagingDataAdapter<DynamicTwo, RecyclerView.ViewHolder>(DiffCallback()) { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { return NewViewHolder( DataBindingUtil.inflate( LayoutInflater.from(parent.context), layout, parent, false ), handler ) } override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { if(holder is NewViewHolder){ holder.bind(getItem(position)) } } }
over 簡單的分頁模擬數據已完成;
它是一個密封類; 它表示加載操作的結果;
2.1 LoadResult.Error
表示加載失敗; 需提供 Throwable 對象.
public data class Error<Key : Any, Value : Any>( val throwable: Throwable ) : LoadResult<Key, Value>()
可用於:
- 異常時返回, HTTP, IO, 數據解析等異常;
- 服務器錯誤碼響應
- 沒有更多數據
2.1 LoadResult.Page
表示加載成功;
參數:
data 數據集合;
prevKey 前頁頁碼 key; //向下一頁加載 給null
nextKey 后頁頁碼 key; //向上一頁加載 給null
public data class Page<Key : Any, Value : Any> constructor(
/**
* Loaded data
*/
val data: List<Value>,
/**
* [Key] for previous page if more data can be loaded in that direction, `null`
* otherwise.
*/
val prevKey: Key?,
/**
* [Key] for next page if more data can be loaded in that direction, `null` otherwise.
*/
val nextKey: Key?,
/**
* Optional count of items before the loaded data.
*/
@IntRange(from = COUNT_UNDEFINED.toLong())
val itemsBefore: Int = COUNT_UNDEFINED,
/**
* Optional count of items after the loaded data.
*/
@IntRange(from = COUNT_UNDEFINED.toLong())
val itemsAfter: Int = COUNT_UNDEFINED
) : LoadResult<Key, Value>() {
分頁配置
參數:
pageSize: 每頁容量
prefetchDistance: 當RecycleView 滑動到底部時, 會自動加載下一頁. 如果能提前預加載, 可以省去部分等待加載的時間.
prefetchDistance 就是距離底部提前加載的距離. 默認 = pageSize; = 0 時將不會加載更多
enablePlaceholders: 允許使用占位符. 想了解的點這里
initialLoadSize: 初始加載數量, 默認 = pageSize * 3
maxSize: 似乎意義沒有那么簡單. 還沒看源碼,不清楚; 不能 < pageSize + prefetchDistance * 2
jumpThreshold: 某閾值! 好吧我攤牌了, 我不知道. [奸笑]
LoadState: 表示加載狀態密封類;
LoadState.NotLoading: 加載完畢, 並且界面也已相應更新
LoadState.Error: 加載失敗.
LoadState.Loading: 正在加載..
lifecycleScope.launch { mAdapter.loadStateFlow.collectLatest { loadStates -> when(loadStates.refresh){ is LoadState.Loading -> { Log.d("pppppppppppppp", "加載中") } is LoadState.Error -> { Log.d("pppppppppppppp", "加載失敗") } is LoadState.NotLoading -> { Log.d("pppppppppppppp", "完事了") } else -> { Log.d("pppppppppppppp", "這是啥啊") } } } //或者: mAdapter.addLoadStateListener { ... } }
用於直接在顯示的分頁數據列表中呈現加載狀態。 例如: 尾部顯示 正在加載, 加載失敗, 沒有更多等;
5.1 自定義 MyLoadStateAdapter 繼承 LoadStateAdapter
重寫 onCreateViewHolder, onBindViewHolder
retry: 如果加載失敗, 想要重試, 則提供該高階函數參數; 否則不需要它
class MyLoadStateAdapter( /** * 當下一頁加載失敗時, 繼續嘗試加載下一頁; */ private val retry: () -> Unit ) : LoadStateAdapter<LoadStateViewHolder>() { override fun onCreateViewHolder( parent: ViewGroup, loadState: LoadState ) = LoadStateViewHolder(parent, retry) override fun onBindViewHolder( holder: LoadStateViewHolder, loadState: LoadState ) = holder.bind(loadState) }
5.2 自定義 LoadStateViewHolder
功能:
- 加載中 顯示 Loading;
- 加載失敗 顯示 錯誤信息. 包括 http, IO 異常, 后台給的錯誤 msg 等;
- 沒有更多
class LoadStateViewHolder ( parent: ViewGroup, retry: () -> Unit ) : RecyclerView.ViewHolder( LayoutInflater.from(parent.context) .inflate(R.layout.view_loading_more, parent, false) ) { private val binding = ViewLoadingMoreBinding.bind(itemView) init { //當點擊重試按鈕時, 調用 PagingDataAdapter 的 retry() 重新嘗試加載 binding.btnLoadingRetry.setOnClickListener { retry() } } fun bind(loadState: LoadState) { // 當加載失敗時. if(loadState is LoadState.Error){ // 將沒有更多封裝成 NoMoreException; 此時顯示沒有更多 View if(loadState.error is NoMoreException){ hideNoMoreUi(false) //顯示 沒有更多 View hideErrUi(true) //隱藏 失敗 View }else{ hideNoMoreUi(true) hideErrUi(false, loadState.error.message) //顯示失敗 View時, 填充錯誤 msg } }else{ hideNoMoreUi(true) hideErrUi(true) } //加載中.. binding.pbLoadingBar.visibility = if(loadState is LoadState.Loading){ View.VISIBLE }else{ View.GONE } } /** * 隱藏沒有更多View; */ private fun hideNoMoreUi(hide: Boolean){ if(hide){ binding.tvLoadingHint.visibility = View.GONE }else{ binding.tvLoadingHint.visibility = View.VISIBLE } } /** * 隱藏 加載失敗View; */ private fun hideErrUi(hide: Boolean, msg: String? = null){ if(hide){ binding.tvLoadingError.visibility = View.GONE binding.btnLoadingRetry.visibility = View.GONE }else{ binding.tvLoadingError.text = msg binding.tvLoadingError.visibility = View.VISIBLE binding.btnLoadingRetry.visibility = View.VISIBLE } } }
順便補一下 NoMoreException; 用法? 在下面 PagingSource 嘍.
class NoMoreException: RuntimeException()
5.3 layout view_loading_more.xml
包含: TextView: 沒有更多; ProgressBar: 加載中; TextView: 錯誤信息; Button: 重試按鈕
<layout> <androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" android:paddingHorizontal="16dp" android:layout_width="match_parent" android:layout_height="54dp"> <View android:layout_width="match_parent" android:layout_height="1px" android:background="#e5e5e5" app:layout_constraintTop_toTopOf="parent"/> <TextView android:id="@+id/tv_loading_hint" android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="14sp" android:textColor="#798080" android:text="已經到底了" android:visibility="gone" app:layout_constraintStart_toStartOf="parent" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintTop_toTopOf="parent" app:layout_constraintBottom_toBottomOf="parent"/> <ProgressBar android:id="@+id/pb_loading_bar" android:layout_width="32dp" android:layout_height="32dp" android:visibility="gone" android:indeterminateTint="#7671F8" app:layout_constraintStart_toStartOf="parent" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintTop_toTopOf="parent" app:layout_constraintBottom_toBottomOf="parent"/> <TextView android:id="@+id/tv_loading_error" android:layout_width="0dp" android:layout_height="wrap_content" android:textColor="@color/shape_red" android:text="錯誤信息" android:layout_marginEnd="8dp" android:maxLines="2" android:ellipsize="end" android:visibility="gone" app:layout_constraintTop_toTopOf="parent" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintEnd_toStartOf="@id/btn_loading_retry" app:layout_constraintStart_toStartOf="parent"/> <Button android:id="@+id/btn_loading_retry" android:layout_width="60dp" android:layout_height="38dp" android:textColor="@color/white" android:text="重試" android:visibility="gone" android:background="@drawable/shape_blue_7671f8_r8" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintTop_toTopOf="parent" app:layout_constraintBottom_toBottomOf="parent"/> </androidx.constraintlayout.widget.ConstraintLayout> </layout>
5.4 PagingSource 需要根據情況 返回不同的 LoadResult
代碼如下, 直接看注釋就可以了;
class DynamicDataSource: PagingSource<Int, DynamicTwo>() { private var maxPage = 1 override fun getRefreshKey(state: PagingState<Int, DynamicTwo>): Int? = null override suspend fun load(params: LoadParams<Int>): LoadResult<Int, DynamicTwo> { try { val nextPageNumber = params.key ?: 1 //超過頁碼時, 返回沒有更多狀態 NoMoreException if(nextPageNumber > maxPage){ return LoadResult.Error(NoMoreException()) } //這是 Retrofit 網絡請求 val map = mapOf("page" to nextPageNumber, "pageSize" to params.loadSize) val param = ApiManager.INSTANCE.getJsonBody(map) val response = ApiManager.INSTANCE.mApi.getDynamicList(param) //后台 響應錯誤碼時; 用 RuntimeException 返回錯誤信息 if(response.code != 200){ return LoadResult.Error(RuntimeException(response.msg)) } //解析響應數據 val jo = response.data val list = jo?.getAsJsonArray("newsList")?.toString()?.toBeanList<DynamicTwo>() ?: mutableListOf() maxPage = jo?.get("totalPage").toString().toInt() //返回正常數據 return LoadResult.Page( data = list, prevKey = null, // Only paging forward. 只向后加載就給null // nextKey 下一頁頁碼; 尾頁給 null; 否則當前頁碼加1 nextKey = nextPageNumber + 1 ) } catch (e: IOException) { // IOException for network failures. return LoadResult.Error(e) } catch (e: HttpException) { // HttpException for any non-2xx HTTP status codes. return LoadResult.Error(e) } catch (e: Exception) { // IOException for network failures. return LoadResult.Error(e) } } }
代碼中 請求參數只給了 page 和 pageSize; 其他參數怎么給?
- DynamicDataSource 的構造方法傳入;
- 動態參數怎么辦? 寫回調, 從ViewModel 中組裝請求數據
- 麻煩怎么辦? 創建 BaseDataSource. 將相似代碼封裝. 請求參數通過高階函數從ViewModel組裝;
5.5 前台使用:
首先正常初始化 Adapter, RecycleView, 並調用 mViewModel?.flow?.collectLatest
其次 RecycleView 的 adaper 不要給 主數據Adapter; 而是給 withLoadStateFooter() 返回的 ConcatAdapter
val stateAdapter = mAdapter.withLoadStateFooter(MyLoadStateAdapter(mAdapter::retry)) mDataBind.rvRecycle.let { it.layoutManager = LinearLayoutManager(mActivity) // **** 這里不要給 mAdapter(主數據 Adapter); 而是給 stateAdapter *** it.adapter = stateAdapter }
PagingDataAdapter 的 withLoadStateFooter 方法會返回一個新的 ConcatAdapter 對象; 請將這個 ConcatAdapter 設置給 RecycleView
withLoadStateFooter 的參數 就是我們自定義的 MyLoadStateAdapter; retry -> mAdapter.retry()
5.6 看一下 LoadStateAdapter 的源碼;
可以發現, 這是個單條目 Adapter.
並且 只有當 LoadState.Loading, LoadState.Error 時才會出現; 當然也可以重寫 displayLoadStateAsItem(), 讓它所有狀態都出現;
當 列表狀態變化時, 會設置 loadState 參數; 動態增刪改 Item;
abstract class LoadStateAdapter<VH : RecyclerView.ViewHolder> : RecyclerView.Adapter<VH>() { var loadState: LoadState = LoadState.NotLoading(endOfPaginationReached = false) set(loadState) { if (field != loadState) { val oldItem = displayLoadStateAsItem(field) val newItem = displayLoadStateAsItem(loadState) if (oldItem && !newItem) { notifyItemRemoved(0) } else if (newItem && !oldItem) { notifyItemInserted(0) } else if (oldItem && newItem) { notifyItemChanged(0) } field = loadState } } final override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): VH { return onCreateViewHolder(parent, loadState) } final override fun onBindViewHolder(holder: VH, position: Int) { onBindViewHolder(holder, loadState) } final override fun getItemViewType(position: Int): Int = getStateViewType(loadState)
//條目數量, final 不可重寫; final override fun getItemCount(): Int = if (displayLoadStateAsItem(loadState)) 1 else 0 abstract fun onCreateViewHolder(parent: ViewGroup, loadState: LoadState): VH abstract fun onBindViewHolder(holder: VH, loadState: LoadState) open fun getStateViewType(loadState: LoadState): Int = 0
//只有當 Loading, Error 時, 才顯示 open fun displayLoadStateAsItem(loadState: LoadState): Boolean { return loadState is LoadState.Loading || loadState is LoadState.Error } }
5.7 LoadStateAdapter 改建頭尾
如果我們把它強行改造成 Header footer:
- 重寫 displayLoadStateAsItem() 不管什么狀態, 都返回true
- loadState 不能重寫, 所以 notifyItemChanged(0) 必被調用;
- 暴力一點, 直接重寫 notifyItemChanged() 讓它什么都不做? 好吧 它也是 final, 不能重寫
- 既然要調刷新, 那就調吧 [破涕為笑]; 那怎么辦 盡量少執行無用代碼唄, 那就 onBindViewHolder() 啥也不干;
- 頭尾由前端控制, Adapter 只需要把這個 固定View顯示就 ok 了
- 如果能阻止 notifyItemChanged(0) 那就更好了. 聰明的你有沒有辦法呢. [666]
最終 Adapter:
class EndViewAdapter(val v: View) : LoadStateAdapter<EndHolder>() { override fun onCreateViewHolder( parent: ViewGroup, loadState: LoadState ) = EndHolder(v) override fun onBindViewHolder(holder: EndHolder, loadState: LoadState){ //啥也不干 } override fun displayLoadStateAsItem(loadState: LoadState) = true } class EndHolder(itemView: View) : RecyclerView.ViewHolder(itemView)
好吧, 一運行, 崩了 [捂臉]; called attach on a child which is not detached
怎么辦, 取消 RecycleView 的刷新閃爍動畫:
(mDataBind.rvRecycle.itemAnimator as SimpleItemAnimator).supportsChangeAnimations = false;
整個 RecycleView 的條目刷新動畫都沒了, 這不是個事啊! 但博主已經沒辦法了 [捂臉]
沒辦法了怎么辦? 不用 Header 了? 當然不是, 我們只是不用 LoadStateAdapter 做頭尾了; 我們用 ConcatAdapter 做頭尾;
就是在 withLoadState... 之后, 再自己組裝 ConcatAdapter
6. MAP: 數據轉換; 有的時候, 我們需要對響應數據 進行預先處理;
例如: 根據條件,預先改變實體內容;
val flow: Flow<PagingData<DynamicTwo>> = Pager( PagingConfig(pageSize = 10, prefetchDistance = 2,initialLoadSize = 10) ) { DynamicDataSource() }.flow .cachedIn(viewModelScope) .map { it.map { entity -> // 這里根據條件, 預先處理數據 if(entity.isLike == 1){ entity.nickName = "變變變, 我是百變小魔女" }else{ entity.nickName = "嗚哈哈哈" } entity } }
例如: 組合實體; 根據條件產生不同實體;
val flow: Flow<PagingData<GroupEntity>> = Pager( PagingConfig(pageSize = 10, prefetchDistance = 2,initialLoadSize = 10) ) { DynamicDataSource() }.flow .cachedIn(viewModelScope) .map { it.map { entity -> // 這里根據條件, 預先處理數據 if(entity.isLike == 1){ GroupEntity.DynamicTwoItem(entity) }else{ GroupEntity.DynamicItem(DynamicEntity()) } } } sealed class GroupEntity{ class DynamicTwoItem (val entity: DynamicTwo): GroupEntity() class DynamicItem (val entity: DynamicEntity): GroupEntity() }
又例如: 插入實體分隔符等
Over