Android Paging

学习内容

  • Paging 3.0 有哪些主要组件。
  • 如何将 Paging 3.0 添加到您的项目中。
  • 如何使用 Paging 3.0 API 将页眉或页脚添加到列表中。
  • 如何使用 Paging 3.0 API 添加列表分隔符。
  • 如何从网络和数据库加载分页数据。

您将构建的内容

在此 Codelab 中,您将从一个示例应用开始构建,该应用中会显示 GitHub 代码库列表。每当用户滚动到所显示列表的末尾时,系统就会触发新的网络请求,并将结果显示在屏幕上。

您将逐步添加代码,完成以下学习内容:

  • 迁移到 Paging 库组件。
  • 将加载状态页眉和页脚添加到列表中。
  • 每次搜索新的代码库时显示加载进度。
  • 在列表中添加分隔符。
  • 添加对数据库的支持,以从网络与数据库加载分页数据

您的应用最终显示效果如下:

e662a697dd078356.png

所需条件

有关架构组件的介绍,请查看“Room with a View”Codelab。有关 Flow 的说明,请查看“带 Kotlin Flow 和 LiveData 的高级协程”Codelab

在此步骤中,您将下载完整的 Codelab 代码,然后运行一个简单的示例应用。

为帮助您尽快入门,我们准备了一个入门级项目,您可以在此项目的基础上进行构建。

如果您已安装 git,只需运行以下命令即可。(您可以在终端/命令行中输入 git --version 进行检查,验证其是否正确执行。)

 git clone https://github.com/googlecodelabs/android-paging

初始状态代码位于 master 分支中。对于某些步骤,您可以参阅以下解决方案:

  • 分支 step5-9_paging_3.0 - 您可以找到第 5 步到第 9 步(为项目添加 Paging 3.0)的解决方案。
  • 分支 step10_loading_state_footer - 您可以找到第 10 步(添加一个显示加载状态的页脚)的解决方案。
  • 分支 step11_loading_state - 您可以找到第 11 步(在查询之间添加加载状态显示)的解决方案。
  • 分支 step12_separators - 您可以找到第 12 步(为应用添加分隔符)的解决方案。
  • 分支 step13-19_network_and_database - 您可以找到第 13 步至第 19 步(为应用添加离线支持)的解决方案

如果您未安装 git,可以点击下方按钮下载此 Codelab 的全部代码:

下载源代码

  1. 解压缩代码,然后在 Android Studio 3.6.1 或更高版本中打开项目。
  2. 在设备或模拟器上运行 app 运行配置。

b3c0dfdb92dfed77.png

应用运行并显示 GitHub 代码库列表,与下图类似:

86fcb1b9b845c2f6.png

利用此应用,您可以在 GitHub 中搜索名称或说明中包含特定字词的代码库。代码库列表按星数降序排列,星数一样时按名称的字母顺序排列。

应用遵循“应用架构指南”中推荐的架构。每个软件包都包含以下内容:

  • api - 使用 Retrofit 的 GitHub API 调用。
  • data - 代码库类,负责触发 API 请求并将响应缓存到内存中。
  • model - Repo 数据模型,也是 Room 数据库中的表;以及 RepoSearchResult 类,供界面用于观察搜索结果数据和网络错误。
  • ui - 与使用 RecyclerView 显示 Activity 有关的类。

每当用户滚动到代码库名称列表末尾或搜索新代码库时,GithubRepository 类都会从网络中检索此列表。查询的结果列表使用 GithubRepository 中的 ConflatedBroadcastChannel 保存在内存中,并以 Flow 向外提供数据。

SearchRepositoriesViewModelGithubRepository 请求数据,并把数据传递给 SearchRepositoriesActivity。我们希望确保在配置变更(例如旋转)时不会多次请求数据,因此会使用 liveData() 构建器方法,在 ViewModel 中将 Flow 转换为 LiveData。这样,LiveData 会在内存中缓存最新结果列表,当 SearchRepositoriesActivity 重新创建完毕后,屏幕上会显示 LiveData 的内容。

从易用性的角度来看,存在以下问题:

  • 用户对列表加载状态一无所知:他们会在搜索新代码库时看到空白屏幕;或者会突然拉到列表末尾,同时会加载针对同一查询的更多结果。
  • 用户无法重试失败的查询。

从实现角度来看,存在以下问题:

  • 内存中列表的长度一直增长,当用户滚动屏幕时很浪费内存。
  • 为了缓存数据,需要将结果从 Flow 转换为 LiveData,这增加了代码的复杂性。
  • 如果需要应用显示多个列表,就需要为每个列表写入大量样板文件。

下面介绍 Paging 库如何解决这些问题,并介绍了使用的组件。

使用 Paging 库,您可以更加轻松地在应用的界面中逐步、流畅地加载数据。Paging API 可为许多功能提供支持;如果没有 Paging API,您就需要在页面中加载数据时动手来实现这些功能:

  • 跟踪要用于检索下一页和上一页的键。
  • 当用户滚动浏览到列表末尾时,自动请求正确的页面。
  • 确保多个请求不会同时触发。
  • 可让您缓存数据:如果您使用的是 Kotlin,就可以在 CoroutineScope 中执行此操作;如果您使用的是 Java,就可以使用 LiveData 来实现。
  • 跟踪加载状态,可在 RecyclerView 列表内容中或界面的其他位置上显示加载状态;如果有失败的加载,可轻松再次重试。
  • 允许您对将要显示的列表执行常见的操作(例如 mapfilter),无论您使用的是 FlowLiveData、RxJava Flowable,还是 Observable,都是如此。
  • 提供一种实现列表分隔符的简单方法。

应用架构指南提出了一个包含以下主要组件的架构提议:

  • 本地数据库,用作向用户呈现并由用户操控的数据的单一可信来源。
  • Web API 服务。
  • 代码库,与数据库和 Web API 服务配合使用,提供统一的数据界面。
  • ViewModel,提供界面专用数据。
  • 界面,直观地呈现 ViewModel 中的数据。

Paging 库使用上述所有的组件,并协调这些组件之间的交互,以便从数据源加载内容“页面”,并在界面中显示相应内容。

此 Codelab 向您介绍 Paging 库及其主要组件:

  • PagingData - 用于存储分页数据的容器。每次数据刷新都会有一个相应的单独 PagingData
  • PagingSource - PagingSource 是用于将数据快照加载到 PagingData 流的基类。
  • Pager.flow - 根据 PagingConfig 和一个定义如何构造实现的 PagingSource 的构造函数,构建一个 Flow<PagingData>
  • PagingDataAdapter - 一个用于在 RecyclerView 中呈现 PagingDataRecyclerView.AdapterPagingDataAdapter 可以连接到 Kotlin FlowLiveData、RxJava Flowable 或 RxJava ObservablePagingDataAdapter 会在页面加载时监听内部 PagingData 加载事件,并于以新对象 PagingData 的形式收到更新后的内容时,在后台线程中使用 DiffUtil 计算细粒度更新。
  • RemoteMediator - 帮助接收来自网络和数据库的数据,实现分页。

在本 Codelab 中,您将实现上述每一个组件的示例。

PagingSource 实现定义了数据源以及如何从这里检索数据。PagingData 对象会查询来自 PagingSource 的数据,响应用户在 RecyclerView 中滚动生成的加载提示。

目前,数据源处理工作很多是由 GithubRepository 负责的,但添加完 Paging 库后,将由 Paging 库处理数据源。

  • GithubService 加载数据,确保系统不会同时触发多个请求。
  • 检索到的数据保留在内存缓存中。
  • 跟踪被请求的网页。

为了构建 PagingSource,您需要定义以下内容:

  • 分页键的类型 - 在我们的示例中,GitHub API 使用了从 1 开始计数将页面编入索引,因此类型为 Int
  • 已加载数据的类型 - 在我们的示例中,我们加载的是 Repo 项。
  • 从何处检索数据 - 我们会从 GithubService 获取数据。我们的数据源将特定于某个查询,因此我们需要确保同时将查询信息传递给 GithubService

接下来,在 data 软件包中,创建一个名为 GithubPagingSourcePagingSource 实现:

