Nebeneffekte in „Compose“

Ein Nebeneffekt ist eine Änderung des Status der App, die außerhalb des Bereichs einer zusammensetzbaren Funktion erfolgt. Aufgrund des Lebenszyklus von Composables und Eigenschaften wie unvorhersehbaren Neuzusammensetzungen, der Ausführung von Neuzusammensetzungen von Composables in unterschiedlicher Reihenfolge oder Neuzusammensetzungen, die verworfen werden können, sollten Composables idealerweise frei von Nebenwirkungen sein.

Manchmal sind jedoch Nebenwirkungen erforderlich, z. B. um ein einmaliges Ereignis auszulösen, wie das Anzeigen einer Snackbar oder das Navigieren zu einem anderen Bildschirm bei einer bestimmten Statusbedingung. Diese Aktionen sollten aus einer kontrollierten Umgebung aufgerufen werden, die den Lebenszyklus der zusammensetzbaren Funktion kennt. Auf dieser Seite erfahren Sie mehr über die verschiedenen APIs für Nebeneffekte, die Jetpack Compose bietet.

Anwendungsfälle für Status und Effekt

Wie in der Dokumentation Thinking in Compose beschrieben, sollten zusammensetzbare Funktionen keine Nebenwirkungen haben. Wenn Sie Änderungen am Status der App vornehmen müssen (wie in der Dokumentation zum Verwalten des Status beschrieben), sollten Sie die Effect APIs verwenden, damit diese Nebenwirkungen auf vorhersehbare Weise ausgeführt werden.

Aufgrund der verschiedenen Möglichkeiten, die Effekte in Compose bieten, können sie leicht überstrapaziert werden. Achten Sie darauf, dass die darin ausgeführten Aufgaben mit der Benutzeroberfläche zusammenhängen und den unidirektionalen Datenfluss nicht unterbrechen, wie in der Dokumentation zum Verwalten des Status erläutert.

LaunchedEffect: Suspend-Funktionen im Bereich einer zusammensetzbaren Funktion ausführen

Wenn Sie während der Lebensdauer einer zusammensetzbaren Funktion Aufgaben ausführen und die Möglichkeit haben möchten, Sperrfunktionen aufzurufen, verwenden Sie die zusammensetzbare Funktion LaunchedEffect. Wenn LaunchedEffect in die Komposition eintritt, wird eine Koroutine mit dem als Parameter übergebenen Codeblock gestartet. Die Koroutine wird abgebrochen, wenn LaunchedEffect die Komposition verlässt. Wenn LaunchedEffect mit anderen Schlüsseln neu zusammengesetzt wird (siehe Abschnitt Effekte neu starten unten), wird die vorhandene Koroutine abgebrochen und die neue Sperrfunktion in einer neuen Koroutine gestartet.

Hier ist beispielsweise eine Animation, die den Alphawert mit einer konfigurierbaren Verzögerung pulsiert:

// Allow the pulse rate to be configured, so it can be sped up if the user is running
// out of time
var pulseRateMs by remember { mutableLongStateOf(3000L) }
val alpha = remember { Animatable(1f) }
LaunchedEffect(pulseRateMs) { // Restart the effect when the pulse rate changes
    while (isActive) {
        delay(pulseRateMs) // Pulse the alpha every pulseRateMs to alert the user
        alpha.animateTo(0f)
        alpha.animateTo(1f)
    }
}

Im Code oben wird die Animation mit der Sperrfunktion delay für die festgelegte Zeit angehalten. Anschließend wird der Alphawert mit animateTo nacheinander auf null und wieder zurück animiert. Dies wird für die gesamte Lebensdauer der zusammensetzbaren Funktion wiederholt.

rememberCoroutineScope: einen kompositionsbezogenen Bereich abrufen, um eine Koroutine außerhalb einer zusammensetzbaren Funktion zu starten

Da LaunchedEffect eine zusammensetzbare Funktion ist, kann sie nur innerhalb anderer zusammensetzbarer Funktionen verwendet werden. Wenn Sie eine Koroutine außerhalb einer zusammensetzbaren Funktion starten möchten, die aber so beschränkt ist, dass sie automatisch abgebrochen wird, sobald sie die Zusammensetzung verlässt, verwenden Sie rememberCoroutineScope. Verwende rememberCoroutineScope auch, wenn du den Lebenszyklus einer oder mehrerer Koroutinen manuell steuern musst, z. B. um eine Animation abzubrechen, wenn ein Nutzerereignis eintritt.

