Paging 3 की खास जानकारी

इस गाइड में, Jetpack Compose के साथ Paging 3 को लागू करने का तरीका बताया गया है. इसमें Room डेटाबेस के साथ और उसके बिना, दोनों तरह के तरीकों को शामिल किया गया है. पेज नंबर डालना, बड़े डेटासेट को मैनेज करने की एक रणनीति है. इसमें पूरे डेटा को एक साथ लोड करने के बजाय, उसे छोटे-छोटे हिस्सों में लोड और डिसप्ले किया जाता है. इन हिस्सों को पेज कहा जाता है.

इनफ़ाइनाइट स्क्रोलिंग फ़ीड वाले किसी भी ऐप्लिकेशन (जैसे, सोशल मीडिया टाइमलाइन, ई-कॉमर्स प्रॉडक्ट का बड़ा कैटलॉग या ईमेल इनबॉक्स) के लिए, डेटा पेज पर बांटने की सुविधा का होना ज़रूरी है. आम तौर पर, उपयोगकर्ता सूची का सिर्फ़ एक छोटा हिस्सा देखते हैं. साथ ही, मोबाइल डिवाइसों की स्क्रीन का साइज़ सीमित होता है. इसलिए, पूरे डेटासेट को लोड करना सही नहीं है. इससे सिस्टम के संसाधनों का इस्तेमाल होता है. साथ ही, इससे ऐप्लिकेशन में रुकावट आ सकती है या वह बंद हो सकता है. इससे उपयोगकर्ता अनुभव खराब हो सकता है. इस समस्या को हल करने के लिए, लेज़ी लोडिंग का इस्तेमाल किया जा सकता है. Compose में LazyList जैसे कॉम्पोनेंट, यूज़र इंटरफ़ेस (यूआई) पर लेज़ी लोडिंग को हैंडल करते हैं. हालांकि, डिस्क या नेटवर्क से डेटा को लेज़ी तरीके से लोड करने से परफ़ॉर्मेंस और बेहतर हो जाती है.

डेटा के पेजों को मैनेज करने के लिए, 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)

नेटवर्क का सेटअप

पिछले उदाहरण, नेटवर्क सेवा पर निर्भर करते हैं. इस सेक्शन में, api.example.com/products एंडपॉइंट से डेटा फ़ेच करने के लिए इस्तेमाल किए गए Retrofit और Serialization सेटअप के बारे में बताया गया है.

डेटा क्लास

कोड के इस उदाहरण में, 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 में, इसके लिए आपको किसी खास अडैप्टर की ज़रूरत नहीं होती. prepend (हेडर) और append (फ़ुटर) के लोड होने की स्थितियों के हिसाब से, मुख्य items {} ब्लॉक से पहले या बाद में, शर्त के हिसाब से item {} ब्लॉक जोड़कर, ठीक वैसा ही व्यवहार हासिल किया जा सकता है.

    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 का इस्तेमाल करके, इसे ऑप्टिमाइज़ किया जा सकता है. साथ ही, 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.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) उपलब्ध कराती है.

सबसे पहले, अपनी 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, PagingData फ़्लो में डेटा ट्रांसफ़ॉर्मेशन (जैसे कि .map कार्रवाइयां) लागू करता है, तो asPagingSourceFactory और asSnapshot() का इस्तेमाल करके इस लॉजिक को टेस्ट किया जा सकता है.

asPagingSourceFactory एक्सटेंशन, स्टैटिक सूची को PagingSource में बदल देता है. इससे रिपॉज़िटरी लेयर को मॉक करना आसान हो जाता है. asSnapshot() एक्सटेंशन, PagingData स्ट्रीम को स्टैंडर्ड Kotlin 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.from() और flowOf() का इस्तेमाल करके स्टैटिक PagingData पास किया जा सकता है. इसके अलावा, अपने टेस्ट के दौरान कंपोज़िशन की संख्या को ट्रैक करने के लिए, SideEffect का इस्तेमाल किया जा सकता है. इससे यह पक्का किया जा सकता है कि आपके कंपोज़ कॉम्पोनेंट, बिना वजह रीकंपोज़ न हो रहे हों.

यहां दिए गए उदाहरण में, लोडिंग की स्थिति को सिम्युलेट करने, लोड की गई स्थिति में ट्रांज़िशन करने, और यूज़र इंटरफ़ेस (यूआई) नोड और रीकंपोज़िशन की संख्या, दोनों की पुष्टि करने का तरीका बताया गया है:

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