مروری بر صفحه‌بندی ۳

این راهنما نحوه پیاده‌سازی Paging 3 با Jetpack Compose را توضیح می‌دهد و پیاده‌سازی‌ها را هم با پایگاه داده Room و هم بدون آن پوشش می‌دهد. صفحه‌بندی یک استراتژی برای مدیریت مجموعه داده‌های بزرگ با بارگذاری و نمایش آنها در تکه‌های کوچک و قابل مدیریت، به نام صفحات، به جای بارگذاری همه چیز به طور همزمان است.

هر برنامه‌ای که دارای فید پیمایش نامحدود باشد (مانند جدول زمانی رسانه‌های اجتماعی، کاتالوگ بزرگی از محصولات تجارت الکترونیک یا صندوق ورودی ایمیل گسترده) نیاز به صفحه‌بندی قوی داده‌ها دارد. از آنجا که کاربران معمولاً فقط بخش کوچکی از یک لیست را مشاهده می‌کنند و دستگاه‌های تلفن همراه اندازه صفحه نمایش محدودی دارند، بارگیری کل مجموعه داده‌ها کارآمد نیست. این کار منابع سیستم را هدر می‌دهد و می‌تواند باعث هنگ کردن یا توقف برنامه شود و تجربه کاربری را بدتر کند. برای حل این مشکل، می‌توانید از بارگذاری تنبل استفاده کنید. در حالی که کامپوننت‌هایی مانند LazyList در Compose بارگذاری تنبل را در سمت رابط کاربری مدیریت می‌کنند، بارگیری داده‌ها به صورت تنبل از دیسک یا شبکه، عملکرد را بیشتر بهبود می‌بخشد.

کتابخانه Paging 3 راه حل پیشنهادی برای مدیریت صفحه بندی داده ها است. اگر از Paging 2 مهاجرت می کنید، برای راهنمایی به Migrate to Paging 3 مراجعه کنید.

پیش‌نیازها

قبل از ادامه، با موارد زیر آشنا شوید:

  • شبکه‌سازی در اندروید (ما در این سند از 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 است.

شما باید دو متد انتزاعی (abstract) را در 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 ) و وضعیت (state) را ارائه می‌دهد. این متد یک 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 و Serialization را که برای واکشی داده‌ها از نقطه پایانی 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 برای نمایش spinnerهای بارگذاری یا دکمه‌های خطای تلاش مجدد در بالا یا پایین لیست خود آشنا باشید.

    در Compose، برای این کار به آداپتور خاصی نیاز ندارید. می‌توانید با اضافه کردن شرطی یک بلوک item {} قبل یا بعد از بلوک main 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 اطلاعات وضعیت بارگذاری را ادغام می‌کند. می‌توانید از این برای نمایش spinnerهای بارگذاری یا پیام‌های خطا برای وضعیت‌های مختلف ( 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.refresh ، وضعیت بارگذاری منبع داده اصلی ( loadState.source.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 را مدیریت می‌کند و به شما امکان می‌دهد موارد حاشیه‌ای، مانند بارگذاری اولیه (Refresh)، افزودن یا آماده‌سازی داده‌ها را بدون نیاز به راه‌اندازی کامل 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 استاندارد کاتلین جمع‌آوری می‌کند و به شما امکان می‌دهد دستورات استاندارد را روی داده‌های تبدیل‌شده اجرا کنید.

@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"
    }
}