Transformieren   Teil von Android Jetpack

Transform ist eine Bibliothek, die auf der Compose-Laufzeit basiert. Sie führt einen @Composable-Block ohne UI aus, um Eingaben in Compose-State umzuwandeln. Wenn sich ein State-Wert im Block ändert, wird der Block automatisch neu zusammengesetzt und der zurückgegebene State-Wert aktualisiert. So können Sie das Modell für die Abhängigkeitsverfolgung und Neuzusammensetzung von Compose in ViewModels, Repositories oder Hintergrunddiensten verwenden.

Anwendungsfälle

  • Reaktive Datentransformation: Rohdatenstreams (z. B. Netzwerkantworten oder Datenbankabfragen) in einen für die Benutzeroberfläche geeigneten Zustand umwandeln.
  • Mehrere Eingaben kombinieren: Sie können mehrere unabhängige Statusquellen (z. B. Nutzereingaben, Einstellungen und Netzwerkstatus) ohne komplexe Flow-Operatoren zu einem einzigen zusammenhängenden Status zusammenführen.
  • Debouncing und Throttling: Implementieren Sie zeitbasiertes Verhalten (z. B. Debouncing von Suchanfragen) mit standardmäßigen Compose-Nebeneffekten wie LaunchedEffect.
  • Bedingte Vorgänge: Führen Sie je nach aktuellen Bedingungen unterschiedliche Logik zur Statuserfassung mit Standardausdrücken vom Typ if-else oder when aus.

API-Übersicht

Das androidx.appstate.transform-Paket bietet folgende Funktionen:

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

Transformation erstellen

Sie können Transformationen entweder eigenständig (mit einer bereitgestellten CoroutineScope) oder innerhalb einer vorhandenen Komposition erstellen. Die Schlüsselparameter für jede Transformation sind initialValue und onUpdate. Der Lambda-Ausdruck onUpdate empfängt den vorherigen Ausgabewert R als Parameter (bei der ersten Komposition mit initialValue initialisiert). Sie können diesen Parameter zurückgeben, um den zuletzt ausgegebenen Status beizubehalten, anstatt ihn auf einen Ladestatus wie { previous -> flow.collectAsState(initial = null).value ?: previous } zurückzusetzen.

Mit transform können Sie beispielsweise Änderungen an einem Suchanfragestring beobachten und teure Berechnungen wie Netzwerkaufrufe entprellen:

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
}

In diesem Snippet:

  • query wird durch ein MutableState unterstützt, das von einer Suchleisten-UI-Komposition gelesen und aktualisiert werden kann.
  • transform verwendet eine initialValue einer leeren Liste, eine CoroutineScope und onUpdate als nachgestelltes Lambda.
  • Im onUpdate-Block behält remember den results-Zustand während der Neuzusammensetzungen bei, während LaunchedEffect ausgeführt wird.
  • LaunchedEffect verwendet query als Schlüssel. Da query durch einen MutableState unterstützt wird, wird der Effekt jedes Mal abgebrochen und neu gestartet, wenn sich query ändert.
  • Damit die API nicht bei jedem Tastendruck aufgerufen wird, wartet der Effekttext 300 ms. Wenn sich die Abfrage vor Ablauf dieser Verzögerung ändert, wird der Effekt abgebrochen und kein Netzwerkaufruf ausgeführt.

In einer @Composable-Funktion können Sie auch die Überladung @Composable transform verwenden, die sich intern einen CoroutineScope merkt und standardmäßig onUpdate auf Dispatchers.Default ausführt. Das ist nützlich, wenn Sie eine rechenintensive Statusableitung (z. B. das Filtern oder Durchsuchen einer großen Liste) vom Haupt-UI-Thread auslagern möchten:

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

Status mit listener beobachten

Wenn Sie auf Änderungen von State in Compose in einer Nicht-UI-Komponente (z. B. einem Zustandsmanager, Repository oder Hintergrunddienst) reagieren möchten, ohne einen abgeleiteten State<R> zu erzeugen, verwenden Sie listener.

listener ist eine suspend-Funktion, die einen @Composable () -> Unit-Block ausführt, bis die aufrufende Koroutine abgebrochen wird. Dabei wird die Funktion automatisch neu zusammengesetzt, wenn sich ein in onUpdate gelesener Status ändert:

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

