Paging 3 概览

本指南介绍了如何使用 Jetpack Compose 实现 Paging 3,涵盖了使用和不使用 Room 数据库的实现。分页是一种用于管理大型数据集的策略,它通过以小而易于管理的数据块(称为“页面”)加载和显示数据,而不是一次性加载所有数据。

任何具有无限滚动 Feed 的应用(例如社交媒体时间轴、大型电子商务产品目录或庞大的电子邮件收件箱)都需要可靠的数据分页功能。由于用户通常只查看列表中的一小部分内容,而移动设备的屏幕尺寸有限,因此加载整个数据集并不高效。这会浪费系统资源,并可能导致卡顿或应用冻结,从而影响用户体验。如需解决此问题,您可以使用延迟加载。虽然 Compose 中的 LazyList 等组件可在界面端处理延迟加载,但从磁盘或网络延迟加载数据可进一步提升性能。

Paging 3 库是处理数据分页的推荐解决方案。 如果您要从 Paging 2 进行迁移,请参阅迁移到 Paging 3 以获取相关指导。

前提条件

在继续操作之前,请先熟悉以下内容:

  • Android 网络(本文档中使用的是 Retrofit,但 Paging 3 可与任何库搭配使用,例如 Ktor)。
  • Compose 界面工具包。

设置依赖项

将以下依赖项添加到应用级 build.gradle.kts 文件中。

dependencies {
  val paging_version = "3.4.0"

  // Paging Compose
  implementation("androidx.paging:paging-compose:$paging_version")

  // Networking dependencies used in this guide
  val retrofit = "3.0.0"
  val kotlinxSerializationJson = "1.9.0"
  val retrofitKotlinxSerializationConverter = "1.0.0"
  val okhttp = "4.12.0"

  implementation("com.squareup.retrofit2:retrofit:$retrofit")
  implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:$kotlinxSerializationJson")
  implementation("com.jakewharton.retrofit:retrofit2-kotlinx-serialization-converter:$retrofitKotlinxSerializationConverter")
  implementation(platform("com.squareup.okhttp3:okhttp-bom:$okhttp"))
  implementation("com.squareup.okhttp3:okhttp")
  implementation("com.squareup.okhttp3:logging-interceptor")
}

定义 Pager

Pager 类是分页的主要入口点。它会构建 PagingData 的响应式流。您应实例化 Pager,并在 ViewModel 中重复使用它。

Pager 需要 PagingConfig 来确定如何提取和呈现数据。

// Configs for pagination
val PAGING_CONFIG = PagingConfig(
    pageSize = 50, // Items requested from data source
    enablePlaceholders = false,
    initialLoadSize = 50,
    prefetchDistance = 10 // Items from the end that trigger the next fetch
)

您可以通过两种方式实现 Pager:不使用数据库(仅限网络)或使用数据库(使用 Room)。

在没有数据库的情况下实现

如果不使用数据库,您需要使用 PagingSource<Key, Value> 来处理按需数据加载。在此示例中,键为 Int,值为 Product

您必须在 PagingSource 中实现两个抽象方法:

  • load:接收 LoadParams 的挂起函数。使用此方法可提取 RefreshAppendPrepend 请求的数据。

  • getRefreshKey:提供用于在分页器失效时重新加载数据的键。此方法根据用户的当前滚动位置 (state.anchorPosition) 计算密钥。

以下代码示例展示了如何实现 ProductPagingSource 类,在使用 Paging 3 且没有本地数据库时,该类对于定义数据提取逻辑是必需的。

class ProductPagingSource : PagingSource<Int, Product>() {
    override fun getRefreshKey(state: PagingState<Int, Product>): Int {

// This is called when the Pager needs to load new data after invalidation
      // (for example, when the user scrolls quickly or the data stream is
      // manually refreshed).

      // It tries to calculate the page key (offset) that is closest to the
      // item the user was last viewing (`state.anchorPosition`).

        return ((state.anchorPosition ?: 0) - state.config.initialLoadSize / 2).coerceAtLeast(0)
    }

    override suspend fun load(params: LoadParams<Int>): LoadResult<Int, Product> {
        return when (params) {
                // Case 1: The very first load or a manual refresh. Start from
                // offset 0.
            is LoadParams.Refresh<Int> -> {
                fetchProducts(0, params.loadSize)
            }
                // Case 2: User scrolled to the end of the list. Load the next
                // 'page' using the stored key.
            is LoadParams.Append<Int> -> {
                fetchProducts(params.key, params.loadSize)
            }
                // Case 3: Loading backward. Not supported in this
                // implementation.
            is LoadParams.Prepend<Int> -> LoadResult.Invalid()
        }
    }
// Helper function to interact with the API service and map the response
//  into a [LoadResult.Page] or [LoadResult.Error].
    private suspend fun fetchProducts(key: Int, limit: Int): LoadResult<Int, Product> {
        return try {
            val response = productService.fetchProducts(limit, key)

            LoadResult.Page(
                data = response.products,
                prevKey = null,
                nextKey = (key + response.products.size).takeIf { nextKey ->
                    nextKey < response.total
                }
            )
        } catch (e: Exception) {
                // Captures network failures or JSON parsing errors to display
                // in the UI.
            LoadResult.Error(e)
        }
    }
}