class GithubPagingSource(
        private val service: GithubService,
        private val query: String
) : PagingSource<Int, Repo>() {
    override suspend fun load(params: LoadParams<Int>): LoadResult<Int, Repo> {
        TODO("Not yet implemented")
    }
   override fun getRefreshKey(state: PagingState<Int, Repo>): Int? {
        TODO("Not yet implemented")
    }

}

我们会看到 PagingSource 需要我们实现两个函数:loadgetRefreshKey

Paging 库将调用 load() 函数,以异步方式提取更多数据,用于在用户滚动过程中显示。LoadParams 对象保存有与加载操作相关的信息,包括以下信息:

  • 要加载的页面的键。如果这是您第一次调用加载,则 LoadParams.key 将为 null。在这种情况下,必须定义初始页面键。在我们这个项目中,您必须将 GITHUB_STARTING_PAGE_INDEX 常量从 GithubRepository 移至 PagingSource 实现,因为这是初始页面键。
  • 加载大小 - 请求加载内容的数量。

加载函数会返回一个 LoadResult。这将替换应用中的 RepoSearchResult,因为 LoadResult 可以根据返回情况,使用以下类型:

  • LoadResult.Page(如果结果返回成功)。
  • LoadResult.Error(如果发生错误)。

在构造 LoadResult.Page 时,如果无法沿相应方向加载列表,则给 nextKeyprevKey 传递 null。例如,在我们的示例中,我们会考虑这样一种情况:如果网络响应成功但列表为空,我们就没有剩余的数据可加载了;因此 nextKey 可以为 null

根据以上所有信息,我们应该能够实现 load() 函数了!

接下来,我们需要实现 getRefreshKey()。刷新键用于对 PagingSource.load() 的后续刷新调用(首次调用为初始加载,使用的是 Pager 提供的 initialKey)。每次 Paging 库要加载新数据来替代当前列表时(例如:滑动刷新时,或因数据库更新、配置更改、进程终止等原因而出现无效现象时,等等),都会发生刷新。通常,后续刷新调用将需要重新开始加载以 PagingState.anchorPosition(表示最近一次访问过的索引)为中心的数据,

GithubPagingSource 实现如下所示:

// GitHub page API is 1 based: https://developer.github.com/v3/#pagination
private const val GITHUB_STARTING_PAGE_INDEX = 1

class GithubPagingSource(
        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 {
                // initial load size = 3 * NETWORK_PAGE_SIZE
                // ensure we're not requesting duplicating items, at the 2nd request
                position + (params.loadSize / NETWORK_PAGE_SIZE)
            }
            LoadResult.Page(
                    data = repos,
                    prevKey = if (position == GITHUB_STARTING_PAGE_INDEX) null else position - 1,
                    nextKey = nextKey
            )
        } catch (exception: IOException) {
            return LoadResult.Error(exception)
        } catch (exception: HttpException) {
            return LoadResult.Error(exception)
        }
    }
    // The refresh key is used for subsequent refresh calls to PagingSource.load after the initial load
    override fun getRefreshKey(state: PagingState<Int, Repo>): Int? {
        // We need to get the previous key (or next key if previous is null) of the page
        // that was closest to the most recently accessed index.
        // Anchor position is the most recently accessed index
        return state.anchorPosition?.let { anchorPosition ->
            state.closestPageToPosition(anchorPosition)?.prevKey?.plus(1)
                ?: state.closestPageToPosition(anchorPosition)?.nextKey?.minus(1)
        }
    }

}

在当前实现中,我们使用 GitHubRepository 中的 Flow<RepoSearchResult> 从网络获取数据,并将数据传递给 ViewModel。然后,使用 ViewModel 将其转换为 LiveData 并传递给界面。每当我们浏览到列表的末尾,并且有更多数据从网络加载时,Flow<RepoSearchResult> 就不仅会包含最新的数据,还会包含之前针对该查询检索到的数据的完整列表。

RepoSearchResult 封装了成功案例和错误案例。成功案例中有代码库数据。错误案例中包含 Exception 原因。有了 Paging 3.0,我们就不再需要 RepoSearchResult,因为该库使用 LoadResult 对成功案例和失败案例建模。您可以随时删除 RepoSearchResult,因为在接下来的几个步骤中,我们会将其替换掉。

如要构建 PagingData,我们首先需要确定要使用哪个 API 向应用的其他层传递 PagingData

  • Kotlin Flow - 使用 Pager.flow
  • LiveData - 使用 Pager.liveData.
  • RxJava Flowable - 使用 Pager.flowable
  • RxJava Observable - 使用 Pager.observable

我们在应用中已使用了 Flow,故将继续使用此方法。但不是使用 Flow<RepoSearchResult>,而是用 Flow<PagingData<Repo>>

无论您使用哪种 PagingData 构建器,都必须传递以下参数:

  • PagingConfig。该类用于设置关于如何从 PagingSource 加载内容的选项,例如提前多久加载、初始加载请求的大小,等等。您必须定义的唯一必需参数是页面大小,即应在每个页面中加载的项数。默认情况下,Paging 会将您加载的所有页面保存在内存中。为确保系统在用户滚动时不会浪费内存,请在 PagingConfig 中设置 maxSize 参数。默认情况下,如果 Paging 可以统计未加载项的数量以及enablePlaceholders 配置标志为 true,那么 Paging 将返回 null 作为尚未加载内容的占位符。如此,您可以在适配器中显示占位符视图。为了简化此 Codelab 中的工作,我们通过传递 enablePlaceholders = false 停用占位符。
  • 一个定义如何创建 PagingSource 的函数。在我们的示例中,我们将为每个新的查询创建一个新的 GithubPagingSource

接下来,我们来修改 GithubRepository

更新 GithubRepository.getSearchResultStream

  • 移除 suspend 修饰符。
  • 返回 Flow<PagingData<Repo>>
  • 构造 Pager.。
fun getSearchResultStream(query: String): Flow<PagingData<Repo>> {
    return Pager(
          config = PagingConfig(
            pageSize = NETWORK_PAGE_SIZE,
            enablePlaceholders = false
         ),
          pagingSourceFactory = { GithubPagingSource(service, query) }
    ).flow
}

清理 GithubRepository

Paging 3.0 可以为我们做很多事情:

  • 处理内存缓存。
  • 在接近列表末尾时请求数据。

这意味着,除了 getSearchResultStream 和我们在其中定义了 NETWORK_PAGE_SIZE 的伴生对象之外,GithubRepository 中的所有其他对象均可移除。现在您的 GithubRepository 应如下所示:

class GithubRepository(private val service: GithubService) {

    fun getSearchResultStream(query: String): Flow<PagingData<Repo>> {
        return Pager(
                config = PagingConfig(
                pageSize = NETWORK_PAGE_SIZE,
                enablePlaceholders = false
             ),
                pagingSourceFactory = { GithubPagingSource(service, query) }
        ).flow
    }

    companion object {
        private const val NETWORK_PAGE_SIZE = 50
    }
}

现在,SearchRepositoriesViewModel 中应包含编译错误。我们来看看需要做出哪些更改。

SearchRepositoriesViewModel 中,我们公开了一个 repoResult: LiveData<RepoSearchResult>repoResult 发挥着内存缓存的作用,供您搜索在配置更改后继续存在的结果。使用 Paging 3.0后,我们就不再需要将 Flow 转换为 LiveData。而是 SearchRepositoriesViewModel 将有一个私有的 Flow<PagingData<Repo>> 成员,其用途与 repoResult 相同。

我们只需使用 String 即可,而无需为每个新查询使用 LiveData 对象。这将帮助我们确保:每当有新的搜索查询与当前查询一致时,我们会仅返回现有的 Flow。只有在新的查询与当前查询不同时,我们才需要调用 repository.getSearchResultStream()

Flow<PagingData> 有一个方便的 cachedIn() 方法,让我们能够在 CoroutineScope 中缓存 Flow<PagingData> 的内容。由于我们是在 ViewModel 中,因此我们将使用 androidx.lifecycle.viewModelScope

为了充分利用 Paging 3.0 中的内置功能,我们将重写 SearchRepositoriesViewModel 的大部分内容。SearchRepositoriesViewModel 可参考以下代码:

class SearchRepositoriesViewModel(private val repository: GithubRepository) : ViewModel() {

