Nebeneffekte in „Compose“

Ein Nebeneffekt ist eine Änderung des App-Status, die außerhalb des Bereichs einer komponierbaren Funktion erfolgt. Aufgrund des Lebenszyklus und der Eigenschaften von Composables wie unvorhersehbare Neukompositionen, Neukompositionen von Composables in unterschiedlicher Reihenfolge oder Neukompositionen, die verworfen werden können, sollten Composables idealerweise keine Nebeneffekte haben.

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

Anwendungsfälle für Status und Effekte

Wie in der Thinking in Compose Dokumentation beschrieben, sollten Composables keine Nebeneffekte haben. Wenn Sie Änderungen am Status der App vornehmen müssen (wie im Dokument Status verwalten beschrieben), sollten Sie die Effect APIs verwenden, damit diese Nebeneffekte auf vorhersehbare Weise ausgeführt werden.

Aufgrund der verschiedenen Möglichkeiten, die Effekte in Compose bieten, können sie leicht überbeansprucht werden. Achten Sie darauf, dass die Arbeit, die Sie darin ausführen, UI-bezogen ist und den unidirektionalen Datenfluss nicht unterbricht, wie in der Dokumentation Status verwalten erläutert.

LaunchedEffect: Suspend-Funktionen im Bereich eines Composables ausführen

Wenn Sie während des Lebenszyklus eines Composables arbeiten und Suspend-Funktionen aufrufen möchten, verwenden Sie das LaunchedEffect Composable. Wenn LaunchedEffect in die Komposition eintritt, wird eine Coroutine mit dem als Parameter übergebenen Codeblock gestartet. Die Coroutine 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 Coroutine abgebrochen und die neue Suspend-Funktion wird in einer neuen Coroutine gestartet.

Hier ist ein Beispiel für 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 obigen Code verwendet die Animation die Suspend-Funktion delay um die festgelegte Zeit zu warten. Anschließend wird der Alphawert mit animateTo sequenziell auf null und wieder zurück animiert. Dieser Vorgang wird während des gesamten Lebenszyklus des Composables wiederholt.

rememberCoroutineScope: Einen kompositionsbezogenen Bereich abrufen, um eine Coroutine außerhalb eines Composables zu starten

LaunchedEffect ist eine komponierbare Funktion und kann daher nur innerhalb anderer komponierbarer Funktionen verwendet werden. Wenn Sie eine Coroutine außerhalb eines Composables starten möchten, aber so, dass sie automatisch abgebrochen wird, sobald sie die Komposition verlässt, verwenden Sie rememberCoroutineScope. Verwenden Sie rememberCoroutineScope auch, wenn Sie den Lebenszyklus einer oder mehrerer Coroutinen manuell steuern müssen, z. B. um eine Animation abzubrechen, wenn ein Nutzerereignis eintritt.

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

Im Anschluss an das vorherige Beispiel können Sie diesen Code verwenden, um eine Snackbar anzuzeigen, wenn der Nutzer auf eine 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: Auf einen Wert in einem Effekt verweisen, der nicht neu gestartet werden soll, wenn sich der Wert ändert

LaunchedEffect wird neu gestartet, wenn sich einer der Schlüsselparameter ändert. In einigen Fällen möchten Sie jedoch möglicherweise einen Wert in Ihrem Effekt erfassen, bei dem der Effekt nicht neu gestartet werden soll, wenn er sich ändert. Dazu müssen Sie rememberUpdatedState verwenden, um einen Verweis auf diesen Wert zu erstellen, der erfasst und aktualisiert werden kann. Dieser Ansatz ist hilfreich für Effekte, die langwierige Vorgänge enthalten, die teuer oder unmöglich neu zu erstellen und neu zu starten sind.

Angenommen, Ihre App hat einen LandingScreen, der nach einiger Zeit verschwindet. Auch wenn LandingScreen neu zusammengesetzt wird, sollte der Effekt, der einige Zeit wartet und benachrichtigt, 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 die onTimeout-Lambda-Funktion immer den neuesten Wert enthält, mit dem LandingScreen neu zusammengesetzt wurde, muss onTimeout mit der Funktion rememberUpdatedState umschlossen werden. Der zurückgegebene State, currentOnTimeout im Code, sollte im Effekt verwendet werden.

DisposableEffect: Effekte, die bereinigt werden müssen

Verwenden SieDisposableEffect für Nebeneffekte, die bereinigt werden müssen, nachdem sich die Schlüssel geändert haben oder wenn das Composable die Komposition verlässt. Wenn sich die DisposableEffect-Schlüssel ändern, muss die komponierbare Funktion ihren aktuellen Effekt verwerfen (bereinigen) und zurücksetzen, indem sie den Effekt noch einmal aufruft.

Sie können beispielsweise Analysedaten basierend auf Lifecycle Ereignissen mit einem LifecycleObserver senden. Wenn Sie in Compose auf diese Ereignisse warten möchten, verwenden Sie DisposableEffect, um den Observer 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 obigen Code fügt der Effekt den observer zum lifecycleOwner hinzu. Wenn sich lifecycleOwner ändert, wird der Effekt verworfen und mit dem neuen lifecycleOwner neu gestartet.