ViewModel 类中,创建 Pager

val productPager = Pager(
    //  Configuration: Defines page size, prefetch distance, and placeholders.
    config = PAGING_CONFIG,
    //  Initial State: Start loading data from the very first index (offset 0).
    initialKey = 0,
    //  Factory: Creates a new instance of the PagingSource whenever the
    // data is invalidated (for example, calling pagingSource.invalidate()).
    pagingSourceFactory = { ProductPagingSource() }
).flow.cachedIn(viewModelScope)

使用数据库实现

使用 Room 时,数据库会自动生成 PagingSource 类。不过,数据库不知道何时从网络中提取更多数据。为了处理这种情况,请实现 RemoteMediator

RemoteMediator.load() 方法提供 loadTypeAppendPrependRefresh)和状态。它会返回一个 MediatorResult,用于指示成功或失败,以及是否已到达分页结束处。

@OptIn(ExperimentalPagingApi::class)
@OptIn(ExperimentalPagingApi::class)
class ProductRemoteMediator : RemoteMediator<Int, Product>() {
    override suspend fun load(
        loadType: LoadType,
        state: PagingState<Int, Product>
    ): MediatorResult {
        return try {
            // Get the count of loaded items to calculate the skip value
            val skip = when (loadType) {
                LoadType.REFRESH -> 0
                LoadType.PREPEND -> return MediatorResult.Success(endOfPaginationReached = true)
                LoadType.APPEND -> {
                    InMemoryDatabaseProvider.INSTANCE.productDao().getCount()
                }
            }

            val response = productService.fetchProducts(
                state.config.pageSize,
                skip
            )

            InMemoryDatabaseProvider.INSTANCE.productDao().apply {
                insertAll(response.products)
            }

            MediatorResult.Success(
                endOfPaginationReached = response.skip + response.limit >= response.total
            )
        } catch (e: Exception) {
            MediatorResult.Error(e)
        }
    }
}

ViewModel 中,实现会大大简化,因为 Room 会处理 PagingSource 类:

val productPager = ProductRepository().fetchProducts().flow.cachedIn(viewModelScope)

网络设置

前面的示例依赖于网络服务。此部分介绍了用于从 api.example.com/products 端点提取数据的 Retrofit 和序列化设置。

数据类

以下代码示例展示了如何定义两个数据类 ProductResponseProduct,这两个类与 kotlinx.serialization 结合使用,用于解析来自网络服务的分页 JSON 响应。

@Serializable
data class ProductResponse(
    val products: List<Product>,
    val total: Int,
    val skip: Int,
    val limit: Int
)

@Serializable
data class Product(
    val id: Int,
    var title: String = "",
    // ... other fields (description, price, etc.)
    val thumbnail: String = ""
)

改装服务

以下代码示例展示了如何为仅限网络的实现定义 Retrofit 服务接口 (ProductService),并指定 Paging 3 库提取数据页所需的端点 (@GET("/products")) 和必需的分页参数 (limit) 和 (skip)。

interface ProductService {
    @GET("/products")
    suspend fun fetchProducts(
        @Query("limit") limit: Int,
        @Query("skip") skip: Int
    ): ProductResponse
}

// Setup logic (abbreviated)
val jsonConverter = Json { ignoreUnknownKeys = true }
val retrofit = Retrofit.Builder()
    .baseUrl("https://api.example.com")
    .addConverterFactory(jsonConverter.asConverterFactory("application/json".toMediaType()))
    // ... client setup
    .build()

在 Compose 中使用数据

