Jetpack Compose 入门系列(十):Paging 3 分页加载

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 的数据流大概是这样:

flowchart LR A[PagingSource] --> B[Pager] B --> C[Flow>] C --> D[ViewModel cachedIn] D --> E[collectAsLazyPagingItems] E --> F[LazyColumn] F --> G[LoadState 显示加载/错误/空状态]

用一句话概括: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)
}

这段配置里发生了什么:

  1. paging-runtime 提供 PagingSourcePagerPagingConfig 等核心能力。
  2. paging-compose 提供 collectAsLazyPagingItems()LazyPagingItems
  3. lifecycle-viewmodel-compose 用来在 Composable 中获取 ViewModel。
  4. 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} 条课程内容。"
        )
    }
}

这段代码里发生了什么:

  1. PagingSource<Int, Course> 表示分页 key 是 Int,列表数据是 Course
  2. params.key ?: 1 表示没有 key 时加载第一页。
  3. params.loadSize 是本次要加载的数据数量。注意它并不等于 PagingConfig.pageSize :首次加载时它等于 initialLoadSize(可能被配得比 pageSize 大),后续 append 时才等于 pageSize
  4. LoadResult.Page 表示加载成功。
  5. prevKey 表示上一页 key,第一页没有上一页,所以是 null
  6. nextKey 表示下一页 key,第 5 页后没有更多,所以是 null
  7. LoadResult.Error 表示加载失败。
  8. CancellationException 是协程被取消时抛出的,不属于加载失败 ,一定要单独 throw 出去;否则用户翻页、退出页面时协程被取消,UI 会误报一次 append 错误。
  9. getRefreshKey() 用来决定刷新时从哪一页附近重新加载:
    • state.anchorPosition 是列表当前"看到"的位置(比如滑到第 47 条)。
    • state.closestPageToPosition(...) 找出这条数据所在的那一页。
    • prevKey + 1 等于当前锚点所在页的页码;如果锚点就在第一页、prevKeynull,就退化用 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)
}

这段代码里发生了什么:

  1. Pager 是 Paging 3 的数据入口。
  2. PagingConfig 配置分页行为。
  3. pageSize = 10 表示每页加载 10 条。
  4. initialLoadSize = 10 表示首次加载 10 条。入门 Demo 里把它和 pageSize 保持一致最省心;真实项目常见做法是首屏多加载一点(例如 pageSize * 2),但那样 PagingSource 里就不能再直接拿 params.loadSize 当每页大小用(否则页码和数据 ID 会算错)。如果你要试首屏多加载一点,createCourses 里的 startId 就要改成按累积偏移算(首页占 initialLoadSize 条,之后每页 pageSize 条),或者干脆让 PagingSource 里的每页大小从 PagingConfig.pageSize 拿,而不是 params.loadSize
  5. prefetchDistance = 3 表示距离底部还有 3 条时提前加载下一页。
  6. enablePlaceholders = false 表示不显示占位 item。占位 item 需要 LoadResult.Page 能返回 itemsBefore / itemsAfter(也就是数据源知道整份数据到底有多少条),后端接口一般不给这两个字段,入门阶段关掉最省事。
  7. pagingSourceFactory 每次需要新数据源时创建新的 CoursePagingSource
  8. .flow 得到 Flow<PagingData<Course>>
  9. 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) 做两件事:

  1. 把 cold flow 转成能被多次订阅的热流;
  2. 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 是一张展示单条课程的卡片组件,完整实现放在第八章综合实战里。

这段代码里发生了什么:

  1. collectAsLazyPagingItems()Flow<PagingData<Course>> 转成 Compose 能使用的 LazyPagingItems<Course>
  2. courses.itemCount 是当前可展示的 item 数量。
  3. courses[index] 取出指定位置的数据。
  4. 取数据时,Paging 会根据滚动位置自动判断是否需要加载更多。
  5. 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 是三个简单的占位组件,完整实现放在第八章综合实战里,本章先聚焦分支切换的逻辑。

这段代码里发生了什么:

  1. loadState.refresh 代表首屏加载或刷新状态。
  2. 首屏加载中显示 Loading。
  3. 首屏失败显示整页错误。
  4. 首屏成功但没有数据,显示空状态。
  5. 有数据时显示列表。
  6. courses.retry() 用来重试失败请求。
  7. 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 和一个新的 FlowcollectAsLazyPagingItems 收集到的其实是一条条不同的流,页码永远从 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,页码就应该交给 PagingSourceLoadResult.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 的分页流程如下:

flowchart TD A[进入页面] --> B[collectAsLazyPagingItems] B --> C[触发 refresh 加载] C --> D[CoursePagingSource.load page 1] D --> E[返回 LoadResult.Page] E --> F[LazyColumn 显示课程] F --> G[向下滑动接近底部] G --> H[Paging 自动 append] H --> I[CoursePagingSource.load 下一页] I --> J{是否成功} J -->|成功| K[追加数据] J -->|失败| L[append LoadState.Error] L --> M[点击 retry]

实战知识点对应表

实战中的效果 使用的 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 分页的核心用法:

  1. Paging 3 的作用:把分页加载、加载状态、重试、刷新这些通用逻辑交给官方分页库处理
  2. PagingSource:定义如何根据 page key 加载一页数据,并返回上一页 / 下一页 key
  3. LoadResult :用 LoadResult.Page 表示加载成功,用 LoadResult.Error 表示加载失败
  4. Pager :通过 PagingConfigpagingSourceFactory 创建分页数据流
  5. cachedIn :把 cold PagingData 流转成可共享的热流,在 ViewModel 生命周期内缓存分页数据,屏幕旋转、跳转返回都不会丢
  6. collectAsLazyPagingItems :在 Compose 中收集 PagingData,变成 LazyColumn 可用的数据
  7. LoadState:区分首屏加载、首屏错误、底部加载、底部错误和空状态
  8. retry / refresh:用 Paging 提供的方法处理重试和刷新,不要自己乱改页码

核心原则:用了 Paging 3,就把分页 key、加载更多和重试刷新交给 Paging 管;UI 只负责展示 LazyPagingItemsLoadState

下一篇我们将进入 综合实战:从 0 到 1 做一个 Compose 小 App------把前面学过的 Navigation 3、ViewModel、UiState、副作用、Material 3 主题和 Paging 3 组织起来,完成一个更接近真实项目的小应用。


如果你在学习过程中有任何疑问,欢迎在评论区留言,我会尽可能回复。

系列文章:

相关推荐
hunterandroid1 小时前
DataStore 工程化实践:迁移、并发更新与异常恢复
android·前端
程序员-珍3 小时前
报错下载android sdk失败
android·java
齊家治國平天下4 小时前
AAOS 电源管理深度解析:休眠/唤醒/功耗优化
android·车载系统·aaos·aosp·电源管理·休眠唤醒·carpowermanager
Xzaveir5 小时前
企业号码展示不可观测怎么办:状态机、拨测事件与异常回放
android·人工智能
2501_916007475 小时前
iOS和macOS应用程序性能分析和优化工具使用综合指南
android·macos·ios·小程序·uni-app·iphone·webview
黄林晴6 小时前
Nearby Connections 重大变更:不再自动开启 Wi-Fi / 蓝牙,适配指南
android
恋猫de小郭6 小时前
Flutter 全新真 3D 实现,用 flutter_scene 能开发一个「我的世界」
android·前端·flutter
HLC++19 小时前
Linux的进程间通信
android·linux·服务器
爱笑鱼1 天前
Binder(二):AIDL 生成的 Proxy、Stub 和 Parcel 到底在做什么?
android