    private var currentQueryValue: String? = null

    private var currentSearchResult: Flow<PagingData<Repo>>? = null

    fun searchRepo(queryString: String): Flow<PagingData<Repo>> {
        val lastResult = currentSearchResult
        if (queryString == currentQueryValue && lastResult != null) {
            return lastResult
        }
        currentQueryValue = queryString
        val newResult: Flow<PagingData<Repo>> = repository.getSearchResultStream(queryString)
                .cachedIn(viewModelScope)
        currentSearchResult = newResult
        return newResult
    }
}

现在,来看看我们对 SearchRepositoriesViewModel 所做的变更:

  • 新增了查询 String 和搜索结果 Flow 成员。
  • 用前述功能更新了 searchRepo() 方法。
  • 移除了 queryLiveDatarepoResult,因为其作用可以通过 Paging 3.0 和 Flow 实现。
  • 移除了 listScrolled(),因为 Paging 库会为我们处理此操作。
  • 移除了 companion object,因为 VISIBLE_THRESHOLD 不再需要。

如要将 PagingData 绑定到 RecyclerView,请使用 PagingDataAdapter。每当系统加载 PagingData 内容时,PagingDataAdapter 就会收到通知,然后它会通知 RecyclerView 进行更新。

更新 ui.ReposAdapter 以便与 PagingData 流配合使用:

  • 目前,ReposAdapter 实现的是 ListAdapter。请将其改为实现 PagingDataAdapter。类主体的其余部分保持不变:
class ReposAdapter : PagingDataAdapter<Repo, RepoViewHolder>(REPO_COMPARATOR) {
// body is unchanged
}

到目前为止,我们已执行很多变更,现在只需再执行一步操作就可以运行应用了,那就是连接界面。

我们来更新 SearchRepositoriesActivity,以与 Paging 3.0 配合使用。如要能够使用 Flow<PagingData>,我们需要启动一个新协程。当重新创建 activity 时,lifecycleScope 负责取消请求。我们将通过它来执行上述操作。

我们还希望确保,每当用户搜索一个新的查询时,系统将取消上一个查询。为此,SearchRepositoriesActivity 可以保留对新 Job 的引用,每次我们搜索一个新查询时,旧的作业将被取消。

我们来创建一个新的搜索函数,其以参数的形式获取查询。该函数应执行以下操作:

  • 取消上一个搜索作业。
  • lifecycleScope 中启动新作业。
  • 调用 viewModel.searchRepo
  • 收集 PagingData 结果。
  • 通过调用 adapter.submitData(pagingData)PagingData 传递给 ReposAdapter
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.searchRepo(query).collectLatest {
           adapter.submitData(it)
       }
   }
}

应在 onCreate() 方法的 SearchRepositoriesActivity 中调用搜索函数。在 updateRepoListFromInput() 中,请用 search() 替换 viewModeladapter

private fun updateRepoListFromInput() {
    binding.searchRepo.text.trim().let {
        if (it.isNotEmpty()) {
            binding.list.scrollToPosition(0)
            search(it.toString())
        }
    }
}

我们希望确保每次执行新搜索时滚动位置都会重置,因此我们使用了 binding.list.scrollToPosition(0)。但是,应该在使用新搜索的结果更新列表适配器时重置位置,而不是在出现新搜索时进行重置。为此,我们可以使用 PagingDataAdapter.loadStateFlow API。每当加载状态发生变化时,Flow 会通过 CombinedLoadStates 对象向我们发送相应信息。

CombinedLoadStates 允许我们获取 3 种不同类型加载操作的加载状态:

  • CombinedLoadStates.refresh - 表示首次加载 PagingData 的加载状态。
  • CombinedLoadStates.prepend - 表示在列表开头加载数据时的加载状态。
  • CombinedLoadStates.append - 表示在列表末尾加载数据的加载状态。

在我们的示例中,我们希望仅在刷新完成(即 LoadStaterefreshNotLoading)时重置滚动位置。

我们需要在初始化搜索时,在 initSearch 方法中,以及每次新释放 Flow 时从该 Flow 中收集数据。让我们滚动到位置 0。

private fun initSearch(query: String) {
    ...
    // First part of the method is unchanged

        // Scroll to top when the list is refreshed from network.
        lifecycleScope.launch {
            adapter.loadStateFlow
                   // Only emit when REFRESH LoadState changes.
                   .distinctUntilChangedBy { it.refresh }
                   // Only react to cases where REFRESH completes i.e., NotLoading.
                   .filter { it.refresh is LoadState.NotLoading }
                   .collect { binding.list.scrollToPosition(0) }
        }
}

现在,我们可以从 updateRepoListFromInput() 中移除 binding.list.scrollToPosition(0)

目前,我们利用 RecyclerView 中附加的 OnScrollListener 来了解何时触发更多数据。我们可以让 Paging 库为我们处理列表滚动。移除 setupScrollListener() 方法以及对它的所有引用。

我们还需要移除对 repoResult 的使用。您的 activity 应如下所示:

class SearchRepositoriesActivity : AppCompatActivity() {

    private lateinit var binding: ActivitySearchRepositoriesBinding
    private lateinit var viewModel: SearchRepositoriesViewModel
    private val adapter = ReposAdapter()

    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.searchRepo(query).collect {
                adapter.submitData(it)
            }
        }
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding = ActivitySearchRepositoriesBinding.inflate(layoutInflater)
        val view = binding.root
        setContentView(view)

        // get the view model
        viewModel = ViewModelProvider(this, Injection.provideViewModelFactory())
                .get(SearchRepositoriesViewModel::class.java)

        // add dividers between RecyclerView's row items
        val decoration = DividerItemDecoration(this, DividerItemDecoration.VERTICAL)
        binding.list.addItemDecoration(decoration)

        initAdapter()
        val query = savedInstanceState?.getString(LAST_SEARCH_QUERY) ?: DEFAULT_QUERY
        search(query)
        initSearch(query)
    }

    override fun onSaveInstanceState(outState: Bundle) {
        super.onSaveInstanceState(outState)
        outState.putString(LAST_SEARCH_QUERY, binding.searchRepo.text.trim().toString())
    }

    private fun initAdapter() {
        binding.list.adapter = adapter
    }

    private fun initSearch(query: String) {
        binding.searchRepo.setText(query)

        binding.searchRepo.setOnEditorActionListener { _, actionId, _ ->
            if (actionId == EditorInfo.IME_ACTION_GO) {
                updateRepoListFromInput()
                true
            } else {
                false
            }
        }
        binding.searchRepo.setOnKeyListener { _, keyCode, event ->
            if (event.action == KeyEvent.ACTION_DOWN && keyCode == KeyEvent.KEYCODE_ENTER) {
                updateRepoListFromInput()
                true
            } else {
                false
            }
        }

        // Scroll to top when the list is refreshed from network.
        lifecycleScope.launch {
            adapter.loadStateFlow
                    // Only emit when REFRESH LoadState for RemoteMediator changes.
                    .distinctUntilChangedBy { it.refresh }
                    // Only react to cases where Remote REFRESH completes i.e., NotLoading.
                    .filter { it.refresh is LoadState.NotLoading }
                    .collect { binding.list.scrollToPosition(0) }
        }
    }

    private fun updateRepoListFromInput() {
        binding.searchRepo.text.trim().let {
            if (it.isNotEmpty()) {
                search(it.toString())
            }
        }
    }

    private fun showEmptyList(show: Boolean) {
        if (show) {
            binding.emptyList.visibility = View.VISIBLE
            binding.list.visibility = View.GONE
        } else {
            binding.emptyList.visibility = View.GONE
            binding.list.visibility = View.VISIBLE
        }
    }

    companion object {
        private const val LAST_SEARCH_QUERY: String = "last_search_query"
        private const val DEFAULT_QUERY = "Android"
    }
}

应用应该可以编译和运行了,但尚未实现加载状态页脚和在出现错误时显示的 Toast。在下一步中,我们将了解如何显示加载状态页脚。

您可以在分支 step5-9_paging_3.0 中找到已完成的上述步骤的完整代码。

在我们的应用中,我们希望能够根据加载状态显示页脚:即,在列表加载期间,我们希望显示进度旋转图标。发生错误时,我们希望显示错误和重试按钮。

