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