Ein DisposableEffect muss eine onDispose-Klausel als letzte Anweisung im Codeblock enthalten. Andernfalls zeigt die IDE einen Build-Zeit-Fehler an.

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 das SideEffect -Composable. Mit SideEffect wird garantiert, dass der Effekt nach jeder erfolgreichen Neukomposition ausgeführt wird. Andererseits ist es falsch, einen Effekt auszuführen, bevor eine erfolgreiche Neukomposition garantiert ist. Das ist der Fall, wenn Sie den Effekt direkt in ein Composable schreiben.

Mit Ihrer Analysebibliothek können Sie beispielsweise Ihre Nutzer nach Segmenten aufteilen, indem Sie allen nachfolgenden Analyseereignissen benutzerdefinierte Metadaten anhängen (in diesem Beispiel „Nutzerattribute“). Wenn Sie den Nutzertyp des aktuellen Nutzers an Ihre Analysebibliothek senden 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 konvertieren

produceState startet eine Coroutine, die auf die Komposition beschränkt ist und Werte in einen zurückgegebenen Stateübertragen kann. Verwenden Sie sie, um Nicht-Compose-Status in Compose-Status zu konvertieren, z. B. um externe abobasierte Status wie Flow, LiveData oder RxJava in die Komposition zu übertragen.

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

Obwohl produceState eine Coroutine erstellt, kann sie auch verwendet werden, um nicht suspendierende Datenquellen zu beobachten. Verwenden Sie die awaitDispose Funktion, um das Abo für diese Quelle zu entfernen.

Im folgenden Beispiel wird gezeigt, wie Sie mit produceState ein Bild aus dem Netzwerk laden. Die komponierbare Funktion loadNetworkImage gibt einen State zurück, der in anderen Composables 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 Statusobjekte in einen anderen Status konvertieren

In Compose, die Neukomposition erfolgt jedes Mal, wenn sich ein beobachtetes Statusobjekt oder eine komponierbare Eingabe ändert. Ein Statusobjekt oder eine Eingabe ändert sich möglicherweise häufiger als die UI tatsächlich aktualisiert werden muss, was zu unnötigen Neukompositionen führt.

Verwenden Sie die derivedStateOf Funktion, wenn sich die Eingaben für ein Composable häufiger ändern, als Sie es neu zusammensetzen müssen. Das ist oft der Fall, wenn sich etwas häufig ändert, z. B. eine Scrollposition, das Composable aber erst reagieren muss, wenn ein bestimmter Schwellenwert überschritten wird. derivedStateOf erstellt ein neues Compose-Statusobjekt, das Sie beobachten können und das nur so oft aktualisiert wird, wie Sie es benötigen. In dieser Hinsicht ähnelt es dem Operator von Kotlin Flows 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 jedes Mal, wenn sich das erste sichtbare Element ändert. Beim Scrollen wird der Wert zu 0, 1, 2, 3, 4, 5 usw. Eine Neukomposition ist jedoch nur erforderlich, wenn der Wert größer als 0 ist. Diese Diskrepanz in der Updatehäufigkeit macht dies zu einem guten Anwendungsfall für derivedStateOf.

Falsche Verwendung

Ein häufiger Fehler ist die Annahme, dass Sie derivedStateOf verwenden sollten, wenn Sie zwei Compose-Statusobjekte kombinieren, weil Sie „Status ableiten“. Das ist jedoch reiner Overhead und nicht erforderlich, wie im folgenden Snippet gezeigt:

// 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 kommt es zu keiner übermäßigen Neukomposition und die Verwendung von derivedStateOf ist nicht erforderlich.

snapshotFlow: Compose-Status in Flows konvertieren

Verwenden Sie snapshotFlow zum Konvertieren von State<T> -Objekten in einen Cold Flow. snapshotFlow führt seinen Block aus, wenn er erfasst wird, und gibt das Ergebnis der darin gelesenen State-Objekte aus. Wenn sich eines der State Objekte ändert, die im snapshotFlow Block gelesen wurden, 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 erfasst, wenn der Nutzer in einer Liste am ersten Element vorbeiscrollt, und die Daten an die Analyse sendet:

val listState = rememberLazyListState()

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

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

Im obigen Code wird listState.firstVisibleItemIndex in einen Flow konvertiert, der die Leistungsfähigkeit der Flow-Operatoren nutzen kann.

Effekte neu starten

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

Die typische Form für diese APIs ist:

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

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

  • Wenn Effekte seltener als nötig neu gestartet werden, kann das 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 Effektblock des Codes verwendet werden, als Parameter zum Effekt-Composable hinzugefügt werden sollten. Außerdem können weitere Parameter hinzugefügt werden, um den Effekt neu zu starten. Wenn die Änderung von einer Variablen nicht dazu führen soll, dass der Effekt neu gestartet wird, sollte die Variable in rememberUpdatedState umschlossen werden. Wenn sich die Variable nie ändert, weil sie in remember ohne Schlüssel umschlossen ist, müssen Sie die Variable nicht als Schlüssel an den Effekt übergeben.

Im obigen DisposableEffect-Code verwendet der Effekt den lifecycleOwner, der in seinem Block verwendet wird, als Parameter, 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 ihr Wert in der Komposition aufgrund der Verwendung von rememberUpdatedState nie ändert. Wenn Sie lifecycleOwner nicht als Parameter übergeben und er sich ändert, wird HomeScreen neu zusammengesetzt, aber DisposableEffect wird nicht verworfen und neu gestartet. Das führt zu Problemen, da ab diesem Zeitpunkt der 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, z. B. das oben gezeigte LaunchedEffect-Beispiel. Überlegen Sie sich jedoch vorher gut, ob das wirklich das ist, was Sie brauchen.