In diesem Beispiel beobachtet listener session.value. Wenn die Sitzung FocusSession.Running ist, wartet LaunchedEffect bis endTimeMillis, bevor die Sitzung auf FocusSession.Idle zurückgesetzt und eine Benachrichtigung über den Abschluss gesendet wird. Wenn der Nutzer die Sitzung pausiert oder vorzeitig beendet, verlässt der Running-Zweig die Komposition und bricht den Timer automatisch ab. Wenn scope abgebrochen wird, beendet listener die Beobachtung.

Gängige Muster

Die folgenden Muster zeigen, wie Sie transform für bedingte Logik verwenden, mehrere Streams kombinieren und den UI-Status in einem ViewModel erzeugen.

Bedingte Statusproduktion

Verwenden Sie standardmäßige Kotlin-if-else- oder when-Ausdrücke in transform, um je nach aktuellen Bedingungen zwischen Upstream-Zustandsquellen zu wechseln. Da das Lesetracking dynamisch ist, beobachtet transform nur den Zweig, der in der aktuellen Neuzusammensetzung ausgeführt wird:

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

Mehrere Statusquellen kombinieren

Da Compose automatisch Statuslesevorgänge verfolgt, können Sie beliebig viele State<T>-Eigenschaften und Flow-Streams in einem einzigen transform-Block kombinieren. Im Gegensatz zu Flow.combine, das nur typisierte Überladungen für bis zu fünf Abläufe bietet, bevor auf einen nicht typisierten Array<Any?> zurückgegriffen wird, ist das Hinzufügen einer weiteren Eingabe in transform immer ein einzelner typsicherer Lesevorgang:

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 in einem ViewModel verwenden

Die nicht zusammensetzbare transform-Überladung führt reaktive Logik in Nicht-UI-Ebenen (z. B. ViewModels oder Repositories) aus, während das Zustandsmodell von Compose verwendet wird. Sie wird so lange ausgeführt, wie die angegebene CoroutineScope aktiv ist. Sie können sie also an den Lebenszyklus einer Komponente binden, indem Sie viewModelScope in einem ViewModel übergeben:

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

In diesem ViewModel liest uiState networkMonitor.isOnline und wechselt dynamisch zwischen der Erfassung von mediaRepository.downloadedTracksFlow im Offlinemodus und mediaRepository.allTracksFlow im Onlinemodus. Dabei wird ein einzelner State<HomeUiState> bereitgestellt, den UI-Kompositionen direkt lesen können. Weitere Informationen zum Kombinieren von transform mit MutableState und SavedStateHandle.saveable finden Sie unter Compose-Status in einem ViewModel verwenden.

Wähle zwischen transform, derivedStateOf und Flow

Anhand der folgenden Richtlinien können Sie zwischen transform, derivedStateOf und Kotlin Flow wählen:

derivedStateOf gegen transform

Sowohl derivedStateOf als auch transform erzeugen einen abgeleiteten State<T>, der die in seinem Block gelesenen Compose-Zustände automatisch verfolgt. Wählen Sie zwischen ihnen aus, je nachdem, ob Sie einen Wert direkt aus vorhandenen State<T>-Objekten berechnen oder @Composable-Features wie collectAsState, produceState und LaunchedEffect benötigen:

Funktion derivedStateOf transform
Lambda-Typ Standard-Kotlin-Lambda (() -> T) @Composable Lambda (@Composable (R) -> R)
Unterstützte Eingaben Nur synchrone State<T>-Lesezugriffe State<T>, Kotlin-Flow (mit collectAsState), suspend-Funktionen und Callback APIs (mit produceState)
Ausführung und Konsistenz Lazy und synchron, wenn .value gelesen wird; wird atomar innerhalb desselben Snapshots ohne initialValue oder CoroutineScope aktualisiert Führt eine aktive Komposition in einem CoroutineScope aus und setzt sie asynchron neu zusammen, nachdem ein Snapshot übernommen wurde. Erfordert einen initialValue.
Status und Nebenwirkungen remember oder APIs mit Nebenwirkungen können nicht aufgerufen werden Unterstützt remember, LaunchedEffect, produceState und wiederverwendbare @Composable-Funktionen

