各位小伙伴们大家好。
随着 Android 11 的正式发布,Jetpack 家族也引入了许多新的成员。我之前有承诺过,对于新引入的 App Startup、Hilt、Paging 3,我会分别写一篇文章进行介绍。
本篇文章,我们要学习的就是 Paging 3 了。
Paging 3 简介
Paging 是 Google 推出的一个应用于 Android 平台的分页加载库。
事实上,Paging 并不是现在才刚刚推出的,而是之前就已经推出过两个版本了。
但 Paging 3 和前面两个版本的变化非常大,甚至可以说是完全不同的东西了。所以即使你之前没有学习过 Paging 的用法也没有关系,把 Paging 3 当成是一个全新的库去学习就可以了。
我相信一定会有很多朋友在学习 Paging 3 的时候会产生和我相同的想法:本身 Android 上的分页功能并不难实现,即使没有 Paging 库我们也完全做得出来,但为什么 Paging 3 要把一个本来还算简单的功能设计得如此复杂呢?
是的,Paging 3 很复杂,至少在你还不了解它的情况下就是如此。我在第一次学习 Paging 3 的时候就直接被劝退了,心想着何必用这玩意委屈自己呢,自己写分页功能又不是做不出来。
后来本着拥抱新技术的态度,我又去学习了一次 Paging 3,这次算是把它基本掌握了,并且还在我的新开源项目 Glance 当中应用了 Paging 3 的技术。
如果现在再让我来评价一下 Paging 3,那么我大概是经历了一个由吐槽到真香的过程。理解了 Paging 3 之后,你会发现它提供了一套非常合理的分页架构,我们只需要按照它提供的架构去编写业务逻辑,就可以轻松实现分页功能。我希望大家在看完这篇文章之后,也能觉得 Paging 3 香起来。
不过,本篇文章我不能保证它的易懂性。虽然很多朋友都觉得我写的文章简单易懂,但 Paging 3 的复杂性在于它关联了太多其他的知识,如协程、Flow、MVVM、RecyclerView、DiffUtil 等等,如果你不能将相关联的这些知识都有所了解,那么想要掌握 Paging 3 就会更有难度。
另外,由于 Paging 3 是 Google 基于 Kotlin 协程全新重写的一个库,所以它主要是应用于 Kotlin 语言(Java 也能用,但是会更加复杂),并且以后这样的库会越来越多,比如 Jetpack Compose 等等。如果你对于 Kotlin 还不太了解的话,可以去参考我的新书《第一行代码 Android 第 3 版》。
上手 Paging 3
经过我自己的总结,我发现如果零散去介绍一些 Paging 3 的知识点是很难能掌握得了这个库的。最好的学习方式就是直接上手,用 Paging 3 去做一个项目,项目做完了,你也基本就掌握了。本篇文章中我们就会采用这种方式来学习。
另外,我相信大家之前应该都做过分页功能,正如我所说,这个功能并不难实现。但是现在,请你完全忘掉过去你所熟知的分页方案,因为它不仅对理解 Paging 3 没有帮助,反而在很大程度上会影响你对 Paging 3 的理解。
是的,不要想着去监听列表滑动事件,滑动到底部的时候发起一个网络请求加载下一页数据。Paging 3 完全不是这么用的,如果你还保留着这种过去的实现思路,在学习 Paging 3 的时候会很受阻。
那么现在就让我们开始吧。
首先新建一个 Android 项目,这里我给它起名为 Paging3Sample。
接下来,我们在 build.gradle 的 dependencies 当中添加必要的依赖库:
dependencies {
...
implementation 'androidx.paging:paging-runtime:3.0.0-beta01'
implementation 'com.squareup.retrofit2:retrofit:2.9.0'
implementation 'com.squareup.retrofit2:converter-gson:2.9.0'
}
注意虽然我刚才说,Paging 3 是要和很多其他关联库结合到一起工作的,但是我们并不需要将这些关联库一一手动引入,引入了 Paging 3 之后,所有的关联库都会被自动下载下来。
另外这里还引入了 Retrofit 的库,因为待会我们会从网络上请求数据,并通过 Paging 3 进行分页展示。
那么在正式开始涉及 Paging 3 的用法之前,让我们先来把网络相关的代码搭建好,方便为 Paging 3 提供分页数据。
这里我准备采用 GitHub 的公开 API 来作为我们这个项目的数据源,请注意 GitHub 在国内虽然一般都是可以访问的,但有时接口并不稳定,如果你无法正常请求到数据的话,请自行科学上网。
我们可以尝试在浏览器中请求如下接口地址:
https://api.github.com/search/repositories?sort=stars&q=Android&per_page=5&page=1
这个接口表示,会返回 GitHub 上所有 Android 相关的开源库,以 Star 数量排序,每页返回 5 条数据,当前请求的是第一页。
服务器响应的数据如下,为了方便阅读,我对响应数据进行了简化:
{
"items": [
{
"id": 31792824,
"name": "flutter",
"description": "Flutter makes it easy and fast to build beautiful apps for mobile and beyond.",
"stargazers_count": 112819,
},
{
"id": 14098069,
"name": "free-programming-books-zh_CN",
"description": ":books: 免费的计算机编程类中文书籍,欢迎投稿",
"stargazers_count": 76056,
},
{
"id": 111583593,
"name": "scrcpy",
"description": "Display and control your Android device",
"stargazers_count": 44713,
},
{
"id": 12256376,
"name": "ionic-framework",
"description": "A powerful cross-platform UI toolkit for building native-quality iOS, Android, and Progressive Web Apps with HTML, CSS, and JavaScript.",
"stargazers_count": 43041,
},
{
"id": 55076063,
"name": "Awesome-Hacking",
"description": "A collection of various awesome lists for hackers, pentesters and security researchers",
"stargazers_count": 42876,
}
]
}
简化后的数据格式还是非常好理解的,items 数组中记录了第一页包含了哪些库,其中 name 表示该库的名字,description 表示该库的描述,stargazers_count 表示该库的 Star 数量。
那么下面我们就根据这个接口来编写网络相关的代码吧,由于这部分都是属于 Retrofit 的用法,我会介绍的比较简略。
首先根据服务器响应的 Json 格式定义对应的实体类,新建一个 Repo 类,代码如下所示:
data class Repo(
@SerializedName("id") val id: Int,
@SerializedName("name") val name: String,
@SerializedName("description") val description: String?,
@SerializedName("stargazers_count") val starCount: Int
)
然后定义一个 RepoResponse 类,以集合的形式包裹 Repo 类:
class RepoResponse(
@SerializedName("items") val items: List<Repo> = emptyList()
)
接下来定义一个 GitHubService 用于提供网络请求接口,如下所示:
interface GitHubService {
@GET("search/repositories?sort=stars&q=Android")
suspend fun searchRepos(@Query("page") page: Int, @Query("per_page") perPage: Int): RepoResponse
companion object {
private const val BASE_URL = "https://api.github.com/"
fun create(): GitHubService {
return Retrofit.Builder()
.baseUrl(BASE_URL)
.addConverterFactory(GsonConverterFactory.create())
.build()
.create(GitHubService::class.java)
}
}
}
这些都是 Retrofit 的标准用法,现在当调用 searchRepos() 函数时,Retrofit 就会自动帮我们向 GitHub 的服务器接口发起一条网络请求,并将响应的数据解析到 RepoResponse 对象当中。
好了,现在网络相关的代码都已经准备好了,下面我们就开始使用 Paging 3 来实现分页加载功能。
Paging 3 有几个非常关键的核心组件,我们需要分别在这几个核心组件中按部就班地实现分页逻辑。
首先最重要的组件就是 PagingSource,我们需要自定义一个子类去继承 PagingSource,然后重写 load() 函数,并在这里提供对应当前页数的数据。
新建一个 RepoPagingSource 继承自 PagingSource,代码如下所示:
class RepoPagingSource(private val gitHubService: GitHubService) : PagingSource<Int, Repo>() {
override suspend fun load(params: LoadParams<Int>): LoadResult<Int, Repo> {
return try {
val page = params.key ?: 1
val pageSize = params.loadSize
val repoResponse = gitHubService.searchRepos(page, pageSize)
val repoItems = repoResponse.items
val prevKey = if (page > 1) page - 1 else null
val nextKey = if (repoItems.isNotEmpty()) page + 1 else null
LoadResult.Page(repoItems, prevKey, nextKey)
} catch (e: Exception) {
LoadResult.Error(e)
}
}
override fun getRefreshKey(state: PagingState<Int, Repo>): Int? = null
}
这段代码并不长,但却需要好好解释一下。
在继承 PagingSource 时需要声明两个泛型类型,第一个类型表示页数的数据类型,我们没有特殊需求,所以直接用整型就可以了。第二个类型表示每一项数据(注意不是每一页)所对应的对象类型,这里使用刚才定义的 Repo。
然后在 load() 函数当中,先通过 params 参数得到 key,这个 key 就是代表着当前的页数。注意 key 是可能为 null 的,如果为 null 的话,我们就默认将当前页数设置为第一页。另外还可以通过 params 参数得到 loadSize,表示每一页包含多少条数据,这个数据的大小我们可以在稍后设置。
接下来调用刚才在 GitHubService 中定义的 searchRepos() 接口,并把 page 和 pageSize 传入,从服务器获取当前页所对应的数据。
最后需要调用 LoadResult.Page() 函数,构建一个 LoadResult 对象并返回。注意 LoadResult.Page() 函数接收 3 个参数,第一个参数传入从响应数据解析出来的 Repo 列表即可,第二和第三个参数分别对应着上一页和下一页的页数。针对于上一页和下一页,我们还额外做了个判断,如果当前页已经是第一页或最后一页,那么它的上一页或下一页就为 null。
这样 load() 函数的作用就已经解释完了,可能你会发现,上述代码还重写了一个 getRefreshKey() 函数。这个函数是 Paging 3.0.0-beta01 版本新增的,以前的 alpha 版中并没有。它是属于 Paging 3 比较高级的用法,我们本篇文章涉及不到,所以直接返回 null 就可以了。
PagingSource 相关的逻辑编写完成之后,接下来需要创建一个 Repository 类。这是 MVVM 架构的一个重要组件,还不了解的朋友可以去参考《第一行代码 Android 第 3 版》第 15 章的内容。
object Repository {
private const val PAGE_SIZE = 50
private val gitHubService = GitHubService.create()
fun getPagingData(): Flow<PagingData<Repo>> {
return Pager(
config = PagingConfig(PAGE_SIZE),
pagingSourceFactory = { RepoPagingSource(gitHubService) }
).flow
}
}
这段代码虽然很短,但是却不易理解,因为用到了协程的 Flow。我无法在这里展开解释 Flow 是什么,你可以简单将它理解成协程中对标 RxJava 的一项技术。
当然这里也没有用到什么复杂的 Flow 技术,正如你所见,上面的代码很简短,相比于理解,这更多是一种固定的写法。
我们定义了一个 getPagingData() 函数,这个函数的返回值是Flow<PagingData<Repo>>
,注意除了 Repo 部分是可以改的,其他部分都是固定的。
在 getPagingData() 函数当中,这里创建了一个 Pager 对象,并调用. flow 将它转换成一个 Flow 对象。在创建 Pager 对象的时候,我们指定了 PAGE_SIZE,也就是每页所包含的数据量。又指定了 pagingSourceFactory,并将我们自定义的 RepoPagingSource 传入,这样 Paging 3 就会用它来作为用于分页的数据源了。
将 Repository 编写完成之后,我们还需要再定义一个 ViewModel,因为 Activity 是不可以直接和 Repository 交互的,要借助 ViewModel 才可以。新建一个 MainViewModel 类,代码如下所示:
class MainViewModel : ViewModel() {
fun getPagingData(): Flow<PagingData<Repo>> {
return Repository.getPagingData().cachedIn(viewModelScope)
}
}
代码很简单,就是调用了 Repository 中定义的 getPagingData() 函数而已。但是这里又额外调用了一个 cachedIn() 函数,这是用于将服务器返回的数据在 viewModelScope 这个作用域内进行缓存,假如手机横竖屏发生了旋转导致 Activity 重新创建,Paging 3 就可以直接读取缓存中的数据,而不用重新发起网络请求了。
写到这里,我们的这个项目已经完成了一大半了,接下来开始进行界面展示相关的工作。
由于 Paging 3 是必须和 RecyclerView 结合使用的,下面我们定义一个 RecyclerView 的子项布局。新建 repo_item.xml,代码如下所示:
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="10dp"
android:orientation="vertical">
<TextView
android:id="@+id/name_text"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:maxLines="1"
android:ellipsize="end"
android:textColor="#5194fd"
android:textSize="20sp"
android:textStyle="bold" />
<TextView
android:id="@+id/description_text"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:maxLines="10"
android:ellipsize="end" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:gravity="end"
tools:ignore="UseCompoundDrawables">
<ImageView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_vertical"
android:layout_marginEnd="5dp"
android:src="@drawable/ic_star"
tools:ignore="ContentDescription" />
<TextView
android:id="@+id/star_count_text"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_vertical" />
</LinearLayout>
</LinearLayout>
这个布局中使用到了一个图片资源,可以到本项目的源码中去获取,源码地址见文章最底部。
接下来定义 RecyclerView 的适配器,但是注意,这个适配器也比较特殊,必须继承自 PagingDataAdapter,代码如下所示:
class RepoAdapter : PagingDataAdapter<Repo, RepoAdapter.ViewHolder>(COMPARATOR) {
companion object {
private val COMPARATOR = object : DiffUtil.ItemCallback<Repo>() {
override fun areItemsTheSame(oldItem: Repo, newItem: Repo): Boolean {
return oldItem.id == newItem.id
}
override fun areContentsTheSame(oldItem: Repo, newItem: Repo): Boolean {
return oldItem == newItem
}
}
}
class ViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
val name: TextView = itemView.findViewById(R.id.name_text)
val description: TextView = itemView.findViewById(R.id.description_text)
val starCount: TextView = itemView.findViewById(R.id.star_count_text)
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
val view = LayoutInflater.from(parent.context).inflate(R.layout.repo_item, parent, false)
return ViewHolder(view)
}
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
val repo = getItem(position)
if (repo != null) {
holder.name.text = repo.name
holder.description.text = repo.description
holder.starCount.text = repo.starCount.toString()
}
}
}
相比于一个传统的 RecyclerView Adapter,这里最特殊的地方就是要提供一个 COMPARATOR。因为 Paging 3 在内部会使用 DiffUtil 来管理数据变化,所以这个 COMPARATOR 是必须的。如果你以前用过 DiffUtil 的话,对此应该不会陌生。
除此之外,我们并不需要传递数据源给到父类,因为数据源是由 Paging 3 在内部自己管理的。同时也不需要重写 getItemCount() 函数了,原因也是相同的,有多少条数据 Paging 3 自己就能够知道。
其他部分就和普通的 RecyclerView Adapter 没什么两样了,相信大家都能够看得明白。
接下来就差最后一步了,让我们把所有的一切都集成到 Activity 当中。
修改 activity_main.xml 布局,在里面定义一个 RecyclerView 和一个 ProgressBar:
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recycler_view"
android:layout_width="match_parent"
android:layout_height="match_parent" />
<ProgressBar
android:id="@+id/progress_bar"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center" />
</FrameLayout>
然后修改 MainActivity 中的代码,如下所示:
class MainActivity : AppCompatActivity() {
private val viewModel by lazy { ViewModelProvider(this).get(MainViewModel::class.java) }
private val repoAdapter = RepoAdapter()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
val recyclerView = findViewById<RecyclerView>(R.id.recycler_view)
val progressBar = findViewById<ProgressBar>(R.id.progress_bar)
recyclerView.layoutManager = LinearLayoutManager(this)
recyclerView.adapter = repoAdapter
lifecycleScope.launch {
viewModel.getPagingData().collect { pagingData ->
repoAdapter.submitData(pagingData)
}
}
repoAdapter.addLoadStateListener {
when (it.refresh) {
is LoadState.NotLoading -> {
progressBar.visibility = View.INVISIBLE
recyclerView.visibility = View.VISIBLE
}
is LoadState.Loading -> {
progressBar.visibility = View.VISIBLE
recyclerView.visibility = View.INVISIBLE
}
is LoadState.Error -> {
val state = it.refresh as LoadState.Error
progressBar.visibility = View.INVISIBLE
Toast.makeText(this, "Load Error: ${state.error.message}", Toast.LENGTH_SHORT).show()
}
}
}
}
}
这里最重要的一段代码就是调用了 RepoAdapter 的 submitData() 函数。这个函数是触发 Paging 3 分页功能的核心,调用这个函数之后,Paging 3 就开始工作了。
submitData() 接收一个 PagingData 参数,这个参数我们需要调用 ViewModel 中返回的 Flow 对象的 collect() 函数才能获取到,collect() 函数有点类似于 Rxjava 中的 subscribe() 函数,总之就是订阅了之后,消息就会源源不断往这里传。
不过由于 collect() 函数是一个挂起函数,只有在协程作用域中才能调用它,因此这里又调用了 lifecycleScope.launch() 函数来启动一个协程。
其他地方应该就没什么需要解释的了,都是一些传统 RecyclerView 的用法,相信大家都能看得懂。
好了,这样我们就把整个项目完成了,在正式运行项目之前,别忘了在你的 AndroidManifest.xml 文件中添加网络权限:
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.example.paging3sample">
<uses-permission android: />
...
</manifest>
现在运行一下程序,效果如下图所示:
可以看到,GitHub 上 Android 相关的开源库已经成功显示出来了。并且你可以不断往下滑,Paging 3 会自动加载更多的数据,仿佛让你永远也滑不到头一样。
如次一来,使用 Paging 3 来进行分页加载的效果也就成功完成了。
总结一下,相比于传统的分页实现方案,Paging 3 将一些琐碎的细节进行了隐藏,比如你不需要监听列表的滑动事件,也不需要知道知道何时应该加载下一页的数据,这些都被 Paging 3 封装掉了。我们只需要按照 Paging 3 搭建好的框架去编写逻辑实现,告诉 Paging 3 如何去加载数据,其他的事情 Paging 3 都会帮我们自动完成。
在底部显示加载状态
根据 Paging 3 的设计,其实我们理论上是不应该在底部看到加载状态的。因为 Paging 3 会在列表还远没有滑动到底部的时候就提前加载更多的数据(这是默认属性,可配置),从而产生一种好像永远滑不到头的感觉。
然而凡事总有意外,比如说当前的网速不太好,虽然 Paging 3 会提前加载下一页的数据,但是当滑动到列表底部的时候,服务器响应的数据可能还没有返回,这个时候就应该在底部显示一个正在加载的状态。
另外,如果网络条件非常糟糕,还可能会出现加载失败的情况,此时应该在列表底部显示一个重试按钮。
那么接下来我们就来实现这个功能,从而让项目变得更加完善。
创建一个 footer_item.xml 布局,用于显示加载进度条和重试按钮:
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="10dp">
<ProgressBar
android:id="@+id/progress_bar"
android:layout_width="wrap_content"
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="Retry" />
</FrameLayout>
然后创建一个 FooterAdapter 来作为 RecyclerView 的底部适配器,注意它必须继承自 LoadStateAdapter,如下所示:
class FooterAdapter(val retry: () -> Unit) : LoadStateAdapter<FooterAdapter.ViewHolder>() {
class ViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
val progressBar: ProgressBar = itemView.findViewById(R.id.progress_bar)
val retryButton: Button = itemView.findViewById(R.id.retry_button)
}
override fun onCreateViewHolder(parent: ViewGroup, loadState: LoadState): ViewHolder {
val view = LayoutInflater.from(parent.context).inflate(R.layout.footer_item, parent, false)
val holder = ViewHolder(view)
holder.retryButton.setOnClickListener {
retry()
}
return holder
}
override fun onBindViewHolder(holder: ViewHolder, loadState: LoadState) {
holder.progressBar.isVisible = loadState is LoadState.Loading
holder.retryButton.isVisible = loadState is LoadState.Error
}
}
这仍然是一个非常简单的 Adapter,需要注意的地方大概只有两点。
第一点,我们使用 Kotlin 的高阶函数来给重试按钮注册点击事件,这样当点击重试按钮时,构造函数中传入的函数类型参数就会被回调,我们待会将在那里加入重试逻辑。
第二点,在 onBindViewHolder() 中会根据 LoadState 的状态来决定如何显示底部界面,如果是正在加载中那么就显示加载进度条,如果是加载失败那么就显示重试按钮。
最后,修改 MainActivity 中的代码,将 FooterAdapter 集成到 RepoAdapter 当中:
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
...
recyclerView.adapter = repoAdapter.withLoadStateFooter(FooterAdapter { repoAdapter.retry() })
...
}
}
代码非常简单,只需要改动一行,调用 RepoAdapter 的 withLoadStateFooter() 函数即可将 FooterAdapter 集成到 RepoAdapter 当中。
另外注意这里使用 Lambda 表达式来作为传递给 FooterAdapter 的函数类型参数,在 Lambda 表示式中,调用 RepoAdapter 的 retry() 函数即可重新加载。
这样我们就把底部显示加载状态的功能完成了,现在来测试一下吧,效果如下图所示。
可以看到,首先我在设备上开启了飞行模式,这样当滑动到列表底部时就会显示重试按钮。
然后把飞行模式关闭,并点击重试按钮,这样加载进度条就会显示出来,并且成功加载出新的数据了。
最后
本文到这里就结束了。
不得不说,我在文章中讲解的这些知识点仍然只是 Paging 3 的基本用法,还有许多高级用法文中并没有涵盖。当然,这些基本用法也是最最常用的用法,所以如果你并不打算成为 Paging 3 大师,掌握文中的这些知识点就已经足够应对日常的开发工作了。
如果你还想要进一步进阶学习 Paging 3,可以参考 Google 官方的 Codelab 项目,地址是:
developer.android.com/codelabs/an…
我们刚才一起编写的 Paging3Sample 项目其实就是从 Google 官方的 Codelab 项目演化而来的,我根据自己的理解重写了这个项目并进行了一定的简化。直接学习原版项目,你将能学到更多的知识。
最后,如果你需要获取 Paging3Sample 项目的源码,请访问以下地址:
另外,如果想要学习 Kotlin 和最新的 Android 知识,可以参考我的新书 《第一行代码 第 3 版》,[点击此处查看详情]
关注我的技术公众号“郭霖”,每个工作日都有优质技术文章推送。