‫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:

  1. אחרי שמשנים מצב קלט, קוראים ל-advance כדי שהבלוק transform יורכב מחדש ויתחיל את ה-LaunchedEffect.
  2. קוראים ל-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.