Z tego przewodnika dowiesz się, jak wdrożyć bibliotekę Paging 3 w Jetpack Compose. Omówimy w nim implementacje z bazą danych Room i bez niej. Stronicowanie to strategia zarządzania dużymi zbiorami danych, która polega na wczytywaniu i wyświetlaniu ich w małych, łatwych do zarządzania częściach, zwanych stronami, zamiast wczytywania wszystkiego naraz.
Każda aplikacja z kanałem z przewijaniem nieskończonym (np. oś czasu w mediach społecznościowych, duży katalog produktów e-commerce lub obszerna skrzynka odbiorcza poczty e-mail) wymaga solidnego podziału danych na strony. Użytkownicy zwykle wyświetlają tylko niewielką część listy, a urządzenia mobilne mają ograniczone rozmiary ekranu, więc wczytywanie całego zbioru danych nie jest efektywne. Marnuje to zasoby systemu i może powodować zacinanie się lub zawieszanie aplikacji, co pogarsza wrażenia użytkownika. Aby rozwiązać ten problem, możesz użyć leniwej
wczytywania. Komponenty takie jak LazyList w Compose obsługują leniwe ładowanie po stronie interfejsu, ale leniwe ładowanie danych z dysku lub sieci dodatkowo zwiększa wydajność.
Biblioteka Paging 3 to zalecane rozwiązanie do obsługi stronicowania danych. Jeśli migrujesz z biblioteki Paging 2, zapoznaj się z tym przewodnikiem.
Wymagania wstępne
Zanim przejdziesz dalej, zapoznaj się z tymi informacjami:
- Sieć na Androidzie (w tym dokumencie używamy Retrofit, ale biblioteka Paging 3 działa z dowolną biblioteką, np. Ktor).
- Zestaw narzędzi interfejsu Compose.
Konfigurowanie zależności
Dodaj te zależności do pliku build.gradle.kts na poziomie aplikacji.
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")
}
Zdefiniuj klasę Pager
Klasa Pager jest głównym punktem wejścia do paginacji. Tworzy strumień reaktywny PagingData. Powinieneś utworzyć instancję Pager i używać jej ponownie w ramach ViewModel.
Pager wymaga PagingConfig, aby określić sposób pobierania i prezentowania danych.
// 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 możesz wdrożyć na 2 sposoby: bez bazy danych (tylko sieć) lub z bazą danych (za pomocą Room).
Wdrażanie bez bazy danych
Jeśli nie używasz bazy danych, potrzebujesz PagingSource<Key, Value> do obsługi ładowania danych na żądanie. W tym przykładzie kluczem jest Int, a wartością jest aProduct.
W klasie PagingSource musisz zaimplementować 2 metody abstrakcyjne:
load: funkcja zawieszania, która otrzymujeLoadParams. Użyj tego parametru, aby pobrać dane dla żądańRefresh,AppendlubPrepend.getRefreshKey: zawiera klucz używany do ponownego wczytywania danych, jeśli stronicowanie jest nieprawidłowe. Ta metoda oblicza klucz na podstawie bieżącej pozycji przewijania użytkownika (state.anchorPosition).
Poniższy przykład kodu pokazuje, jak zaimplementować klasę ProductPagingSource, która jest niezbędna do zdefiniowania logiki pobierania danych podczas korzystania z biblioteki Paging 3 bez lokalnej bazy danych.
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)
}
}
}
Na zajęciach ViewModel utwórz 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)
Implementacja z bazą danych
Podczas korzystania z Room baza danych automatycznie generuje klasę PagingSource.
Baza danych nie wie jednak, kiedy pobrać więcej danych z sieci. Aby sobie z tym poradzić, zaimplementuj RemoteMediator.
Metoda RemoteMediator.load() zwraca loadType (Append, Prepend lub Refresh) i stan. Zwraca wartość MediatorResult wskazującą powodzenie lub niepowodzenie oraz informację, czy osiągnięto koniec paginacji.
@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)
}
}
}
W ViewModel implementacja jest znacznie prostsza, ponieważ Room obsługuje klasę PagingSource:
val productPager = ProductRepository().fetchProducts().flow.cachedIn(viewModelScope)
Konfiguracja sieci
W poprzednich przykładach wykorzystano usługę sieciową. W tej sekcji znajdziesz konfigurację Retrofit i Serialization używaną do pobierania danych z punktu końcowego api.example.com/products.
Klasy danych
Poniższy przykład kodu pokazuje, jak zdefiniować 2 klasy danych, ProductResponse i Product, które są używane z kotlinx.serialization do analizowania podzielonej na strony odpowiedzi JSON z usługi sieciowej.
@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 = ""
)
Usługa modernizacji
Poniższy przykład kodu pokazuje, jak zdefiniować interfejs usługi Retrofit (ProductService) dla implementacji tylko sieciowej, określając punkt końcowy (@GET("/products")) i niezbędne parametry stronicowania (limit) i (skip) wymagane przez bibliotekę Paging 3 do pobierania stron danych.
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()
Korzystanie z danych w Compose
Po skonfigurowaniu Pager możesz wyświetlać dane w interfejsie.
Zbierz przepływ: użyj
collectAsLazyPagingItems(), aby przekształcić przepływ w obiekt elementów leniwego stronicowania z zachowaniem stanu.val productPagingData = mainViewModel.productPager.collectAsLazyPagingItems()Powstały obiekt
LazyPagingItemszawiera liczbę elementów i indeksowany dostęp, dzięki czemu może być bezpośrednio używany przez metodęLazyColumndo renderowania elementów listy.Powiąż z
LazyColumn: przekaż dane do listyLazyColumn. Jeśli migrujesz z listyRecyclerView, możesz znać sposób używaniawithLoadStateHeaderAndFooterdo wyświetlania spinnerów wczytywania lub przycisków ponawiania błędów u góry lub u dołu listy.W Compose nie potrzebujesz do tego specjalnej przejściówki. Możesz uzyskać dokładnie to samo zachowanie, warunkowo dodając blok
item {}przed lub po głównym blokuitems {}, reagując bezpośrednio na stany wczytywaniaprepend(nagłówek) iappend(stopka).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() }) } } }
Więcej informacji o tym, jak funkcje Compose pozwalają skutecznie wyświetlać kolekcje elementów, znajdziesz w artykule Listy i siatki.
Obsługa stanów wczytywania
Obiekt PagingData zawiera informacje o stanie wczytywania. Możesz użyć tego parametru, aby wyświetlać wskaźniki ładowania lub komunikaty o błędach w różnych stanach (refresh, append lub prepend).
Aby zapobiec niepotrzebnym ponownym kompozycjom i zapewnić, że interfejs będzie reagować tylko na istotne przejścia w cyklu życia ładowania, należy filtrować obserwacje stanu. loadState jest często aktualizowany w wyniku zmian wewnętrznych, więc bezpośrednie odczytywanie go w przypadku złożonych zmian stanu może powodować zacinanie się.
Możesz to zoptymalizować, używając snapshotFlow do obserwowania stanu i stosując operatory Flow, takie jak właściwość distinctUntilChangedBy. Jest to szczególnie przydatne podczas wyświetlania pustych stanów lub wywoływania efektów ubocznych, takich jak pasek powiadomień o błędzie:
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"
)
}
}
Podczas sprawdzania stanu odświeżania w celu wyświetlenia pełnoekranowego wskaźnika wczytywania używaj derivedStateOf, aby zapobiec niepotrzebnym ponownym kompozycjom.
Jeśli używasz RemoteMediator (np. w implementacji bazy danych Room), wyraźnie sprawdzaj stan wczytywania źródła danych (loadState.source.refresh), a nie właściwość loadState.refresh. Właściwość wygody może zgłosić, że pobieranie z sieci zostało zakończone, zanim baza danych zakończy dodawanie nowych elementów do interfejsu. Zaznaczenie tego pola source gwarantuje, że interfejs będzie w pełni zsynchronizowany z lokalną bazą danych, co zapobiegnie zbyt wczesnemu zniknięciu wskaźnika ładowania.
// 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()
}
}
Możesz też sprawdzić, czy występuje LoadState.Error, aby wyświetlić użytkownikowi przyciski ponownej próby lub komunikaty o błędach. Zalecamy używanie LoadState.Error, ponieważ ujawnia on podstawowy wyjątek i umożliwia korzystanie z wbudowanej funkcji retry() na potrzeby przywracania danych przez użytkownika.
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() }
)
}
Testowanie implementacji
Testowanie implementacji paginacji zapewnia prawidłowe wczytywanie danych, stosowanie przekształceń zgodnie z oczekiwaniami oraz prawidłowe reagowanie interfejsu na zmiany stanu. Biblioteka Paging 3 udostępnia specjalny artefakt testowy (androidx.paging:paging-testing), który upraszcza ten proces.
Najpierw dodaj zależność testową do pliku build.gradle:
testImplementation("androidx.paging:paging-testing:$paging_version")
Testowanie warstwy danych
Aby bezpośrednio przetestować PagingSource, użyj TestPager. To narzędzie obsługuje podstawowe mechanizmy biblioteki Paging 3 i umożliwia niezależne weryfikowanie przypadków brzegowych, takich jak początkowe wczytywanie (odświeżanie), dołączanie lub dodawanie danych bez konieczności pełnej konfiguracji 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)
}
Testowanie logiki i przekształceń ViewModel
Jeśli ViewModel stosuje do przepływu PagingData przekształcenia danych (np. operacje .map), możesz przetestować tę logikę za pomocą asPagingSourceFactory i asSnapshot().
Rozszerzenie asPagingSourceFactory przekształca statyczną listę w PagingSource, co ułatwia tworzenie wersji próbnej warstwy repozytorium. Rozszerzenie asSnapshot() zbiera strumień PagingData w standardowym strumieniu Kotlin List, co umożliwia uruchamianie standardowych asercji na przekształconych danych.
@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)
}
Testy interfejsu, które weryfikują stany i ponowne kompozycje
Podczas testowania interfejsu sprawdź, czy komponenty Compose prawidłowo renderują dane i odpowiednio reagują na stany ładowania. Możesz przekazywać statyczne
PagingData za pomocą PagingData.from() i flowOf(), aby symulować strumienie danych.
Możesz też użyć SideEffect, aby śledzić liczbę ponownych kompozycji podczas testów i upewnić się, że komponenty Compose nie są ponownie komponowane bez potrzeby.
Poniższy przykład pokazuje, jak symulować stan ładowania, przejść do stanu załadowania i sprawdzić zarówno węzły interfejsu, jak i liczbę ponownych kompozycji:
@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"
}
}