注意,我只講了codelab的50%左右,但對paging3和flow的概念講完了
通常有codelab,我都會直接叫人去看,但唯獨paging3,我覺得值得一講,不僅是這個功能非常重要,同時這個package我認為相比其他android的東西,其實不好理解
你要先懂flow或livedata、mvvm、recyclerview、資料庫的分頁等等,但這也是為什麼大家會說paging3強大的地方,他已經幫我們封裝好這些行為,其實只需要把使用邏輯整理一下,就可以實現分頁列表的recyclerview,但如果你已經會寫paging了,可以跳過這篇
雖說我會講paging,但我只會聊到step 10,後面我覺得更偏向MVVM架構和singleTrust了,coroutine flow的東西不多,一個不負責任教學
首先,我會用基於codelab的範例再做簡化,做個最基本的paging
同時我會就我當初看codelab時,覺得較難理解的地方做詳細解釋,也會講到為什麼flow是官方推薦的資料格式,如果以coroutine的角度看這篇,會覺得跟系列文離題,但如果今天從paging3的角度看,其實你要了解flow的特性,才會知道為什麼選擇flow,他幫我們做了甚麼
首先,gradle加入
// retrofit
implementation "com.squareup.retrofit2:retrofit:2.9.0"
implementation "com.squareup.retrofit2:converter-gson:2.9.0"
// paging
implementation "androidx.paging:paging-runtime-ktx:3.0.1"
我們會打這支apihttps://api.github.com/search/repositories?sort=stars
retrofit長這樣
@GET("search/repositories?sort=stars")
suspend fun searchRepos(
@Query("q") query: String,
@Query("page") page: Int,
@Query("per_page") itemsPerPage: Int
): ReturnDataType
在paging3裡面,最重要的就是pagingSource和pagingData
讓我們從最核心的開始講,paging sourcr包含了load 和 getRefreshKey,而pagingSource也包含兩個參數< key, value> key是用來和後端對應要用從哪裡拿資料的辨識符,value是數據本身的類型,也就是回傳的data class類型
class RepoPagingSource (private val service :Connect, val query:String) :PagingSource<Int, Item>() {
override fun getRefreshKey(state: PagingState<Int, Item>): Int? {
}
override suspend fun load(params: LoadParams<Int>): LoadResult<Int, Item> {
}
}
load方法正如其名,是用來載入資料的,會在用戶滾動時做出異步載入,而我們需要在裡面提供
在第一次使用時,LoadParams.key會是null,所以需要設置初始值,一般建議初始值會比size稍微大一些,以google搜尋資料來說,用戶會比較注意前面的資料,ex.size 10筆,insitiaze size 30筆
load function會返回一個LoadResult,而loadResult就像我們之前封裝的seal class,包含LoadResult.Page、LoadResult.Error兩種狀態,讓用戶可以判斷請求狀態
但資料庫的資料不會是無限的對吧,如果滾動到最前面或最後面,怎麼辦呢?
通常後端會給我們一個emptyList,這時我們就將nextKey或prevKey設置成null
這張圖解說了,load()如何透過key進行每次加載,並提供新的KEY
override suspend fun load(params: LoadParams<Int>): LoadResult<Int, Item> {
val position = params.key ?: GITHUB_STARTING_PAGE_INDEX
val apiQuery = query
return try {
val response = service.searchRepos(apiQuery, position, params.loadSize)//拿到result
val repos = response.items//拿到list
val nextKey = if (repos.isEmpty()) {
null
} else {
// initial load size = 3 * NETWORK_PAGE_SIZE
// ensure we're not requesting duplicating items, at the 2nd request
position + (params.loadSize / NETWORK_PAGE_SIZE)//因為一開始*3,所以這邊要算是幾倍
}//檢查還有沒有下一頁
LoadResult.Page(
data = repos,
prevKey = if (position == GITHUB_STARTING_PAGE_INDEX) null else position - 1,
nextKey = nextKey
)
} catch (exception: IOException) {
Timber.e(exception)
return LoadResult.Error(exception)
} catch (exception: HttpException) {
Timber.e(exception)
return LoadResult.Error(exception)
}
}
直翻就是拿到刷新的key,啥?
準確來說,它的作用是讓pagingSource在刷新時(滾動刷新、數據庫更改等等),能夠從以載入分頁數據的中間刷新
以State.anchorPosition作為最新訪過的索引,找到正確的LoadParams.key
override fun getRefreshKey(state: PagingState<Int, Item>): Int? {
return state.anchorPosition?.let { anchorPosition ->
state.closestPageToPosition(anchorPosition)?.prevKey?.plus(1)
?: state.closestPageToPosition(anchorPosition)?.nextKey?.minus(1)
}
}//計算頁碼
//文檔給的
// Replaces ItemKeyedDataSource.
override fun getRefreshKey(state: PagingState): String? {
return state.anchorPosition?.let { anchorPosition ->
state.getClosestItemToPosition(anchorPosition)?.id
}
}
// Replacing PositionalDataSource.
override fun getRefreshKey(state: PagingState): Int? {
return state.anchorPosition
}
https://developer.android.com/topic/libraries/architecture/paging/v3-migration?hl=zh-cn
https://developer.android.com/codelabs/android-paging#4
這個方法會在初始加載後刷新或失效時,返回KEY,讓下次的LOAD可以刷新,而在後續刷新時,LOAD也會自動呼叫這個FUNCTION
paging config,pagingConfig非常重要,他會定義載入的基本行為,例如
這個就很簡單,傳入上面講的source即可
class PagingRepo (private val service : Connect) {
fun getSearchResultStream(query: String): Flow<PagingData<Item>> {
return Pager(
config = PagingConfig(
pageSize = NETWORK_PAGE_SIZE,
enablePlaceholders = false,
),
pagingSourceFactory = { RepoPagingSource(service, query) }
).flow
}
}
paging做了方便地操作符cacheIn()
讓我們可以透過傳入CoroutineScope,在該scope建立緩存,如果對資料作處理,ex. map{} 請務必在cacheIn()之前使用,以免多次操作
val newResult: Flow<PagingData<Item>> =
repo.getSearchResultStream(queryString).cachedIn(viewModelScope)
對的,到目前這步,paging的功能都還沒完成,我們目前只做了資料的部分,但還沒做ui顯始,paging3定義了一個pagingdapter,配合前面的pagingSource和pagingConfig使用
那怎麼用呢?基本上你把ListAdater改成PagingAdapter就好了
炒雞簡單,直接跳過code
private var searchJob: Job? = null
private fun search(query: String) {
// Make sure we cancel the previous job before creating a new one
searchJob?.cancel()
searchJob = lifecycleScope.launch {
viewModel.getPagingFlow(query).collectLatest {
Timber.d("collectLatest")
mAdapter.submitData(it)
}
}
}
這邊透過一個searchJob變數,去控制paging的取消,可以確保每次請求錢都會取消前一個請求,並且可以支持搜尋時也取消前一個請求,所以我維持codelab的寫法
到這裡已經可以用paging囉
我們在滾動時,有時滾太快,他會先到底部,遲一點才更新內容,但這在ui體驗上是不好的,好在透過pagingAdapter我們可以輕鬆地加入頁首/尾
首先創建繼承LoadStateAdapter的類別,注意,他的onBindViewHolder有loadState: LoadState參數,我們就能透過這個去判斷要如何處理ui了,這邊不貼全部的code了,每個人實作又不一樣,這邊是借codelan的例子改的
class ReposLoadStateAdapter(private val retry: () -> Unit) : LoadStateAdapter<ReposLoadStateViewHolder>() {
override fun onBindViewHolder(holder: ReposLoadStateViewHolder, loadState: LoadState) {
holder.bind(loadState)
}
override fun onCreateViewHolder(parent: ViewGroup, loadState: LoadState): ReposLoadStateViewHolder {
return ReposLoadStateViewHolder.create(parent, retry)
}
}
fun withLoadStateHeaderAndFooter(
header: LoadStateAdapter<*>,
footer: LoadStateAdapter<*>
): ConcatAdapter {
addLoadStateListener { loadStates ->
header.loadState = loadStates.prepend
footer.loadState = loadStates.append
}
return ConcatAdapter(header, this, footer)
}
值得注意的是,這邊會回傳concatAdapter,所以如果將
binding.rvPaging.apply {
layoutManager = LinearLayoutManager(requireContext())
adapter = mAdapter.withLoadStateHeaderAndFooter(
header = ReposLoadStateAdapter { mAdapter.retry() },
footer = ReposLoadStateAdapter { mAdapter.retry() }
)
}
寫成類似這樣的話,沒有用,因為concatAdapter是3~6行,第7行的mAdapter還是舊的
binding.rvPaging.apply {
layoutManager = LinearLayoutManager(requireContext())
mAdapter.withLoadStateHeaderAndFooter(
header = ReposLoadStateAdapter { mAdapter.retry() },
footer = ReposLoadStateAdapter { mAdapter.retry() }
)
adapter = mAdapter
}
那要如何未收到emptyList或是error做ui處理呢?畢竟不能給用戶看個全白頁面吧
首先,在你的layout加入你需要的元件,然後先隱藏,接著
//fragment
mAdapter.addLoadStateListener { loadState ->
// show empty list
binding.emptyList.isVisible = loadState.refresh is LoadState.NotLoading && mAdapter.itemCount == 0
// Only show the list if refresh succeeds.
binding.rvPaging.isVisible = loadState.source.refresh is LoadState.NotLoading
// Show loading spinner during initial load or refresh.
binding.progressBar.isVisible = loadState.source.refresh is LoadState.Loading
// Show the retry state if initial load or refresh fails.
binding.retryButton.isVisible = loadState.source.refresh is LoadState.Error
}
對的,可以從這裡根據loadState去改變ui,這樣子,一個最最最基本的paging 就完成了
首先,有看前前篇的,應該可以發現兩篇有異曲同工之妙,前前篇我說了flow如何取代liveData,而這篇的paging同樣可以用liveData或是flow實現,那為什麼我用flow呢?因為我講coroutine因為我覺得在這個case裡,用flow會更簡潔,此外也包含了文章所述所有flow的優點,而且網路上更多資源是用flow做paging的,之後可以更方便查資訊
而在fragment的這段,前前天其實有提到類似的概念,只是這邊配合codelab我就保留這種做法
private var searchJob: Job? = null
private fun search(query: String) {
// Make sure we cancel the previous job before creating a new one
searchJob?.cancel()
searchJob = lifecycleScope.launch {
viewModel.getPagingFlow(query).collectLatest {
Timber.d("collectLatest")
mAdapter.submitData(it)
}
}
}