Transform เป็นส่วนหนึ่งของ Android Jetpack
Transform เป็นไลบรารีที่สร้างขึ้นในรันไทม์ Compose ซึ่งเรียกใช้บล็อก @Composable โดยไม่มี UI เพื่อเปลี่ยนอินพุตเป็น State ของ Compose
เมื่อใดก็ตามที่ State ที่อ่านภายในบล็อกมีการเปลี่ยนแปลง บล็อกจะจัดองค์ประกอบใหม่และอัปเดต State ที่แสดงผลโดยอัตโนมัติ ซึ่งช่วยให้คุณใช้โมเดลการติดตามทรัพยากร Dependency และการจัดองค์ประกอบใหม่ของ Compose ใน ViewModel, ที่เก็บ หรือบริการที่ทำงานอยู่เบื้องหลังได้
กรณีการใช้งาน
- การเปลี่ยนรูปแบบข้อมูลแบบรีแอ็กทีฟ: แปลงสตรีมข้อมูลดิบ (เช่น การตอบสนองของเครือข่ายหรือการค้นหาฐานข้อมูล) เป็นสถานะที่พร้อมสำหรับ UI
- รวมอินพุตหลายรายการ: รวมแหล่งที่มาของสถานะอิสระหลายรายการ (เช่น อินพุตของผู้ใช้ การตั้งค่า และสถานะเครือข่าย) เป็นสถานะเดียวที่สอดคล้องกันโดยไม่ต้องใช้โอเปอเรเตอร์
Flowที่ซับซ้อน - การดีเบานซ์และการควบคุม: ใช้ลักษณะการทำงานตามเวลา (เช่น การหน่วงเวลาคำค้นหา) โดยใช้ผลข้างเคียงมาตรฐานของ Compose เช่น
LaunchedEffect - การดำเนินการแบบมีเงื่อนไข: เรียกใช้ตรรกะการรวบรวมสถานะที่แตกต่างกันตามเงื่อนไขปัจจุบันโดยใช้นิพจน์
if-elseหรือwhenมาตรฐาน
ภาพรวม API
แพ็กเกจ androidx.appstate.transform มีฟังก์ชันต่อไปนี้
package androidx.appstate.transform
import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
public fun <R> transform(
initialValue: R,
scope: CoroutineScope,
dispatcher: CoroutineDispatcher = Dispatchers.Default,
onUpdate: @Composable (R) -> R,
): State<R>
@Composable
public fun <R> transform(
initialValue: R,
dispatcher: CoroutineDispatcher = Dispatchers.Default,
onUpdate: @Composable (R) -> R,
): State<R>
public suspend fun listener(
dispatcher: CoroutineDispatcher = Dispatchers.Default,
onUpdate: @Composable () -> Unit,
)
สร้างการเปลี่ยนรูปแบบ
คุณสามารถสร้างการเปลี่ยนรูปแบบด้วยตัวเอง (โดยใช้ CoroutineScope ที่มีให้) หรือภายในองค์ประกอบที่มีอยู่ก็ได้ พารามิเตอร์หลักสำหรับการเปลี่ยนรูปแบบคือ initialValue และ onUpdate onUpdate lambda จะได้รับค่าเอาต์พุตก่อนหน้า R เป็นพารามิเตอร์ (เริ่มต้นเป็น initialValue ในองค์ประกอบแรก) คุณสามารถแสดงผลพารามิเตอร์นี้เพื่อรักษาสถานะที่แสดงผลล่าสุดไว้แทนที่จะรีเซ็ตเป็นสถานะกำลังโหลด เช่น { previous -> flow.collectAsState(initial = null).value ?: previous }
ตัวอย่างเช่น คุณสามารถใช้ transform เพื่อสังเกตการเปลี่ยนแปลงสตริงคำค้นหาและลดการประมวลผลที่ใช้ทรัพยากรมาก เช่น การเรียกใช้เครือข่าย
var query: String by mutableStateOf("") val searchResults: List<String> by transform<List<String>>( initialValue = emptyList(), scope = coroutineScope, ) { var results by remember { mutableStateOf(emptyList<String>()) } LaunchedEffect(query) { if (query.isBlank()) { results = emptyList() return@LaunchedEffect } delay(300.milliseconds) results = api.search(query) } results }
ในข้อมูลโค้ดนี้
queryได้รับการสนับสนุนโดยMutableStateซึ่งคอมโพสิเบิล UI ของแถบค้นหาสามารถอ่านและอัปเดตได้transformรับinitialValueของรายการว่างเปล่า,CoroutineScopeและonUpdateเป็น Lambda ต่อท้าย- ภายในบล็อก
onUpdaterememberจะเก็บสถานะresultsไว้ตลอดการจัดองค์ประกอบใหม่ขณะที่LaunchedEffectทำงาน LaunchedEffectใช้queryเป็นคีย์ เนื่องจากqueryได้รับการสนับสนุนจากMutableStateระบบจึงยกเลิกและเปิดใช้เอฟเฟกต์อีกครั้งทุกครั้งที่queryมีการเปลี่ยนแปลง- เพื่อหลีกเลี่ยงการเรียก API ทุกครั้งที่กดแป้นพิมพ์ เนื้อหาของเอฟเฟกต์จะรอ 300 มิลลิวินาที หากการค้นหามีการเปลี่ยนแปลงก่อนที่ความล่าช้านั้นจะเสร็จสมบูรณ์ ระบบจะยกเลิกเอฟเฟกต์และไม่มีการเรียกใช้เครือข่าย
ภายในฟังก์ชัน @Composable คุณยังใช้โอเวอร์โหลด @Composable transform ซึ่งจะจดจำ CoroutineScope ภายในและเรียกใช้ onUpdate ใน Dispatchers.Default โดยค่าเริ่มต้นได้ด้วย ซึ่งจะมีประโยชน์เมื่อคุณต้องการลดภาระการรับค่าสถานะที่มีค่าใช้จ่ายสูง (เช่น การกรองหรือการค้นหารายการขนาดใหญ่) ออกจากเธรด UI หลัก
@Composable fun LogViewerScreen( entriesState: State<List<LogEntry>>, filterQueryState: State<String>, ) { // Calls rememberCoroutineScope() internally and runs onUpdate on // Dispatchers.Default so main-thread UI recomposition isn't blocked val matchingEntries by transform<List<LogEntry>>( initialValue = emptyList(), dispatcher = Dispatchers.Default, ) { val entries by entriesState val filterQuery by filterQueryState if (filterQuery.isBlank()) { entries } else { entries.filter { entry -> entry.matchesQuery(filterQuery) } } } // Render matchingEntries... }
สังเกตสถานะด้วย listener
เมื่อต้องการตอบสนองต่อการเปลี่ยนแปลง State ของ Compose ในคอมโพเนนต์ที่ไม่ใช่ UI (เช่น เครื่องมือจัดการสถานะ, ที่เก็บ หรือบริการที่ทำงานอยู่เบื้องหลัง) โดยไม่สร้าง State<R> ที่ได้มา ให้ใช้ listener
listener เป็นฟังก์ชัน suspend ที่เรียกใช้บล็อก @Composable () -> Unit จนกว่าโครูทีนที่เรียกจะถูกยกเลิก โดยจะจัดองค์ประกอบใหม่โดยอัตโนมัติเมื่อใดก็ตามที่สถานะที่อ่านภายใน onUpdate มีการเปลี่ยนแปลง
class FocusSessionManager( private val stateStore: StateStore, private val notificationManager: NotificationManager, scope: CoroutineScope, ) { val session: State<FocusSession> = stateStore.getState(FocusSessionKey) init { scope.launch { listener { when (val current = session.value) { is FocusSession.Running -> { LaunchedEffect(current.endTimeMillis) { val remainingMillis = current.endTimeMillis - System.currentTimeMillis() if (remainingMillis > 0) { delay(remainingMillis) } stateStore.setState(FocusSessionKey, FocusSession.Idle) notificationManager.notify( NOTIFICATION_ID, buildSessionCompleteNotification(current.taskId), ) } } FocusSession.Idle, is FocusSession.Paused -> Unit } } } } // startFocus, pauseFocus, resumeFocus, endFocus... }
ในตัวอย่างนี้ listener สังเกตเห็น session.value เมื่อเซสชันเป็น FocusSession.Running LaunchedEffect จะรอจนถึง endTimeMillis ก่อนที่จะรีเซ็ตเซสชันเป็น FocusSession.Idle และโพสต์การแจ้งเตือนว่าเสร็จสมบูรณ์ หากผู้ใช้หยุดชั่วคราวหรือสิ้นสุดเซสชันก่อนเวลา สาขา Running จะออกจาก Composition และยกเลิกตัวจับเวลาโดยอัตโนมัติ เมื่อยกเลิก scope แล้ว listener จะหยุดสังเกตการณ์
รูปแบบทั่วไป
รูปแบบต่อไปนี้แสดงวิธีใช้ transform สำหรับตรรกะแบบมีเงื่อนไข การรวมสตรีมหลายรายการ และการสร้างสถานะ UI ใน ViewModel
การผลิตตามสถานะแบบมีเงื่อนไข
ใช้นิพจน์ if-else หรือ when ของ Kotlin มาตรฐานภายใน transform เพื่อสลับระหว่างแหล่งที่มาของสถานะอัปสตรีมตามเงื่อนไขปัจจุบัน เนื่องจากการติดตามการอ่านเป็นแบบไดนามิก transform จะสังเกตเฉพาะสาขาที่ทำงานในการจัดองค์ประกอบใหม่ปัจจุบันเท่านั้น
var searchQuery by mutableStateOf("") val displayedTasks: State<List<Task>> = transform( initialValue = emptyList(), scope = coroutineScope, ) { if (searchQuery.isBlank()) { val recentTasks by tasksRepository.recentTasks.collectAsState( initial = emptyList(), ) recentTasks } else { val matchingTasks by remember(searchQuery) { tasksRepository.searchTasks(searchQuery) }.collectAsState(initial = emptyList()) matchingTasks } }
รวมแหล่งข้อมูลหลายรายการของรัฐ
เนื่องจาก Compose จะติดตามการอ่านสถานะโดยอัตโนมัติ คุณจึงรวมพร็อพเพอร์ตี้ State<T> และสตรีม Flow จำนวนเท่าใดก็ได้ไว้ในบล็อก transform เดียว Flow.combine ซึ่งให้เฉพาะโอเวอร์โหลดที่พิมพ์สำหรับโฟลว์สูงสุด 5 รายการก่อนที่จะกลับไปใช้ Array<Any?> ที่ไม่ได้พิมพ์ การเพิ่มอินพุตอื่นใน transform จะเป็นการอ่านที่ปลอดภัยต่อประเภทเดียวเสมอ ดังนี้
var showCompleted by mutableStateOf(false) var selectedCategory by mutableStateOf<Category?>(null) val dashboardState: State<DashboardUiState> = transform( initialValue = DashboardUiState(), scope = coroutineScope, ) { val tasks by tasksRepository.tasksFlow.collectAsState(initial = emptyList()) val sortOrder by userPrefsRepository.sortOrderFlow.collectAsState( initial = SortOrder.BY_DEADLINE, ) val focusSession by focusSessionManager.session val isOnline by networkMonitor.isOnline val visibleTasks = tasks .filter { task -> (showCompleted || !task.isCompleted) && (selectedCategory == null || task.category == selectedCategory) } .sortedWith(sortOrder.comparator) DashboardUiState( tasks = visibleTasks, selectedCategory = selectedCategory, focusSession = focusSession, isOnline = isOnline, ) }
ใช้ transform ใน ViewModel
โอเวอร์โหลด transform ที่ไม่สามารถคอมโพสได้จะเรียกใช้ตรรกะเชิงรับในเลเยอร์ที่ไม่ใช่ UI (เช่น ViewModel หรือที่เก็บข้อมูล) ขณะใช้โมเดลสถานะของ Compose โดยจะทำงานตราบใดที่ CoroutineScope ที่ระบุยังทำงานอยู่ คุณจึงเชื่อมโยงกับวงจรของคอมโพเนนต์ได้โดยส่ง viewModelScope ไว้ใน ViewModel ดังนี้
class HomeViewModel( private val mediaRepository: MediaRepository, private val networkMonitor: NetworkMonitor, ) : ViewModel() { val uiState: State<HomeUiState> = transform<HomeUiState>( initialValue = HomeUiState.Loading, scope = viewModelScope, ) { val isOnline by networkMonitor.isOnline val tracks = if (isOnline) { mediaRepository.allTracksFlow.collectAsState(initial = null).value } else { mediaRepository.downloadedTracksFlow .collectAsState(initial = null).value } ?: return@transform HomeUiState.Loading HomeUiState.Success( tracks = tracks, isOnline = isOnline, ) } }
ใน ViewModel นี้ uiState จะอ่าน networkMonitor.isOnline และสลับระหว่างการรวบรวม mediaRepository.downloadedTracksFlow เมื่อออฟไลน์และ mediaRepository.allTracksFlow เมื่อออนไลน์แบบไดนามิก โดยแสดง State<HomeUiState> รายการเดียวที่ UI Composable สามารถอ่านได้โดยตรง ดูรายละเอียดเพิ่มเติมเกี่ยวกับการรวม transform เข้ากับ MutableState และ SavedStateHandle.saveable ได้ที่ใช้ Compose State ใน ViewModel
เลือกระหว่าง transform, derivedStateOf และ Flow
ใช้หลักเกณฑ์ต่อไปนี้เพื่อเลือกระหว่าง transform, derivedStateOf และ Flow ของ Kotlin
derivedStateOf พบกับ transform
ทั้ง derivedStateOf และ transform จะสร้าง State<T> ที่ได้มาซึ่งจะติดตามสถานะการเขียนที่อ่านภายในบล็อกโดยอัตโนมัติ
เลือกระหว่าง 2 ตัวเลือกนี้โดยพิจารณาว่าคุณกำลังคำนวณค่าจากออบเจ็กต์ State<T> ที่มีอยู่โดยตรง หรือต้องการฟีเจอร์ @Composable เช่น collectAsState, produceState และ LaunchedEffect
| ความสามารถ | derivedStateOf |
transform |
|---|---|---|
| ประเภทแลมบ์ดา | Kotlin Lambda มาตรฐาน (() -> T) |
@Composable lambda (@Composable (R) -> R) |
| อินพุตที่รองรับ | การอ่านแบบซิงโครนัส State<T> เท่านั้น |
State<T>, Kotlin Flow (ใช้ collectAsState), ฟังก์ชัน suspend และ Callback API (ใช้ produceState) |
| การดำเนินการและความสม่ำเสมอ | Lazy และ Synchronous เมื่ออ่าน .value โดยจะอัปเดตแบบอะตอมภายในสแนปชอตเดียวกันโดยไม่มี initialValue หรือ CoroutineScope |
เรียกใช้ Composition ที่ใช้งานอยู่ใน CoroutineScope และจัดองค์ประกอบใหม่แบบอะซิงโครนัสหลังจากคอมมิตสแนปชอต ต้องใช้ initialValue |
| สถานะและผลข้างเคียง | เรียก remember หรือ API ที่มีผลข้างเคียงไม่ได้ |
รองรับฟังก์ชัน remember, LaunchedEffect, produceState และ @Composable ที่นำกลับมาใช้ใหม่ได้ |
ใช้ derivedStateOf เมื่ออินพุตทั้งหมดเป็นออบเจ็กต์ State<T> อยู่แล้ว และคุณไม่จำเป็นต้องใช้ API ของ @Composable
- การดึงข้อมูลแบบซิงโครนัสและสแนปชอตเดียวกัน: การคำนวณค่าหรือรายการที่กรองจากพร็อพเพอร์ตี้
State<T>ที่มีอยู่โดยไม่ต้องจัดการCoroutineScopeหรือระบุตัวยึดตำแหน่งinitialValueเนื่องจากderivedStateOfจะประเมินพร้อมกันเมื่อมีการอ่าน.valueผู้เรียกที่อ่าน.valueทันทีหลังจากอัปเดตสถานะอินพุตจะสังเกตเห็นผลลัพธ์ที่อัปเดตในสแนปชอตเดียวกันเสมอ - ลดความถี่ในการอัปเดตออบเซิร์ฟเวอร์: การคำนวณค่าที่เปลี่ยนแปลงน้อยกว่าแหล่งที่มา
State<T>(เช่น การตรวจสอบว่าออฟเซ็ตการเลื่อนเกินเกณฑ์หรือไม่) ดูรายละเอียดเพิ่มเติมได้ที่เอกสารประกอบderivedStateOf
ใช้ transform เมื่อการดึงข้อมูลต้องใช้รันไทม์ Compose
- การรวม
State<T>เข้ากับสตรีมแบบอะซิงโครนัส: การรวบรวมFlowของ Kotlin จากที่เก็บหรือฐานข้อมูลโดยใช้collectAsStateควบคู่ไปกับอินพุตState<T> - การรีสตาร์ทงานอัปสตรีมเมื่ออินพุตมีการเปลี่ยนแปลง: การใช้
remember(key)ภายในบล็อกเพื่อสร้างFlowที่กำหนดพารามิเตอร์ขึ้นมาใหม่เฉพาะเมื่ออินพุตState<T>ที่เฉพาะเจาะจงอัปเดต - การเรียกใช้ผลข้างเคียงหรือการจดจำสถานะระดับกลาง: การใช้
LaunchedEffect(เช่น เพื่อลดการตีกลับอินพุตหรือเรียกใช้ตัวจับเวลา)produceState(เพื่อโหลดข้อมูลจากฟังก์ชันsuspendหรือรวม Callback ด้วยawaitDispose) หรือrememberเพื่อรักษาสถานะในการจัดองค์ประกอบใหม่
ตัวอย่างต่อไปนี้เปรียบเทียบ derivedStateOf กับบล็อก transform ที่รวม State<T> และที่เก็บ Flow เข้าด้วยกัน
class TaskListViewModel( private val tasksRepository: TasksRepository, private val preferencesManager: TaskPreferencesManager, ) : ViewModel() { var searchQuery by mutableStateOf("") private set // Computing a value from existing State<T>: use derivedStateOf val hasSearchQuery: State<Boolean> = derivedStateOf { searchQuery.isNotBlank() } // Combining State<T> with a Flow and remember(key): use transform val uiState: State<TaskListUiState> = transform<TaskListUiState>( initialValue = TaskListUiState.Loading, scope = viewModelScope, ) { val showCompleted by preferencesManager.showCompleted val tasks by remember(showCompleted) { tasksRepository.observeTasks(showCompleted) }.collectAsState(initial = null) val currentTasks = tasks ?: return@transform TaskListUiState.Loading val filteredTasks = currentTasks.filter { task -> task.title.contains(searchQuery, ignoreCase = true) } TaskListUiState.Success(filteredTasks) } }
ในตัวอย่างนี้ hasSearchQuery จะตรวจสอบเฉพาะสถานะ searchQuery ดังนั้น derivedStateOf จะคำนวณแบบ Lazy โดยไม่มี CoroutineScope ในทางตรงกันข้าม uiState จะรวม preferencesManager.showCompleted และ searchQuery เข้ากับ Flow แบบอะซิงโครนัสจาก tasksRepository และใช้ remember(showCompleted) เพื่อหลีกเลี่ยงการสร้าง Flow ขึ้นมาใหม่เมื่อมีการเปลี่ยนแปลงเฉพาะ searchQuery เท่านั้น
กรณีที่ควรใช้ Flow แทน transform
transform ออกแบบมาเพื่อแสดงค่าปัจจุบันของสถานะ ไม่ใช่สตรีมของเหตุการณ์ หากสถานะมีการเปลี่ยนแปลงหลายครั้งก่อนการจัดองค์ประกอบใหม่ครั้งถัดไป Compose จะใช้เฉพาะค่าล่าสุด และจะข้ามการอัปเดตเมื่อค่าใหม่เท่ากับ (==) ค่าก่อนหน้า ใช้ Flow ของ Kotlin แทนเมื่อตรรกะของคุณต้องประมวลผลทุกค่าตามลำดับ
- ผลรวมและค่าเฉลี่ยสะสม: การคำนวณ เช่น ค่าเฉลี่ยเคลื่อนที่ ผลรวมสะสม หรือประวัติการเลิกทำ (
runningFoldหรือscan) ต้องนับทุกค่าเพียงครั้งเดียว ในบล็อกtransformระบบจะข้ามการอัปเดตอย่างรวดเร็วก่อนการจัดองค์ประกอบใหม่ การอ่านค่าที่เหมือนกัน 2 ครั้งติดต่อกัน (เช่น เซ็นเซอร์ส่งค่า10.02 ครั้ง) จะไม่ทริกเกอร์การจัดองค์ประกอบใหม่ และการจัดองค์ประกอบใหม่ที่ทริกเกอร์โดยสถานะอื่นๆ ในบล็อกเดียวกันอาจนับค่าปัจจุบัน 2 ครั้ง คำนวณค่าที่รันอยู่ในFlowก่อน จากนั้นรวบรวมผลลัพธ์ในtransformหากต้องการรวมกับสถานะอื่นๆ - การบันทึกเหตุการณ์และคิว: ไปป์ไลน์ที่บันทึกการโต้ตอบของผู้ใช้ ประมวลผลคำขอเครือข่ายที่อยู่ในคิว หรือบันทึกธุรกรรมที่ต้องบันทึกการเปลี่ยนแปลงระดับกลางทุกรายการ (
A -> B -> CหรือA -> B -> A) - เหตุการณ์ที่เกิดขึ้นครั้งเดียว: การดำเนินการต่างๆ เช่น การเล่นเสียงหรือการส่งคำสั่งฮาร์ดแวร์ ซึ่งการส่งคำสั่งเดียวกัน 2 ครั้งติดต่อกันจะต้องทำงาน 2 ครั้ง
transformต้องใช้initialValueและข้ามเอาต์พุตที่เท่ากันที่ต่อเนื่องกัน - สตรีมที่ไวต่อแรงดันย้อนกลับ: สตรีม (เช่น การอ่านไฟล์หรือซ็อกเก็ต) ที่ผู้ผลิตที่รวดเร็วต้องระงับเมื่อผู้บริโภคตามไม่ทัน การเขียนสถานะ Compose จะไม่ระงับและเขียนทับค่าก่อนหน้าแทน
ทดสอบการเปลี่ยนรูปแบบ
เนื่องจากการเปลี่ยนรูปแบบอาศัยรันไทม์ Compose และ Kotlin Coroutine การทดสอบจึงต้องประสานงานการดำเนินการแบบอะซิงโครนัสและการจัดองค์ประกอบสถานะใหม่ ใช้อาร์ติแฟกต์ androidx.appstate.transform:transform-testing เพื่อควบคุมเครื่องจัดตารางเวลา Coroutine และทริกเกอร์การจัดองค์ประกอบใหม่ใน Unit Test
แพ็กเกจ androidx.appstate.transform.testing มี API ต่อไปนี้
package androidx.appstate.transform.testing
import kotlin.coroutines.CoroutineContext
import kotlin.coroutines.EmptyCoroutineContext
import kotlin.time.Duration
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.test.TestCoroutineScheduler
import kotlinx.coroutines.test.TestDispatcher
import kotlinx.coroutines.test.TestResult
public fun transformTest(
context: CoroutineContext = EmptyCoroutineContext,
block: suspend TransformTestScope.() -> Unit,
): TestResult
public sealed interface TransformTestScope : CoroutineScope {
public val testCoroutineScheduler: TestCoroutineScheduler
public val testDispatcher: TestDispatcher
public val backgroundScope: CoroutineScope
public fun advance()
public fun advanceTimeBy(duration: Duration)
public fun advanceUntilIdle()
}
transformTest จะรวม runTest และเรียกใช้บล็อกการทดสอบด้วยตัวรับ TransformTestScope:
backgroundScope:CoroutineScopeสำหรับการเรียกใช้การเปลี่ยนรูปแบบที่ไม่ควรบล็อกการทดสอบให้เสร็จสมบูรณ์ ระบบจะยกเลิกการทดสอบโดยอัตโนมัติเมื่อการทดสอบเสร็จสิ้น ซึ่งจะหยุดการเปลี่ยนรูปแบบtestDispatcher:TestDispatcherที่ลิงก์กับtestCoroutineSchedulerส่งค่านี้ไปยังพารามิเตอร์dispatcherของtransformเพื่อให้การจัดองค์ประกอบใหม่ทำงานในตัวจัดตารางเวลาทดสอบแทนที่จะเป็นค่าเริ่มต้นDispatchers.Defaultadvance: เรียกใช้ Coroutine ที่รอดำเนินการในเครื่องจัดตารางเวลาการทดสอบ ส่งการแจ้งเตือนการใช้สแนปชอตของ Compose (Snapshot.sendApplyNotifications) และจัดองค์ประกอบtransformใหม่เพื่อให้เอาต์พุตStateอัปเดตadvanceTimeBy(duration)และadvanceUntilIdle: เลื่อนนาฬิกาเสมือนในtestCoroutineSchedulerสำหรับการดำเนินการที่ขึ้นอยู่กับเวลา
ทดสอบการอัปเดตสถานะ
หากต้องการทดสอบ transform ให้ส่ง backgroundScope และ testDispatcher จาก TransformTestScope แล้วเรียกใช้ advance หลังจากอัปเดตสถานะอินพุตหรือ Flow:
@Test fun testStateUpdate() = transformTest { var sourceState by mutableStateOf(0) val state = transform( initialValue = 0, scope = backgroundScope, dispatcher = testDispatcher, ) { sourceState } assertThat(state.value).isEqualTo(0) // Modify the source state and advance to trigger recomposition. sourceState = 1 advance() assertThat(state.value).isEqualTo(1) }
ทดสอบการดำเนินการตามเวลา
เมื่อ transform ใช้ผลข้างเคียงตามเวลา เช่น LaunchedEffect และ delay (เช่น การดีบาวน์คำค้นหา) ให้รวม advanceTimeBy(duration) กับ advance:
- หลังจากแก้ไขสถานะอินพุตแล้ว ให้เรียกใช้
advanceเพื่อให้บล็อกtransformจัดองค์ประกอบใหม่และเริ่มLaunchedEffect - เรียกใช้
advanceTimeBy(duration)เพื่อเลื่อนนาฬิกาเสมือนไปข้างหน้า แล้วเรียกใช้advanceเพื่อเรียกใช้งานที่กำหนดเวลาไว้ที่การประทับเวลานั้นและจัดองค์ประกอบtransformใหม่
@Test fun testDebouncedSearch() = transformTest { var query by mutableStateOf("") val searchResults = transform<List<String>>( initialValue = emptyList(), scope = backgroundScope, dispatcher = testDispatcher, ) { var results by remember { mutableStateOf(emptyList<String>()) } LaunchedEffect(query) { if (query.isBlank()) { results = emptyList() return@LaunchedEffect } delay(300.milliseconds) results = api.search(query) } results } query = "compose" // Recompose to start the LaunchedEffect for the new query. advance() // Results don't update before the 300ms debounce delay completes. advanceTimeBy(200.milliseconds) advance() assertThat(searchResults.value).isEmpty() // Advance past the remaining 100ms and trigger recomposition. advanceTimeBy(100.milliseconds) advance() assertThat(searchResults.value).containsExactly("Compose State", "Compose UI") }
ทดสอบ ViewModel หรือตัวเก็บสถานะ
เนื่องจาก transform เรียกใช้ Composition ใน scope ที่ระบุและกำหนดค่าเริ่มต้นของพารามิเตอร์ dispatcher เป็น Dispatchers.Default คลาสที่สร้าง transform ควรยอมรับ CoroutineScope และ CoroutineDispatcher เป็นพารามิเตอร์คอนสตรัคเตอร์ ใน ViewModel ให้ส่ง CoroutineScope ที่แทรกไปยังเครื่องมือสร้าง ViewModel(coroutineScope) และส่งต่อ CoroutineDispatcher ไปยัง transform
class HomeViewModel( private val mediaRepository: MediaRepository, private val networkMonitor: NetworkMonitor, coroutineScope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate), dispatcher: CoroutineDispatcher = Dispatchers.Default, ) : ViewModel(coroutineScope) { val uiState: State<HomeUiState> = transform<HomeUiState>( initialValue = HomeUiState.Loading, scope = viewModelScope, dispatcher = dispatcher, ) { val isOnline by networkMonitor.isOnline val tracks = if (isOnline) { mediaRepository.allTracksFlow.collectAsState(initial = null).value } else { mediaRepository.downloadedTracksFlow .collectAsState(initial = null).value } ?: return@transform HomeUiState.Loading HomeUiState.Success( tracks = tracks, isOnline = isOnline, ) } } @Test fun testHomeViewModelOfflineState() = transformTest { val mediaRepository = FakeMediaRepository() val networkMonitor = FakeNetworkMonitor() val viewModel = HomeViewModel( mediaRepository = mediaRepository, networkMonitor = networkMonitor, coroutineScope = backgroundScope, dispatcher = testDispatcher, ) val downloadedTrack = Track(id = "1", isDownloaded = true) val streamingTrack = Track(id = "2", isDownloaded = false) mediaRepository.emitAllTracks(listOf(downloadedTrack, streamingTrack)) mediaRepository.emitDownloadedTracks(listOf(downloadedTrack)) advance() assertThat(viewModel.uiState.value).isEqualTo( HomeUiState.Success( tracks = listOf(downloadedTrack, streamingTrack), isOnline = true, ) ) // Switch offline and verify it switches to downloadedTracksFlow. networkMonitor.setOnline(false) // First advance() recomposes into the offline branch (where collectAsState // initially returns null -> Loading) and starts collecting downloadedTracksFlow. advance() // Second advance() applies the collected downloadedTracksFlow value and // recomposes uiState with the offline Success state. advance() assertThat(viewModel.uiState.value).isEqualTo( HomeUiState.Success( tracks = listOf(downloadedTrack), isOnline = false, ) ) }
ในการทดสอบนี้ การส่ง backgroundScope และ testDispatcher ไปยัง HomeViewModel จะเชื่อมโยง Composition viewModelScope และ transform กับตัวจัดตารางเวลาการทดสอบ ดังนั้นการเรียกใช้ advance แต่ละครั้งจะประมวลผลการปล่อยจาก FakeMediaRepository และ FakeNetworkMonitor ในเชิงกำหนด ดูรูปแบบเพิ่มเติมเกี่ยวกับการใช้ SavedStateHandle.saveable และ Snapshot.withMutableSnapshot ใน ViewModel ได้ที่ใช้สถานะ Compose ใน ViewModel