Verwenden Sie derivedStateOf, wenn alle Eingaben bereits State<T>-Objekte sind und Sie keine @Composable-APIs benötigen:

  • Synchrone Ableitungen aus demselben Snapshot: Berechnen eines Werts oder einer gefilterten Liste aus vorhandenen State<T>-Properties, ohne eine CoroutineScope zu verwalten oder einen Platzhalter-initialValue bereitzustellen. Da derivedStateOf synchron ausgewertet wird, wenn .value gelesen wird, sehen Aufrufer, die .value unmittelbar nach dem Aktualisieren eines Eingabestatus lesen, immer das aktualisierte Ergebnis im selben Snapshot.
  • Häufigkeit der Observer-Aktualisierungen reduzieren: Berechnen eines Werts, der sich seltener ändert als seine Quelle State<T> (z. B. Prüfen, ob ein Scroll-Offset einen Grenzwert überschreitet). Weitere Informationen finden Sie in der Dokumentation zu derivedStateOf.

Verwenden Sie transform, wenn für Ihre Ableitung die Compose-Laufzeit erforderlich ist:

  • State<T> mit asynchronen Streams kombinieren: Einen Kotlin-Flow aus einem Repository oder einer Datenbank mit collectAsState zusammen mit State<T>-Eingaben erfassen.
  • Upstream-Arbeit bei Eingabeänderungen neu starten: Mit remember(key) im Block wird ein parametrisierter Flow nur dann neu erstellt, wenn eine bestimmte State<T>-Eingabe aktualisiert wird.
  • Nebeneffekte ausführen oder Zwischenstatus speichern: Verwenden Sie LaunchedEffect (z. B. zum Entprellen von Eingaben oder zum Ausführen von Timern), produceState (zum Laden von Daten aus einer suspend-Funktion oder zum Verpacken eines Callbacks mit awaitDispose) oder remember, um den Status über Neuzusammensetzungen hinweg beizubehalten.

Im folgenden Beispiel wird derivedStateOf mit einem transform-Block verglichen, der State<T> und ein Repository Flow kombiniert:

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

In diesem Beispiel prüft hasSearchQuery nur den Status searchQuery, sodass derivedStateOf ihn ohne CoroutineScope verzögert berechnet. Im Gegensatz dazu kombiniert uiState preferencesManager.showCompleted und searchQuery mit einem asynchronen Flow von tasksRepository und verwendet remember(showCompleted), um zu vermeiden, dass Flow neu erstellt wird, wenn sich nur searchQuery ändert.

Wann Flow anstelle von transform verwendet werden sollte

transform ist so konzipiert, dass es den aktuellen Wert des Status darstellt, nicht einen Stream von Ereignissen. Wenn sich ein Status vor der nächsten Neuzusammensetzung mehrmals ändert, verwendet Compose nur den neuesten Wert und überspringt Aktualisierungen, wenn der neue Wert dem vorherigen Wert entspricht (==). Verwenden Sie stattdessen Kotlin-Flow, wenn Ihre Logik jeden Wert der Reihe nach verarbeiten muss:

  • Laufende Summen und Durchschnittswerte: Bei Berechnungen wie gleitenden Durchschnittswerten, kumulativen Summen oder dem Rückgängigmachen des Verlaufs (runningFold oder scan) muss jeder Wert einmal gezählt werden. In einem transform-Block werden schnelle Aktualisierungen vor der Neuzusammensetzung übersprungen. Zwei identische Messwerte hintereinander (z. B. ein Sensor, der zweimal 10.0 ausgibt) lösen keine Neuzusammensetzung aus. Neuzusammensetzungen, die durch andere Zustände im selben Block ausgelöst werden, können den aktuellen Wert zweimal zählen. Berechnen Sie den laufenden Wert zuerst in einem Flow und erfassen Sie das Ergebnis dann in transform, wenn Sie es mit einem anderen Status kombinieren müssen.
  • Ereignisprotokollierung und Warteschlangen: Pipelines, die Nutzerinteraktionen protokollieren, in die Warteschlange gestellte Netzwerkanfragen verarbeiten oder Transaktionen aufzeichnen, bei denen jede Zwischenänderung (A -> B -> C oder A -> B -> A) aufgezeichnet werden muss.
  • Einmalige Ereignisse: Aktionen wie das Abspielen eines Tons oder das Senden eines Hardwarebefehls, bei denen das zweimalige Senden desselben Befehls hintereinander zweimal ausgeführt werden muss. transform erfordert einen initialValue und überspringt aufeinanderfolgende gleiche Ausgaben.
  • Backpressure-sensitive Streams: Streams (z. B. Datei- oder Socket-Lesevorgänge), bei denen ein schneller Producer angehalten werden muss, wenn ein Consumer zurückfällt. Schreibvorgänge im Compose-Status werden nicht ausgesetzt, sondern überschreiben vorherige Werte.

