iT邦幫忙

2021 iThome 鐵人賽

DAY 21
0

注意,我只講了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"

開始paging邏輯

我們會打這支api
https://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 source

讓我們從最核心的開始講,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

load方法正如其名,是用來載入資料的,會在用戶滾動時做出異步載入,而我們需要在裡面提供

  1. 用來載入的key
  2. 載入資料的size

在第一次使用時,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)
    }
}

getRefreshKey

直翻就是拿到刷新的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

pagingData

pagingConfig

paging config,pagingConfig非常重要,他會定義載入的基本行為,例如

  1. pageSize - 每次載入大小,PagingConfig.pageSize 應該要有合理大小,足以在不同裝置螢幕顯示,且不會造成頻繁載入新資訊。但要注意列表更新時是否會延遲。
  2. prefetchDistance - 提前請求距離,Prefetch distance which defines how far from the edge of loaded content an access must be to trigger further loading.
  3. enablePlaceholders - 啟用placeHolder for null, Defines whether PagingData may display null placeholders, if the PagingSource provides them.
  4. initialLoadSize - 初始大小
  5. maxSize - 預設是没有上限的,因此页面永远不会被丢弃。如果您确实要丢弃页面,请确保将 maxSize 保持在一个足够大的数字,以免用户改变滚动方向时产生过多的网络请求。最小值为 pageSize + prefetchDistance * 2。
  6. jumpThreshold - Defines a threshold for the number of items scrolled outside the bounds of loaded items before Paging should give up on loading pages incrementally, and instead jump to the user's position by triggering REFRESH via invalidate.

pagingSourceFactory

這個就很簡單,傳入上面講的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
    }
}

在viewModel做緩存

paging做了方便地操作符cacheIn()

讓我們可以透過傳入CoroutineScope,在該scope建立緩存,如果對資料作處理,ex. map{} 請務必在cacheIn()之前使用,以免多次操作

val newResult: Flow<PagingData<Item>> = 
    repo.getSearchResultStream(queryString).cachedIn(viewModelScope)

用pagingAdapter配合使用

對的,到目前這步,paging的功能都還沒完成,我們目前只做了資料的部分,但還沒做ui顯始,paging3定義了一個pagingdapter,配合前面的pagingSource和pagingConfig使用

那怎麼用呢?基本上你把ListAdater改成PagingAdapter就好了

炒雞簡單,直接跳過code

在fragment開始paging吧

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
}

empty page

那要如何未收到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越來越遠了,本篇約涵蓋codelab的40~50%,請轉codelab看更多code sample

為甚麼選flow呢?

首先,有看前前篇的,應該可以發現兩篇有異曲同工之妙,前前篇我說了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)
        }
    }
}

連結

必看

codelab
文檔


上一篇
day20 在ui蒐集flow,能取代liveData嗎?
下一篇
day22 熱流sharedFlow
系列文
解鎖kotlin coroutine的各種姿勢-新手篇30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言