设置 Pager 后,您可以在界面中显示数据。

  1. 收集 flow:使用 collectAsLazyPagingItems() 将 flow 转换为具有状态感知的延迟分页项对象。

    val productPagingData = mainViewModel.productPager.collectAsLazyPagingItems()
    

    生成的 LazyPagingItems 对象提供商品数量和索引访问权限,因此 LazyColumn 方法可以直接使用该对象来呈现列表项。

  2. 绑定到 LazyColumn:将数据传递给 LazyColumn 列表。如果您要从 RecyclerView 列表进行迁移,您可能熟悉使用 withLoadStateHeaderAndFooter 在列表顶部或底部显示加载微调框或错误重试按钮。

    在 Compose 中,您无需为此使用特殊适配器。您可以通过在主 items {} 代码块之前或之后有条件地添加 item {} 代码块,直接对 prepend(标题)和 append(页脚)加载状态做出反应,从而实现完全相同的行为。

    LazyColumn {
        // --- HEADER (Equivalent to loadStateHeader) ---
        // Reacts to 'prepend' states when scrolling towards the top
        if (productPagingData.loadState.prepend is LoadState.Loading) {
            item {
                Box(modifier = Modifier.fillMaxWidth(), contentAlignment =
                Alignment.Center) {
                    CircularProgressIndicator(modifier = Modifier.padding(16.dp))
                }
            }
        }
        if (productPagingData.loadState.prepend is LoadState.Error) {
            item {
                ErrorHeader(onRetry = { productPagingData.retry() })
            }
        }
    
        // --- MAIN LIST ITEMS ---
        items(count = productPagingData.itemCount) { index ->
            val product = productPagingData[index]
            if (product != null) {
                UserPagingListItem(product = product)
            }
        }
    
        // --- FOOTER (Equivalent to loadStateFooter) ---
        // Reacts to 'append' states when scrolling towards the bottom
        if (productPagingData.loadState.append is LoadState.Loading) {
            item {
                Box(modifier = Modifier.fillMaxWidth(), contentAlignment =
                Alignment.Center) {
                    CircularProgressIndicator(modifier = Modifier.padding(16.dp))
                }
            }
        }
        if (productPagingData.loadState.append is LoadState.Error) {
            item {
               ErrorFooter(onRetry = { productPagingData.retry() })
            }
        }
    }
    

如需详细了解如何通过 Compose 的功能有效地显示商品集合,请参阅列表和网格

处理加载状态

PagingData 对象集成了加载状态信息。您可以使用此属性针对不同状态(refreshappendprepend)显示加载微调器或错误消息。

为防止不必要的重组并确保界面仅对加载生命周期中的有意义的过渡做出反应,您应过滤状态观测。由于 loadState 会因内部更改而频繁更新,因此直接读取它以获取复杂的状态变化可能会导致卡顿。

您可以使用 snapshotFlow 观察状态并应用 distinctUntilChangedBy 属性等 Flow 运算符来优化此过程。在显示空状态或触发副作用(例如错误 snackbar)时,此功能尤为有用:

val snackbarHostState = remember { SnackbarHostState() }

LaunchedEffect(productPagingData.loadState) {
    snapshotFlow { productPagingData.loadState }
        // Filter out updates that don't change the refresh state
        .distinctUntilChangedBy { it.refresh }
        // Only react when the state is an Error
        .filter { it.refresh is LoadState.Error }
        .collect { loadState ->
            val error = (loadState.refresh as LoadState.Error).error
            snackbarHostState.showSnackbar(
                message = "Data failed to load: ${error.localizedMessage}",
                actionLabel = "Retry"
            )
        }
}

在检查刷新状态以显示全屏加载微调器时,请使用 derivedStateOf 来防止不必要的重组。

此外,如果您使用的是 RemoteMediator(例如在之前的 Room 数据库实现中),请明确检查底层数据源 (loadState.source.refresh) 的加载状态,而不是便捷的 loadState.refresh 属性。便利属性可能会在数据库完成向界面添加新项之前报告网络提取已完成。检查 source 可确保界面与本地数据库完全同步,防止加载程序过早消失。

// Safely check the refresh state for a full-screen spinner
// without triggering unnecessary recompositions
val isRefreshing by remember {
    derivedStateOf { productPagingData.loadState.source.refresh is LoadState.Loading }
}
if (isRefreshing) {
    // Show UI for refreshing (for example, full screen spinner)
    Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
        CircularProgressIndicator()
    }
}

您还可以检查 LoadState.Error,以向用户显示重试按钮或错误消息。我们建议使用 LoadState.Error,因为它会公开底层异常,并启用内置的 retry() 函数以供用户恢复。

if (refreshState is LoadState.Error) {
   val e = refreshState as LoadState.Error

   // This composable should ideally replace the entire list if the initial load
   // fails.
   ErrorScreen(
       message = "Data failed to load: ${e.error.localizedMessage}",
       onClickRetry = { productPagingData.retry() }
   )
}

测试实现效果

测试分页实现可确保数据正确加载、转换按预期应用,以及界面对状态变化做出适当的反应。Paging 3 库提供了一个专用测试制品 (androidx.paging:paging-testing) 来简化此流程。

首先,将测试依赖项添加到 build.gradle 文件中:

testImplementation("androidx.paging:paging-testing:$paging_version")

测试数据层

如需直接测试 PagingSource,请使用 TestPager。此实用程序可处理 Paging 3 的底层机制,让您无需完整的 Pager 设置即可独立验证边缘情况,例如初始加载(刷新)、附加或前置数据。

