Jetpack Compose 入门系列(十):Paging 3 分页加载
一般真实 App 里的列表往往有成百上千条数据,不能一次性全部加载,本篇解决一个问题:如何在 Compose 中使用 Paging 3 实现分页加载、刷新与重试、底部加载状态 。(真正的下拉手势可以在此基础上用 Material3 的
PullToRefreshBox包裹LazyColumn,本文先聚焦分页数据本身。)
一、为什么需要 Paging 3
前面我们写过课程列表,大多是这样:
kotlin
val courses = listOf(
Course(id = 1, title = "Compose 基础控件"),
Course(id = 2, title = "Compose 布局与 State"),
Course(id = 3, title = "Navigation 3 页面导航")
)
这种固定列表适合 Demo,但真实业务里列表通常是从接口分页加载的,比如:
- 商品列表
- 课程列表
- 评论列表
- 消息列表
- 搜索结果
如果手动做分页,你需要自己处理:
- 当前第几页
- 是否正在加载
- 是否还有下一页
- 首屏加载错误
- 底部加载错误
- 刷新与重试
- 防止重复请求
- 滑动到底部自动加载下一页
这些逻辑不是不能写,但每个列表都手写一遍,很容易变成"分页祖传代码"。
Paging 3 就是 Android 官方提供的分页库,专门处理这些问题。
来做个对比:
| 手动分页 | Paging 3 |
|---|---|
| 自己维护 page | PagingSource 负责 page key |
| 自己判断加载更多 | LazyPagingItems 自动触发 |
| 自己管理首屏 / 底部状态 | LoadState 统一表示 |
| 自己写重试逻辑 | retry() |
| 自己写刷新逻辑 | refresh() |
| 自己防重复请求 | Paging 内部处理大部分场景 |
Compose 中使用 Paging 3 的数据流大概是这样:
用一句话概括:PagingSource 负责怎么加载一页数据,Pager 负责把分页数据变成 Flow,Compose 负责把 Flow 显示成列表。
关键点:Paging 3 不是一个 UI 组件,而是一套分页数据管线。Compose 只是通过
collectAsLazyPagingItems()把它接到LazyColumn上。
二、添加 Paging 3 依赖
这篇会用到 Paging Runtime 和 Paging Compose。
2.1 libs.versions.toml
下面版本号仅为撰写时的一个可用组合,实际请以 Compose BOM 发布页、Paging 3 Release Notes 和 AGP / Kotlin 官方 release notes 为准。
toml
[versions]
agp = "9.2.1"
kotlin = "2.2.10"
composeBom = "2026.02.01"
activity = "1.8.0"
lifecycle = "2.11.0"
paging = "3.5.0"
coroutines = "1.11.0"
[libraries]
androidx-compose-bom = { module = "androidx.compose:compose-bom", version.ref = "composeBom" }
androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "activity" }
androidx-compose-material3 = { module = "androidx.compose.material3:material3" }
androidx-compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview" }
androidx-lifecycle-viewmodel-compose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "lifecycle" }
androidx-paging-runtime = { module = "androidx.paging:paging-runtime", version.ref = "paging" }
androidx-paging-compose = { module = "androidx.paging:paging-compose", version.ref = "paging" }
kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "coroutines" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
2.2 app/build.gradle.kts
kotlin
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.compose.compiler)
}
android {
namespace = "com.example.pagingdemo"
compileSdk = 36
defaultConfig {
applicationId = "com.example.pagingdemo"
minSdk = 24
targetSdk = 36
versionCode = 1
versionName = "1.0"
}
buildFeatures {
compose = true
}
}
dependencies {
implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.activity.compose)
implementation(libs.androidx.compose.material3)
implementation(libs.androidx.compose.ui.tooling.preview)
implementation(libs.androidx.lifecycle.viewmodel.compose)
implementation(libs.androidx.paging.runtime)
implementation(libs.androidx.paging.compose)
implementation(libs.kotlinx.coroutines.android)
}
这段配置里发生了什么:
paging-runtime提供PagingSource、Pager、PagingConfig等核心能力。paging-compose提供collectAsLazyPagingItems()和LazyPagingItems。lifecycle-viewmodel-compose用来在 Composable 中获取 ViewModel。coroutines用来模拟网络延迟。
Paging 3 的核心在
androidx.paging包里,Compose 只是负责把分页数据接到 UI 上。
三、PagingSource:告诉 Paging 怎么加载一页数据
Paging 3 的第一步,是写一个 PagingSource。
你可以把它理解成:给我一个页码,我返回这一页的数据,以及上一页 / 下一页的页码。
先定义数据模型:
kotlin
data class Course(
val id: Int,
val title: String,
val description: String
)
然后写 PagingSource:
kotlin
import androidx.paging.PagingSource
import androidx.paging.PagingState
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.delay
class CoursePagingSource : PagingSource<Int, Course>() {
override suspend fun load(
params: LoadParams<Int>
): LoadResult<Int, Course> {
return try {
val page = params.key ?: 1
// 本次要加载多少条:首次由 initialLoadSize 决定,后续由 pageSize 决定
val loadSize = params.loadSize
delay(800)
val courses = createCourses(
page = page,
pageSize = loadSize
)
LoadResult.Page(
data = courses,
prevKey = if (page == 1) null else page - 1,
nextKey = if (page >= 5) null else page + 1
)
} catch (cancel: CancellationException) {
// 协程取消不是加载失败,必须原样抛出,否则会被上层当成 append 错误闪一下
throw cancel
} catch (throwable: Throwable) {
LoadResult.Error(throwable)
}
}
override fun getRefreshKey(
state: PagingState<Int, Course>
): Int? {
val anchorPosition = state.anchorPosition ?: return null
val anchorPage = state.closestPageToPosition(anchorPosition) ?: return null
return anchorPage.prevKey?.plus(1) ?: anchorPage.nextKey?.minus(1)
}
}
private fun createCourses(
page: Int,
pageSize: Int
): List<Course> {
val startId = (page - 1) * pageSize + 1
return List(pageSize) { index ->
val id = startId + index
Course(
id = id,
title = "Compose 课程 $id",
description = "这是第 $page 页的第 ${index + 1} 条课程内容。"
)
}
}
这段代码里发生了什么:
PagingSource<Int, Course>表示分页 key 是Int,列表数据是Course。params.key ?: 1表示没有 key 时加载第一页。params.loadSize是本次要加载的数据数量。注意它并不等于PagingConfig.pageSize:首次加载时它等于initialLoadSize(可能被配得比pageSize大),后续 append 时才等于pageSize。LoadResult.Page表示加载成功。prevKey表示上一页 key,第一页没有上一页,所以是null。nextKey表示下一页 key,第 5 页后没有更多,所以是null。LoadResult.Error表示加载失败。CancellationException是协程被取消时抛出的,不属于加载失败 ,一定要单独throw出去;否则用户翻页、退出页面时协程被取消,UI 会误报一次 append 错误。getRefreshKey()用来决定刷新时从哪一页附近重新加载:state.anchorPosition是列表当前"看到"的位置(比如滑到第 47 条)。state.closestPageToPosition(...)找出这条数据所在的那一页。prevKey + 1等于当前锚点所在页的页码;如果锚点就在第一页、prevKey是null,就退化用nextKey - 1。- 有了这个 key,
refresh()之后 Paging 会从"用户当时看到的位置"重新加载,而不是每次都跳回第一页。
LoadResult.Page 的三个关键参数:
| 参数 | 含义 |
|---|---|
data |
当前页数据 |
prevKey |
上一页 key,没有就传 null |
nextKey |
下一页 key,没有就传 null |
PagingSource只关心一件事:给定一个 key,加载一页数据。不要把 UI 状态、按钮状态、Snackbar 这些东西塞进来。
四、Pager:把 PagingSource 包装成 Flow
有了 PagingSource,下一步用 Pager 创建分页数据流。
通常我们会在 ViewModel 里写:
kotlin
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.paging.Pager
import androidx.paging.PagingConfig
import androidx.paging.PagingData
import androidx.paging.cachedIn
import kotlinx.coroutines.flow.Flow
class CourseListViewModel : ViewModel() {
val courses: Flow<PagingData<Course>> = Pager(
config = PagingConfig(
pageSize = 10,
initialLoadSize = 10,
prefetchDistance = 3,
enablePlaceholders = false
),
pagingSourceFactory = {
CoursePagingSource()
}
).flow.cachedIn(viewModelScope)
}
这段代码里发生了什么:
Pager是 Paging 3 的数据入口。PagingConfig配置分页行为。pageSize = 10表示每页加载 10 条。initialLoadSize = 10表示首次加载 10 条。入门 Demo 里把它和pageSize保持一致最省心;真实项目常见做法是首屏多加载一点(例如pageSize * 2),但那样PagingSource里就不能再直接拿params.loadSize当每页大小用(否则页码和数据 ID 会算错)。如果你要试首屏多加载一点,createCourses里的startId就要改成按累积偏移算(首页占initialLoadSize条,之后每页pageSize条),或者干脆让PagingSource里的每页大小从PagingConfig.pageSize拿,而不是params.loadSize。prefetchDistance = 3表示距离底部还有 3 条时提前加载下一页。enablePlaceholders = false表示不显示占位 item。占位 item 需要LoadResult.Page能返回itemsBefore/itemsAfter(也就是数据源知道整份数据到底有多少条),后端接口一般不给这两个字段,入门阶段关掉最省事。pagingSourceFactory每次需要新数据源时创建新的CoursePagingSource。.flow得到Flow<PagingData<Course>>。cachedIn(viewModelScope)把分页结果缓存在 ViewModel 生命周期内。
PagingConfig 常用参数:
| 参数 | 作用 | 示例 |
|---|---|---|
pageSize |
每页大小 | 10、20 |
initialLoadSize |
首次加载数量 | 默认 pageSize * 3,入门 Demo 建议先和 pageSize 保持一致 |
prefetchDistance |
距离底部多少条时预加载 | 2、3、5 |
enablePlaceholders |
是否启用占位符 | 入门一般 false(需要 LoadResult.Page 提供 itemsBefore / itemsAfter) |
为什么要用 cachedIn(viewModelScope)?
Pager(...).flow 本身是 cold flow:每一个 collector 都会启动一份独立的分页流水线 。如果 UI 直接收集这个 cold flow,屏幕旋转、跳到详情页再返回时,collectAsLazyPagingItems 都会重新开始一轮 collect,先前已经加载好的页会全部丢失,滚动位置也回到 0。
cachedIn(viewModelScope) 做两件事:
- 把 cold flow 转成能被多次订阅的热流;
- 在
viewModelScope里缓存分页数据,多个下游 collector 共享同一份。
结果就是:只要 ViewModel 还活着,无论 UI 重组多少次、Composable 走了几遍,读到的都是同一份已经加载好的分页数据。
ViewModel 中暴露的是
Flow<PagingData<T>>,不是List<T>。Paging 会按需把数据一页页交给 UI。
五、Compose 中显示 PagingData
在 Compose 里,Paging 3 不是用普通 items(list) 显示,而是先收集成 LazyPagingItems。
kotlin
import androidx.compose.runtime.Composable
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.paging.compose.collectAsLazyPagingItems
@Composable
private fun CourseListRoute(
viewModel: CourseListViewModel = viewModel()
) {
val courses = viewModel.courses.collectAsLazyPagingItems()
CourseListScreen(courses = courses)
}
然后在 LazyColumn 中显示(这段列表逻辑后面会被复用,先把它拆成一个独立的 CoursePagingList):
kotlin
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.paging.compose.LazyPagingItems
import androidx.paging.compose.itemKey
@Composable
private fun CoursePagingList(
courses: LazyPagingItems<Course>
) {
LazyColumn {
items(
count = courses.itemCount,
key = courses.itemKey { it.id }
) { index ->
val course = courses[index]
if (course != null) {
CourseItem(course = course)
}
}
}
}
注意
items(count = ..., key = ...)这个重载来自androidx.compose.foundation.lazy.items,别忘了 import;LazyPagingItems本身没有同名的成员方法。这里用到的CourseItem是一张展示单条课程的卡片组件,完整实现放在第八章综合实战里。
这段代码里发生了什么:
collectAsLazyPagingItems()把Flow<PagingData<Course>>转成 Compose 能使用的LazyPagingItems<Course>。courses.itemCount是当前可展示的 item 数量。courses[index]取出指定位置的数据。- 取数据时,Paging 会根据滚动位置自动判断是否需要加载更多。
itemKey { it.id }给列表项稳定 key。
和普通列表对比一下:
| 普通列表 | Paging 列表 |
|---|---|
List<Course> |
LazyPagingItems<Course> |
items(courses) |
items(count = courses.itemCount) |
course.id |
courses.itemKey { it.id } |
| 手动触底加载 | Paging 自动按需加载 |
使用 Paging Compose 时,不需要自己监听 LazyColumn 是否滑到底。读取
courses[index]时,Paging 会根据prefetchDistance自动加载下一页。
Paging Compose 3.3+ 还提供了更简洁的重载:items(items = courses, key = { it.id }) { course -> course?.let { CourseItem(it) } },参数名从count / itemKey换成items / key,本文为了让每一步都直白,先用count + itemKey的写法。
六、LoadState:处理加载中、错误、空状态
Paging 3 把加载状态统一放在 loadState 里。
常用的有:
| 状态 | 含义 |
|---|---|
loadState.refresh |
首次加载或刷新状态 |
loadState.append |
加载下一页状态 |
loadState.prepend |
加载上一页状态。只有需要从当前位置往上加载更早数据的场景才用得到(比如聊天历史向上翻、日历向前翻页),常见的向下无限滚动列表可以忽略 |
每个状态可能是:
| 类型 | 含义 |
|---|---|
LoadState.Loading |
正在加载 |
LoadState.NotLoading |
当前没有加载 |
LoadState.Error |
加载失败 |
补充:这里的
loadState.refresh/append/prepend其实是loadState.source.refresh等的简写,对应PagingSource的状态。如果以后接入 Room + Retrofit 的RemoteMediator(本地缓存 + 远程刷新),还有一组loadState.mediator.refresh/append/prepend表示远端拉取的状态,本文暂不涉及。
先处理首屏状态。下面这个 CourseListScreen 只演示 loadState.refresh 的各种分支怎么切换;第八章综合实战会把它套上 Scaffold 并复用同一个签名的实现,这里你可以先当成一个"最小版本"来看:
kotlin
@Composable
private fun CourseListScreen(
courses: LazyPagingItems<Course>
) {
when (val refreshState = courses.loadState.refresh) {
is LoadState.Loading -> {
LoadingContent(text = "正在加载课程......")
}
is LoadState.Error -> {
ErrorContent(
message = refreshState.error.message ?: "课程加载失败",
onRetryClick = {
courses.retry()
}
)
}
is LoadState.NotLoading -> {
if (courses.itemCount == 0) {
EmptyContent(
onRefreshClick = {
courses.refresh()
}
)
} else {
CoursePagingList(courses = courses)
}
}
}
}
这里的
LoadingContent/ErrorContent/EmptyContent是三个简单的占位组件,完整实现放在第八章综合实战里,本章先聚焦分支切换的逻辑。
这段代码里发生了什么:
loadState.refresh代表首屏加载或刷新状态。- 首屏加载中显示 Loading。
- 首屏失败显示整页错误。
- 首屏成功但没有数据,显示空状态。
- 有数据时显示列表。
courses.retry()用来重试失败请求。courses.refresh()用来刷新整个分页数据。
retry() 和 refresh() 别搞混:
| API | 行为 | 什么时候用 |
|---|---|---|
retry() |
只重试上次失败的那次加载,已经成功的部分不会重新请求 | 首屏加载失败或 append 加载失败后点"重试" |
refresh() |
丢掉当前所有分页数据,从头重新拉一次,滚动位置和状态都会重置 | 用户下拉刷新、切换筛选条件、外部数据发生变化 |
一句话记:失败了想再试一次用 retry();想让整个列表回到"刚打开"的状态用 refresh()。
底部状态放在列表 Footer 里,通常把它作为 LazyColumn 的最后一个 item:
kotlin
LazyColumn {
items(count = courses.itemCount, key = courses.itemKey { it.id }) { index ->
courses[index]?.let { CourseItem(it) }
}
// 关键:把 Footer 放到列表最后
item {
AppendLoadStateFooter(
appendState = courses.loadState.append,
onRetryClick = { courses.retry() }
)
}
}
AppendLoadStateFooter 的实现如下:
kotlin
@Composable
private fun AppendLoadStateFooter(
appendState: LoadState,
onRetryClick: () -> Unit
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
when (appendState) {
is LoadState.Loading -> {
Text(text = "正在加载更多......")
}
is LoadState.Error -> {
Text(text = appendState.error.message ?: "加载更多失败")
OutlinedButton(onClick = onRetryClick) {
Text(text = "重试")
}
}
is LoadState.NotLoading -> {
if (appendState.endOfPaginationReached) {
Text(
text = "--- 已经到底了 ---",
style = MaterialTheme.typography.bodySmall
)
} else {
Text(text = "继续向下滑动加载更多")
}
}
}
}
}
refresh管首屏和刷新,append管加载更多。不要把这两个状态混在一起,否则首屏错误和底部错误会互相干扰。另外记得判断appendState.endOfPaginationReached------当PagingSource返回nextKey = null后,append会一直停在NotLoading,如果不区分就会给用户显示"继续向下滑动",但其实已经到底了。
上面的示例直接把refreshState.error.message拿给用户看,只是为了让 Demo 能显示出后端返回的错误。真实项目里error.message常是SocketTimeoutException: failed to connect to xxx (port 443)这种技术信息,务必映射成用户能看懂的文案,例如根据异常类型分别显示"网络不太顺畅"或"服务器开小差了"。
七、常见错误:Paging 3 最容易写错的地方
7.1 不要每次重组都创建 Pager
❌ 错误:
kotlin
@Composable
fun CourseListScreen() {
val courses = Pager(
config = PagingConfig(pageSize = 10),
pagingSourceFactory = { CoursePagingSource() }
).flow.collectAsLazyPagingItems()
LazyColumn {
items(count = courses.itemCount) { index ->
courses[index]?.let { CourseItem(it) }
}
}
}
这段代码的问题是:Pager(...) 直接写在 @Composable 函数体里,CourseListScreen 每一次重组都会新建一个 Pager 和一个新的 Flow 。collectAsLazyPagingItems 收集到的其实是一条条不同的流,页码永远从 1 开始,滚动位置也会被重置。
顺带一提,上面反例里的
items(count = ...)也没传key,这个坑我们在 7.3 会专门讲,别顺手把这里的写法拷进项目。
✅ 正确:
kotlin
class CourseListViewModel : ViewModel() {
val courses = Pager(
config = PagingConfig(pageSize = 10),
pagingSourceFactory = { CoursePagingSource() }
).flow.cachedIn(viewModelScope)
}
Pager 放 ViewModel,UI 只负责收集。
7.2 不要手动维护 page 又用 Paging
❌ 错误:
kotlin
var page by rememberSaveable { mutableIntStateOf(1) }
val listState = rememberLazyListState()
// 手动监听滚动到底,然后手动加 page
LaunchedEffect(listState) {
snapshotFlow { listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index }
.collect { lastVisible ->
if (lastVisible != null && lastVisible >= courses.size - 1) {
page++
viewModel.loadPage(page)
}
}
}
既然已经用了 Paging,页码就应该交给 PagingSource 和 LoadResult.Page 管,滑到底加载也是 LazyPagingItems 内部根据 prefetchDistance 自动做的事。
✅ 正确:
kotlin
LoadResult.Page(
data = courses,
prevKey = if (page == 1) null else page - 1,
nextKey = if (page >= 5) null else page + 1
)
7.3 不要忽略 LoadState
❌ 错误:
kotlin
LazyColumn {
items(count = courses.itemCount) { index ->
courses[index]?.let { CourseItem(it) }
}
}
这只能显示数据,用户不知道首屏是否加载中,也不知道失败后怎么重试。而且 items(count = ...) 没传 key,刷新时 Compose 只能按下标复用 item,动画会错位、rememberSaveable 状态会乱串。
✅ 正确:
kotlin
when (courses.loadState.refresh) {
is LoadState.Loading -> LoadingContent("正在加载课程......")
is LoadState.Error -> ErrorContent(...)
is LoadState.NotLoading -> CoursePagingList(courses)
}
Paging 3 的难点不在显示列表,而在正确处理
LoadState。首屏、底部、空状态都要分清楚。
八、综合实战:课程列表 Paging 3 Demo
下面把前面的知识串起来,写一个完整可运行的课程分页列表:
- 首屏加载课程
- 自动加载下一页
- 第 3 页模拟一次加载失败
- 底部显示加载更多状态
- 底部错误支持重试
- 首屏支持错误、空状态、刷新
为了让 Demo 聚焦在 Paging 3 本身,这里给
Scaffold没有配TopAppBar,标题"课程列表"直接作为LazyColumn的第一个 item,会随着列表一起滚动。真实项目中,标题几乎都会放到Scaffold(topBar = { TopAppBar(...) })里固定在顶部。
完整代码如下:
kotlin
package com.example.pagingdemo
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.paging.LoadState
import androidx.paging.Pager
import androidx.paging.PagingConfig
import androidx.paging.PagingData
import androidx.paging.PagingSource
import androidx.paging.PagingState
import androidx.paging.cachedIn
import androidx.paging.compose.LazyPagingItems
import androidx.paging.compose.collectAsLazyPagingItems
import androidx.paging.compose.itemKey
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
MaterialTheme {
CourseListRoute()
}
}
}
}
data class Course(
val id: Int,
val title: String,
val description: String
)
class CoursePagingSource : PagingSource<Int, Course>() {
override suspend fun load(
params: LoadParams<Int>
): LoadResult<Int, Course> {
return try {
val page = params.key ?: 1
val loadSize = params.loadSize
delay(800)
if (page == 3) {
throw IllegalStateException("第 3 页加载失败,请点击重试")
}
val courses = createCourses(
page = page,
pageSize = loadSize
)
LoadResult.Page(
data = courses,
prevKey = if (page == 1) null else page - 1,
nextKey = if (page >= 5) null else page + 1
)
} catch (cancel: CancellationException) {
throw cancel
} catch (throwable: Throwable) {
LoadResult.Error(throwable)
}
}
override fun getRefreshKey(
state: PagingState<Int, Course>
): Int? {
val anchorPosition = state.anchorPosition ?: return null
val anchorPage = state.closestPageToPosition(anchorPosition) ?: return null
return anchorPage.prevKey?.plus(1) ?: anchorPage.nextKey?.minus(1)
}
}
private fun createCourses(
page: Int,
pageSize: Int
): List<Course> {
val startId = (page - 1) * pageSize + 1
return List(pageSize) { index ->
val id = startId + index
Course(
id = id,
title = "Compose 课程 $id",
description = "这是第 $page 页的第 ${index + 1} 条课程内容。"
)
}
}
class CourseListViewModel : ViewModel() {
val courses: Flow<PagingData<Course>> = Pager(
config = PagingConfig(
pageSize = 10,
initialLoadSize = 10,
prefetchDistance = 3,
enablePlaceholders = false
),
pagingSourceFactory = {
CoursePagingSource()
}
).flow.cachedIn(viewModelScope)
}
@Composable
private fun CourseListRoute(
viewModel: CourseListViewModel = viewModel()
) {
val courses = viewModel.courses.collectAsLazyPagingItems()
CourseListScreen(courses = courses)
}
@Composable
private fun CourseListScreen(
courses: LazyPagingItems<Course>
) {
Scaffold { innerPadding ->
val contentModifier = Modifier
.padding(innerPadding)
.fillMaxSize()
when (val refreshState = courses.loadState.refresh) {
is LoadState.Loading -> {
LoadingContent(
text = "正在加载课程......",
modifier = contentModifier
)
}
is LoadState.Error -> {
ErrorContent(
message = refreshState.error.message ?: "课程加载失败",
onRetryClick = { courses.retry() },
modifier = contentModifier
)
}
is LoadState.NotLoading -> {
if (courses.itemCount == 0) {
EmptyContent(
onRefreshClick = { courses.refresh() },
modifier = contentModifier
)
} else {
CoursePagingList(
courses = courses,
modifier = contentModifier
)
}
}
}
}
}
@Composable
private fun CoursePagingList(
courses: LazyPagingItems<Course>,
modifier: Modifier = Modifier
) {
LazyColumn(
modifier = modifier,
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
item {
Text(
text = "课程列表",
style = MaterialTheme.typography.headlineMedium
)
}
items(
count = courses.itemCount,
key = courses.itemKey { it.id }
) { index ->
val course = courses[index]
if (course != null) {
CourseItem(course = course)
}
}
item {
AppendLoadStateFooter(
appendState = courses.loadState.append,
onRetryClick = {
courses.retry()
}
)
}
}
}
@Composable
private fun CourseItem(
course: Course
) {
Card(
modifier = Modifier.fillMaxWidth(),
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(
text = course.title,
style = MaterialTheme.typography.titleMedium
)
Text(
text = course.description,
style = MaterialTheme.typography.bodyMedium
)
}
}
}
@Composable
private fun AppendLoadStateFooter(
appendState: LoadState,
onRetryClick: () -> Unit
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
when (appendState) {
is LoadState.Loading -> {
Text(text = "正在加载更多......")
}
is LoadState.Error -> {
Text(text = appendState.error.message ?: "加载更多失败")
OutlinedButton(onClick = onRetryClick) {
Text(text = "重试")
}
}
is LoadState.NotLoading -> {
if (appendState.endOfPaginationReached) {
Text(
text = "--- 已经到底了 ---",
style = MaterialTheme.typography.bodySmall
)
} else {
Text(text = "继续向下滑动加载更多")
}
}
}
}
}
@Composable
private fun LoadingContent(
text: String,
modifier: Modifier = Modifier
) {
Column(
modifier = modifier.padding(24.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
CircularProgressIndicator()
Text(
text = text,
modifier = Modifier.padding(top = 12.dp)
)
}
}
@Composable
private fun ErrorContent(
message: String,
onRetryClick: () -> Unit,
modifier: Modifier = Modifier
) {
Column(
modifier = modifier.padding(24.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(text = message)
Button(onClick = onRetryClick) {
Text(text = "重试")
}
}
}
@Composable
private fun EmptyContent(
onRefreshClick: () -> Unit,
modifier: Modifier = Modifier
) {
Column(
modifier = modifier.padding(24.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(text = "暂无课程")
Button(onClick = onRefreshClick) {
Text(text = "刷新")
}
}
}
这份 Demo 的分页流程如下:
实战知识点对应表
| 实战中的效果 | 使用的 API | 对应章节 |
|---|---|---|
| 定义分页数据源 | PagingSource<Int, Course> |
三 |
| 返回分页结果 | LoadResult.Page / LoadResult.Error |
三 |
| 配置分页参数 | PagingConfig |
四 |
| 创建分页流 | Pager(...).flow |
四 |
| ViewModel 缓存分页数据 | cachedIn(viewModelScope) |
四 |
| Compose 收集分页数据 | collectAsLazyPagingItems() |
五 |
| LazyColumn 显示分页数据 | LazyPagingItems + itemKey |
五、八 |
| 首屏加载和错误 | loadState.refresh |
六、八 |
| 底部加载和错误 | loadState.append |
六、八 |
| 重试失败请求 | retry() |
六、八 |
| 刷新列表 | refresh() |
六、八 |
Paging 3 的完整链路是:PagingSource 负责加载一页,Pager 负责组织分页流,ViewModel 负责缓存,Compose 负责展示和处理 LoadState。
九、Paging 3 速查表
| 你想做什么 | 推荐写法 |
|---|---|
| 定义分页数据源 | class XxxPagingSource : PagingSource<Int, Xxx>() |
| 加载一页数据 | override suspend fun load(...) |
| 返回成功结果 | LoadResult.Page(data, prevKey, nextKey) |
| 返回失败结果 | LoadResult.Error(throwable) |
| 配置分页 | PagingConfig(pageSize = 20) |
| 创建分页流 | Pager(config, pagingSourceFactory).flow |
| 缓存分页数据 | .cachedIn(viewModelScope) |
| Compose 收集数据 | collectAsLazyPagingItems() |
| 显示 item 数量 | lazyPagingItems.itemCount |
| 获取 item | lazyPagingItems[index] |
| 处理首屏状态 | loadState.refresh |
| 处理底部状态 | loadState.append |
| 重试 | lazyPagingItems.retry() |
| 刷新 | lazyPagingItems.refresh() |
十、总结
本篇你学到了 Compose 中 Paging 3 分页的核心用法:
- Paging 3 的作用:把分页加载、加载状态、重试、刷新这些通用逻辑交给官方分页库处理
PagingSource:定义如何根据 page key 加载一页数据,并返回上一页 / 下一页 keyLoadResult:用LoadResult.Page表示加载成功,用LoadResult.Error表示加载失败Pager:通过PagingConfig和pagingSourceFactory创建分页数据流cachedIn:把 coldPagingData流转成可共享的热流,在 ViewModel 生命周期内缓存分页数据,屏幕旋转、跳转返回都不会丢collectAsLazyPagingItems:在 Compose 中收集PagingData,变成LazyColumn可用的数据LoadState:区分首屏加载、首屏错误、底部加载、底部错误和空状态retry/refresh:用 Paging 提供的方法处理重试和刷新,不要自己乱改页码
核心原则:用了 Paging 3,就把分页 key、加载更多和重试刷新交给 Paging 管;UI 只负责展示
LazyPagingItems和LoadState。
下一篇我们将进入 综合实战:从 0 到 1 做一个 Compose 小 App------把前面学过的 Navigation 3、ViewModel、UiState、副作用、Material 3 主题和 Paging 3 组织起来,完成一个更接近真实项目的小应用。
如果你在学习过程中有任何疑问,欢迎在评论区留言,我会尽可能回复。
系列文章:
- Jetpack Compose 入门系列(一):从零搭建到基础控件使用
- Jetpack Compose 入门系列(二):布局到 State 的使用
- Jetpack Compose 入门系列(三):MVVM + 协程 实现网络请求列表
- Jetpack Compose 入门系列(四):动画基本使用
- Jetpack Compose 入门系列(五):自定义布局与 ConstraintLayout
- Jetpack Compose 入门系列(六):Navigation 3 页面导航
- Jetpack Compose 入门系列(七):ViewModel 与界面状态管理
- Jetpack Compose 入门系列(八):Compose 中的副作用处理
- Jetpack Compose 入门系列(九):Compose 中的主题与 Material 3
- Jetpack Compose 入门系列(十):Paging 3 分页加载(本文)