ภาพรวมของ Paging 3

คู่มือนี้อธิบายวิธีใช้ Paging 3 กับ Jetpack Compose โดยครอบคลุมการใช้งานทั้งที่มีและไม่มีฐานข้อมูล Room การแบ่งหน้าเป็นกลยุทธ์ ในการจัดการชุดข้อมูลขนาดใหญ่โดยการโหลดและแสดงชุดข้อมูลเป็นกลุ่มเล็กๆ ที่จัดการได้ ซึ่งเรียกว่าหน้า แทนที่จะโหลดทุกอย่างพร้อมกัน

แอปที่มีฟีดแบบเลื่อนได้ไม่รู้จบ (เช่น ไทม์ไลน์โซเชียลมีเดีย แคตตาล็อกผลิตภัณฑ์อีคอมเมิร์ซขนาดใหญ่ หรือกล่องจดหมายอีเมลจำนวนมาก) ต้องมีการแบ่งหน้าข้อมูลที่แข็งแกร่ง เนื่องจากโดยปกติแล้วผู้ใช้จะดูเพียงส่วนเล็กๆ ของรายการ และอุปกรณ์เคลื่อนที่มีขนาดหน้าจอจำกัด การโหลดทั้งชุดข้อมูลจึงไม่มีประสิทธิภาพ ซึ่งจะสิ้นเปลืองทรัพยากรของระบบและอาจทำให้เกิดอาการกระตุกหรือ แอปค้าง ซึ่งจะทำให้ประสบการณ์ของผู้ใช้แย่ลง คุณสามารถใช้การโหลดเลย์ซีเพื่อแก้ปัญหานี้ได้ แม้ว่าคอมโพเนนต์อย่าง LazyList ใน Compose จะจัดการการโหลดแบบ Lazy ในฝั่ง UI แต่การโหลดข้อมูลแบบ Lazy จากดิสก์หรือเครือข่ายจะช่วยเพิ่ม ประสิทธิภาพได้ดียิ่งขึ้น

ไลบรารี Paging 3 เป็นโซลูชันที่แนะนําสําหรับการจัดการการแบ่งหน้าของข้อมูล หากคุณย้ายข้อมูลจาก Paging 2 โปรดดูคำแนะนำในหัวข้อย้ายข้อมูลไปยัง Paging 3

สิ่งที่ต้องมีก่อน

โปรดทำความคุ้นเคยกับข้อมูลต่อไปนี้ก่อนดำเนินการต่อ

  • การเชื่อมต่อเครือข่ายใน Android (เราใช้ Retrofit ในเอกสารนี้ แต่ Paging 3 ใช้ได้กับไลบรารีทุกประเภท เช่น Ktor)
  • ชุดเครื่องมือ UI ของ Compose

ตั้งค่าทรัพยากร Dependency

เพิ่มทรัพยากร Dependency ต่อไปนี้ลงในไฟล์ 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 ได้ 2 วิธี ได้แก่ แบบไม่มีฐานข้อมูล (เครือข่ายเท่านั้น) หรือแบบมีฐานข้อมูล (ใช้ Room)

ใช้โดยไม่ต้องมีฐานข้อมูล

เมื่อไม่ได้ใช้ฐานข้อมูล คุณจะต้องมี PagingSource<Key, Value> เพื่อจัดการการโหลดข้อมูลตามต้องการ ในตัวอย่างนี้ คีย์คือ Int และค่าคือ a Product

คุณต้องใช้ 2 เมธอดแบบนามธรรมใน 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 class โดยอัตโนมัติ อย่างไรก็ตาม ฐานข้อมูลไม่ทราบว่าจะดึงข้อมูลเพิ่มเติมจากเครือข่ายเมื่อใด หากต้องการ จัดการปัญหานี้ ให้ใช้ 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

คลาสข้อมูล

ตัวอย่างโค้ดต่อไปนี้แสดงวิธีกำหนดคลาสข้อมูล 2 คลาส ได้แก่ 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 แล้ว คุณจะแสดงข้อมูลใน UI ได้

  1. รวบรวมโฟลว์: ใช้ collectAsLazyPagingItems() เพื่อแปลงโฟลว์ เป็นออบเจ็กต์รายการการแบ่งหน้าแบบเลซี่ที่รับรู้สถานะ

    val productPagingData = mainViewModel.productPager.collectAsLazyPagingItems()
    

    ออบเจ็กต์ LazyPagingItems ที่ได้จะแสดงจำนวนรายการและการเข้าถึงที่จัดทำดัชนี ซึ่งช่วยให้เมธอด LazyColumn ใช้ข้อมูลดังกล่าวได้โดยตรงเพื่อ แสดงผลรายการ

  2. เชื่อมโยงกับ 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) ได้

คุณควรกรองการสังเกตสถานะเพื่อป้องกันการประกอบที่ไม่จำเป็นและตรวจสอบว่า UI ตอบสนองต่อ การเปลี่ยนผ่านที่มีความหมายในวงจรการโหลดเท่านั้น เนื่องจาก 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 ที่สะดวก พร็อพเพอร์ตี้ความสะดวกอาจรายงานว่าการดึงข้อมูลเครือข่ายเสร็จสมบูรณ์ก่อนที่ฐานข้อมูลจะเพิ่มรายการใหม่ลงใน UI เสร็จ การตรวจสอบ source จะช่วยให้มั่นใจได้ว่า UI จะซิงค์กับ ฐานข้อมูลในเครื่องอย่างสมบูรณ์ ซึ่งจะป้องกันไม่ให้โปรแกรมโหลดหายไปเร็วเกินไป

// 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() }
   )
}

ทดสอบการใช้งาน

การทดสอบการติดตั้งใช้งานการแบ่งหน้าช่วยให้มั่นใจได้ว่าข้อมูลจะโหลดอย่างถูกต้อง การเปลี่ยนรูปแบบจะใช้ตามที่คาดไว้ และ UI จะตอบสนองต่อการเปลี่ยนแปลงสถานะอย่างถูกต้อง ไลบรารี Paging 3 มีอาร์ติแฟกต์การทดสอบเฉพาะ (androidx.paging:paging-testing) เพื่อลดความซับซ้อนของกระบวนการนี้

ก่อนอื่นให้เพิ่มทรัพยากร Dependency การทดสอบลงในไฟล์ 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 ลงใน 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)
}

การทดสอบ UI เพื่อยืนยันสถานะและการจัดองค์ประกอบใหม่

เมื่อทดสอบ UI ให้ตรวจสอบว่าคอมโพเนนต์ Compose แสดงผลข้อมูลอย่างถูกต้องและตอบสนองต่อสถานะการโหลดอย่างเหมาะสม คุณส่งผ่านข้อมูลแบบคงที่ PagingData โดยใช้ PagingData.from() และ flowOf() เพื่อจำลองสตรีมข้อมูลได้ นอกจากนี้ คุณยังใช้ SideEffect เพื่อติดตามจำนวนการจัดองค์ประกอบใหม่ระหว่างการทดสอบได้ เพื่อให้มั่นใจว่าคอมโพเนนต์ Compose จะไม่จัดองค์ประกอบใหม่โดยไม่จำเป็น

ตัวอย่างต่อไปนี้แสดงวิธีจำลองสถานะการโหลด เปลี่ยนไปใช้สถานะที่โหลดแล้ว และยืนยันทั้งโหนด UI และจำนวนการจัดองค์ประกอบใหม่

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