rememberCoroutineScope ist eine zusammensetzbare Funktion, die einen CoroutineScope zurückgibt, der an den Punkt der Komposition gebunden ist, an dem sie aufgerufen wird. Der Bereich wird abgebrochen, wenn der Aufruf die Komposition verlässt.

Im vorherigen Beispiel könnten Sie diesen Code verwenden, um ein Snackbar anzuzeigen, wenn der Nutzer auf ein Button tippt:

@Composable
fun MoviesScreen(snackbarHostState: SnackbarHostState) {

    // Creates a CoroutineScope bound to the MoviesScreen's lifecycle
    val scope = rememberCoroutineScope()

    Scaffold(
        snackbarHost = {
            SnackbarHost(hostState = snackbarHostState)
        }
    ) { contentPadding ->
        Column(Modifier.padding(contentPadding)) {
            Button(
                onClick = {
                    // Create a new coroutine in the event handler to show a snackbar
                    scope.launch {
                        snackbarHostState.showSnackbar("Something happened!")
                    }
                }
            ) {
                Text("Press me")
            }
        }
    }
}

rememberUpdatedState: Verweis auf einen Wert in einem Effekt, der nicht neu gestartet werden soll, wenn sich der Wert ändert

LaunchedEffect wird neu gestartet, wenn sich einer der Schlüsselparameter ändert. In einigen Situationen möchten Sie jedoch möglicherweise einen Wert in Ihrem Effekt erfassen, bei dessen Änderung der Effekt nicht neu gestartet werden soll. Dazu müssen Sie mit rememberUpdatedState einen Verweis auf diesen Wert erstellen, der erfasst und aktualisiert werden kann. Dieser Ansatz ist hilfreich für Effekte, die langlebige Vorgänge enthalten, die möglicherweise teuer oder unerschwinglich sind, wenn sie neu erstellt und neu gestartet werden müssen.

Angenommen, Ihre App hat ein LandingScreen, das nach einiger Zeit verschwindet. Auch wenn LandingScreen neu zusammengesetzt wird, sollte der Effekt, der eine gewisse Zeit wartet und dann meldet, dass die Zeit abgelaufen ist, nicht neu gestartet werden:

@Composable
fun LandingScreen(onTimeout: () -> Unit) {

    // This will always refer to the latest onTimeout function that
    // LandingScreen was recomposed with
    val currentOnTimeout by rememberUpdatedState(onTimeout)

    // Create an effect that matches the lifecycle of LandingScreen.
    // If LandingScreen recomposes, the delay shouldn't start again.
    LaunchedEffect(true) {
        delay(SplashWaitTimeMillis)
        currentOnTimeout()
    }

    /* Landing screen content */
}

Um einen Effekt zu erstellen, der dem Lebenszyklus der Aufrufstelle entspricht, wird eine unveränderliche Konstante wie Unit oder true als Parameter übergeben. Im obigen Code wird LaunchedEffect(true) verwendet. Damit der Lambda-Ausdruck onTimeout immer den neuesten Wert enthält, mit dem LandingScreen neu zusammengesetzt wurde, muss onTimeout mit der Funktion rememberUpdatedState umschlossen werden. Die zurückgegebenen State, currentOnTimeout im Code, sollten im Effekt verwendet werden.

DisposableEffect: Effekte, die bereinigt werden müssen

Für Nebenwirkungen, die nach dem Ändern der Schlüssel oder wenn die komponierbare Funktion die Komposition verlässt, bereinigt werden müssen, verwenden Sie DisposableEffect. Wenn sich die DisposableEffect-Schlüssel ändern, muss die komponierbare Funktion den aktuellen Effekt verwerfen (bereinigen) und durch erneutes Aufrufen des Effekts zurücksetzen.

Sie können beispielsweise Analyseereignisse basierend auf Lifecycle-Ereignissen mithilfe eines LifecycleObserver senden. Wenn Sie diese Ereignisse in Compose beobachten möchten, verwenden Sie einen DisposableEffect, um den Beobachter bei Bedarf zu registrieren und die Registrierung aufzuheben.

