يوضّح هذا الدليل كيفية تنفيذ مكتبة Paging 3 باستخدام Jetpack Compose، ويشمل عمليات التنفيذ التي تستخدم قاعدة بيانات 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 والقيمة هي a
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لعرض أدوات التحميل الدوّارة أو أزرار إعادة المحاولة عند حدوث خطأ في أعلى القائمة أو أسفلها.في 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. ويكون هذا مفيدًا بشكل خاص عند عرض حالات فارغة أو تشغيل آثار جانبية، مثل شريط إعلام منبثق للخطأ:
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"
}
}