Transform   Jest częścią Android Jetpack.

Transform to biblioteka oparta na środowisku wykonawczym Compose, która uruchamia blok @Composable bez interfejsu, aby przekształcać dane wejściowe w State Compose. Za każdym razem, gdy zmieni się dowolny odczyt State w bloku, blok automatycznie ponownie się skomponuje i zaktualizuje zwracany State. Dzięki temu możesz używać modelu śledzenia zależności i ponownej kompozycji Compose w ViewModel, repozytoriach lub usługach w tle.

Przypadki użycia

  • Reaktywne przekształcanie danych: konwertowanie nieprzetworzonych danych (takich jak odpowiedzi sieciowe lub zapytania do bazy danych) na stan gotowy do użycia w interfejsie.
  • Łączenie wielu danych wejściowych: scalanie wielu niezależnych źródeł stanu (takich jak dane wejściowe użytkownika, ustawienia i stan sieci) w jeden spójny stan bez złożonych operatorów Flow.
  • Debouncing i throttling: zaimplementuj zachowanie oparte na czasie (np. debouncing zapytań) za pomocą standardowych efektów ubocznych Compose, takich jak LaunchedEffect.
  • Operacje warunkowe: uruchamiaj różne funkcje zbierania danych o stanie na podstawie bieżących warunków, korzystając ze standardowych wyrażeń if-else lub when.

Przegląd interfejsów API

Pakiet androidx.appstate.transform udostępnia te funkcje:

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,
)

Tworzenie przekształcenia

Przekształcenia możesz tworzyć samodzielnie (za pomocą udostępnionego CoroutineScope) lub w ramach istniejącej kompozycji. Kluczowe parametry każdej transformacji to initialValue i onUpdate. Funkcja lambda onUpdate otrzymuje poprzednią wartość wyjściową R jako parametr (zainicjowany jako initialValue przy pierwszej kompozycji). Możesz zwrócić ten parametr, aby zachować ostatni wyemitowany stan zamiast resetować go do stanu wczytywania, np. { previous -> flow.collectAsState(initial = null).value ?: previous }.

Możesz na przykład użyć transform, aby obserwować zmiany w ciągu zapytania i ograniczyć kosztowne obliczenia, takie jak wywołania sieciowe:

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
}

W tym fragmencie kodu:

  • query jest obsługiwany przez MutableState, który może być odczytywany i aktualizowany przez komponent interfejsu funkcji kompozycyjnej paska wyszukiwania.
  • transform przyjmuje initialValue pustej listy, CoroutineScope i onUpdate jako końcową funkcję lambda.
  • Wewnątrz bloku onUpdate funkcja remember zachowuje stan results między rekompozycjami, podczas gdy LaunchedEffect jest uruchomiona.
  • LaunchedEffect przyjmuje query jako klucz. Ponieważ query jest wspierany przez MutableState, efekt jest anulowany i uruchamiany ponownie za każdym razem, gdy query się zmienia.
  • Aby uniknąć wywoływania interfejsu API przy każdym naciśnięciu klawisza, treść efektu czeka 300 ms. Jeśli zapytanie zmieni się przed upływem tego czasu, efekt zostanie anulowany i nie zostanie wykonane żadne wywołanie sieciowe.

W funkcji @Composable możesz też użyć przeciążenia @Composable transform, które wewnętrznie zapamiętuje CoroutineScope i domyślnie uruchamia onUpdate w Dispatchers.Default. Jest to przydatne, gdy chcesz odciążyć główny wątek UI od kosztownego wyprowadzania stanu (np. filtrowania lub przeszukiwania dużej listy):

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

Obserwuj stan za pomocą listener

Jeśli chcesz reagować na zmiany State w Compose w komponencie innym niż interfejs użytkownika (np. w menedżerze stanu, repozytorium lub usłudze w tle) bez tworzenia pochodnego State<R>, użyj listener.

