Обзор системы пейджинга 3

В этом руководстве объясняется, как реализовать пагинацию 3 с помощью Jetpack Compose, рассматриваются варианты реализации как с базой данных Room, так и без нее. Пагинация — это стратегия управления большими наборами данных путем загрузки и отображения их небольшими, управляемыми фрагментами, называемыми страницами, вместо загрузки всего сразу.

Любое приложение с бесконечной прокруткой ленты (например, лента социальных сетей, большой каталог товаров для электронной коммерции или обширный почтовый ящик) требует надежной постраничной обработки данных. Поскольку пользователи обычно просматривают лишь небольшую часть списка, а мобильные устройства имеют ограниченные размеры экрана, загрузка всего набора данных неэффективна. Это приводит к нерациональному использованию системных ресурсов и может вызывать рывки или зависания приложения, ухудшая пользовательский опыт. Для решения этой проблемы можно использовать отложенную загрузку . Хотя такие компоненты, как LazyList в Compose, обрабатывают отложенную загрузку на стороне пользовательского интерфейса, отложенная загрузка данных с диска или сети дополнительно повышает производительность.

Библиотека Paging 3 — рекомендуемое решение для обработки постраничной навигации данных. Если вы переходите с Paging 2, см. раздел «Переход на Paging 3» для получения рекомендаций.

Предварительные требования

Прежде чем продолжить, ознакомьтесь со следующей информацией:

  • Работа с сетью на Android (в этом документе мы используем Retrofit , но Paging 3 работает с любой библиотекой, например, Ktor ).
  • Набор инструментов Compose UI.

Настройка зависимостей

Добавьте следующие зависимости в файл 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 . Используйте её для получения данных для запросов Refresh , Append или Prepend .

  • 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() предоставляет loadType ( Append , Prepend или Refresh ) и состояние. Он возвращает объект 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)

Настройка сети

В предыдущих примерах использовалась сетевая служба. В этом разделе описана настройка Retrofit и сериализации для получения данных из конечной точки api.example.com/products .

Классы данных

В следующем примере кода показано, как определить два класса данных, ProductResponse и Product , которые используются с 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 ) для реализации, работающей только в сети, указав конечную точку ( @GET("/products") ) и необходимые параметры пагинации ( limit ) и ( skip ), требуемые библиотекой Paging 3 для получения страниц данных.

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. Соберите поток : используйте collectAsLazyPagingItems() для преобразования потока в объект элементов отложенной постраничной навигации, учитывающий состояние.

    val productPagingData = mainViewModel.productPager.collectAsLazyPagingItems()
    

    Полученный объект LazyPagingItems предоставляет количество элементов и индексированный доступ, что позволяет методу LazyColumn напрямую использовать его для отображения элементов списка.

  2. Привязка к LazyColumn : Передача данных в список LazyColumn . Если вы переходите с RecyclerView -списка, вам может быть знакомо использование withLoadStateHeaderAndFooter для отображения индикаторов загрузки или кнопок повторной попытки при возникновении ошибок вверху или внизу списка.

    В Compose для этого не требуется специальный адаптер. Точно такого же поведения можно добиться, условно добавляя блок item {} до или после основного блока items {} , реагируя непосредственно на состояния загрузки 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 содержит информацию о состоянии загрузки. Вы можете использовать его для отображения индикаторов загрузки или сообщений об ошибках для различных состояний ( refresh , append или prepend ).

Чтобы предотвратить ненужные перекомпоновки и гарантировать, что пользовательский интерфейс реагирует только на значимые переходы в жизненном цикле загрузки, следует фильтровать наблюдения за состоянием. Поскольку loadState часто обновляется при внутренних изменениях, прямое чтение его для сложных изменений состояния может вызывать задержки.

Оптимизировать это можно, используя snapshotFlow для отслеживания состояния и применяя операторы Flow, такие как свойство distinctUntilChangedBy . Это особенно полезно при отображении пустых состояний или запуске побочных эффектов, например, ошибки 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 ) для упрощения этого процесса.

Сначала добавьте зависимость 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 , вы можете протестировать эту логику, используя asPagingSourceFactory и asSnapshot() .

Расширение asPagingSourceFactory преобразует статический список в PagingSource , упрощая создание заглушек для слоя репозитория. Расширение asSnapshot() собирает поток PagingData в стандартный List Kotlin, позволяя выполнять стандартные проверки преобразованных данных.

@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 с помощью PagingData.from() и flowOf() для имитации потоков данных. Кроме того, вы можете использовать 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"
    }
}