Transformationen testen

Da Transformationen auf der Compose-Laufzeit und Kotlin-Koroutinen basieren, müssen beim Testen die asynchrone Ausführung und die Neuzusammensetzung des Status koordiniert werden. Verwenden Sie das Artefakt androidx.appstate.transform:transform-testing, um den Coroutine-Planer zu steuern und Neuzusammensetzungen in Unittests auszulösen.

Das androidx.appstate.transform.testing-Paket bietet folgende APIs:

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 umschließt runTest und führt den Testblock mit einem TransformTestScope-Empfänger aus:

  • backgroundScope: Ein CoroutineScope zum Ausführen von Transformationen, die den Abschluss des Tests nicht blockieren sollten. Er wird automatisch abgebrochen, wenn der Test abgeschlossen ist, wodurch die Transformation beendet wird.
  • testDispatcher: Ein TestDispatcher, der mit testCoroutineScheduler verknüpft ist. Übergib diesen Wert an den Parameter dispatcher von transform, damit die Neuzusammensetzung im Test-Scheduler ausgeführt wird, anstatt standardmäßig Dispatchers.Default zu verwenden.
  • advance: Führt ausstehende Koroutinen im Testplaner aus, sendet Compose-Snapshot-Anwendungsbenachrichtigungen (Snapshot.sendApplyNotifications) und setzt die transform neu zusammen, damit die Ausgabe State aktualisiert wird.
  • advanceTimeBy(duration) und advanceUntilIdle: Stellen Sie die virtuelle Uhr auf testCoroutineScheduler für zeitabhängige Vorgänge vor.

Statusaktualisierungen testen

Wenn Sie einen transform testen möchten, übergeben Sie backgroundScope und testDispatcher aus TransformTestScope und rufen Sie dann advance auf, nachdem Sie einen Eingabestatus oder Flow aktualisiert haben:

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

Zeitbasierte Vorgänge testen

Wenn ein transform zeitbasierte Nebenwirkungen wie LaunchedEffect und delay verwendet (z. B. Entprellen von Suchanfragen), kombinieren Sie advanceTimeBy(duration) mit advance:

  1. Nachdem Sie einen Eingabestatus geändert haben, rufen Sie advance auf, damit der transform-Block neu zusammengesetzt wird und die LaunchedEffect startet.
  2. Rufen Sie advanceTimeBy(duration) auf, um die virtuelle Uhr vorzustellen, und rufen Sie dann advance auf, um die für diesen Zeitstempel geplanten Aufgaben auszuführen und die transform neu zu erstellen.

@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 oder State Holder testen

Da transform die Komposition im bereitgestellten scope ausführt und der Parameter dispatcher standardmäßig auf Dispatchers.Default gesetzt ist, sollten Klassen, die ein transform erstellen, einen CoroutineScope und einen CoroutineDispatcher als Konstruktorparameter akzeptieren. Übergeben Sie in einer ViewModel die eingefügte CoroutineScope an den ViewModel(coroutineScope)-Konstruktor und leiten Sie die CoroutineDispatcher an transform weiter:

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

In diesem Test werden backgroundScope und testDispatcher an HomeViewModel übergeben, wodurch die viewModelScope- und transform-Komposition an den Testplaner gebunden wird. So verarbeitet jeder advance-Aufruf deterministisch Emissionen von FakeMediaRepository und FakeNetworkMonitor. Weitere Muster für die Verwendung von SavedStateHandle.saveable und Snapshot.withMutableSnapshot in einem ViewModel finden Sie unter Compose-Status in einem ViewModel verwenden.