3f6f2cd47b55de92.png 661da51b58c32b8c.png

我们所需构建的页眉/页脚应遵循如下原则:即,将相应列表附加在我们实际显示的列表项开头(作为页眉)或末尾(作为页脚)。页眉/页脚是仅包含一个元素的列表,该元素根据 Paging LoadState,显示进度条或带有重试按钮的错误消息。

由于根据加载状态显示页眉/页脚以及实现重试机制属于常见任务,Paging 3.0 可以帮助我们完成这两项任务。

对于页眉/页脚的实现,我们将使用 LoadStateAdapterRecyclerView.Adapter 的实现会自动收到关于加载状态变化的通知。它会确保,仅 LoadingError 状态才会导致显示相应项,并且会在项被移除、插入或更改时会通知 RecyclerView,具体取决于 LoadState

对于重试机制,我们使用 adapter.retry()。从本质上讲,此方法最终会为相应页面调用 PagingSource 实现。系统将通过 Flow<PagingData> 自动传递响应。

我们来看一下页眉/页脚的实现。

与任何列表一样,我们需要创建以下 3 个文件:

  • 布局文件,其中包含用于显示进度、错误和重试按钮的界面元素
  • ViewHolder 文件 - 根据 Paging LoadState 将界面项设置为可见
  • 适配器文件,定义如何创建和绑定 ViewHolder。我们不扩展 RecyclerView.Adapter,而是扩展 Paging 3.0 中提供的 LoadStateAdapter

创建视图布局

为代码库的加载状态创建 repos_load_state_footer_view_item 布局。它应该包括 ProgressBarTextView(用于显示错误)和重试 Button。项目中已声明必要的字符串和维度。

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    xmlns:tools="http://schemas.android.com/tools"
    android:orientation="vertical"
    android:padding="8dp">
    <TextView
        android:id="@+id/error_msg"
        android:textColor="?android:textColorPrimary"
        android:textSize="@dimen/error_text_size"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_gravity="center"
        android:textAlignment="center"
        tools:text="Timeout"/>
    <ProgressBar
        android:id="@+id/progress_bar"
        style="?android:attr/progressBarStyle"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_gravity="center"/>
    <Button
        android:id="@+id/retry_button"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_gravity="center"
        android:text="@string/retry"/>
</LinearLayout>

创建 ViewHolder

ui 文件夹 ** 中创建一个名为 ReposLoadStateViewHolderViewHolder 文件。**它应接收一个重试函数作为参数,当您按下“重试”按钮时,系统会调用此函数。创建一个 bind() 函数,用于接收 LoadState 作为参数,并根据 LoadState 设置每个视图的可见性。使用 ViewBindingReposLoadStateViewHolder 实现,如下所示:

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 view = LayoutInflater.from(parent.context)
                    .inflate(R.layout.repos_load_state_footer_view_item, parent, false)
            val binding = ReposLoadStateFooterViewItemBinding.bind(view)
            return ReposLoadStateViewHolder(binding, retry)
        }
    }
}

创建 LoadStateAdapter

此外,在 ui 文件夹中,创建一个扩展 LoadStateAdapterReposLoadStateAdapter。因为重试函数可在构造时传递给 ViewHolder,所以适配器应该接收重试函数作为参数。

与任何 Adapter 一样,我们需要实现 onBind()onCreate() 方法。LoadStateAdapter 简化了这种实现,因为它可以在以上两个函数中传递 LoadState。在 onBindViewHolder() 中,绑定 ViewHolder。在 onCreateViewHolder() 中,根据父 ViewGroup 和重试函数,定义如何创建 ReposLoadStateViewHolder

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)
    }
}

现在我们已经获得页脚的所有元素,接下来我们将它们与列表绑定。为此,PagingDataAdapter 提供了 3 种有用的方法:

  • withLoadStateHeader - 如果您只想显示页眉,您应该使用此方法。在这种方法中,您的列表仅支持在列表开头添加显示项。
  • withLoadStateFooter - 如果您只想显示页脚,您应该使用此方法。在这种方法中,您的列表仅支持在列表末尾添加显示项。
  • withLoadStateHeaderAndFooter - 如果您想同时显示页眉和页脚,您应该使用此方法,即列表可以在开头和末尾都分页。

更新 SearchRepositoriesActivity.initAdapter() 方法并在适配器上调用 withLoadStateHeaderAndFooter()。重试函数可以调用 adapter.retry()

private fun initAdapter() {
    binding.list.adapter = adapter.withLoadStateHeaderAndFooter(
            header = ReposLoadStateAdapter { adapter.retry() },
            footer = ReposLoadStateAdapter { adapter.retry() }
    )
}

由于我们的列表可以无限滚动,如果您想快速查看页脚,只需将手机或模拟器置于飞行模式,然后滚动屏幕直至列表末尾。

我们来运行应用!

您可以在 step10_loading_state_footer 分支中找到截至目前已完成步骤的完整代码。

您可能已经注意到,我们目前遇到了两个问题:

  • 迁移到 Paging 3.0 后,我们便无法在结果列表为空时显示相关消息。
  • 每当您搜索新查询时,在我们收到网络响应之前,当前查询结果会一直保留在屏幕上。这是很糟糕的用户体验!我们希望改为显示进度条或重试按钮。

ab9ff1b8b033179e.png bd744ff3ddc280c3.png

要一举解决这两个问题,您需要根据 SearchRepositoriesActivity 中加载状态的变化采取相应操作。

显示空列表消息

首先,我们来恢复空列表消息。此类消息应该仅在系统已加载列表且列表中项目数为 0 时显示。为了知悉列表何时加载,我们将使用 PagingDataAdapter.addLoadStateListener() 方法。每当加载状态发生变化时,此回调都会通过 CombinedLoadStates 对象通知我们。

CombinedLoadStates 为我们提供以下两项的加载状态:一项是我们定义的 PageSource,另一项是网络、数据库所需的 RemoteMediator(稍后再对此进行详细介绍)。

SearchRepositoriesActivity.initAdapter() 中,我们调用 addLoadStateListener。当 CombinedLoadStatesrefresh 状态为 NotLoadingadapter.itemCount == 0 时,此列表为空。然后,我们调用 showEmptyList

private fun initAdapter() {
    binding.list.adapter = adapter.withLoadStateHeaderAndFooter(
            header = ReposLoadStateAdapter { adapter.retry() },
            footer = ReposLoadStateAdapter { adapter.retry() }
    )
    adapter.addLoadStateListener { loadState ->
        // show empty list
        val isListEmpty = loadState.refresh is LoadState.NotLoading && adapter.itemCount == 0
        showEmptyList(isListEmpty)
  }
}

显示加载状态

我们来更新 activity_search_repositories.xml,在其中加入重试按钮、进度条这两个界面元素:

<androidx.constraintlayout.widget.ConstraintLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".ui.SearchRepositoriesActivity">
    <com.google.android.material.textfield.TextInputLayout
        android:id="@+id/input_layout"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:layout_marginEnd="8dp"
        android:layout_marginStart="8dp"
        android:layout_marginTop="8dp"
        android:layout_marginLeft="8dp"
        android:layout_marginRight="8dp"
        style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent">
        <EditText
            android:id="@+id/search_repo"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:hint="@string/search_hint"
            android:imeOptions="actionSearch"
            android:inputType="textNoSuggestions"
            android:selectAllOnFocus="true"
            tools:text="Android"/>
    </com.google.android.material.textfield.TextInputLayout>

    <androidx.recyclerview.widget.RecyclerView
        android:id="@+id/list"
        android:layout_width="0dp"
        android:layout_height="0dp"
        android:paddingVertical="@dimen/row_item_margin_vertical"
        android:scrollbars="vertical"
        app:layoutManager="LinearLayoutManager"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@+id/input_layout"
        tools:ignore="UnusedAttribute"/>

    <ProgressBar
        android:id="@+id/progress_bar"
        style="?android:attr/progressBarStyle"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_gravity="center"
        app:layout_constraintLeft_toLeftOf="parent"
        app:layout_constraintRight_toRightOf="parent"
        app:layout_constraintTop_toTopOf="parent"
        app:layout_constraintBottom_toBottomOf="parent"/>

    <Button
        android:id="@+id/retry_button"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="@string/retry"
        app:layout_constraintLeft_toLeftOf="parent"
        app:layout_constraintRight_toRightOf="parent"
        app:layout_constraintTop_toTopOf="parent"
        app:layout_constraintBottom_toBottomOf="parent"/>

    <TextView android:id="@+id/emptyList"
        android:layout_width="0dp"
        android:layout_height="match_parent"
        android:gravity="center"
        android:text="@string/no_results"
        android:textSize="@dimen/repo_name_size"
        android:visibility="gone"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent"/>