listener to funkcja suspend, która uruchamia blok @Composable () -> Unit, dopóki wywołująca współprogram nie zostanie anulowana, automatycznie rekomponując się, gdy zmieni się stan odczytu w 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...
}

W tym przykładzie listener obserwuje session.value. Gdy sesja jest w stanie FocusSession.Running, LaunchedEffect czeka do endTimeMillis, zanim zresetuje sesję do stanu FocusSession.Idle i opublikuje powiadomienie o zakończeniu. Jeśli użytkownik wstrzyma lub zakończy sesję wcześniej, gałąź Running opuści kompozycję i automatycznie anuluje minutnik. Gdy scope zostanie anulowany, listener przestanie obserwować.

Typowe wzorce

Poniższe wzorce pokazują, jak używać transform do logiki warunkowej, łączenia wielu strumieni i generowania stanu interfejsu w ViewModel.

Produkcja stanu warunkowego

Używaj standardowych wyrażeń if-else lub when w języku Kotlin w funkcji transform, aby przełączać się między źródłami stanu nadrzędnego na podstawie bieżących warunków. Ponieważ śledzenie odczytu jest dynamiczne, transform obserwuje tylko gałąź, która działa w bieżącej rekompozycji:

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

Łączenie wielu źródeł stanu

Compose automatycznie śledzi odczyty stanu, więc możesz połączyć dowolną liczbę właściwości State<T> i strumieni Flow w jednym bloku transform. W przeciwieństwie do Flow.combine, który zapewnia tylko typowane przeciążenia dla maksymalnie 5 przepływów, zanim powróci do nietypowanego Array<Any?>, dodanie kolejnych danych wejściowych w transform zawsze jest pojedynczym odczytem z zabezpieczeniami wpisywania:

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,
    )
}

Używanie transform w ViewModel

Przeciążenie transform, które nie jest elementem kompozycyjnym, uruchamia logikę reaktywną w warstwach innych niż interfejs (np. ViewModels lub repozytoria) podczas korzystania z modelu stanu Compose. Działa tak długo, jak długo aktywny jest podany CoroutineScope, więc możesz powiązać go z cyklem życia komponentu, przekazując viewModelScope w 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,
        )
    }
}

W tym ViewModel uiState odczytuje networkMonitor.isOnline i dynamicznie przełącza się między zbieraniem mediaRepository.downloadedTracksFlow w trybie offline i mediaRepository.allTracksFlow w trybie online, udostępniając pojedynczy State<HomeUiState>, który elementy kompozycyjne interfejsu mogą odczytywać bezpośrednio. Więcej informacji o łączeniu transform z MutableState i SavedStateHandle.saveable znajdziesz w artykule na temat używania stanu Compose w ViewModel.

Wybierz transform, derivedStateOf lub Flow

Skorzystaj z tych wskazówek, aby wybrać między transform, derivedStateOf i Kotlinem Flow:

derivedStateOf – transform

Zarówno derivedStateOf, jak i transform generują pochodny obiekt State<T>, który automatycznie śledzi stany Compose odczytywane w jego bloku. Wybierz jedną z nich w zależności od tego, czy obliczasz wartość bezpośrednio z istniejących obiektów State<T>, czy potrzebujesz funkcji @Composable, takich jak collectAsState, produceState i LaunchedEffect:

Możliwości derivedStateOf transform
Typ Lambda Standardowa funkcja lambda w języku Kotlin (() -> T) @Composable lambda (@Composable (R) -> R)
Obsługiwane dane wejściowe Tylko synchroniczne odczyty State<T> State<T>, Kotlin Flow (z użyciem collectAsState), funkcje suspend i interfejsy API oparte na wywołaniach zwrotnych (z użyciem produceState)
Realizacja i spójność Leniwe i synchroniczne, gdy odczytywana jest wartość .value; aktualizuje się niepodzielnie w ramach tego samego zrzutu bez initialValue ani CoroutineScope Uruchamia aktywną kompozycję w CoroutineScope i rekomponuje asynchronicznie po zatwierdzeniu zrzutu; wymaga initialValue
Stan i efekty uboczne Nie można wywołać interfejsów API remember ani interfejsów API z efektami ubocznymi Obsługuje funkcje remember, LaunchedEffect, produceState i funkcje @Composable wielokrotnego użytku