@Composable
fun HomeScreen(
    lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current,
    onStart: () -> Unit, // Send the 'started' analytics event
    onStop: () -> Unit // Send the 'stopped' analytics event
) {
    // Safely update the current lambdas when a new one is provided
    val currentOnStart by rememberUpdatedState(onStart)
    val currentOnStop by rememberUpdatedState(onStop)

    // If `lifecycleOwner` changes, dispose and reset the effect
    DisposableEffect(lifecycleOwner) {
        // Create an observer that triggers our remembered callbacks
        // for sending analytics events
        val observer = LifecycleEventObserver { _, event ->
            if (event == Lifecycle.Event.ON_START) {
                currentOnStart()
            } else if (event == Lifecycle.Event.ON_STOP) {
                currentOnStop()
            }
        }

        // Add the observer to the lifecycle
        lifecycleOwner.lifecycle.addObserver(observer)

        // When the effect leaves the Composition, remove the observer
        onDispose {
            lifecycleOwner.lifecycle.removeObserver(observer)
        }
    }

    /* Home screen content */
}

Im Code oben wird der observer durch den Effekt zum lifecycleOwner hinzugefügt. Wenn sich lifecycleOwner ändert, wird der Effekt verworfen und mit dem neuen lifecycleOwner neu gestartet.

Ein DisposableEffect muss eine onDispose-Anweisung als letzte Anweisung im Codeblock enthalten. Andernfalls wird in der IDE ein Build-Fehler angezeigt.

SideEffect: Compose-Status in Nicht-Compose-Code veröffentlichen

Wenn Sie den Compose-Status für Objekte freigeben möchten, die nicht von Compose verwaltet werden, verwenden Sie die zusammensetzbare Funktion SideEffect. Wenn Sie SideEffect verwenden, wird der Effekt nach jeder erfolgreichen Neuzusammensetzung ausgeführt. Andererseits ist es nicht korrekt, einen Effekt auszuführen, bevor eine erfolgreiche Neuzusammensetzung garantiert ist. Das ist der Fall, wenn der Effekt direkt in eine zusammensetzbare Funktion geschrieben wird.

Ihre Analysebibliothek ermöglicht es Ihnen beispielsweise, Ihre Nutzerpopulation zu segmentieren, indem Sie allen nachfolgenden Analyseereignissen benutzerdefinierte Metadaten („Nutzereigenschaften“ in diesem Beispiel) hinzufügen. Wenn Sie den Nutzertyp des aktuellen Nutzers an Ihre Analytikbibliothek übermitteln möchten, verwenden Sie SideEffect, um den Wert zu aktualisieren.

@Composable
fun rememberFirebaseAnalytics(user: User): FirebaseAnalytics {
    val analytics: FirebaseAnalytics = remember {
        FirebaseAnalytics()
    }

    // On every successful composition, update FirebaseAnalytics with
    // the userType from the current User, ensuring that future analytics
    // events have this metadata attached
    SideEffect {
        analytics.setUserProperty("userType", user.userType)
    }
    return analytics
}

produceState: Nicht-Compose-Status in Compose-Status umwandeln

produceState startet eine Koroutine, die auf die Komposition beschränkt ist und Werte in einen zurückgegebenen State übertragen kann. Damit können Sie einen Nicht-Compose-Zustand in einen Compose-Zustand umwandeln, z. B. einen externen, abobasierten Zustand wie Flow, LiveData oder RxJava in die Komposition einfügen.

Der Producer wird gestartet, wenn produceState in die Komposition eintritt, und abgebrochen, wenn er die Komposition verlässt. Der zurückgegebene State wird zusammengeführt. Wenn derselbe Wert festgelegt wird, wird keine Neuzusammensetzung ausgelöst.

Obwohl produceState eine Koroutine erstellt, kann es auch verwendet werden, um nicht-suspendierende Datenquellen zu beobachten. Wenn Sie das Abo für diese Quelle entfernen möchten, verwenden Sie die Funktion awaitDispose.

Im folgenden Beispiel wird gezeigt, wie Sie mit produceState ein Bild aus dem Netzwerk laden. Die zusammensetzbare Funktion loadNetworkImage gibt ein State zurück, das in anderen zusammensetzbaren Funktionen verwendet werden kann.