</androidx.constraintlayout.widget.ConstraintLayout>

点击重试按钮应该会触发 PagingData 的重新加载。为此,我们在 onClickListener 实现中调用 adapter.retry(),与实现页眉/页脚类似:

// SearchRepositoriesActivity.kt

override fun onCreate(savedInstanceState: Bundle?) {
    ...
    binding.retryButton.setOnClickListener { adapter.retry() }
}

接下来,我们来根据 SearchRepositoriesActivity.initAdapter 中加载状态的变化采取相应操作。因为我们只想在执行新查询时显示进度条,所以我们需要依赖从分页源(尤其是 CombinedLoadStates.source.refresh)加载,还要依赖于LoadStateLoadingError。此外,在上一步中我们提及了一个功能,可以在出现错误时显示 Toast,因此,我们务必要将此功能也纳入进来。为了显示错误消息,我们必须检查 CombinedLoadStates.prependCombinedLoadStates.append 是否为 LoadState.Error 的实例,并检索源于该错误的错误消息。

请更新 SearchRepositoriesActivity.initAdapter 方法,以获取此功能:

private fun initAdapter() {
    binding.list.adapter = adapter.withLoadStateHeaderAndFooter(
            header = ReposLoadStateAdapter { adapter.retry() },
            footer = ReposLoadStateAdapter { adapter.retry() }
    )
    adapter.addLoadStateListener { loadState ->
        // show empty list
        val isListEmpty = loadState.refresh is LoadState.NotLoading && adapter.itemCount == 0
        showEmptyList(isListEmpty)

        // Only show the list if refresh succeeds.
        binding.list.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

        // Toast on any error, regardless of whether it came from RemoteMediator or PagingSource
        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(
                    this,
                    "\uD83D\uDE28 Wooops ${it.error}",
                    Toast.LENGTH_LONG
            ).show()
        }
    }
}

现在,让我们运行这个应用,看看它的运行情况如何!

大功告成!按照当前设置,Paging 库组件可以适时触发 API 请求,处理内存缓存以及显示数据。运行应用并尝试搜索代码库。

您可以在分支 step11_loading_state 中找到已完成的上述步骤的完整代码。

提高列表可读性的一种方法是添加分隔符。例如,在我们的应用中,由于代码库会按星数降序排序,因此,我们可以每 10,000 颗星提供一个分隔符。为了实现这个功能,可以使用 Paging 3.0 API 向 PagingData 插入分隔符。

170f5fa2945e7d95.png

PagingData 中添加分隔符后,屏幕上显示的列表会有改变。我们不再只显示 Repo 对象,还会显示分隔符对象。因此,我们必须将我们从 ViewModel 公开的界面模型从 Repo 改为另一种可封装以下两种类型的类型:RepoItemSeparatorItem。接下来,为了支持分隔符,必须更新界面:

  • 为分隔符添加布局和 ViewHolder
  • 更新 RepoAdapter 以支持创建和绑定分隔符与代码库。

让我们逐步执行此步骤,看看实现效果。

更改界面模型

目前 SearchRepositoriesViewModel.searchRepo() 会返回 Flow<PagingData<Repo>>。为了同时支持代码库和分隔符,我们将使用 SearchRepositoriesViewModel 在同一个文件中创建一个 UiModel 密封类。UiModel 对象有两种类型:RepoItemSeparatorItem

sealed class UiModel {
    data class RepoItem(val repo: Repo) : UiModel()
    data class SeparatorItem(val description: String) : UiModel()
}

因为我们希望按每 10,000 颗星数来分隔代码库,因此需要在 RepoItem 上创建一个扩展属性,将我们获得的星数向上舍入:

private val UiModel.RepoItem.roundedStarCount: Int
    get() = this.repo.stars / 10_000

插入分隔符

SearchRepositoriesViewModel.searchRepo() 现在应返回 Flow<PagingData<UiModel>>。将 currentSearchResult 设置为相同类型。

class SearchRepositoriesViewModel(private val repository: GithubRepository) : ViewModel() {

    private var currentQueryValue: String? = null

    private var currentSearchResult: Flow<PagingData<UiModel>>? = null

    fun searchRepo(queryString: String): Flow<PagingData<UiModel>> {
        ...
    }
}

来看看实现发生了什么变化!目前 repository.getSearchResultStream(queryString) 返回 Flow<PagingData<Repo>>,因此我们需要添加的第一个操作是将每个 Repo 转换为 UiModel.RepoItem。为此,可以使用 Flow.map 运算符,然后映射每个 PagingData,通过当前的 Repo 构建新的 UiModel.Repo,从而生成 Flow<PagingData<UiModel.RepoItem>>

...
val newResult: Flow<PagingData<UiModel>> = repository.getSearchResultStream(queryString)
                .map { pagingData -> pagingData.map { UiModel.RepoItem(it) } }
...

现在,我们插入分隔符。每次释放 Flow 时,我们都会调用 PagingData.insertSeparators()。此方法会返回一个包含每个原始元素的 PagingData,并让您能够根据给定的前后元素,生成可选分隔符。在边界条件下(在列表的开头或末尾),其前面或后面的元素将是 null。如果不需要创建分隔符,则返回 null

由于我们已将 PagingData 元素的类型从 UiModel.Repo 更改为 UiModel,因此必须显式设置 insertSeparators() 方法的类型参数。

searchRepo() 方法应如下所示:

fun searchRepo(queryString: String): Flow<PagingData<UiModel>> {
    val lastResult = currentSearchResult
    if (queryString == currentQueryValue && lastResult != null) {
        return lastResult
    }
    currentQueryValue = queryString
    val newResult: Flow<PagingData<UiModel>> = repository.getSearchResultStream(queryString)
            .map { pagingData -> pagingData.map { UiModel.RepoItem(it) } }
            .map {
                it.insertSeparators<UiModel.RepoItem, UiModel> { before, after ->
                    if (after == null) {
                        // we're at the end of the list
                        return@insertSeparators null
                    }

                    if (before == null) {
                        // we're at the beginning of the list
                        return@insertSeparators UiModel.SeparatorItem("${after.roundedStarCount}0.000+ stars")
                    }
                    // check between 2 items
                    if (before.roundedStarCount > after.roundedStarCount) {
                        if (after.roundedStarCount >= 1) {
                            UiModel.SeparatorItem("${after.roundedStarCount}0.000+ stars")
                        } else {
                            UiModel.SeparatorItem("< 10.000+ stars")
                        }
                    } else {
                        // no separator
                        null
                    }
                }
            }
            .cachedIn(viewModelScope)
    currentSearchResult = newResult
    return newResult
}

支持多种视图类型

SeparatorItem 对象需要在 RecyclerView 中显示。此处我们仅显示字符串,因此我们需要在 res/layout 文件夹中创建一个带有 TextViewseparator_view_item 布局:

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:background="@color/separatorBackground">

    <TextView
        android:id="@+id/separator_description"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:padding="@dimen/row_item_margin_horizontal"
        android:textColor="@color/separatorText"
        android:textSize="@dimen/repo_name_size"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent"
        tools:text="10000+ stars" />
</androidx.constraintlayout.widget.ConstraintLayout>

ui 文件夹中创建一个 SeparatorViewHolder,这里我们只需将字符串绑定到 TextView

class SeparatorViewHolder(view: View) : RecyclerView.ViewHolder(view) {
    private val description: TextView = view.findViewById(R.id.separator_description)

    fun bind(separatorText: String) {
        description.text = separatorText
    }

    companion object {
        fun create(parent: ViewGroup): SeparatorViewHolder {
            val view = LayoutInflater.from(parent.context)
                    .inflate(R.layout.separator_view_item, parent, false)
            return SeparatorViewHolder(view)
        }
    }
}