Używaj derivedStateOf, gdy wszystkie dane wejściowe są już obiektami State<T> i nie potrzebujesz interfejsów API @Composable:

  • Synchroniczne pochodne z tego samego zrzutu: obliczanie wartości lub filtrowanej listy na podstawie istniejących właściwości State<T> bez zarządzania CoroutineScope lub podawania obiektu zastępczego initialValue. Ponieważ derivedStateOf jest oceniany synchronicznie podczas odczytu .value, elementy wywołujące, które odczytują .value natychmiast po zaktualizowaniu stanu wejściowego, zawsze widzą zaktualizowany wynik w tym samym zrzucie.
  • Zmniejszenie częstotliwości aktualizacji obserwatorów: obliczanie wartości, która zmienia się rzadziej niż jej źródło State<T> (np. sprawdzanie, czy przesunięcie przewijania przekracza próg). Więcej informacji znajdziesz w dokumentacji derivedStateOf.

Użyj transform, gdy pochodna wymaga środowiska wykonawczego Compose:

  • Łączenie State<T> ze strumieniami asynchronicznymi: zbieranie danych Kotlin Flow z repozytorium lub bazy danych za pomocą collectAsState i danych wejściowych State<T>.
  • Ponowne uruchamianie zadań nadrzędnych po zmianie danych wejściowych: używanie remember(key) wewnątrz bloku do ponownego tworzenia sparametryzowanego Flow tylko wtedy, gdy określone dane wejściowe State<T> zostaną zaktualizowane.
  • Uruchamianie efektów ubocznych lub zapamiętywanie stanu pośredniego: używanie LaunchedEffect (np. do eliminowania drgań styków lub uruchamiania liczników czasu), produceState (do wczytywania danych z funkcji suspend lub opakowywania wywołania zwrotnego za pomocą awaitDispose) lub remember do zachowywania stanu między rekompozycjami.

W tym przykładzie porównujemy derivedStateOf z blokiem transform, który łączy State<T> i repozytorium 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)
    }
}

W tym przykładzie hasSearchQuery sprawdza tylko stan searchQuery, więc derivedStateOf oblicza go leniwie bez CoroutineScope. Z kolei uiState łączy preferencesManager.showCompleted i searchQuery z asynchronicznym Flow z tasksRepository i używa remember(showCompleted), aby uniknąć ponownego tworzenia Flow, gdy zmienia się tylko searchQuery.

Kiedy używać Flow zamiast transform

transform reprezentuje bieżącą wartość stanu, a nie strumień zdarzeń. Jeśli stan zmieni się wielokrotnie przed kolejną rekompozycją, Compose użyje tylko najnowszej wartości i pominie aktualizacje, gdy nowa wartość będzie równa (==) poprzedniej. Jeśli logika musi przetwarzać każdą wartość po kolei, użyj funkcji Flow w języku Kotlin:

  • Sumy i średnie bieżące: obliczenia takie jak średnie ruchome, sumy skumulowane czy historia cofania (runningFold lub scan) wymagają, aby każda wartość była liczona raz. W bloku transform szybkie aktualizacje przed rekompozycją są pomijane, 2 identyczne odczyty z rzędu (np. czujnik emitujący 10.0 dwukrotnie) nie wywołują rekompozycji, a rekompozycje wywołane przez inne stany w tym samym bloku mogą liczyć bieżącą wartość dwukrotnie. Najpierw oblicz wartość bieżącą w Flow, a następnie zbierz wynik w transform, jeśli chcesz połączyć go z innym stanem.
  • Rejestrowanie zdarzeń i kolejki: potoki, które rejestrują interakcje użytkowników, przetwarzają żądania sieciowe w kolejce lub rejestrują transakcje, w przypadku których należy zarejestrować każdą zmianę pośrednią (A -> B -> C lub A -> B -> A).
  • Zdarzenia jednorazowe: działania takie jak odtworzenie dźwięku lub wysłanie polecenia sprzętowego, w przypadku których wysłanie tego samego polecenia 2 razy z rzędu musi spowodować jego dwukrotne wykonanie. transform wymaga initialValue i pomija kolejne równe dane wyjściowe.
  • Strumienie wrażliwe na przeciwciśnienie: strumienie (takie jak odczyty plików lub gniazd), w których szybki producent musi zawiesić działanie, gdy konsument pozostaje w tyle. Zapisy stanu tworzenia nie są zawieszane i zastępują poprzednie wartości.