@Composable
fun loadNetworkImage(
    url: String,
    imageRepository: ImageRepository = ImageRepository()
): State<Result<Image>> {
    // Creates a State<T> with Result.Loading as initial value
    // If either `url` or `imageRepository` changes, the running producer
    // will cancel and will be re-launched with the new inputs.
    return produceState<Result<Image>>(initialValue = Result.Loading, url, imageRepository) {
        // In a coroutine, can make suspend calls
        val image = imageRepository.load(url)

        // Update State with either an Error or Success result.
        // This will trigger a recomposition where this State is read
        value = if (image == null) {
            Result.Error
        } else {
            Result.Success(image)
        }
    }
}

derivedStateOf: Ein oder mehrere Zustandsobjekte in einen anderen Zustand konvertieren

In Compose erfolgt die Neuzusammensetzung jedes Mal, wenn sich ein beobachtetes Zustandsobjekt oder eine zusammensetzbare Eingabe ändert. Ein Zustandsobjekt oder eine Eingabe kann sich häufiger ändern, als die Benutzeroberfläche tatsächlich aktualisiert werden muss, was zu einer unnötigen Neuzusammensetzung führt.

Sie sollten die Funktion derivedStateOf verwenden, wenn sich die Eingaben für eine zusammensetzbare Funktion häufiger ändern, als Sie sie neu zusammensetzen müssen. Das ist häufig der Fall, wenn sich etwas oft ändert, z. B. eine Scrollposition, die komponierbare Funktion aber erst reagieren muss, wenn ein bestimmter Schwellenwert überschritten wird. derivedStateOf erstellt ein neues Compose-Zustandsobjekt, das Sie beobachten können und das nur so oft aktualisiert wird, wie Sie es benötigen. In dieser Hinsicht verhält er sich ähnlich wie der Kotlin Flow-Operator distinctUntilChanged().

Richtige Verwendung

Das folgende Snippet zeigt einen geeigneten Anwendungsfall für derivedStateOf:

@Composable
// When the messages parameter changes, the MessageList
// composable recomposes. derivedStateOf does not
// affect this recomposition.
fun MessageList(messages: List<Message>) {
    Box {
        val listState = rememberLazyListState()

        LazyColumn(state = listState) {
            // ...
        }

        // Show the button if the first visible item is past
        // the first item. We use a remembered derived state to
        // minimize unnecessary compositions
        val showButton by remember {
            derivedStateOf {
                listState.firstVisibleItemIndex > 0
            }
        }

        AnimatedVisibility(visible = showButton) {
            ScrollToTopButton()
        }
    }
}

In diesem Snippet ändert sich firstVisibleItemIndex, wenn sich das erste sichtbare Element ändert. Beim Scrollen wird der Wert zu 0, 1, 2, 3, 4, 5 usw. Eine Neuzusammensetzung ist jedoch nur erforderlich, wenn der Wert größer als 0 ist. Diese unterschiedliche Updatehäufigkeit bedeutet, dass dies ein guter Anwendungsfall für derivedStateOf ist.

Falsche Verwendung

Ein häufiger Fehler ist die Annahme, dass Sie beim Kombinieren von zwei Compose-Zustandsobjekten derivedStateOf verwenden sollten, da Sie „Zustand ableiten“. Dies ist jedoch nur ein Mehraufwand und nicht erforderlich, wie im folgenden Snippet zu sehen ist:

// DO NOT USE. Incorrect usage of derivedStateOf.
var firstName by remember { mutableStateOf("") }
var lastName by remember { mutableStateOf("") }

val fullNameBad by remember { derivedStateOf { "$firstName $lastName" } } // This is bad!!!
val fullNameCorrect = "$firstName $lastName" // This is correct

In diesem Snippet muss fullName genauso oft aktualisiert werden wie firstName und lastName. Daher findet keine übermäßige Neuzusammensetzung statt und die Verwendung von derivedStateOf ist nicht erforderlich.

snapshotFlow: Compose-Status in Flows konvertieren

Verwenden Sie snapshotFlow, um State<T>-Objekte in einen Cold Flow zu konvertieren. snapshotFlow führt den Block aus, wenn er erfasst wird, und gibt das Ergebnis der darin gelesenen State-Objekte aus. Wenn sich eines der State-Objekte, die im snapshotFlow-Block gelesen werden, ändert, gibt der Flow den neuen Wert an seinen Collector aus, wenn der neue Wert nicht mit dem zuvor ausgegebenen Wert übereinstimmt (dieses Verhalten ähnelt dem von Flow.distinctUntilChanged).