更新 ReposAdapter 以支持 UiModel,而不是 Repo

  • PagingDataAdapter 参数从 Repo 更新为 UiModel
  • 实现 UiModel 比较器,并用其替换 REPO_COMPARATOR
  • 创建 SeparatorViewHolder 并将其与 UiModel.SeparatorItem 的说明绑定。

由于我们现在需要显示 2 个不同的 ViewHolder,因此请将 RepoViewHolder 替换为 ViewHolder:

  • 更新 PagingDataAdapter 参数
  • 更新 onCreateViewHolder 返回类型
  • 更新 onBindViewHolder holder 参数

您的最终 ReposAdapter 将如下所示:

class ReposAdapter : PagingDataAdapter<UiModel, ViewHolder>(UIMODEL_COMPARATOR) {

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
        return if (viewType == R.layout.repo_view_item) {
            RepoViewHolder.create(parent)
        } else {
            SeparatorViewHolder.create(parent)
        }
    }

    override fun getItemViewType(position: Int): Int {
        return when (getItem(position)) {
            is UiModel.RepoItem -> R.layout.repo_view_item
            is UiModel.SeparatorItem -> R.layout.separator_view_item
            null -> throw UnsupportedOperationException("Unknown view")
        }
    }

    override fun onBindViewHolder(holder: ViewHolder, position: Int) {
        val uiModel = getItem(position)
        uiModel.let {
            when (uiModel) {
                is UiModel.RepoItem -> (holder as RepoViewHolder).bind(uiModel.repo)
                is UiModel.SeparatorItem -> (holder as SeparatorViewHolder).bind(uiModel.description)
            }
        }
    }

    companion object {
        private val UIMODEL_COMPARATOR = object : DiffUtil.ItemCallback<UiModel>() {
            override fun areItemsTheSame(oldItem: UiModel, newItem: UiModel): Boolean {
                return (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
        }
    }
}

大功告成!运行应用时,您应该能看到分隔符!

您可以在分支 step12_separators 中找到已完成的上述步骤的完整代码。

让我们将数据保存到本地数据库,从而给应用添加离线支持。这样,该数据库将成为应用的单一可信数据来源,而且我们可以一直从该数据库中加载数据。每当没有更多数据时,就会从网络中请求更多数据,然后将其保存在数据库中。由于该数据库是可靠数据来源,因此当保存更多数据后,界面会自动更新。

以下是添加离线支持所需执行的操作:

  1. 创建一个 Room 数据库、一份用于保存 Repo 对象的表,以及一个用于处理 Repo 对象的 DAO。
  2. 通过实现 RemoteMediator,定义当我们到达数据库中数据末尾时如何从网络中加载数据。
  3. 将 Repos 表作为数据源构建 Pager,使用 RemoteMediator 加载和保存数据。

让我们逐一执行这些步骤!

我们的 Repo 对象需要保存在数据库中,所以我们首先应使用 tableName = "repos",将 Repo 类设置为实体,其中 Repo.id 是主键。为此,请给 Repo 类添加 @Entity(tableName = "repos") 注解,为 id 添加 @PrimaryKey 注解。现在 Repo 类应如下所示:

@Entity(tableName = "repos")
data class Repo(
    @PrimaryKey @field:SerializedName("id") val id: Long,
    @field:SerializedName("name") val name: String,
    @field:SerializedName("full_name") val fullName: String,
    @field:SerializedName("description") val description: String?,
    @field:SerializedName("html_url") val url: String,
    @field:SerializedName("stargazers_count") val stars: Int,
    @field:SerializedName("forks_count") val forks: Int,
    @field:SerializedName("language") val language: String?
)

新建一个 db 软件包。我们将在此软件包中实现访问数据库中数据的类和定义数据库的类。

通过创建一个带 @Dao 注解的 RepoDao 接口,实现数据访问对象 (DAO),以访问 repos 表。我们需要在 Repo 上执行以下操作:

  • 插入 Repo 对象列表。如果表中已有 Repo 对象,请将其替换掉。
 @Insert(onConflict = OnConflictStrategy.REPLACE)
 suspend fun insertAll(repos: List<Repo>)
  • 查询名称或说明中包含查询字符串的 repos,按照星数对那些结果进行降序排序,在星数相同的情况下再按名称的字母顺序排序。返回 PagingSource<Int, Repo>,而不是返回 List<Repo>。这样,repos 表就会成为 Paging 的数据源。
@Query("SELECT * FROM repos WHERE " +
  "name LIKE :queryString OR description LIKE :queryString " +
  "ORDER BY stars DESC, name ASC")
fun reposByName(queryString: String): PagingSource<Int, Repo>
  • 清除 Repos 表中的所有数据。
@Query("DELETE FROM repos")
suspend fun clearRepos()

RepoDao 应如下所示:

@Dao
interface RepoDao {

    @Insert(onConflict = OnConflictStrategy.REPLACE)
    suspend fun insertAll(repos: List<Repo>)

    @Query("SELECT * FROM repos WHERE " +
   "name LIKE :queryString OR description LIKE :queryString " +
   "ORDER BY stars DESC, name ASC")
    fun reposByName(queryString: String): PagingSource<Int, Repo>

    @Query("DELETE FROM repos")
    suspend fun clearRepos()
}

实现 Repo 数据库:

  • 创建一个 RepoDatabase 抽象类,用于扩展 RoomDatabase
  • 为类添加 @Database 注解,设置容纳 Repo 类的实体列表,将数据库版本设置为 1。在本 Codelab 中,我们不需要导出架构。
  • 定义一个抽象函数,用于返回 ReposDao
  • companion object 中创建一个 getInstance() 函数,该函数会在不存在 RepoDatabase 对象时创建该对象。

您的 RepoDatabase 如下所示:

@Database(
    entities = [Repo::class],
    version = 1,
    exportSchema = false
)
abstract class RepoDatabase : RoomDatabase() {

    abstract fun reposDao(): RepoDao

    companion object {

        @Volatile
        private var INSTANCE: RepoDatabase? = null

        fun getInstance(context: Context): RepoDatabase =
                INSTANCE ?: synchronized(this) {
                    INSTANCE
                            ?: buildDatabase(context).also { INSTANCE = it }
                }

        private fun buildDatabase(context: Context) =
                Room.databaseBuilder(context.applicationContext,
                        RepoDatabase::class.java, "Github.db")
                        .build()
    }
}

现在,数据库已经设置完毕,我们来看看如何从网络请求数据,并将其保存在数据库中。

对于需要在界面中显示的数据,Paging 库使用数据库作为单一可信数据来源。每当数据库中没有更多的数据时,我们就需要从网络请求更多数据。为了解决此问题,Paging 3.0 定义了 RemoteMediator 抽象类,并需要实现一个 load() 方法。每当需要从网络中加载更多数据时,系统就会调用该方法。此类会返回一个 MediatorResult 对象,该对象有两种状态:

  • Error - 从网络中请求获取数据时遇到错误。
  • Success - 成功从网络中获取了数据。这里我们还需要传递一个信号,指明是否可以加载更多数据。例如,如果网络响应成功,但我们的代码库列表为空,则表示已无法加载更多数据。

data 软件包中,创建一个名为 GithubRemoteMediator 的新类,用于扩展 RemoteMediator。每次新查询都会重新创建该类,所以它将以参数的形式接收以下信息。

  • 查询 String
  • GithubService - 以便我们可以发出网络请求。
  • RepoDatabase - 以便我们可以保存通过网络请求获得的数据。
@OptIn(ExperimentalPagingApi::class)
class GithubRemoteMediator(
    private val query: String,
    private val service: GithubService,
    private val repoDatabase: RepoDatabase
) : RemoteMediator<Int, Repo>() {

    override suspend fun load(loadType: LoadType, state: PagingState<Int, Repo>): MediatorResult {

   }
}

为了能够构建网络请求,加载方法有 2 个参数,这两个参数应该可以提供我们所需的全部信息:

  • PagingState - 此参数为我们提供以下信息:之前加载过的页面、列表中最近访问过的索引,以及在初始化分页数据流时定义的 PagingConfig
  • LoadType - 此参数为我们提供以下信息:在之前已加载数据的情况下,我们是否需要在数据末尾 (LoadType.APPEND) 或开头 (LoadType.PREPEND) 加载数据,或这是否是我们第一次加载数据 (LoadType.REFRESH)。

例如,如果加载类型为 LoadType.APPEND,我们会从 PagingState 检索上一项内容。据此我们应该能够通过计算出下一个要加载的页面,确定如何加载下一组 Repo 对象。

接下来,您将了解如何计算上一个和下一个加载页面的键。

对 GitHub API 而言,用于请求 Repos 页面的页面键只是一个页面索引,每次获得下一页时该索引值便会递增。这意味着,如果有一个 Repo 对象,您可以根据“页面索引 + 1”请求下一组 Repo 对象。同样的,您可以根据“页面索引 - 1”请求前一组 Repo 对象。在特定页面响应中接收的所有 Repo 对象,都包含相同的上一页键和下一页键。

当我们获得从 PagingState 加载的最后一项内容时,我们无法知道此内容所属页面的索引。为解决这个问题,可以再添加一个表,用于存储每个 Repo 的下一页或上一页的键,我们可以将它命名为 remote_keys。虽然您可以在 Repo 表中进行此操作,但建议创建一个新表,用于存储与 Repo 关联的下一页和上一页远程键,这样就可以建立更好的分离性

db 软件包中,我们创建一个名为 RemoteKeys 的新数据类,使用 @Entity 进行注解,并添加以下 3 个属性:代码库 id(也是主键)以及上一页键和下一页键(当无法在列表开头或末尾添加数据时,这两个属性可以是 null)。

@Entity(tableName = "remote_keys")
data class RemoteKeys(
    @PrimaryKey
    val repoId: Long,
    val prevKey: Int?,
    val nextKey: Int?
)

创建一个 RemoteKeysDao 接口。我们需要以下功能:

  • 插入 RemoteKeys 的列表,每当我们从网络中获取 Repos 时,就会生成相应的远程键。
  • 根据 Repo id 获取一个 RemoteKey
  • 清除 RemoteKeys,每当出现新查询时,我们将使用它。
@Dao
interface RemoteKeysDao {

    @Insert(onConflict = OnConflictStrategy.REPLACE)
    suspend fun insertAll(remoteKey: List<RemoteKeys>)

    @Query("SELECT * FROM remote_keys WHERE repoId = :repoId")
    suspend fun remoteKeysRepoId(repoId: Long): RemoteKeys?

    @Query("DELETE FROM remote_keys")
    suspend fun clearRemoteKeys()
}

请将 RemoteKeys 表添加到我们的数据库中,并提供对 RemoteKeysDao 的访问权限。为此,请按如下步骤更新 RepoDatabase

  • 将 RemoteKeys 添加到实体列表中。
  • RemoteKeysDao 定义成抽象函数。
@Database(
        entities = [Repo::class, RemoteKeys::class],
        version = 1,
        exportSchema = false
)
abstract class RepoDatabase : RoomDatabase() {

    abstract fun reposDao(): RepoDao
    abstract fun remoteKeysDao(): RemoteKeysDao

    ...
    // rest of the class doesn't change
}

现在我们保存了远程键,让我们返回到 GithubRemoteMediator,了解如何使用它们。此类将取代 GithubPagingSource。从 GithubRemoteMediator 中复制 GithubPagingSourceGITHUB_STARTING_PAGE_INDEX 声明,并删除 GithubPagingSource 类。

让我们来看一下可以如何实现 GithubRemoteMediator.load() 方法:

  1. 确定我们需要根据 LoadType 从网络中加载什么页面。
  2. 触发网络请求。
  3. 网络请求完成后,如果收到的代码库列表不为空,则执行以下操作:
  4. 为每个 Repo 计算 RemoteKeys
  5. 如果是一个新查询 (loadType = REFRESH),就清除数据库。
  6. RemoteKeysRepos 保存在数据库中。
  7. 返回 MediatorResult.Success(endOfPaginationReached = false)
  8. 如果代码库列表为空,则返回 MediatorResult.Success(endOfPaginationReached = true)。如果请求数据时出错,则返回 MediatorResult.Error

代码的整体效果如下。稍后,我们将替换 TODO。

override suspend fun load(loadType: LoadType, state: PagingState<Int, Repo>): MediatorResult {
    val page = when (loadType) {
        LoadType.REFRESH -> {
         // TODO
        }
        LoadType.PREPEND -> {
        // TODO
        }
        LoadType.APPEND -> {
        // TODO
        }
    }
    val apiQuery = query + IN_QUALIFIER

    try {
        val apiResponse = service.searchRepos(apiQuery, page, state.config.pageSize)

        val repos = apiResponse.items
        val endOfPaginationReached = repos.isEmpty()
        repoDatabase.withTransaction {
            // clear all tables in the database
            if (loadType == LoadType.REFRESH) {
                repoDatabase.remoteKeysDao().clearRemoteKeys()
                repoDatabase.reposDao().clearRepos()
            }
            val prevKey = if (page == GITHUB_STARTING_PAGE_INDEX) null else page - 1
            val nextKey = if (endOfPaginationReached) null else page + 1
            val keys = repos.map {
                RemoteKeys(repoId = it.id, prevKey = prevKey, nextKey = nextKey)
            }
            repoDatabase.remoteKeysDao().insertAll(keys)
            repoDatabase.reposDao().insertAll(repos)
        }
        return MediatorResult.Success(endOfPaginationReached = endOfPaginationReached)
    } catch (exception: IOException) {
        return MediatorResult.Error(exception)
    } catch (exception: HttpException) {
        return MediatorResult.Error(exception)
    }
}

让我们看看如何根据 LoadType 查找要加载的页面。

现在,我们知道了使用页面键后,GithubRemoteMediator.load() 方法中会发生什么情况,现在来看看如何计算页面键。此计算具体取决于 LoadType

LoadType.APPEND

当我们需要在当前所加载数据集的末尾加载数据时,加载参数为 LoadType.APPEND。现在根据数据库中的最后一项,我们需要计算网络页面键。

  1. 我们需要获取从数据库中加载的最后一项 Repo 内容的远程键,让我们使用函数对其进行分隔:
    private suspend fun getRemoteKeyForLastItem(state: PagingState<Int, Repo>): RemoteKeys? {
        // Get the last page that was retrieved, that contained items.
        // From that last page, get the last item
        return state.pages.lastOrNull() { it.data.isNotEmpty() }?.data?.lastOrNull()
                ?.let { repo ->
                    // Get the remote keys of the last item retrieved
repoDatabase.remoteKeysDao().remoteKeysRepoId(repo.id)
                }
    }
  1. 如果 remoteKeys 为 null,则表示刷新结果尚未纳入数据库中。我们可以通过 endOfPaginationReached = false 返回“Success”,因为如果 RemoteKeys 变为非 null,Paging 将再次调用此方法。如果 remoteKeys 不是 null,但其 prevKeynull,这表明我们已到达要在其后附加内容的分页末尾。
val page = when (loadType) {
    LoadType.APPEND -> {
        val remoteKeys = getRemoteKeyForLastItem(state)
        // If remoteKeys is null, that means the refresh result is not in the database yet.
        // We can return Success with endOfPaginationReached = false because Paging
        // will call this method again if RemoteKeys becomes non-null.
        // If remoteKeys is NOT NULL but its prevKey is null, that means we've reached
        // the end of pagination for append.
        val nextKey = remoteKeys?.nextKey
        if (nextKey == null) {
            return MediatorResult.Success(endOfPaginationReached = remoteKeys != null)
        }
        nextKey
    }
      ...
  }

LoadType.PREPEND

当我们需要在当前加载的数据集的开头加载数据时,加载参数为 LoadType.PREPEND。我们需要根据数据库中的第一条项,计算网络页面键。

  1. 我们需要获取从数据库中加载的第一个 Repo的远程键,让我们使用函数将其分隔开:
private suspend fun getRemoteKeyForFirstItem(state: PagingState<Int, Repo>): RemoteKeys? {
    // Get the first page that was retrieved, that contained items.
    // From that first page, get the first item
    return state.pages.firstOrNull { it.data.isNotEmpty() }?.data?.firstOrNull()
            ?.let { repo ->
                // Get the remote keys of the first items retrieved
                repoDatabase.remoteKeysDao().remoteKeysRepoId(repo.id)
            }
}
  1. 如果 remoteKeys 为 null,则表示刷新结果尚未纳入数据库中。我们可以通过 endOfPaginationReached = false 返回“Success”,因为如果 RemoteKeys 变为非 null,Paging 将再次调用此方法。如果 remoteKeys 不是 null,但其 prevKeynull,这表明我们已到达要在其后附加内容的分页末尾。
val page = when (loadType) {
    LoadType.PREPEND -> {
        val remoteKeys = getRemoteKeyForFirstItem(state)
        // If remoteKeys is null, that means the refresh result is not in the database yet.
        // We can return Success with `endOfPaginationReached = false` because Paging
        // will call this method again if RemoteKeys becomes non-null.
        // If remoteKeys is NOT NULL but its prevKey is null, that means we've reached
        // the end of pagination for prepend.
        val prevKey = remoteKeys?.prevKey
        if (prevKey == null) {
            return MediatorResult.Success(endOfPaginationReached = remoteKeys != null)
        }
        prevKey
    }

      ...
  }

LoadType.REFRESH

首次加载数据时或者调用 PagingDataAdapter.refresh() 时,LoadType.REFRESH 都会被调用,所以现在对于加载数据,引用点是 state.anchorPosition。如果这是第一次加载,则 anchorPositionnull。当调用 PagingDataAdapter.refresh() 时,anchorPosition 是所显示列表中的第一个可见位置,因此我们需要加载包含该特定项的页面。

  1. 根据 state 中的 anchorPosition,可以通过调用 state.closestItemToPosition() 获取距离该位置最近的 Repo 项。
  2. 根据 Repo 项,我们可以从数据库中获取 RemoteKeys
private suspend fun getRemoteKeyClosestToCurrentPosition(
        state: PagingState<Int, Repo>
): RemoteKeys? {
    // The paging library is trying to load data after the anchor position
    // Get the item closest to the anchor position
    return state.anchorPosition?.let { position ->
        state.closestItemToPosition(position)?.id?.let { repoId ->
   repoDatabase.remoteKeysDao().remoteKeysRepoId(repoId)
        }
    }
}
  1. 如果 remoteKey 不为 null,我们可以从中获取 nextKey。在 GitHub API 中,页面键会依序递增。因此要获取含有当前项的页面,我们只需从 remoteKey.nextKey 中减去 1。
  2. 如果 RemoteKeynull(因为 anchorPositionnull),我们需要加载的页面是初始页面:GITHUB_STARTING_PAGE_INDEX

页面计算的完整代码如下所示:

val page = when (loadType) {
    LoadType.REFRESH -> {
        val remoteKeys = getRemoteKeyClosestToCurrentPosition(state)
        remoteKeys?.nextKey?.minus(1) ?: GITHUB_STARTING_PAGE_INDEX
    }
    LoadType.PREPEND -> {
        val remoteKeys = getRemoteKeyForFirstItem(state)
        val prevKey = remoteKeys?.prevKey
        if (prevKey == null) {
            return MediatorResult.Success(endOfPaginationReached = remoteKeys != null)
        }
        prevKey
    }
    LoadType.APPEND -> {
        val remoteKeys = getRemoteKeyForLastItem(state)
        val nextKey = remoteKeys?.nextKey
        if (nextKey == null) {
            return MediatorResult.Success(endOfPaginationReached = remoteKeys != null)
        }
        nextKey
    }
}

现在,我们在 ReposDao 中实现了 GithubRemoteMediatorPagingSource,我们需要更新 GithubRepository.getSearchResultStream 才能使用它们。

为此,GithubRepository 需要访问数据库。让我们将此数据库作为参数,传入构造函数中。此外,类将因此使用 GithubRemoteMediator

class GithubRepository(
        private val service: GithubService,
        private val database: RepoDatabase
) { ... }

更新 Injection 文件。

  • provideGithubRepository 方法应获取一个以参数形式获取上下文,并且在 GithubRepository 构造函数中调用 RepoDatabase.getInstance
  • provideViewModelFactory 方法应获取以参数形式获取上下文,并将其传递给 provideGithubRepository
object Injection {

    private fun provideGithubRepository(context: Context): GithubRepository {
        return GithubRepository(GithubService.create(), RepoDatabase.getInstance(context))
    }

    fun provideViewModelFactory(context: Context): ViewModelProvider.Factory {
        return ViewModelFactory(provideGithubRepository(context))
    }
}

更新 SearchRepositoriesActivity.onCreate() 方法并将上下文传递给 Injection.provideViewModelFactory()

// get the view model
viewModel = ViewModelProvider(this, Injection.provideViewModelFactory(this))
        .get(SearchRepositoriesViewModel::class.java)

让我们返回到 GithubRepository。首先,为了能够按名称搜索代码库,我们必须将 % 添加到查询字符串的开头和末尾。然后,当调用 reposDao.reposByName 时,我们会得到一个 PagingSource。每当我们更改数据库时,PagingSource 都会失效,所以我们需要告诉 Paging 如何获取 PagingSource 的新实例。为此,我们只需创建一个调用数据库查询的函数:

// appending '%' so we can allow other characters to be before and after the query string
val dbQuery = "%${query.replace(' ', '%')}%"
val pagingSourceFactory =  { database.reposDao().reposByName(dbQuery)}

现在,我们可以更改 Pager 构建器,以使用 GithubRemoteMediatorpagingSourceFactoryPager 是一个实验性 API,因此必须使用 @OptIn 为其添加注解:

@OptIn(ExperimentalPagingApi::class)
return Pager(
        config = PagingConfig(
            pageSize = NETWORK_PAGE_SIZE,
            enablePlaceholders = false
         ),
        remoteMediator = GithubRemoteMediator(
                query,
                service,
                database
        ),
        pagingSourceFactory = pagingSourceFactory
).flow

大功告成!我们来运行应用!

更新加载状态来源

现在,我们一起构建的应用会从网络中加载数据并将数据保存在数据库中;但如果应用要在首次加载网页期间(在 SearchRepositoriesActivity.initAdapter 中)显示一个指示“正在加载”的旋转图标,仍然要依赖 LoadState.source。现在,我们需要仅针对来自 RemoteMediator 的加载操作显示“正在加载”旋转图标。为此,我们需要从 LoadState.source 更改为 LoadState.mediator

private fun initAdapter() {
         ...
        adapter.addLoadStateListener { loadState ->
            // Only show the list if refresh succeeds.
            binding.list.isVisible = loadState.mediator?.refresh is LoadState.NotLoading
            // Show loading spinner during initial load or refresh.
            binding.progressBar.isVisible = loadState.mediator?.refresh is LoadState.Loading
            // Show the retry state if initial load or refresh fails.
            binding.retryButton.isVisible = loadState.mediator?.refresh is LoadState.Error
            ... // everything else stays the same
    }

您可以在分支 step13-19_network_and_database 中找到截至目前已完成的步骤的完整代码。

添加完所有组件后,让我们回顾一下所学的内容。

  • PagingSource 用于从您定义的数据源异步加载数据。
  • Pager.flow 用于创建一个基于配置的 Flow<PagingData> 和一个定义如何实例化 PagingSource 的函数。
  • 每当通过 PagingSource 加载新数据时,Flow 都会发送新的 PagingData
  • 界面将观察更改后的 PagingData,并使用 PagingDataAdapter 更新用于呈现数据的 RecyclerView
  • 要从界面加载“失败重试”,请使用 PagingDataAdapter.retry 方法。从本质上讲,Paging 库将触发 PagingSource.load() 方法。
  • 要为列表添加分隔符,请创建高等级受支持类型分隔符。然后,使用 PagingData.insertSeparators() 方法实现您的分隔符生成逻辑。
  • 要显示加载状态的页眉或页脚,请使用 PagingDataAdapter.withLoadStateHeaderAndFooter() 方法并实现 LoadStateAdapter。如果要根据加载状态执行其他操作,请使用 PagingDataAdapter.addLoadStateListener() 回调。
  • 如需处理网络和数据库,请实现 RemoteMediator