Testuj przekształcenia

Przekształcenia opierają się na środowisku wykonawczym Compose i funkcjach Coroutines w języku Kotlin, dlatego ich testowanie wymaga koordynacji asynchronicznego wykonywania i rekompozycji stanu. Użyj artefaktu androidx.appstate.transform:transform-testing, aby kontrolować harmonogram współprogramów i wywoływać rekompozycje w testach jednostkowych.

Pakiet androidx.appstate.transform.testing udostępnia te interfejsy 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 opakowuje runTest i wykonuje blok testowy z odbiornikiem TransformTestScope:

  • backgroundScope: CoroutineScope do uruchamiania przekształceń, które nie powinny blokować ukończenia testu. Zostanie ono automatycznie anulowane po zakończeniu testu, co spowoduje zatrzymanie przekształcenia.
  • testDispatcher: TestDispatcher połączony z testCoroutineScheduler. Przekaż to do parametru dispatcher funkcji transform, aby rekompozycja była uruchamiana w harmonogramie testowym, a nie domyślnie w Dispatchers.Default.
  • advance: uruchamia oczekujące współprogramy w harmonogramie testów, wysyła powiadomienia o zastosowaniu migawki Compose (Snapshot.sendApplyNotifications) i ponownie tworzy transform, aby zaktualizować dane wyjściowe State.
  • advanceTimeBy(duration) i advanceUntilIdle: przesuń zegar wirtualny na testCoroutineScheduler w przypadku operacji zależnych od czasu.

Aktualizacje stanu testu

Aby przetestować transform, przekaż backgroundScope i testDispatcher z TransformTestScope, a następnie wywołaj advance po zaktualizowaniu stanu wejściowego lub 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)
}

Testowanie operacji czasowych

Gdy transform używa efektów ubocznych opartych na czasie, takich jak LaunchedEffect i delay (np. debouncing zapytań), połącz advanceTimeBy(duration) z advance:

  1. Po zmodyfikowaniu stanu wejściowego wywołaj funkcję advance, aby blok transform został ponownie skomponowany i uruchomił LaunchedEffect.
  2. Wywołaj advanceTimeBy(duration), aby przesunąć wirtualny zegar do przodu, a następnie wywołaj advance, aby uruchomić zadania zaplanowane na tę sygnaturę czasową i ponownie skomponować 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")
}

Testowanie ViewModel lub zmiennej stanu

transform uruchamia kompozycję w podanym scope i domyślnie ustawia parametr dispatcher na Dispatchers.Default, dlatego klasy, które tworzą transform, powinny akceptować CoroutineScope i CoroutineDispatcher jako parametry konstruktora. W klasie ViewModel przekaż wstrzyknięty obiekt CoroutineScope do konstruktora ViewModel(coroutineScope) i przekaż obiekt CoroutineDispatcher do 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,
        )
    )
}

W tym teście przekazanie backgroundScope i testDispatcher do HomeViewModel wiąże jego viewModelScope i transform Composition z harmonogramem testów, dzięki czemu każde wywołanie advance deterministycznie przetwarza emisje z FakeMediaRepository i FakeNetworkMonitor. Więcej wzorców używania SavedStateHandle.saveable i Snapshot.withMutableSnapshot w ViewModel znajdziesz w artykule Używanie stanu Compose w ViewModel.