深入探索 Paging 3.0: 分页加载来自网络和数据库的数据 | MAD Skills

深入探索 Paging 3.0: 分页加载来自网络和数据库的数据 | MAD Skills

欢迎回到 MAD Skills 系列之 Paging 3.0!在上一篇文章《获取数据并绑定到 UI | MAD Skills》中,我们在 ViewModel 中集成了 Pager,并利用配合 PagingDataAdapter 向 UI 填充数据,我们也添加了加载状态指示器,并在出现错误时重新加载。

这次,我们把难度提升一个档次。目前为止,我们都是直接通过网络加载数据,而这样的操作只适用于理想环境。我们有时候可能遇到网络连接缓慢,或者完全断网的情况。同时,即使网络状况良好,我们也不会希望自己的应用成为数据黑洞——在导航到每个界面时都拉取数据是一种十分浪费的行为。

解决这一问题的方法便是从 本地缓存 加载数据,并且只在必要的时候进行刷新。对缓存数据的更新必须先到达本地缓存,再传播至 ViewModel。这样一来,本地缓存便可成为唯一可信的数据源。对我们来说十分方便的是 Paging 库在 Room 库一些小小的帮助下已经可以应对这种场景。下面就让我们开始吧!点击这里 查看 Paging: 显示数据及其加载状态视频,了解更多详情。

使用 Room 创建 PagingSource

由于我们将要分页的数据源会来自本地而不是直接依赖 API,那么我们要做的第一件事便是更新 PagingSource。好消息是,我们要做的工作很少。是因为我前面提到的 "来自 Room 的小小帮助" 吗?事实上这里的帮助远不止于一点: 只需要在 Room 的 DAO 中为 PagingSource 添加声明,便可通过 DAO 获取 PagingSource

@Dao
interface RepoDao {
    @Query(
        "SELECT * FROM repos WHERE " +
            "name LIKE :queryString"
    )
    fun reposByName(queryString: String): PagingSource<Int, Repo>
}

我们现在可以在 GitHubRepository 中更新 Pager 的构造函数来使用新的 PagingSource 了:

fun getSearchResultStream(query: String): Flow<PagingData<Repo>> {
        
        …
        val pagingSourceFactory = { database.reposDao().reposByName(dbQuery) }

        @OptIn(ExperimentalPagingApi::class)
        return Pager(
           config = PagingConfig(
                pageSize = NETWORK_PAGE_SIZE,
                enablePlaceholders = false
            ),
            pagingSourceFactory = pagingSourceFactory,
            remoteMediator = …,
        ).flow
    }

RemoteMediator

目前为止一切顺利……不过我们好像忘记了什么。本地的数据库要如何填充数据呢?来看看 RemoteMediator,当数据库中的数据加载完毕时,它负责从网络加载更多数据。让我们看看它是如何工作的。

了解 RemoteMediator 的关键在于认识到它是一个回调。RemoteMediator 的结果永远不会展示在 UI 上,因为它只是 Paging 用于通知作为开发者的我们: PagingSource 的数据已经耗尽。更新数据库并通知 Paging,这是我们自己的工作。与 PagingSource 类似,RemoteMediator 有两个泛型参数: 查询参数类型和返回值类型。

@OptIn(ExperimentalPagingApi::class)
class GithubRemoteMediator(
    …
) : RemoteMediator<Int, Repo>() {
    …
}

让我们来仔细观察下 RemoteMediator 中的抽象方法。第一个方法是 initialize(),它是在所有加载开始前,RemoteMediator 调用的第一个方法,它的返回值为 InitializeActionInitializeAction 可以是 LAUNCH_INITIAL_REFRESH,也可以是 SKIP_INITIAL_REFRESH。前者表示在调用 load() 方法时携带的加载类型为 refresh,后者意味着只有在 UI 明确发起请求时才会使用 RemoteMediator 执行刷新操作。在我们的用例中,由于仓库状态可能更新得颇为频繁,所以我们返回 LAUNCH_INITIAL_REFRESH

  override suspend fun initialize(): InitializeAction {
        return InitializeAction.LAUNCH_INITIAL_REFRESH
    }

接下来我们来看 load 方法。load 方法在 loadTypePagingState 所定义的边界处调用,加载类型可以是 refreshappendprepend。这一方法负责获取数据,将其持久化在磁盘上并通知处理结果,其结果可以是 ErrorSuccess。如果结果是 Error,加载状态将会反映这一结果,并可能重试加载。如果加载成功,需要通知 Pager 是否可以加载更多数据。

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

        val page = when (loadType) {
            LoadType.REFRESH -> …
            LoadType.PREPEND -> …
            LoadType.APPEND -> …
        }

        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 {
                …
                repoDatabase.reposDao().insertAll(repos)
            }
            return MediatorResult.Success(endOfPaginationReached = endOfPaginationReached)
        } catch (exception: IOException) {
            return MediatorResult.Error(exception)
        } catch (exception: HttpException) {
            return MediatorResult.Error(exception)
        }
    }