Im folgenden Beispiel wird ein Nebeneffekt gezeigt, der in Analytics erfasst wird, wenn der Nutzer über das erste Element in einer Liste hinausscrollt:

val listState = rememberLazyListState()

LazyColumn(state = listState) {
    // ...
}

LaunchedEffect(listState) {
    snapshotFlow { listState.firstVisibleItemIndex }
        .map { index -> index > 0 }
        .distinctUntilChanged()
        .filter { it == true }
        .collect {
            MyAnalyticsService.sendScrolledPastFirstItemEvent()
        }
}

Im Code oben wird listState.firstVisibleItemIndex in einen Flow konvertiert, der von den leistungsstarken Flow-Operatoren profitieren kann.

Effekte werden neu gestartet

Einige Effekte in Compose, wie LaunchedEffect, produceState oder DisposableEffect, nehmen eine variable Anzahl von Argumenten (Schlüsseln) an, die verwendet werden, um den laufenden Effekt abzubrechen und einen neuen mit den neuen Schlüsseln zu starten.

Das typische Format für diese APIs ist:

EffectName(restartIfThisKeyChanges, orThisKey, orThisKey, ...) { block }

Aufgrund der Feinheiten dieses Verhaltens können Probleme auftreten, wenn die zum Neustarten des Effekts verwendeten Parameter nicht die richtigen sind:

  • Wenn Sie Effekte seltener als nötig neu starten, kann dies zu Fehlern in Ihrer App führen.
  • Wenn Effekte häufiger als nötig neu gestartet werden, kann das ineffizient sein.

Als Faustregel gilt, dass veränderliche und unveränderliche Variablen, die im Effekt-Codeblock verwendet werden, als Parameter zur kombinierbaren Funktion des Effekts hinzugefügt werden sollten. Darüber hinaus können weitere Parameter hinzugefügt werden, um den Neustart des Effekts zu erzwingen. Wenn die Änderung einer Variablen nicht dazu führen soll, dass der Effekt neu gestartet wird, sollte die Variable in rememberUpdatedState eingeschlossen werden. Wenn sich die Variable nie ändert, weil sie in einem remember ohne Schlüssel verpackt ist, müssen Sie die Variable nicht als Schlüssel an den Effekt übergeben.

Im oben gezeigten DisposableEffect-Code wird der Effekt als Parameter für die im Block verwendeten lifecycleOwner verwendet, da jede Änderung daran dazu führen sollte, dass der Effekt neu gestartet wird.

@Composable
fun HomeScreen(
    lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current,
    onStart: () -> Unit, // Send the 'started' analytics event
    onStop: () -> Unit // Send the 'stopped' analytics event
) {
    // These values never change in Composition
    val currentOnStart by rememberUpdatedState(onStart)
    val currentOnStop by rememberUpdatedState(onStop)

    DisposableEffect(lifecycleOwner) {
        val observer = LifecycleEventObserver { _, event ->
            /* ... */
        }

        lifecycleOwner.lifecycle.addObserver(observer)
        onDispose {
            lifecycleOwner.lifecycle.removeObserver(observer)
        }
    }
}

currentOnStart und currentOnStop sind nicht als DisposableEffect-Schlüssel erforderlich, da sich ihre Werte in Composition aufgrund der Verwendung von rememberUpdatedState nie ändern. Wenn Sie lifecycleOwner nicht als Parameter übergeben und sich der Wert ändert, wird HomeScreen neu zusammengesetzt, aber DisposableEffect wird nicht verworfen und neu gestartet. Das führt zu Problemen, weil ab diesem Zeitpunkt die falsche lifecycleOwner verwendet wird.

Konstanten als Schlüssel

Sie können eine Konstante wie true als Effektschlüssel verwenden, damit sie dem Lebenszyklus der Aufrufstelle folgt. Es gibt gültige Anwendungsfälle dafür, wie das oben gezeigte LaunchedEffect-Beispiel. Bevor Sie das tun, sollten Sie sich jedoch genau überlegen, ob das wirklich das ist, was Sie brauchen.