Transform בארגז הכלים Android Jetpack.
Transform היא ספרייה שמבוססת על סביבת זמן הריצה של Compose, שמריצה בלוק @Composable בלי ממשק משתמש כדי להמיר קלט ל-State של Compose.
בכל פעם ש-State שנקרא בתוך הבלוק משתנה, הבלוק יוצר קומפוזיציה מחדש באופן אוטומטי ומעדכן את ה-State שהוחזר. כך אפשר להשתמש במודל מעקב התלות והקומפוזיציה מחדש של Compose ב-ViewModels, במאגרים או בשירותים שפועלים ברקע.
תרחישים לדוגמה
- טרנספורמציה של נתונים ריאקטיביים: המרה של מקורות נתונים גולמיים (כמו תגובות רשת או שאילתות של מסדי נתונים) למצב שמוכן לממשק המשתמש.
- שילוב של כמה מקורות קלט: מיזוג של כמה מקורות מצב בלתי תלויים (כמו קלט של משתמש, הגדרות וסטטוס רשת) למצב אחיד אחד בלי אופרטורים מורכבים של
Flow. - Debouncing ו-throttling: הטמעת התנהגות מבוססת-זמן (כמו debouncing של שאילתות חיפוש) באמצעות תופעות לוואי רגילות של 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. ה-lambda onUpdate מקבלת את ערך הפלט הקודם 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בתור למבדה נגררת. - בתוך הבלוק
onUpdate, הפונקציהrememberשומרת על המצב של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
כשרוצים להגיב לשינויים ב-Compose State ברכיב שאינו ממשק משתמש (כמו מנהל מצב, מאגר או שירות שפועל ברקע) בלי ליצור State<R> נגזר, משתמשים ב-listener.
listener היא פונקציה של suspend שמריצה בלוק של @Composable () -> Unit עד שה-coroutine שקורא לה מבוטל, ויוצרת מחדש באופן אוטומטי בכל פעם שכל מצב שנקרא בתוך 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 יוצא מהקומפוזיציה ומבטל את הטיימר באופן אוטומטי. כשהפעולה scope מבוטלת, listener מפסיק את התצפית.
דפוסים נפוצים
התבניות הבאות מראות איך להשתמש ב-transform ללוגיקה מותנית, לשילוב כמה מקורות נתונים וליצירת מצב של ממשק המשתמש ב-ViewModel.
הפקת מצב מותנה
משתמשים בביטויי Kotlin רגילים מסוג if-else או when בתוך 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, שמספק עומסי יתר מוקלדים רק לעד חמישה תהליכים לפני שהוא חוזר ל-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 שאינו קומפוזבילי מריץ לוגיקה ריאקטיבית בשכבות שאינן ממשק משתמש (כמו ViewModels או מאגרים) תוך שימוש במודל המצב של 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 קומפוזביליים יכולים לקרוא ישירות. לפרטים נוספים על שילוב של transform עם MutableState ו-SavedStateHandle.saveable, ראו שימוש ב-Compose State ב-ViewModel.
בוחרים בין transform, derivedStateOf ו-Flow
כדי לבחור בין transform, derivedStateOf ו-Kotlin Flow, תוכלו להיעזר בהנחיות הבאות:
derivedStateOf מול transform
גם derivedStateOf וגם transform יוצרים State<T> נגזר שעוקב באופן אוטומטי אחרי מצבי Compose שנקראים בתוך הבלוק שלו.
כדי לבחור ביניהם, צריך להחליט אם אתם מחשבים ערך ישירות מאובייקטים קיימים של State<T> או שאתם צריכים תכונות של @Composable כמו collectAsState, produceState ו-LaunchedEffect:
| פונקציה | derivedStateOf |
transform |
|---|---|---|
| סוג למדא | Lambda רגיל של Kotlin (() -> T) |
@Composable למבדה (@Composable (R) -> R) |
| סוגי קלט נתמכים | קריאות סינכרוניות State<T> לקריאה בלבד |
State<T>, Kotlin Flow (באמצעות collectAsState), פונקציות suspend וממשקי callback API (באמצעות produceState) |
| ביצוע ועקביות | הפעולה מתבצעת באופן עצל וסינכרוני כשקוראים את .value; העדכונים מתבצעים באופן אטומי באותה תמונת מצב בלי initialValue או CoroutineScope |
מריץ קומפוזיציה פעילה ב-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 מחשב אותו באופן עצל בלי CoroutineScope. לעומת זאת, ב-uiState משולבים preferencesManager.showCompleted ו-searchQuery עם Flow אסינכרוני מ-tasksRepository, ונעשה שימוש ב-remember(showCompleted) כדי להימנע מיצירה מחדש של Flow כשמשתנה רק searchQuery.
מתי כדאי להשתמש ב-Flow במקום ב-transform
transform נועד לייצג את הערך הנוכחי של המצב, ולא זרם של אירועים. אם מצב משתנה כמה פעמים לפני ה-recomposition הבא, Compose משתמש רק בערך האחרון, ומדלג על עדכונים כשהערך החדש שווה (==) לערך הקודם. במקום זאת, השתמשו ב-Flow של Kotlin כשהלוגיקה צריכה לעבד כל ערך לפי הסדר:
- סיכומים וממוצעים מצטברים: חישובים כמו ממוצעים נעים, סכומים מצטברים או היסטוריית ביטולים (
runningFoldאוscan) דורשים שכל ערך ייספר פעם אחת. בבלוקtransform, המערכת מדלגת על עדכונים מהירים לפני הרכבה מחדש, שתי קריאות זהות ברצף (כמו חיישן שפולט10.0פעמיים) לא מפעילות הרכבה מחדש, והרכבות מחדש שמופעלות על ידי מצבים אחרים באותו בלוק יכולות לספור את הערך הנוכחי פעמיים. קודם מחשבים את הערך הרץ ב-Flow, ואז אוספים את התוצאה ב-transformאם צריך לשלב אותה עם מצב אחר. - רישום אירועים ביומן ותורים: צינורות עיבוד נתונים שמתעדים אינטראקציות של משתמשים, מעבדים בקשות לאחזור מהרשת בתור או מתעדים עסקאות שבהן צריך לתעד כל שינוי ביניים (
A -> B -> CאוA -> B -> A). - אירועים חד-פעמיים: פעולות כמו השמעת צליל או שליחת פקודה לחומרה, שבהן שליחת אותה פקודה פעמיים ברצף צריכה לפעול פעמיים.
הפונקציה
transformדורשתinitialValueומדלגת על פלטים זהים עוקבים. - זרמים שרגישים ללחץ נגדי: זרמים (כמו קריאות של קבצים או שקעים) שבהם יצרן מהיר צריך להשהות את הפעולה כשצרכן מפגר מאחור. פעולות כתיבה של מצב Compose לא מושהות, ובמקום זאת הן מחליפות את הערכים הקודמים.
בדיקת הטרנספורמציות
מכיוון שהטרנספורמציות מסתמכות על זמן הריצה של Compose ועל פונקציות Coroutine של Kotlin, כדי לבדוק אותן צריך לתאם בין הביצוע האסינכרוני לבין ה-recomposition של המצב. משתמשים ברכיב androidx.appstate.transform:transform-testing כדי לשלוט בתזמון של פונקציות ה-coroutine ולהפעיל הרכבות מחדש בבדיקות יחידה.
החבילה 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.Default.-
advance: מריצה קורוטינות בהמתנה במתזמן הבדיקות, שולחת התראות על החלת תמונת מצב של 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 מריצה את הקומפוזיציה שלה ב-scope שסופק, וברירת המחדל של הפרמטר dispatcher היא Dispatchers.Default, מחלקות שיוצרות transform צריכות לקבל CoroutineScope ו-CoroutineDispatcher כפרמטרים של בנאי. ב-ViewModel, מעבירים את ה-CoroutineScope המוחדר ל-constructor של 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 מקשרת את הקומפוזיציה של viewModelScope ו-transform למתזמן הבדיקות, כך שכל קריאה ל-advance מעבדת באופן דטרמיניסטי את הפליטות מ-FakeMediaRepository ומ-FakeNetworkMonitor. דוגמאות נוספות לשימוש ב-SavedStateHandle.saveable וב-Snapshot.withMutableSnapshot ב-ViewModel מפורטות במאמר שימוש ב-Compose State ב-ViewModel.