1. 简介
这是一个演示分页库高级用法的 Codelab。如果您不熟悉分页的概念,或不熟悉分页库本身,请查看 分页基础 Codelab。
您将学到什么
- 分页 3 的主要组件是什么。
- 如何在项目中添加分页 3。
- 如何使用分页 3 API 在列表中添加页眉或页脚。
- 如何使用分页 3 API 添加列表分隔符。
- 如何从网络和数据库中分页。
您将构建什么
在这个 Codelab 中,您将从一个示例应用程序开始,该应用程序已经显示了 GitHub 存储库的列表。每当用户滚动到已显示列表的末尾时,就会触发一个新的网络请求,并在屏幕上显示其结果。
您将通过一系列步骤添加代码,以实现以下目标
- 迁移到 分页库 组件。
- 在列表中添加加载状态页眉和页脚。
- 在每次新的存储库搜索之间显示加载进度。
- 在列表中添加分隔符。
- 添加数据库支持,以便从网络和数据库中分页。
以下是您的应用程序最终的样子
您需要什么
- Android Studio Arctic Fox.
- 熟悉以下架构组件:LiveData、ViewModel、View. 绑定 以及 " 应用程序架构指南” 中建议的架构。
- 熟悉 协程 和 Kotlin Flow。
有关架构组件的介绍,请查看 带视图的 Room Codelab。有关 Flow 的介绍,请查看 Kotlin Flow 和 LiveData 高级协程 Codelab。
2. 设置您的环境
在此步骤中,您将下载整个 Codelab 的代码,然后运行一个简单的示例应用程序。
为了让您尽快入门,我们为您准备了一个入门项目,供您构建。
如果您安装了 git,您可以简单地运行以下命令。(您可以通过在终端/命令行中键入 git --version
来检查,并验证它是否可以正确执行。)
git clone https://github.com/android/codelab-android-paging
代码位于 /advanced
文件夹中。打开 start
项目。
end
项目包含您应该结束的代码,因此如果您遇到问题,可以随时查看它。
如果您没有安装 git,您可以单击以下按钮下载此 Codelab 的所有代码
- 解压缩代码,然后在 Android Studio 中打开该项目。
- 在设备或模拟器上运行
app
运行配置。
该应用程序运行并显示 GitHub 存储库的列表,类似于以下列表
3. 项目概述
该应用程序允许您在 GitHub 上搜索其名称或描述包含特定词语的存储库。存储库列表按星级数降序排列,然后按名称字母顺序排列。
该应用程序遵循 " 应用程序架构指南” 中推荐的架构。以下是您将在每个包中找到的内容
- api - 使用 Retrofit 进行的 Github API 调用。
- data - 存储库类,负责触发 API 请求并将响应缓存到内存中。
- model -
Repo
数据模型,它也是 Room 数据库中的一个表;以及RepoSearchResult
,一个由 UI 用于观察搜索结果数据和网络错误的类。 - ui - 与显示带有
RecyclerView
的Activity
相关的类。
GithubRepository
类每次用户滚动到列表末尾或用户搜索新存储库时,都会从网络中检索存储库名称列表。查询的结果列表保存在 GithubRepository
中的 ConflatedBroadcastChannel
中,并作为 Flow
公开。
SearchRepositoriesViewModel
从 GithubRepository
请求数据,并将其公开给 SearchRepositoriesActivity
。因为我们希望确保在配置更改(例如旋转)时不会多次请求数据,所以在 ViewModel
中使用 liveData()
构建器方法将 Flow
转换为 LiveData
。这样, LiveData
就会将最新的结果列表缓存到内存中,当 SearchRepositoriesActivity
被重新创建时, LiveData
的内容将显示在屏幕上。 ViewModel
公开
- 一个
LiveData<UiState>
- 一个函数
(UiAction) -> Unit
UiState
是渲染应用程序 UI 所需的所有内容的表示形式,不同的字段对应于不同的 UI 组件。它是一个不可变对象,这意味着它无法更改;但是,可以生成它的新版本并由 UI 观察。在我们的例子中,它的新版本是由于用户的操作而产生的:要么搜索新的查询,要么滚动列表以获取更多内容。
用户操作由 UiAction
类型恰如其分地表示。将与 ViewModel
交互的 API 封装在一个类型中具有以下好处
- API 表面积小:可以添加、删除或更改操作,但
ViewModel
的方法签名永远不会改变。这使得重构本地化并且不太可能泄漏抽象或接口实现。 - 更轻松的并发管理:正如您将在后面的 Codelab 中看到的那样,保证某些请求的执行顺序非常重要。通过使用
UiAction
对 API 进行强类型化,我们可以编写具有严格要求的代码,说明什么可以发生以及什么时候可以发生。
从可用性的角度来看,我们遇到了以下问题
- 用户无法获取有关列表加载状态的信息:他们在搜索新存储库时会看到一个空屏幕,或者在加载同一查询的更多结果时会突然看到列表结束。
- 用户无法重试失败的查询。
- 列表在方向更改或进程死亡后始终滚动到顶部。
从实现的角度来看,我们遇到了以下问题
- 列表在内存中无限增长,当用户滚动时会浪费内存。
- 我们必须将结果从
Flow
转换为LiveData
以对其进行缓存,从而增加了代码的复杂性。 - 如果我们的应用程序需要显示多个列表,我们会发现每个列表都需要编写大量的样板代码。
让我们找出分页库如何帮助我们解决这些问题以及它包含哪些组件。
4. 分页库组件
分页库使您能够更轻松地在应用程序的 UI 中以增量方式优雅地加载数据。分页 API 为您在需要以分页方式加载数据时需要手动实现的许多功能提供了支持
- 跟踪用于检索下一页和上一页的键。
- 当用户滚动到列表末尾时,会自动请求正确的页面。
- 确保不会同时触发多个请求。
- 允许您缓存数据:如果您使用的是 Kotlin,这将在
CoroutineScope
中完成;如果您使用的是 Java,这可以通过LiveData
完成。 - 跟踪加载状态,并允许您在
RecyclerView
列表项或 UI 中的任何其他位置显示加载状态,并轻松重试失败的加载操作。 - 允许您对将要显示的列表执行常见操作,例如
map
或filter
,无论您使用的是Flow
、LiveData
还是 RxJavaFlowable
或Observable
。 - 提供了一种简单的方法来实现列表分隔符。
应用程序架构指南 提出了一个具有以下主要组件的架构
- 一个本地数据库,作为向用户展示的数据和用户操作数据的唯一数据源。
- 一个 Web API 服务。
- 一个与数据库和 Web API 服务协同工作的存储库,提供统一的数据接口。
- 一个为 UI 提供特定数据的
ViewModel
。 - UI,它显示了
ViewModel
中数据的视觉表示形式。
分页库与所有这些组件协同工作,并协调它们之间的交互,以便它可以从数据源中加载内容的“页面”,并在 UI 中显示该内容。
这个 Codelab 向您介绍分页库及其主要组件
PagingData
- 用于分页数据的容器。每次刷新数据都会有一个单独的对应PagingData
。PagingSource
-PagingSource
是将数据快照加载到PagingData
流中的基类。Pager.flow
- 基于PagingConfig
和一个定义如何构建已实现的PagingSource
的函数,构建一个Flow<PagingData>
。PagingDataAdapter
- 一种RecyclerView.Adapter
,用于在RecyclerView
中展示PagingData
。PagingDataAdapter
可以连接到 KotlinFlow
、LiveData
、RxJavaFlowable
或 RxJavaObservable
。PagingDataAdapter
监听内部PagingData
加载事件,并在后台线程上使用DiffUtil
计算细粒度更新,以响应以新PagingData
对象形式接收的更新内容。RemoteMediator
- 帮助从网络和数据库实现分页。
在本代码实验室中,您将实现上述每个组件的示例。
5. 定义数据源
PagingSource
实现定义了数据源以及如何从该源检索数据。 PagingData
对象根据用户在 RecyclerView
中滚动时生成的加载提示从 PagingSource
查询数据。
目前,GithubRepository
具有数据源的大量职责,这些职责将在我们完成添加分页库后由分页库处理。
- 从
GithubService
加载数据,确保不会同时触发多个请求。 - 保留检索数据的内存缓存。
- 跟踪要请求的页面。
要构建 PagingSource
,您需要定义以下内容
- 分页键的类型 - 在我们的例子中,Github API 使用 1 为基础的索引数字来表示页面,因此类型为
Int
。 - 加载数据的类型 - 在我们的例子中,我们正在加载
Repo
项目。 - 从哪里检索数据 - 我们正在从
GithubService
获取数据。我们的数据源将特定于某个查询,因此我们需要确保我们也向GithubService
传递查询信息。
因此,在 data
包中,让我们创建一个名为 GithubPagingSource
的 PagingSource
实现
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
要求我们实现两个函数:load()
和 getRefreshKey()
。
load()
函数将由分页库调用,以异步获取更多数据,这些数据将在用户滚动时显示。 LoadParams
对象保留与加载操作相关的信息,包括以下内容
- 要加载的页面的键。如果这是第一次调用加载,
LoadParams.key
将为null
。在这种情况下,您将必须定义初始页面键。对于我们的项目,您将必须从GithubRepository
将GITHUB_STARTING_PAGE_INDEX
常量移到您的PagingSource
实现中,因为这是初始页面键。 - 加载大小 - 要加载的项目的请求数量。
加载函数返回一个 LoadResult
。这将替换我们在应用程序中使用 RepoSearchResult
,因为 LoadResult
可以采用以下类型之一
LoadResult.Page
,如果结果成功。LoadResult.Error
,如果发生错误。
在构造 LoadResult.Page
时,如果列表不能在相应方向加载,则为 nextKey
或 prevKey
传递 null
。例如,在我们的例子中,我们可以认为,如果网络响应成功但列表为空,我们就没有任何数据可以加载;因此 nextKey
可以为 null
。
基于所有这些信息,我们应该能够实现 load()
函数!
接下来我们需要实现 getRefreshKey()
。刷新键用于后续对 PagingSource.load()
的刷新调用(第一次调用是初始加载,它使用 Pager
提供的 initialKey
)。每当分页库想要加载新数据以替换当前列表时,就会发生刷新,例如,在滑动刷新或由于数据库更新、配置更改、进程死亡等导致的无效化时。通常,后续刷新调用将希望重新开始加载以 PagingState.anchorPosition
为中心的 data,它代表最近访问的索引。
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)
}
}
}
6. 构建和配置 PagingData
在我们当前的实现中,我们在 GitHubRepository
中使用一个 Flow<RepoSearchResult>
从网络获取数据并将其传递给 ViewModel
。然后,ViewModel
将其转换为 LiveData
并将其公开给 UI。每当我们到达显示列表的末尾并从网络加载更多数据时,Flow<RepoSearchResult>
将包含该查询之前检索到的所有数据,以及最新数据。
RepoSearchResult
封装了成功和错误情况。成功情况包含仓库数据。错误情况包含 Exception
原因。使用 Paging 3,我们不再需要 RepoSearchResult
,因为库使用 LoadResult
对成功和错误情况进行建模。您可以随意删除 RepoSearchResult
,因为在接下来的几个步骤中,我们将替换它。
要构造 PagingData
,我们首先需要决定要使用哪个 API 将 PagingData
传递到应用程序的其他层。
- Kotlin
Flow
- 使用Pager.flow
。 LiveData
- 使用Pager.liveData
。- RxJava
Flowable
- 使用Pager.flowable
。 - RxJava
Observable
- 使用Pager.observable
。
由于我们已经在应用程序中使用 Flow
,因此我们将继续使用这种方法;但我们将使用 Flow<PagingData<Repo>>
,而不是使用 Flow<RepoSearchResult>
。
无论您使用哪个 PagingData
生成器,您都必须传递以下参数
PagingConfig
。此类设置有关如何从PagingSource
加载内容的选项,例如,加载多远,初始加载的大小请求等。您必须定义的唯一强制参数是页面大小——每页应加载多少个项目。默认情况下,分页将保留您加载的所有页面。为了确保在用户滚动时不会浪费内存,请在PagingConfig
中设置maxSize
参数。默认情况下,如果分页可以计算未加载的项目,并且如果enablePlaceholders
配置标志为 true,则分页将返回 null 项目作为未加载内容的占位符。这样,您将能够在适配器中显示占位符视图。为了简化本代码实验室的工作,让我们通过传递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 为我们做了很多事情
- 处理内存缓存。
- 在用户接近列表末尾时请求数据。
这意味着我们的 GithubRepository
中的所有其他内容都可以删除,除了 getSearchResultStream
和我们在其中定义 NETWORK_PAGE_SIZE
的伴生对象。您的 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 {
const val NETWORK_PAGE_SIZE = 50
}
}
您现在应该在 SearchRepositoriesViewModel
中遇到编译错误。让我们看看在那里需要进行哪些更改!
7. 在 ViewModel 中请求和缓存 PagingData
在解决编译错误之前,让我们回顾一下 ViewModel
中的类型
sealed class UiAction {
data class Search(val query: String) : UiAction()
data class Scroll(
val visibleItemCount: Int,
val lastVisibleItemPosition: Int,
val totalItemCount: Int
) : UiAction()
}
data class UiState(
val query: String,
val searchResult: RepoSearchResult
)
在我们的 UiState
中,我们公开了一个 searchResult
; searchResult
的作用是作为结果搜索的内存缓存,该缓存可以在配置更改后继续存在。使用 Paging 3,我们不再需要将我们的 Flow
转换为 LiveData
。相反,SearchRepositoriesViewModel
现在将公开一个 StateFlow<UiState>
。此外,我们完全删除了 searchResult
val,而是选择公开一个单独的 Flow<PagingData<Repo>>
,它与 searchResult
的作用相同。
PagingData
是一个自包含的类型,包含要显示在 RecyclerView
中的数据的更新的可变流。 PagingData
的每次发射都是完全独立的,并且对于单个查询,可能会发射多个 PagingData
。因此,PagingData
的 Flows
应该独立于其他 Flows
公开。
此外,作为用户体验的优势,对于每次输入的新查询,我们希望滚动到列表顶部以显示第一个搜索结果。但是,由于分页数据可能多次发射,我们只希望在用户**尚未**开始滚动时滚动到列表顶部。
为此,让我们更新 UiState
并添加 lastQueryScrolled
和 hasNotScrolledForCurrentSearch
的字段。这些标志将阻止我们在不应该滚动到列表顶部时滚动。
data class UiState(
val query: String = DEFAULT_QUERY,
val lastQueryScrolled: String = DEFAULT_QUERY,
val hasNotScrolledForCurrentSearch: Boolean = false
)
让我们重新审视我们的架构。由于对 ViewModel
的所有请求都通过单个入口点 - 定义为 (UiAction) -> Unit
的 accept
字段 - 我们需要执行以下操作。
- 将该入口点转换为包含我们感兴趣的类型的流。
- 转换这些流。
- 将这些流组合回
StateFlow<UiState>
。
用更具功能性的术语来说,我们将 reduce
UiAction
的发射到 UiState
。它就像一条装配线: UiAction
类型是输入的原材料,它们会引起影响(有时称为变异),而 UiState
是已完成的输出,可以绑定到 UI。这有时被称为使 UI 成为 UiState
的函数。
让我们重写 ViewModel
以在两个不同的流中处理每个 UiAction
类型,然后使用一些 Kotlin Flow
操作符将它们转换为 StateFlow<UiState>
。
首先,我们将 ViewModel
中 state
的定义更新为使用 StateFlow
而不是 LiveData
,同时还添加一个字段以公开 PagingData
的 Flow
。
/**
* Stream of immutable states representative of the UI.
*/
val state: StateFlow<UiState>
val pagingDataFlow: Flow<PagingData<Repo>>
接下来,我们将 UiAction.Scroll
子类的定义更新。
sealed class UiAction {
...
data class Scroll(val currentQuery: String) : UiAction()
}
请注意,我们删除了 UiAction.Scroll
数据类中的所有字段,并将它们替换为单个 currentQuery
字符串。这使我们能够将滚动操作与特定查询相关联。我们还删除了 shouldFetchMore
扩展,因为它不再使用。这也需要在进程死亡后恢复,因此我们确保更新 SearchRepositoriesViewModel
中的 onCleared()
方法。
class SearchRepositoriesViewModel{
...
override fun onCleared() {
savedStateHandle[LAST_SEARCH_QUERY] = state.value.query
savedStateHandle[LAST_QUERY_SCROLLED] = state.value.lastQueryScrolled
super.onCleared()
}
}
// This is outside the ViewModel class, but in the same file
private const val LAST_QUERY_SCROLLED: String = "last_query_scrolled"
这也是介绍实际从 GithubRepository
创建 pagingData
Flow
的方法的好时机。
class SearchRepositoriesViewModel(
...
) : ViewModel() {
override fun onCleared() {
...
}
private fun searchRepo(queryString: String): Flow<PagingData<Repo>> =
repository.getSearchResultStream(queryString)
}
Flow<PagingData>
有一个方便的 cachedIn()
方法,它允许我们缓存 Flow<PagingData>
的内容在一个 CoroutineScope
中。由于我们是在 ViewModel
中,我们将使用 androidx.lifecycle.viewModelScope
。
现在,我们可以开始将 ViewModel
中的 accept
字段转换为 UiAction
流。将 SearchRepositoriesViewModel
的 init
块替换为以下内容。
class SearchRepositoriesViewModel(
...
) : ViewModel() {
...
init {
val initialQuery: String = savedStateHandle.get(LAST_SEARCH_QUERY) ?: DEFAULT_QUERY
val lastQueryScrolled: String = savedStateHandle.get(LAST_QUERY_SCROLLED) ?: DEFAULT_QUERY
val actionStateFlow = MutableSharedFlow<UiAction>()
val searches = actionStateFlow
.filterIsInstance<UiAction.Search>()
.distinctUntilChanged()
.onStart { emit(UiAction.Search(query = initialQuery)) }
val queriesScrolled = actionStateFlow
.filterIsInstance<UiAction.Scroll>()
.distinctUntilChanged()
// This is shared to keep the flow "hot" while caching the last query scrolled,
// otherwise each flatMapLatest invocation would lose the last query scrolled,
.shareIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(stopTimeoutMillis = 5000),
replay = 1
)
.onStart { emit(UiAction.Scroll(currentQuery = lastQueryScrolled)) }
}
}
让我们看一下上面的代码片段。我们从两个项目开始, initialQuery
String
来自保存状态或默认值,以及 lastQueryScrolled
,一个 String
,表示用户已与列表交互的最后一个搜索词。接下来,我们开始将 Flow
拆分为特定 UiAction
类型。
UiAction.Search
用于用户每次输入特定查询时。UiAction.Scroll
用于用户每次在特定查询处于焦点状态时滚动列表时。
UiAction.Scroll Flow
应用了一些额外的转换。让我们回顾一下它们。
shareIn
:这是必需的,因为当这个Flow
最终被使用时,它使用flatmapLatest
操作符被使用。每次上游发射时,flatmapLatest
将取消它正在操作的最后一个Flow
,并根据它收到的新流开始工作。在我们的例子中,这将导致我们丢失用户滚动的最后一个查询的值。因此,我们使用Flow
操作符和replay
值 1 来缓存最后一个值,这样当新的查询进来时它不会丢失。onStart
:也用于缓存。如果应用程序被杀死,但用户已经滚动过一个查询,我们不想将列表滚动到顶部,导致他们再次失去位置。
仍然应该有编译错误,因为我们还没有定义 state
、 pagingDataFlow
和 accept
字段。让我们来解决它。使用应用于每个 UiAction
的转换,我们现在可以使用它们来创建我们 PagingData
和 UiState
的流。
init {
...
pagingDataFlow = searches
.flatMapLatest { searchRepo(queryString = it.query) }
.cachedIn(viewModelScope)
state = combine(
searches,
queriesScrolled,
::Pair
).map { (search, scroll) ->
UiState(
query = search.query,
lastQueryScrolled = scroll.currentQuery,
// If the search query matches the scroll query, the user has scrolled
hasNotScrolledForCurrentSearch = search.query != scroll.currentQuery
)
}
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(stopTimeoutMillis = 5000),
initialValue = UiState()
)
accept = { action ->
viewModelScope.launch { actionStateFlow.emit(action) }
}
}
}
我们对 searches
流使用 flatmapLatest
操作符,因为每个新的搜索查询都需要创建一个新的 Pager
。接下来,我们将 cachedIn
操作符应用于 PagingData
流,以使它在 viewModelScope
中保持活跃,并将结果分配给 pagingDataFlow
字段。在 UiState
方面,我们使用 combine 操作符来填充所需的 UiState
字段,并将结果 Flow
分配给公开的 state
字段。我们还将 accept
定义为一个 lambda,它启动一个挂起函数,该函数为我们的状态机提供数据。
就是这样!我们现在从字面意义和反应式编程的角度来看,都有一个功能性的 ViewModel
!
8. 使适配器使用 PagingData
要将 PagingData
绑定到 RecyclerView
,请使用 PagingDataAdapter
。当 PagingData
内容加载时, PagingDataAdapter
会收到通知,然后它会发出信号让 RecyclerView
更新。
更新 ui.ReposAdapter
以使用 PagingData
流。
- 现在,
ReposAdapter
实现了ListAdapter
。使其改为实现PagingDataAdapter
。类主体其余部分保持不变。
class ReposAdapter : PagingDataAdapter<Repo, RepoViewHolder>(REPO_COMPARATOR) {
// body is unchanged
}
到目前为止,我们已经做了很多更改,但现在我们只需要一步就可以运行应用程序 - 我们只需要连接 UI!
9. 触发网络更新
用 Flow 替换 LiveData
让我们更新 SearchRepositoriesActivity
以使用 Paging 3。为了能够使用 Flow<PagingData>
,我们需要启动一个新的协程。我们将在 lifecycleScope
中执行此操作,它负责在活动被重新创建时取消请求。
幸运的是,我们不需要做太多更改。与其 observe()
一个 LiveData
,我们改为 launch()
一个 coroutine
并 collect()
一个 Flow
。 UiState
将与 PagingAdapter
LoadState
Flow
组合在一起,以确保如果用户已经滚动,我们不会在 PagingData
的新发射中将列表滚动回顶部。
首先,由于我们现在将状态作为 StateFlow
而不是 LiveData
返回,因此 Activity
中对 LiveData
的所有引用都应替换为 StateFlow
,确保还为 pagingData
Flow
添加一个参数。第一个地方是在 bindState
方法中。
private fun ActivitySearchRepositoriesBinding.bindState(
uiState: StateFlow<UiState>,
pagingData: Flow<PagingData<Repo>>,
uiActions: (UiAction) -> Unit
) {
...
}
此更改具有级联效应,因为我们现在必须更新 bindSearch()
和 bindList()
。 bindSearch()
的更改最小,所以让我们从那里开始。
private fun ActivitySearchRepositoriesBinding.bindSearch(
uiState: StateFlow<UiState>,
onQueryChanged: (UiAction.Search) -> Unit
) {
searchRepo.setOnEditorActionListener {...}
searchRepo.setOnKeyListener {...}
lifecycleScope.launch {
uiState
.map { it.query }
.distinctUntilChanged()
.collect(searchRepo::setText)
}
}
这里的主要变化是需要启动一个协程,并从 UiState
Flow
收集查询更改。
解决滚动问题并绑定数据
现在是滚动部分。首先,与最后两次更改一样,我们将 LiveData
替换为 StateFlow
并为 pagingData
Flow
添加一个参数。完成此操作后,我们可以继续进行滚动侦听器。请注意,以前,我们使用附加到 RecyclerView
的 OnScrollListener
来知道何时触发更多数据。Paging 库为我们处理列表滚动,但我们仍然需要 OnScrollListener
作为用户是否已为当前查询滚动列表的信号。在 bindList()
方法中,让我们将 setupScrollListener()
替换为内联 RecyclerView.OnScrollListener
。我们还完全删除了 setupScrollListener()
方法。
private fun ActivitySearchRepositoriesBinding.bindList(
repoAdapter: ReposAdapter,
uiState: StateFlow<UiState>,
pagingData: Flow<PagingData<Repo>>,
onScrollChanged: (UiAction.Scroll) -> Unit
) {
list.addOnScrollListener(object : RecyclerView.OnScrollListener() {
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
if (dy != 0) onScrollChanged(UiAction.Scroll(currentQuery = uiState.value.query))
}
})
// the rest of the code is unchanged
}
接下来,我们设置管道以创建一个 shouldScrollToTop
布尔标志。完成此操作后,我们有两个可以从 collect
的流: PagingData
Flow
和 shouldScrollToTop
Flow
。
private fun ActivitySearchRepositoriesBinding.bindList(
repoAdapter: ReposAdapter,
uiState: StateFlow<UiState>,
pagingData: Flow<PagingData<Repo>>,
onScrollChanged: (UiAction.Scroll) -> Unit
) {
list.addOnScrollListener(...)
val notLoading = repoAdapter.loadStateFlow
// Only emit when REFRESH LoadState for the paging source changes.
.distinctUntilChangedBy { it.source.refresh }
// Only react to cases where REFRESH completes i.e., NotLoading.
.map { it.source.refresh is LoadState.NotLoading }
val hasNotScrolledForCurrentSearch = uiState
.map { it.hasNotScrolledForCurrentSearch }
.distinctUntilChanged()
val shouldScrollToTop = combine(
notLoading,
hasNotScrolledForCurrentSearch,
Boolean::and
)
.distinctUntilChanged()
lifecycleScope.launch {
pagingData.collectLatest(repoAdapter::submitData)
}
lifecycleScope.launch {
shouldScrollToTop.collect { shouldScroll ->
if (shouldScroll) list.scrollToPosition(0)
}
}
}
在上面,我们对 pagingData
Flow
使用了 collectLatest
,以便在 pagingData
发出新事件时取消对先前 pagingData
事件的收集。对于 shouldScrollToTop
标志,PagingDataAdapter.loadStateFlow
的事件与 UI 中显示的内容同步,因此在布尔标志发出为真时立即调用 list.scrollToPosition(0)
是安全的。
在 LoadStateFlow
中的类型是 CombinedLoadStates
对象。
CombinedLoadStates
允许我们获取三种不同类型加载操作的加载状态。
CombinedLoadStates.refresh
- 表示首次加载PagingData
的加载状态。CombinedLoadStates.prepend
- 表示加载列表开头数据的加载状态。CombinedLoadStates.append
- 表示加载列表末尾数据的加载状态。
在我们的案例中,我们希望仅在刷新完成后重置滚动位置,即 LoadState
为 refresh
,NotLoading
。
现在我们可以从 updateRepoListFromInput()
中移除 binding.list.scrollToPosition(0)
。
完成所有这些操作后,您的活动应该看起来像
class SearchRepositoriesActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val binding = ActivitySearchRepositoriesBinding.inflate(layoutInflater)
val view = binding.root
setContentView(view)
// get the view model
val viewModel = ViewModelProvider(this, Injection.provideViewModelFactory(owner = this))
.get(SearchRepositoriesViewModel::class.java)
// add dividers between RecyclerView's row items
val decoration = DividerItemDecoration(this, DividerItemDecoration.VERTICAL)
binding.list.addItemDecoration(decoration)
// bind the state
binding.bindState(
uiState = viewModel.state,
pagingData = viewModel.pagingDataFlow,
uiActions = viewModel.accept
)
}
/**
* Binds the [UiState] provided by the [SearchRepositoriesViewModel] to the UI,
* and allows the UI to feed back user actions to it.
*/
private fun ActivitySearchRepositoriesBinding.bindState(
uiState: StateFlow<UiState>,
pagingData: Flow<PagingData<Repo>>,
uiActions: (UiAction) -> Unit
) {
val repoAdapter = ReposAdapter()
list.adapter = repoAdapter
bindSearch(
uiState = uiState,
onQueryChanged = uiActions
)
bindList(
repoAdapter = repoAdapter,
uiState = uiState,
pagingData = pagingData,
onScrollChanged = uiActions
)
}
private fun ActivitySearchRepositoriesBinding.bindSearch(
uiState: StateFlow<UiState>,
onQueryChanged: (UiAction.Search) -> Unit
) {
searchRepo.setOnEditorActionListener { _, actionId, _ ->
if (actionId == EditorInfo.IME_ACTION_GO) {
updateRepoListFromInput(onQueryChanged)
true
} else {
false
}
}
searchRepo.setOnKeyListener { _, keyCode, event ->
if (event.action == KeyEvent.ACTION_DOWN && keyCode == KeyEvent.KEYCODE_ENTER) {
updateRepoListFromInput(onQueryChanged)
true
} else {
false
}
}
lifecycleScope.launch {
uiState
.map { it.query }
.distinctUntilChanged()
.collect(searchRepo::setText)
}
}
private fun ActivitySearchRepositoriesBinding.updateRepoListFromInput(onQueryChanged: (UiAction.Search) -> Unit) {
searchRepo.text.trim().let {
if (it.isNotEmpty()) {
list.scrollToPosition(0)
onQueryChanged(UiAction.Search(query = it.toString()))
}
}
}
private fun ActivitySearchRepositoriesBinding.bindList(
repoAdapter: ReposAdapter,
uiState: StateFlow<UiState>,
pagingData: Flow<PagingData<Repo>>,
onScrollChanged: (UiAction.Scroll) -> Unit
) {
list.addOnScrollListener(object : RecyclerView.OnScrollListener() {
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
if (dy != 0) onScrollChanged(UiAction.Scroll(currentQuery = uiState.value.query))
}
})
val notLoading = repoAdapter.loadStateFlow
// Only emit when REFRESH LoadState for the paging source changes.
.distinctUntilChangedBy { it.source.refresh }
// Only react to cases where REFRESH completes i.e., NotLoading.
.map { it.source.refresh is LoadState.NotLoading }
val hasNotScrolledForCurrentSearch = uiState
.map { it.hasNotScrolledForCurrentSearch }
.distinctUntilChanged()
val shouldScrollToTop = combine(
notLoading,
hasNotScrolledForCurrentSearch,
Boolean::and
)
.distinctUntilChanged()
lifecycleScope.launch {
pagingData.collectLatest(repoAdapter::submitData)
}
lifecycleScope.launch {
shouldScrollToTop.collect { shouldScroll ->
if (shouldScroll) list.scrollToPosition(0)
}
}
}
}
我们的应用程序应该可以编译和运行,但没有加载状态页脚和在错误时显示的 Toast
。在下一步中,我们将看到如何显示加载状态页脚。
10. 在页脚中显示加载状态
在我们的应用程序中,我们希望能够根据加载状态显示页脚:当列表正在加载时,我们希望显示进度旋转器。如果发生错误,我们希望显示错误和重试按钮。
我们需要构建的页眉/页脚遵循需要在显示的实际项目列表的开头(作为页眉)或结尾(作为页脚)追加的列表的概念。页眉/页脚是一个列表,其中只有一个元素:一个根据 Paging LoadState
显示进度条或错误和重试按钮的视图。
由于根据加载状态显示页眉/页脚和实现重试机制是常见任务,因此 Paging 3 API 为我们提供了这两种功能。
对于 **页眉/页脚实现**,我们将使用 LoadStateAdapter
。此 RecyclerView.Adapter
实现会自动收到加载状态变化的通知。它确保只有 Loading
和 Error
状态导致项目显示,并在根据 LoadState
移除、插入或更改项目时通知 RecyclerView
。
对于 **重试机制**,我们使用 adapter.retry()
。在幕后,此方法最终会调用您 PagingSource
实现来获取正确的页面。响应将通过 Flow<PagingData>
自动传播。
让我们看看我们的页眉/页脚实现是什么样子的!
与任何列表一样,我们需要创建 3 个文件
- **布局文件** 包含用于显示进度、错误和重试按钮的 UI 元素
- **
ViewHolder
** **文件** 根据 PagingLoadState
使 UI 项目可见 - **适配器文件** 定义如何创建和绑定
ViewHolder
。我们不会扩展RecyclerView.Adapter
,而是会扩展 Paging 3 中的 **LoadStateAdapter
**。
创建视图布局
为我们的存储库加载状态创建 repos_load_state_footer_view_item
布局。它应该包含一个 ProgressBar
、一个 TextView
(用于显示错误)和一个重试 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
文件夹中创建一个名为 ReposLoadStateViewHolder
的新 ViewHolder
**.** 它应该接收重试函数作为参数,以便在按下重试按钮时调用。创建一个 bind()
函数,该函数接收 LoadState
作为参数,并根据 LoadState
设置每个视图的可见性。使用 ViewBinding
实现的 ReposLoadStateViewHolder
看起来像这样
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
文件夹中创建一个扩展 LoadStateAdapter
的 ReposLoadStateAdapter
。适配器应该接收重试函数作为参数,因为重试函数将在构造时传递给 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
- 如果我们想要显示页眉和页脚 - 如果列表可以在两个方向分页。
更新 ActivitySearchRepositoriesBinding.bindState()
方法,并在适配器上调用 withLoadStateHeaderAndFooter()
。作为重试函数,我们可以调用 adapter.retry()
。
private fun ActivitySearchRepositoriesBinding.bindState(
uiState: StateFlow<UiState>,
pagingData: Flow<PagingData<Repo>>,
uiActions: (UiAction) -> Unit
) {
val repoAdapter = ReposAdapter()
list.adapter = repoAdapter.withLoadStateHeaderAndFooter(
header = ReposLoadStateAdapter { repoAdapter.retry() },
footer = ReposLoadStateAdapter { repoAdapter.retry() }
)
...
}
由于我们有一个无限滚动的列表,因此查看页脚的一种简单方法是将您的手机或模拟器置于飞行模式,并滚动到列表的末尾。
让我们运行应用程序!
11. 在活动中显示加载状态
您可能已经注意到,我们目前有两个问题
- 在迁移到 Paging 3 时,我们失去了在结果列表为空时显示消息的功能。
- 每当您搜索新的查询时,当前查询结果都会保留在屏幕上,直到我们获得网络响应。这是糟糕的用户体验!相反,我们应该显示进度条或重试按钮。
这两个问题的解决方案是在我们的 SearchRepositoriesActivity
中对加载状态更改做出反应。
显示空列表消息
首先,让我们恢复空列表消息。此消息应该仅在列表加载并且列表中的项目数为 0 时显示。要了解列表何时加载,我们将使用 PagingDataAdapter.loadStateFlow
属性。此 Flow
每当通过 CombinedLoadStates
对象更改加载状态时都会发出事件。
CombinedLoadStates
为我们提供我们定义的 PageSource
或网络和数据库案例所需的 RemoteMediator
的加载状态(稍后将详细介绍)。
在 SearchRepositoriesActivity.bindList()
中,我们直接从 loadStateFlow
收集。当 CombinedLoadStates
的 refresh
状态为 NotLoading
且 adapter.itemCount == 0
时,列表为空。然后,我们分别切换 emptyList
和 list
的可见性
private fun ActivitySearchRepositoriesBinding.bindList(
repoAdapter: ReposAdapter,
uiState: StateFlow<UiState>,
pagingData: Flow<PagingData<Repo>>,
onScrollChanged: (UiAction.Scroll) -> Unit
) {
...
lifecycleScope.launch {
repoAdapter.loadStateFlow.collect { loadState ->
val isListEmpty = loadState.refresh is LoadState.NotLoading && repoAdapter.itemCount == 0
// show empty list
emptyList.isVisible = isListEmpty
// Only show the list if refresh succeeds.
list.isVisible = !isListEmpty
}
}
}
}
显示加载状态
让我们更新我们的 activity_search_repositories.xml
,以包括重试按钮和进度条 UI 元素
<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
private fun ActivitySearchRepositoriesBinding.bindList(
repoAdapter: ReposAdapter,
uiState: StateFlow<UiState>,
pagingData: Flow<PagingData<Repo>>,
onScrollChanged: (UiAction.Scroll) -> Unit
) {
retryButton.setOnClickListener { repoAdapter.retry() }
...
}
接下来,让我们在 SearchRepositoriesActivity.bindList
中对加载状态更改做出反应。由于我们只希望在有新查询时显示进度条,因此我们需要依赖于我们的分页源的加载类型,特别是 CombinedLoadStates.source.refresh
和 LoadState
:Loading
或 Error
。此外,我们在上一步中注释掉的一项功能是在出现错误时显示 Toast
,因此让我们确保也将其带回来。为了显示错误消息,我们将不得不检查 CombinedLoadStates.prepend
或 CombinedLoadStates.append
是否是 LoadState.Error
的实例,并从错误中检索错误消息。
让我们更新 SearchRepositoriesActivity
方法中的 ActivitySearchRepositoriesBinding.bindList
,使其具有此功能
private fun ActivitySearchRepositoriesBinding.bindList(
repoAdapter: ReposAdapter,
uiState: StateFlow<UiState>,
pagingData: Flow<PagingData<Repo>>,
onScrollChanged: (UiAction.Scroll) -> Unit
) {
...
lifecycleScope.launch {
repoAdapter.loadStateFlow.collect { loadState ->
val isListEmpty = loadState.refresh is LoadState.NotLoading && repoAdapter.itemCount == 0
// show empty list
emptyList.isVisible = isListEmpty
// Only show the list if refresh succeeds.
list.isVisible = !isListEmpty
// Show loading spinner during initial load or refresh.
progressBar.isVisible = loadState.source.refresh is LoadState.Loading
// Show the retry state if initial load or refresh fails.
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@SearchRepositoriesActivity,
"\uD83D\uDE28 Wooops ${it.error}",
Toast.LENGTH_LONG
).show()
}
}
}
}
现在让我们运行应用程序并查看它的工作原理!
就是这样!使用当前设置,Paging 库组件会在正确的时间触发 API 请求、处理内存缓存并显示数据。运行应用程序,然后尝试搜索存储库。
12. 添加列表分隔符
提高列表可读性的一种方法是添加分隔符。例如,在我们的应用程序中,由于存储库按星级降序排序,因此我们可以在每 10k 星级处添加分隔符。为了帮助实现这一点,Paging 3 API 允许将分隔符插入 PagingData
。
在 PagingData
中添加分隔符会导致我们显示在屏幕上的列表的修改。我们不再只显示 Repo
对象,还显示分隔符对象。因此,我们必须将从 ViewModel
公开的 UI 模型从 Repo
更改为可以封装两种类型类型的其他类型:RepoItem
和 SeparatorItem
。接下来,我们必须更新 UI 以支持分隔符
- 为分隔符添加布局和
ViewHolder
。 - 更新
RepoAdapter
以支持创建和绑定分隔符和存储库。
让我们一步一步地进行操作,看看实现是什么样的。
更改 UI 模型
目前 SearchRepositoriesViewModel.searchRepo()
返回 Flow<PagingData<Repo>>
。为了同时支持存储库和分隔符,我们将在与 SearchRepositoriesViewModel
相同的文件中创建一个 UiModel
密封类。我们可以有 2 种类型的 UiModel
对象:RepoItem
和 SeparatorItem
。
sealed class UiModel {
data class RepoItem(val repo: Repo) : UiModel()
data class SeparatorItem(val description: String) : UiModel()
}
由于我们希望根据 10k 星星数量将仓库分开,让我们在 RepoItem
上创建一个扩展属性,以便为我们四舍五入星星数量
private val UiModel.RepoItem.roundedStarCount: Int
get() = this.repo.stars / 10_000
插入分隔符
SearchRepositoriesViewModel.searchRepo()
现在应该返回 Flow<PagingData<UiModel>>
。
class SearchRepositoriesViewModel(
private val repository: GithubRepository,
private val savedStateHandle: SavedStateHandle
) : ViewModel() {
...
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()
方法的样子
private fun searchRepo(queryString: String): Flow<PagingData<UiModel>> =
repository.getSearchResultStream(queryString)
.map { pagingData -> pagingData.map { UiModel.RepoItem(it) } }
.map {
it.insertSeparators { 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
}
}
}
支持多种视图类型
SeparatorItem
对象需要在我们的 RecyclerView
中显示。我们这里只显示一个字符串,所以让我们在 res/layout
文件夹中创建一个带有 TextView
的 separator_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,请用 ViewHolder 替换 RepoViewHolder
- 更新
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
}
}
}
就这样!运行应用程序时,您应该能够看到分隔符!
13. 从网络和数据库分页
让我们通过将数据保存到本地数据库来为我们的应用程序添加离线支持。这样,数据库将成为我们应用程序的真相来源,我们将始终从那里加载数据。只要我们没有更多数据,我们就会从网络请求更多数据,然后将其保存到数据库中。由于数据库是真相来源,因此在保存更多数据时,UI 将自动更新。
以下是我们添加离线支持需要做的事情
- 创建一个 Room 数据库,一个用于保存
Repo
对象的表,以及一个 DAO,我们将使用它来处理Repo
对象。 - 通过实现一个
RemoteMediator
来定义如何在到达数据库中的数据末尾时从网络加载数据。 - 基于 Repos 表作为数据源和用于加载和保存数据的
RemoteMediator
来构建一个Pager
。
让我们一步一步地进行!
14. 定义 Room 数据库、表和 DAO
我们的 Repo
对象需要保存在数据库中,所以让我们从将 Repo
类设为实体开始,tableName = "repos"
,其中 Repo.id
是主键。为此,请使用 @Entity(tableName = "repos")
对 Repo
类进行注释,并将 @PrimaryKey
注释添加到 id
。以下是您现在的 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>)
- 查询包含查询字符串(在名称或描述中)的仓库,并按星星数量降序排序,然后按名称字母顺序排序。不要返回
List<Repo>
,而是返回PagingSource<Int, Repo>
。这样,repos
表将成为分页的数据源。
@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 数据库
- 创建一个扩展
RoomDatabase
的抽象类RepoDatabase
。 - 使用
@Database
对该类进行注释,将实体列表设置为包含Repo
类,并将数据库版本设置为 1。在本代码实验室中,我们不需要导出模式。 - 定义一个返回
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()
}
}
现在我们已经设置了数据库,让我们看看如何从网络请求数据并将其保存到数据库中。
15. 请求和保存数据 - 概述
分页库使用数据库作为需要在 UI 中显示的数据的真相来源。只要我们没有更多数据在数据库中,我们就需要从网络请求更多数据。为了帮助实现这一点,Paging 3 定义了 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 {
}
}
为了能够构建网络请求,load 方法有两个参数,它们应该为我们提供所需的所有信息
PagingState
- 这将为我们提供有关之前加载的页面的信息,列表中最近访问的索引以及我们在初始化分页流时定义的PagingConfig
。LoadType
- 这将告诉我们是否需要在之前加载的数据的末尾 (LoadType.APPEND
) 或开头 (LoadType.PREPEND
) 加载数据,或者这是否是我们第一次加载数据 (LoadType.REFRESH
)。
例如,如果加载类型为 LoadType.APPEND
,那么我们将从 PagingState
中检索最后加载的项目。基于此,我们应该能够通过计算要加载的下一页来找出如何加载下一批 Repo
对象。
在下一部分,您将了解如何计算要加载的下一页和上一页的键。
16. 计算和保存远程页面键
为了 Github API 的目的,我们用来请求仓库页面的页面键只是一个页面索引,在获取下一页时会递增。这意味着,给定一个 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
}
17. 请求和保存数据 - 实现
现在我们已经保存了远程键,让我们回到 GithubRemoteMediator
,看看如何使用它们。这个类将取代我们的 GithubPagingSource
。让我们将 GithubPagingSource
中的 GITHUB_STARTING_PAGE_INDEX
声明复制到我们的 GithubRemoteMediator
中,并删除 GithubPagingSource
类。
让我们看看如何实现 GithubRemoteMediator.load()
方法。
- 根据
LoadType
找出我们需要从网络加载的页面。 - 触发网络请求。
- 网络请求完成后,如果接收到的仓库列表不为空,则执行以下操作:
- 我们为每个
Repo
计算RemoteKeys
。 - 如果这是一个新的查询 (
loadType = REFRESH
),则我们会清空数据库。 - 将
RemoteKeys
和Repos
保存到数据库中。 - 返回
MediatorResult.Success(endOfPaginationReached = false)
。 - 如果仓库列表为空,则返回
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
查找要加载的页面。
18. 根据 LoadType 获取页面
现在我们知道了在 GithubRemoteMediator.load()
方法中拥有页面键后会发生什么,让我们看看如何计算它。这将取决于 LoadType
。
LoadType.APPEND
当我们需要在当前加载的数据集的末尾加载数据时,加载参数为 LoadType.APPEND
。因此,现在,我们需要根据数据库中的最后一个项目来计算网络页面键。
- 我们需要获取从数据库中加载的最后一个
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)
}
}
- 如果
remoteKeys
为空,则表示刷新结果尚未在数据库中。我们可以返回带有endOfPaginationReached = false
的 Success,因为 Paging 将在 RemoteKeys 变为非空时再次调用此方法。如果 remoteKeys 不为null
,但其nextKey
为null
,则表示我们已经到达了追加分页的末尾。
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 nextKey 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
。我们需要根据数据库中的第一个项目来计算网络页面键。
- 我们需要获取从数据库中加载的第一个
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)
}
}
- 如果
remoteKeys
为空,则表示刷新结果尚未在数据库中。我们可以返回带有endOfPaginationReached = false
的 Success,因为 Paging 将在 RemoteKeys 变为非空时再次调用此方法。如果 remoteKeys 不为null
,但其prevKey
为null
,则表示我们已经到达了预置分页的末尾。
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.
val prevKey = remoteKeys?.prevKey
if (prevKey == null) {
return MediatorResult.Success(endOfPaginationReached = remoteKeys != null)
}
prevKey
}
...
}
LoadType.REFRESH
LoadType.REFRESH
在我们第一次加载数据时或在调用 PagingDataAdapter.refresh()
时被调用;因此,现在我们加载数据的参考点是 state.anchorPosition
。如果这是第一次加载,则 anchorPosition
为 null
。当调用 PagingDataAdapter.refresh()
时,anchorPosition
是显示列表中第一个可见位置,因此我们需要加载包含该特定项目的页面。
- 根据
state
中的anchorPosition
,我们可以通过调用state.closestItemToPosition()
获取最接近该位置的Repo
项目。 - 根据
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)
}
}
}
- 如果
remoteKey
不为空,则我们可以从中获取nextKey
。在 Github API 中,页面键按顺序递增。因此,要获取包含当前项目的页面,我们只需从remoteKey.nextKey
中减去 1。 - 如果
RemoteKey
为null
(因为anchorPosition
为null
),则我们需要加载的页面是初始页面: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
}
}
19. 更新分页 Flow 的创建
现在我们已经在 GithubRemoteMediator
和 ReposDao
中实现了 PagingSource
,我们需要更新 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, owner: SavedStateRegistryOwner): ViewModelProvider.Factory {
return ViewModelFactory(owner, provideGithubRepository(context))
}
}
更新 SearchRepositoriesActivity.onCreate()
方法,并将上下文传递给 Injection.provideViewModelFactory()
。
// get the view model
val viewModel = ViewModelProvider(
this, Injection.provideViewModelFactory(
context = this,
owner = 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
构建器,使用 GithubRemoteMediator
和 pagingSourceFactory
。由于 Pager
是一个实验性 API,因此我们必须使用 @OptIn
注解它。
@OptIn(ExperimentalPagingApi::class)
return Pager(
config = PagingConfig(
pageSize = NETWORK_PAGE_SIZE,
enablePlaceholders = false
),
remoteMediator = GithubRemoteMediator(
query,
service,
database
),
pagingSourceFactory = pagingSourceFactory
).flow
就这样!让我们运行应用程序吧!
在使用 RemoteMediator 时对加载状态做出反应
到目前为止,当从 CombinedLoadStates
读取时,我们一直从 CombinedLoadStates.source
读取。但是,当使用 RemoteMediator
时,只能通过检查 CombinedLoadStates.source
和 CombinedLoadStates.mediator
来获得准确的加载信息。特别地,我们目前在 source
LoadState
为 NotLoading
时,会触发滚动到列表顶部以进行新的查询。我们还必须确保新添加的 RemoteMediator
的 LoadState
也为 NotLoading
。
为此,我们定义一个枚举,它概括了由 Pager
获取的列表的显示状态。
enum class RemotePresentationState {
INITIAL, REMOTE_LOADING, SOURCE_LOADING, PRESENTED
}
有了上面的定义,我们就可以比较 CombinedLoadStates
的连续发射,并使用它们来确定列表中项目的精确状态。
@OptIn(ExperimentalCoroutinesApi::class)
fun Flow<CombinedLoadStates>.asRemotePresentationState(): Flow<RemotePresentationState> =
scan(RemotePresentationState.INITIAL) { state, loadState ->
when (state) {
RemotePresentationState.PRESENTED -> when (loadState.mediator?.refresh) {
is LoadState.Loading -> RemotePresentationState.REMOTE_LOADING
else -> state
}
RemotePresentationState.INITIAL -> when (loadState.mediator?.refresh) {
is LoadState.Loading -> RemotePresentationState.REMOTE_LOADING
else -> state
}
RemotePresentationState.REMOTE_LOADING -> when (loadState.source.refresh) {
is LoadState.Loading -> RemotePresentationState.SOURCE_LOADING
else -> state
}
RemotePresentationState.SOURCE_LOADING -> when (loadState.source.refresh) {
is LoadState.NotLoading -> RemotePresentationState.PRESENTED
else -> state
}
}
}
.distinctUntilChanged()
以上内容使我们能够更新 notLoading
Flow
的定义,该定义用于检查我们是否可以滚动到列表顶部。
val notLoading = repoAdapter.loadStateFlow
.asRemotePresentationState()
.map { it == RemotePresentationState.PRESENTED }
同样,在显示初始页面加载时的加载微调器(在 SearchRepositoriesActivity
中的 bindList
扩展中)时,应用程序仍然依赖于 LoadState.source
。我们现在想要做的只是在从 RemoteMediator
加载时才显示加载微调器。其他可见性取决于 LoadStates
的 UI 元素也存在此问题。因此,我们按如下方式更新 LoadStates
到 UI 元素的绑定。
private fun ActivitySearchRepositoriesBinding.bindList(
header: ReposLoadStateAdapter,
repoAdapter: ReposAdapter,
uiState: StateFlow<UiState>,
onScrollChanged: (UiAction.Scroll) -> Unit
) {
...
lifecycleScope.launch {
repoAdapter.loadStateFlow.collect { loadState ->
...
val isListEmpty = loadState.refresh is LoadState.NotLoading && repoAdapter.itemCount == 0
// show empty list
emptyList.isVisible = isListEmpty
// Only show the list if refresh succeeds, either from the local db or the remote.
list.isVisible = loadState.source.refresh is LoadState.NotLoading || loadState.mediator?.refresh is LoadState.NotLoading
// Show loading spinner during initial load or refresh.
progressBar.isVisible = loadState.mediator?.refresh is LoadState.Loading
// Show the retry state if initial load or refresh fails.
retryButton.isVisible = loadState.mediator?.refresh is LoadState.Error && repoAdapter.itemCount == 0
}
}
}
}
此外,因为我们以数据库作为唯一的事实来源,所以有可能在应用程序启动时,数据库中存在数据,但 RemoteMediator
的刷新失败。这是一个有趣的边缘情况,但我们可以轻松地处理它。为此,我们可以保存对标题 LoadStateAdapter
的引用,并覆盖其 LoadState
,使其仅当其刷新状态存在错误时才与 RemoteMediator 的 LoadState
相同。否则,我们使用默认值。
private fun ActivitySearchRepositoriesBinding.bindList(
header: ReposLoadStateAdapter,
repoAdapter: ReposAdapter,
uiState: StateFlow<UiState>,
onScrollChanged: (UiAction.Scroll) -> Unit
) {
...
lifecycleScope.launch {
repoAdapter.loadStateFlow.collect { loadState ->
// Show a retry header if there was an error refreshing, and items were previously
// cached OR default to the default prepend state
header.loadState = loadState.mediator
?.refresh
?.takeIf { it is LoadState.Error && repoAdapter.itemCount > 0 }
?: loadState.prepend
...
}
}
}
你可以在 end 文件夹中找到此代码实验室中完成的所有步骤的完整代码。
20. 总结
现在我们已经添加了所有组件,让我们回顾一下我们学到的内容!
PagingSource
会异步地从你定义的源加载数据。Pager.flow
基于配置和定义如何实例化PagingSource
的函数来创建一个Flow<PagingData>
。Flow
每当PagingSource
加载新数据时,都会发出一个新的PagingData
。- UI 会观察更改后的
PagingData
,并使用PagingDataAdapter
来更新呈现数据的RecyclerView
。 - 要从 UI 重新尝试失败的加载,请使用
PagingDataAdapter.retry
方法。在后台,Paging 库将触发PagingSource.load()
方法。 - 要在列表中添加分隔符,请创建一个包含分隔符作为支持类型之一的高级类型。然后使用
PagingData.insertSeparators()
方法实现分隔符生成逻辑。 - 要将加载状态显示为页眉或页脚,请使用
PagingDataAdapter.withLoadStateHeaderAndFooter()
方法并实现LoadStateAdapter
。如果您想根据加载状态执行其他操作,请使用PagingDataAdapter.addLoadStateListener()
回调。 - 要使用网络和数据库,请实现
RemoteMediator
。 - 添加
RemoteMediator
会导致LoadStatesFlow
中的mediator
字段更新。