阅读 536

Jetpack系列(六) — Paging3

Jetpack系列(六) — Paging3

Paging 简单介绍

初步印象

Paging 就是Google提供的分页功能的标准库,基于RecycleView实现分页功能

Paging 支持 Kotlin 中的 Flow

Paging 当中不用再自己来判断分页状态,以前写分页时会有分页完成、还有数据、加载失败等状态需要手动判断,Paging 只用关注LoadResult.PageLoadResult.Error两个,至于是否有更多数据可以通过LoadResult.Page() 参数设定

基本概念

PagingData 分页数据的容器

PagingSource 用于将数据加载到PagingData流中,通常 PagingSource 用于进行数据库请求

  • Pager对象从PagingSource对象调用load()方法,为它提供LoadParams对象,并作为回报接收LoadResult对象。

Pager.flow 封装PagingData,通过Flow<PagingData> 连接UI 和 数据

PagingDataAdapterRecyclerView.Adapter 实现类,显示PagingData的适配器

  • PagingDataAdapter可以连接到KotlinFlowLiveDataRxJava FlowableRxJava 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() 封装PagingConfigPagingSource

    • 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) }
    复制代码

加载状态

  1. PagingDataAdapter 有三个方法

    • withLoadStateHeaderAndFooter()
    • withLoadStateFooter()
    • withLoadStateHeader()
      // SearchFragment.kt
      binding.list.adapter = adapter.withLoadStateHeaderAndFooter(
          header = ReposLoadStateAdapter { adapter.retry() },
          footer = ReposLoadStateAdapter { adapter.retry() },
      )
      复制代码
  2. 编写状态的适配器,继承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)
            }
        }
    }
    复制代码
    1. 监听状态,上述两步只是添加了下拉或上滑的状态布局,还有一种加载状态可以通过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()
                  }
              }
      复制代码

分割布局

  1. 有些界面可能需要根据数据新增一些条目,比如官方例子当中当关注数大于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)
        }
    }
    复制代码
  2. 通过密封类UiModel 区分不同的布局

    sealed class UiModel {
        data class RepoItem(val repo: Repo) : UiModel()
        data class SeparatorItem(val description: String) : UiModel()
    }
    复制代码
  3. 修改适配器,加载不同的布局

    // 列表
    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为空表示加载完成

paging3-source-load.svg

知识点二: 显示加载状态

  • Paging 库通过 LoadState 对象公开可在界面中使用的加载状态

  • 如果没有正在执行的加载操作且没有错误,则 LoadStateLoadState.NotLoading 对象

  • 如果有正在执行的加载操作,则 LoadStateLoadState.Loading 对象

  • 如果出现错误,则 LoadStateLoadState.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
    复制代码

相关链接

Jetpack系列(一) — Navigation

Jetpack系列(二) — Lifecycle

Jetpack系列(三) — LiveData

Jetpack系列(四) — ViewModel

Jetpack系列(五) — Room

参考资料

codelabs

官网

文章分类
Android