@Test
fun testProductPagingSource() = runTest {
    val pagingSource = ProductPagingSource(mockApiService)

    // Create a TestPager to interact with the PagingSource
    val pager = TestPager(
        config = PAGING_CONFIG,
        pagingSource = pagingSource
    )

    // Trigger an initial load
    val result = pager.refresh() as PagingSource.LoadResult.Page

    // Assert the data size and edge cases like next/prev keys
    assertEquals(50, result.data.size)
    assertNull(result.prevKey)
    assertEquals(50, result.nextKey)
}

测试 ViewModel 逻辑和转换

如果您的 ViewModel 将数据转换(例如 .map 操作)应用于 PagingData 流,您可以使用 asPagingSourceFactoryasSnapshot() 测试此逻辑。

asPagingSourceFactory 扩展函数可将静态列表转换为 PagingSource,从而更轻松地模拟代码库层。asSnapshot() 扩展函数将 PagingData 流收集到标准 Kotlin List 中,从而让您能够对转换后的数据运行标准断言。

@Test
fun testViewModelTransformations() = runTest {
    // 1. Mock your initial data using asPagingSourceFactory
    val mockProducts = listOf(Product(1, "A"), Product(2, "B"))
    val pagingSourceFactory = mockProducts.asPagingSourceFactory()

    // 2. Pass the mocked factory to your ViewModel or Pager
    val pager = Pager(
        config = PagingConfig(pageSize = 10),
        pagingSourceFactory = pagingSourceFactory
    )

    // 3. Apply your ViewModel transformations (for example, mapping to a UI
    //    model)
    val transformedFlow = pager.flow.map { pagingData ->
        pagingData.map { product -> product.title.uppercase() }
    }

    // 4. Extract the data as a List using asSnapshot()
    val snapshot: List<String> = transformedFlow.asSnapshot(this)

    // 5. Verify the transformation
    assertEquals(listOf("A", "B"), snapshot)
}

用于验证状态和重组的界面测试

测试界面时,请验证您的 Compose 组件是否能正确呈现数据,并能对加载状态做出适当的反应。您可以使用 PagingData.from()flowOf() 传递静态 PagingData,以模拟数据流。此外,您还可以使用 SideEffect 在测试期间跟踪重组次数,以确保您的 Compose 组件不会不必要地重组。

以下示例演示了如何模拟加载状态、过渡到已加载状态,以及验证界面节点和重组次数:

@get:Rule
val composeTestRule = createComposeRule()

@Test
fun testProductList_loadingAndDataStates() {
    val context = InstrumentationRegistry.getInstrumentation().targetContext

    // Create a MutableStateFlow to emit different PagingData states over time
    val pagingDataFlow = MutableStateFlow(PagingData.empty<Product>())
    var recompositionCount = 0

    composeTestRule.setContent {
        val lazyPagingItems = pagingDataFlow.collectAsLazyPagingItems()

        // Track recompositions of this composable
        SideEffect { recompositionCount++ }

        ProductListScreen(lazyPagingItems = lazyPagingItems)
    }

    // 1. Simulate initial loading state
    pagingDataFlow.value = PagingData.empty(
        sourceLoadStates = LoadStates(
            refresh = LoadState.Loading,
            prepend = LoadState.NotLoading(endOfPaginationReached = false),
            append = LoadState.NotLoading(endOfPaginationReached = false)
        )
    )

    // Verify that the loading indicator is displayed
    composeTestRule.onNodeWithTag("LoadingSpinner").assertIsDisplayed()

    // 2. Simulate data loaded state
    val mockItems = listOf(
        Product(id = 1, title = context.getString(R.string.product_a_title)),
        Product(id = 2, title = context.getString(R.string.product_b_title))
    )

    pagingDataFlow.value = PagingData.from(
        data = mockItems,
        sourceLoadStates = LoadStates(
            refresh = LoadState.NotLoading(endOfPaginationReached = false),
            prepend = LoadState.NotLoading(endOfPaginationReached = false),
            append = LoadState.NotLoading(endOfPaginationReached = false)
        )
    )

    // Wait for the UI to settle and verify the items are displayed
    composeTestRule.waitForIdle()
    composeTestRule.onNodeWithText(context.getString(R.string.product_a_title)).assertIsDisplayed()
    composeTestRule.onNodeWithText(context.getString(R.string.product_b_title)).assertIsDisplayed()

    // 3. Verify recomposition counts
    // Assert that recompositions are within expected limits (for example,
    // initial composition + updating to load state + updating to data state)
    assert(recompositionCount <= 3) {
        "Expected less than or equal to 3 recompositions, but got $recompositionCount"
    }
}