由于 load 方法是一个有返回值的挂起函数,所以 UI 可以精确地反映加载完成的状态。在上一篇文章中,我们简要介绍了 withLoadStateHeaderAndFooter 扩展函数,并了解了如何使用它来加载头部和底部。我们可以观察到,该扩展函数的名字中包含了一个类型: LoadState。让我们进一步了解这一类型。

LoadState、LoadStates 以及 CombinedLoadStates

由于分页是一系列异步事件,所以通过 UI 反映加载数据的当前状态十分重要。在分页操作中,Pager 的加载状态是通过 CombinedLoadStates 类型表示的。

顾名思义,这个类型是其他表示加载信息的类型的组合。这些类型包括:

LoadState 是一个完整描述下列加载状态的密封类:

  • Loading
  • NotLoading
  • Error

LoadStates 是包含以下三种 LoadState 值的数据类:

  • append
  • prepend
  • refresh

通常来讲,prependappend 加载状态会用于响应额外的数据获取,而 refresh 加载状态则用来响应初始加载、刷新和重试。

由于 Pager 可能会从 PagingSource 或者 RemoteMediator 加载数据,所以 CombinedLoadStates 有两个 LoadState 字段。其中名为 source 的字段用于 PagingSource,而名为 mediator 的字段用于 RemoteMediator

方便起见,CombinedLoadStatesLoadStates 相似,同样含有 refreshappendprepend 字段,它们会基于 Paging 的配置和其他语义反映 RemoteMediatorPagingSourceLoadState。请务必查看相关文档以确定这些字段在不同场景下的行为。

使用这些信息更新我们的 UI 就像从 PagingAdapter 暴露的 loadStateFlow 中获取数据一样简单。在我们的应用中,我们可以在第一次加载时使用这些信息显示一个加载指示器:

lifecycleScope.launch {
    repoAdapter.loadStateFlow.collect { loadState ->
        // 在刷新出错时显示重试头部,并且展示之前缓存的状态或者展示默认的 prepend 状态
        header.loadState = loadState.mediator
            ?.refresh
            ?.takeIf { it is LoadState.Error && repoAdapter.itemCount > 0 }
            ?: loadState.prepend

        val isListEmpty = loadState.refresh is LoadState.NotLoading && repoAdapter.itemCount == 0
        // 显示空列表
        emptyList.isVisible = isListEmpty
        // 无论数据来自本地数据库还是远程数据,仅在刷新成功时显示列表。
        list.isVisible =  loadState.source.refresh is LoadState.NotLoading || loadState.mediator?.refresh is LoadState.NotLoading
        // 在初始加载或刷新时显示加载指示器
        progressBar.isVisible = loadState.mediator?.refresh is LoadState.Loading
        // 如果初始加载或刷新失败,显示重试状态
        retryButton.isVisible = loadState.mediator?.refresh is LoadState.Error && repoAdapter.itemCount == 0
    }
}

我们开始从 Flow 收集数据,并在 Pager 尚未加载且现存列表为空时,使用 CombinedLoadStates.refresh 字段展示进度条。我们之所以使用 refresh 字段,是因为我们只希望在第一次启动应用、或者明确触发了刷新时才展示大进度条。我们还可以检查是否有加载状态出错并通知用户。

回顾

在本文中,我们实现了以下功能:

  • 使用数据库作为唯一可信数据源,并对数据进行分页;
  • 使用 RemoteMediator 填充基于 Room 的 PagingSource;
  • 使用来自 PagingAdapter 的 LoadStateFlow 更新带有进度条的 UI。

感谢您的阅读,下一篇文章将是 本系列 的最后一篇,敬请期待。

欢迎您 点击这里 向我们提交反馈,或分享您喜欢的内容、发现的问题。您的反馈对我们非常重要,感谢您的支持!

版权声明

禁止一切形式的转载-禁止商用-禁止衍生 申请授权

脉脉不得语
脉脉不得语
Zhengzhou Website
Android Developer | https://androiddevtools.cn and https://androidweekly.io WebMaster | GDG Zhengzhou Funder & Ex Organizer | http://Toast.show(∞) Podcast Host

你已经成功订阅到 Android 开发技术周报
太棒了!接下来,完成检验以获得全部访问权限 Android 开发技术周报
欢迎回来!你已经成功登录了。
Unable to sign you in. Please try again.
成功!您的帐户已完全激活,您现在可以访问所有内容。
Error! Stripe checkout failed.
Success! Your billing info is updated.
Error! Billing info update failed.