Jetpack系列(六) — Paging3
Paging 简单介绍
初步印象
Paging 就是Google提供的分页功能的标准库,基于RecycleView实现分页功能
Paging 支持 Kotlin 中的 Flow
Paging 当中不用再自己来判断分页状态,以前写分页时会有分页完成、还有数据、加载失败等状态需要手动判断,Paging 只用关注
LoadResult.Page
和LoadResult.Error
两个,至于是否有更多数据可以通过LoadResult.Page()
参数设定
基本概念
PagingData
分页数据的容器
PagingSource
用于将数据加载到PagingData
流中,通常PagingSource
用于进行数据库请求
Pager
对象从PagingSource
对象调用load()
方法,为它提供LoadParams
对象,并作为回报接收LoadResult
对象。
Pager.flow
封装PagingData
,通过Flow<PagingData>
连接UI 和 数据
PagingDataAdapter
是RecyclerView.Adapter
实现类,显示PagingData
的适配器
PagingDataAdapter
可以连接到Kotlin
的Flow
、LiveData
、RxJava Flowable
或RxJava Observable
。PagingDataAdapter
在加载页面时侦听内部PagingData
变化,并在后台线程上使用DiffUtil
计算以新PagingData
对象形式接收更新。
RemoteMediator
帮助实现从网络和数据库分页
Paging 基本使用
实现分页
-
定义数据源
DataSource
继承自PagingSource
PagingSource
实现类需要重写两个方法load()
和getRefreshKey()
,load()
当中触发异步加载
(备注:这里我用的是3.0.0-rc01
,其他版本可能不用实现getRefreshKey()
)- 数据正常返回
LoadResult.Page
,加载失败返回LoadResult.Error
/** * 数据源 * 封装 LoadResult.Page */ class RepoPagingSource( private val service: GithubService, private val query: String ) : PagingSource<Int, Repo>() { override suspend fun load(params: LoadParams<Int>): LoadResult<Int, Repo> { val position = params.key ?: GITHUB_STARTING_PAGE_INDEX val apiQuery = query + IN_QUALIFIER return try { val response = service.searchRepos(apiQuery, position, params.loadSize) val repos = response.items val nextKey = if (repos.isEmpty()) { null } else { // Google 例子当中这里是配合 PagingConfig中的pageSize // LoadParams.loadSize 默认是 initialLoadSize = pageSize * 3, loadSize 其余时候是pageSize // 第一次 page:1 per_page:50 * 3 // 第二次 page:4 per_page:50 // 第三次 page:5 per_page:50 // 第三次 page:6 per_page:50 position + (params.loadSize / PAGING_REMOTE_PAGE_SIZE) } LoadResult.Page( data = repos, prevKey = if (position == GITHUB_STARTING_PAGE_INDEX) null else position - 1, nextKey = nextKey ) } catch (exception: IOException) { LoadResult.Error(exception) } catch (exception: HttpException) { LoadResult.Error(exception) } } // 为下一个[PagingSource]提供用于初始[load]的[Key] // 这里就是 prevKey 和 nextKey override fun getRefreshKey(state: PagingState<Int, Repo>): Int? { return state.anchorPosition?.let { anchorPosition -> state.closestPageToPosition(anchorPosition)?.prevKey?.plus(1) ?: state.closestPageToPosition(anchorPosition)?.nextKey?.minus(1) } } } 复制代码
-
Repository封装PagingData
-
使用
Pager()
封装PagingConfig
和PagingSource
-
PagingConfig
当中可以配置initialLoadSize
,那就可以省略上一步中的注释,
class RepoRepository( private val service: GithubService ) { fun getSearchResultStream(query: String): Flow<PagingData<Repo>> { return Pager( config = globalPagingConfig, pagingSourceFactory = { RepoPagingSource(service, query) } ).flow } } // 单独配置 全局分页配置 val globalPagingConfig: PagingConfig @AnyThread get() = PagingConfig( pageSize = PAGING_REMOTE_PAGE_SIZE, enablePlaceholders = false // 定义[PagingData]是否可以显示“null”占位符 ) 复制代码
-
-
ViewModel绑定Flow
-
这里对官方
Demo
稍微调整一下,官方Demo
直接通过函数返回值加载数据,这里映入LiveData
,直接在Fragment
当中观察数据 -
mSearchKeyLiveData.postValue(keyWord)
会触发加载class SearchViewModel constructor( private val repo: RepoRepository ) : ViewModel() { private val mSearchKeyLiveData = MutableLiveData(SearchFragment.DEFAULT_QUERY) val repoListLiveData: LiveData<PagingData<Repo>> = mSearchKeyLiveData.asFlow().flatMapLatest {str -> repo.getSearchResultStream(str) .cachedIn(viewModelScope) }.asLiveData() fun searchRepo(keyWord: String) { val lastResult = mSearchKeyLiveData.value if (keyWord == lastResult) { // 避免重复提交 return } mSearchKeyLiveData.value = keyWord mSearchKeyLiveData.postValue(keyWord) } } 复制代码
-
-
定义PagingDataAdapter继承类
-
PagingDataAdapter
需要DiffUtil.ItemCallback
用于判断 是否需要加载新的Item
-
这里是用
Databing
替换一下,其他的个官方Demo
一模一样// 列表 class ReposAdapter : PagingDataAdapter<UiModel, RecyclerView.ViewHolder>(REPO_COMPARATOR) { override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { val repoItem = getItem(position) holder.bind(repoItem) } override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder = RepoViewHolder.create(parent) companion object { private val REPO_COMPARATOR = object : DiffUtil.ItemCallback<Repo>() { override fun areItemsTheSame(oldItem: Repo, newItem: Repo): Boolean = oldItem.fullName == newItem.fullName override fun areContentsTheSame(oldItem: Repo, newItem: Repo): Boolean = oldItem == newItem } } } // 列表 class RepoViewHolder(binding: ItemRepoBinding) : RecyclerView.ViewHolder(binding.root) { private val name: TextView = binding.repoName private val description: TextView = binding.repoDescription private val stars: TextView = binding.repoStars private val language: TextView = binding.repoLanguage private val forks: TextView = binding.repoForks private var repo: Repo? = null fun bind(repo: Repo?) { if (repo == null) { val resources = itemView.resources name.text = resources.getString(R.string.loading) description.visibility = View.GONE language.visibility = View.GONE stars.text = resources.getString(R.string.unknown) forks.text = resources.getString(R.string.unknown) } else { showRepoData(repo) } } private fun showRepoData(repo: Repo) { this.repo = repo name.text = repo.fullName // if the description is missing, hide the TextView var descriptionVisibility = View.GONE if (repo.description != null) { description.text = repo.description descriptionVisibility = View.VISIBLE } description.visibility = descriptionVisibility stars.text = repo.stars.toString() forks.text = repo.forks.toString() // if the language is missing, hide the label and the value var languageVisibility = View.GONE if (!repo.language.isNullOrEmpty()) { val resources = this.itemView.context.resources language.text = resources.getString(R.string.language, repo.language) languageVisibility = View.VISIBLE } language.visibility = languageVisibility } companion object { fun create(parent: ViewGroup): RepoViewHolder { val binding = ItemRepoBinding.inflate( LayoutInflater.from(parent.context), parent, false ) return RepoViewHolder(binding) } } } 复制代码
-
-
RecyclerView
加载数据RecyclerView
加载数据只需要通过adapter.submitData(lifecycle, it)
绑定数据即可- 触发加载也是通过
viewModel.searchRepo(DEFAULT_QUERY)
实现
// 触发加载 private fun initData() { viewModel.searchRepo(DEFAULT_QUERY) } // 观察数据 observe(viewModel.repoListLiveData) { adapter.submitData(lifecycle, it) } 复制代码
加载状态
-
PagingDataAdapter
有三个方法withLoadStateHeaderAndFooter()
withLoadStateFooter()
withLoadStateHeader()
// SearchFragment.kt binding.list.adapter = adapter.withLoadStateHeaderAndFooter( header = ReposLoadStateAdapter { adapter.retry() }, footer = ReposLoadStateAdapter { adapter.retry() }, ) 复制代码
-
编写状态的适配器,继承
LoadStateAdapter
// ReposAdapter.kt 这里我把同一列表的适配器都放在一起 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) } } class ReposLoadStateViewHolder( private val binding: ReposLoadStateFooterViewItemBinding, retry: () -> Unit ) : RecyclerView.ViewHolder(binding.root) { init { binding.retryButton.setOnClickListener { retry.invoke() } } fun bind(loadState: LoadState) { if (loadState is LoadState.Error) { binding.errorMsg.text = loadState.error.localizedMessage } binding.progressBar.isVisible = loadState is LoadState.Loading binding.retryButton.isVisible = loadState is LoadState.Error binding.errorMsg.isVisible = loadState is LoadState.Error } companion object { fun create(parent: ViewGroup, retry: () -> Unit): ReposLoadStateViewHolder { val binding = ReposLoadStateFooterViewItemBinding.inflate( LayoutInflater.from(parent.context), parent, false ) return ReposLoadStateViewHolder(binding, retry) } } } 复制代码
-
监听状态,上述两步只是添加了下拉或上滑的状态布局,还有一种加载状态可以通过
addLoadStateListener()
监听实现adapter.addLoadStateListener { loadState -> val isListEmpty = loadState.refresh is LoadState.NotLoading && adapter.itemCount == 0 showEmptyList(isListEmpty) binding.list.isVisible = loadState.source.refresh is LoadState.NotLoading binding.progressBar.isVisible = loadState.source.refresh is LoadState.Loading binding.retryButton.isVisible = loadState.source.refresh is LoadState.Error val errorState = loadState.source.append as? LoadState.Error ?: loadState.source.prepend as? LoadState.Error ?: loadState.append as? LoadState.Error ?: loadState.prepend as? LoadState.Error errorState?.let { Toast.makeText(context, "\uD83D\uDE28 Wooops ${it.error}", Toast.LENGTH_SHORT) .show() } } 复制代码
-
分割布局
-
有些界面可能需要根据数据新增一些条目,比如官方例子当中当关注数大于10000的时候分阶段显示点赞数,
PagingData
的扩展方法insertSeparators()
正好适用// SearchViewModel.kt class SearchViewModel constructor( private val repo: RepoRepository ) : ViewModel() { private val mSearchKeyLiveData = MutableLiveData(SearchFragment.DEFAULT_QUERY) val repoListLiveData: LiveData<PagingData<UiModel>> = mSearchKeyLiveData.asFlow().flatMapLatest {str -> repo.getSearchResultStream(str) .map { pagingData -> pagingData.map { UiModel.RepoItem(it) } } .map { it.insertSeparators { before, after -> if (after == null) { return@insertSeparators null } if (before == null) { return@insertSeparators UiModel.SeparatorItem("${after.roundedStarCount}0.000+ stars") } if (before.roundedStarCount > after.roundedStarCount) { if (after.roundedStarCount >= 1) { UiModel.SeparatorItem("${after.roundedStarCount}0.000+ stars") } else { UiModel.SeparatorItem("< 10.000+ stars") } } else { null } } } .cachedIn(viewModelScope) }.asLiveData() fun searchRepo(keyWord: String) { val lastResult = mSearchKeyLiveData.value if (keyWord == lastResult) { // 避免重复提交 return } mSearchKeyLiveData.value = keyWord mSearchKeyLiveData.postValue(keyWord) } } 复制代码
-
通过密封类
UiModel
区分不同的布局sealed class UiModel { data class RepoItem(val repo: Repo) : UiModel() data class SeparatorItem(val description: String) : UiModel() } 复制代码
-
修改适配器,加载不同的布局
// 列表 class ReposAdapter : PagingDataAdapter<UiModel, RecyclerView.ViewHolder>(REPO_COMPARATOR) { override fun getItemViewType(position: Int): Int = when (getItem(position)) { is UiModel.RepoItem -> R.layout.item_repo is UiModel.SeparatorItem -> R.layout.item_separator_view null -> throw UnsupportedOperationException("Unknown view") } override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { val repoItem = getItem(position) repoItem?.let { when (repoItem) { is UiModel.RepoItem -> (holder as RepoViewHolder).bind(repoItem.repo) is UiModel.SeparatorItem -> (holder as SeparatorViewHolder).bind(repoItem.description) } } } override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder = if (viewType == R.layout.item_repo) { RepoViewHolder.create(parent) } else { SeparatorViewHolder.create(parent) } companion object { private val REPO_COMPARATOR = object : DiffUtil.ItemCallback<UiModel>() { // RepoItem fullName // SeparatorItem description override fun areItemsTheSame(oldItem: UiModel, newItem: UiModel): Boolean = (oldItem is UiModel.RepoItem && newItem is UiModel.RepoItem && oldItem.repo.fullName == newItem.repo.fullName) || (oldItem is UiModel.SeparatorItem && newItem is UiModel.SeparatorItem && oldItem.description == newItem.description) override fun areContentsTheSame(oldItem: UiModel, newItem: UiModel): Boolean = oldItem == newItem } } } // 列表 class RepoViewHolder(binding: ItemRepoBinding) : RecyclerView.ViewHolder(binding.root) { private val name: TextView = binding.repoName private val description: TextView = binding.repoDescription private val stars: TextView = binding.repoStars private val language: TextView = binding.repoLanguage private val forks: TextView = binding.repoForks private var repo: Repo? = null fun bind(repo: Repo?) { if (repo == null) { val resources = itemView.resources name.text = resources.getString(R.string.loading) description.visibility = View.GONE language.visibility = View.GONE stars.text = resources.getString(R.string.unknown) forks.text = resources.getString(R.string.unknown) } else { showRepoData(repo) } } private fun showRepoData(repo: Repo) { this.repo = repo name.text = repo.fullName // if the description is missing, hide the TextView var descriptionVisibility = View.GONE if (repo.description != null) { description.text = repo.description descriptionVisibility = View.VISIBLE } description.visibility = descriptionVisibility stars.text = repo.stars.toString() forks.text = repo.forks.toString() // if the language is missing, hide the label and the value var languageVisibility = View.GONE if (!repo.language.isNullOrEmpty()) { val resources = this.itemView.context.resources language.text = resources.getString(R.string.language, repo.language) languageVisibility = View.VISIBLE } language.visibility = languageVisibility } companion object { fun create(parent: ViewGroup): RepoViewHolder { val binding = ItemRepoBinding.inflate( LayoutInflater.from(parent.context), parent, false ) return RepoViewHolder(binding) } } } 复制代码
相关知识点
知识点一:
load()
函数如何接收每次加载的键并为后续加载提供键
nextKey
为空表示加载完成
知识点二: 显示加载状态
-
Paging 库通过
LoadState
对象公开可在界面中使用的加载状态 -
如果没有正在执行的加载操作且没有错误,则
LoadState
为LoadState.NotLoading
对象 -
如果有正在执行的加载操作,则
LoadState
为LoadState.Loading
对象 -
如果出现错误,则
LoadState
为LoadState.Error
对象// 这里通过监听状态加载进度条 // 参见上述加载状态 复制代码
知识点三:
RemoteMediator
封装数据库和网络
-
加载操作在
RemoteMediator
实现类的load方法当中 -
Pager()
当中remoteMediator
传入RemoteMediator
的实现类// 官方代码 return Pager( config = PagingConfig(pageSize = NETWORK_PAGE_SIZE, enablePlaceholders = false), remoteMediator = GithubRemoteMediator( query, service, database ), pagingSourceFactory = pagingSourceFactory ).flow 复制代码