במדריך הזה מוסבר איך להטמיע את ספריית Paging 3 עם Jetpack פיתוח נייטיב. המדריך כולל הסברים על הטמעות עם מסד נתונים של Room וגם בלי. חלוקה לעמודים היא שיטה לניהול קבוצות נתונים גדולות. במקום לטעון את כל הנתונים בבת אחת, המערכת טוענת ומציגה אותם בחלקים קטנים ונוחים לניהול שנקראים עמודים.
כל אפליקציה שכוללת פיד עם גלילה מתמשכת (כמו ציר זמן ברשת חברתית, קטלוג גדול של מוצרים למסחר אלקטרוני או תיבת דואר נכנס גדולה) צריכה להשתמש בעימוד נתונים חזק. בדרך כלל, משתמשים רואים רק חלק קטן מהרשימה, ובמכשירים ניידים יש מסכים קטנים, ולכן טעינה של כל מערך הנתונים לא יעילה. היא מבזבזת משאבי מערכת ועלולה לגרום לבעיות בביצועים או לקריסות של האפליקציה, וכך לפגוע בחוויית המשתמש. כדי לפתור את הבעיה, אפשר להשתמש בטעינה עצלה. רכיבים כמו LazyList ב-Compose מטפלים בטעינה מדורגת בצד ממשק המשתמש, אבל טעינה מדורגת של נתונים מהדיסק או מהרשת משפרת עוד יותר את הביצועים.
ספריית 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. אפשר להשתמש בזה כדי לאחזר נתונים לבקשות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, אפשר להציג את הנתונים בממשק המשתמש.
איסוף הפריטים בתהליך: משתמשים ב-
collectAsLazyPagingItems()כדי להמיר את הפריטים בתהליך לאובייקט של פריטים עם מצב מודע להחלפה זמנית של נתונים.val productPagingData = mainViewModel.productPager.collectAsLazyPagingItems()אובייקט
LazyPagingItemsשמתקבל מספק ספירות פריטים וגישה לאינדקס, כך שאפשר להשתמש בו ישירות בשיטהLazyColumnכדי לעבד את הפריטים ברשימה.קישור ל-
LazyColumn: העברת הנתונים לרשימתLazyColumn. אם אתם מבצעים העברה מרשימתRecyclerView, יכול להיות שאתם מכירים את השימוש ב-withLoadStateHeaderAndFooterכדי להציג אנימציות של טעינה או לחצנים לניסיון חוזר של שגיאה בחלק העליון או התחתון של הרשימה.במצב כתיבה, לא צריך מתאם מיוחד בשביל זה. אפשר להשיג את אותה התנהגות בדיוק על ידי הוספה מותנית של בלוק
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) כדי לפשט את התהליך הזה.
קודם מוסיפים את התלות בבדיקה לקובץ 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"
}
}