day21 开分支,浅谈kotlin paging3 with flow

注意,我只讲了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
文档


<<:  Day28 Apex 模拟配对实作

>>:  Day 23 [Python ML、资料视觉化] 直方图、密度图

网路是怎样连接的(三)浏览器与HTTP

思考重点 网页浏览器怎麽获取网站消息 当我们输入网时会发生什麽事 常见的404 not found意...

鬼故事 - 不修拉,这辈子都不可能修的

鬼故事 - 不修拉,这辈子都不可能修的 Credit: 天兵公园 灵感来源:UCCU Hacker ...

JS 执行绪与同步 非同步 DAY50

这里额外补充2个观念 Not Defined VS undefined Not Defined co...

闲聊STM32CubeMX和STM32CubeIDE

我这分享很适合刚入门STM32的新手!~ 来闲聊一下STM32的生态吧,我在11月09日和11月10...

JavaScript Day 29. 立即函式 IIFE

立即函式,也称 Immediately Invoked Function Expression,简称...