Nebeneffekte in „Compose“

Ein Nebeneffekt ist eine Änderung des App-Status, die außerhalb des Umfangs einer zusammensetzbaren Funktion erfolgt. Aufgrund des Lebenszyklus und der Eigenschaften von zusammensetzbaren Funktionen wie unvorhersehbaren Neuzusammensetzungen, der Ausführung von Neuzusammensetzungen in unterschiedlicher Reihenfolge oder aufgrund von Neuzusammensetzungen, die verworfen werden können, sollten diese im Idealfall frei von Nebeneffekten sein.

Manchmal sind jedoch Nebeneffekte erforderlich, z. B. um ein einmaliges Ereignis auszulösen, z. B. das Anzeigen einer Snackbar oder das Aufrufen eines anderen Bildschirms unter bestimmten Bedingungen. Diese Aktionen sollten über eine kontrollierte Umgebung aufgerufen werden, in der der Lebenszyklus der zusammensetzbaren Funktion bekannt ist. Auf dieser Seite erfahren Sie mehr über die verschiedenen APIs mit Nebeneffekten, die Jetpack Compose bietet.

Anwendungsfälle und Auswirkungen

Wie in der Dokumentation In Compose erläutert, sollten zusammensetzbare Funktionen frei von Nebeneffekten sein. Wenn Sie den Status der Anwendung ändern müssen (wie in der Dokumentation zur Verwaltung des Status beschrieben), sollten Sie die Effect APIs verwenden, damit diese Nebeneffekte auf vorhersehbare Weise ausgeführt werden.

Aufgrund der verschiedenen Effekte, die sich in der Funktion „Compose“ ergeben, können sie leicht zu stark genutzt werden. Achten Sie darauf, dass sie sich auf die Benutzeroberfläche beziehen und den unidirektionalen Datenfluss nicht beeinträchtigen, wie in der Dokumentation zum Verwalten von Status erläutert.

LaunchedEffect: zum Ausführen von Aussetzungsfunktionen im Bereich einer zusammensetzbaren Funktion

Verwenden Sie die zusammensetzbare Funktion LaunchedEffect, um Sperrfunktionen sicher aus einer zusammensetzbaren Funktion aufzurufen. Wenn LaunchedEffect in die Zusammensetzung eintritt, wird eine Koroutine mit dem Codeblock gestartet, der als Parameter übergeben wird. Die Koroutine wird abgebrochen, wenn LaunchedEffect die Zusammensetzung verlässt. Wenn LaunchedEffect mit anderen Tasten neu zusammengesetzt wird (siehe Abschnitt Neustarts von Effekten unten), wird die vorhandene Koroutine abgebrochen und die neue Anhaltefunktion in einer neuen Koroutine gestartet.

Wenn zum Beispiel ein Snackbar in einem Scaffold angezeigt wird, erfolgt die Darstellung mit der SnackbarHostState.showSnackbar-Funktion, bei der es sich um eine Anhalten-Funktion handelt.

@Composable
fun MyScreen(
    state: UiState<List<Movie>>,
    snackbarHostState: SnackbarHostState
) {

    // If the UI state contains an error, show snackbar
    if (state.hasError) {

        // `LaunchedEffect` will cancel and re-launch if
        // `scaffoldState.snackbarHostState` changes
        LaunchedEffect(snackbarHostState) {
            // Show snackbar using a coroutine, when the coroutine is cancelled the
            // snackbar will automatically dismiss. This coroutine will cancel whenever
            // `state.hasError` is false, and only start when `state.hasError` is true
            // (due to the above if-check), or if `scaffoldState.snackbarHostState` changes.
            snackbarHostState.showSnackbar(
                message = "Error message",
                actionLabel = "Retry message"
            )
        }
    }

    Scaffold(
        snackbarHost = {
            SnackbarHost(hostState = snackbarHostState)
        }
    ) { contentPadding ->
        // ...
    }
}

Im Code oben wird eine Koroutine ausgelöst, wenn der Status einen Fehler enthält. Andernfalls wird sie abgebrochen. Da sich die LaunchedEffect-Aufrufwebsite in einer if-Anweisung befindet, wird die Anweisung, die auf "false" gesetzt ist, entfernt, wenn LaunchedEffect in der Komposition enthalten war. Daher wird die Koroutine abgebrochen.

rememberCoroutineScope: Rufen Sie einen Bereich ab, bei dem die Zusammensetzung berücksichtigt wird, 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, aber so eingeschränkt sind, dass sie nach dem Verlassen der Zusammensetzung automatisch abgebrochen wird, verwenden Sie rememberCoroutineScope. Verwenden Sie rememberCoroutineScope auch immer, wenn Sie den Lebenszyklus einer oder mehrerer Koroutinen manuell steuern müssen, z. B. um eine Animation abzubrechen, wenn ein Nutzerereignis eintritt.

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

Gemäß dem 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: auf einen Wert verweisen, der nicht neu gestartet werden sollte, wenn sich der Wert ändert

LaunchedEffect wird neu gestartet, wenn sich einer der Schlüsselparameter ändert. In manchen Situationen möchten Sie jedoch möglicherweise einen Wert für Ihren Effekt erfassen, der besagt, dass er nicht neu gestartet werden soll, wenn er sich ändert. Dazu muss mit rememberUpdatedState ein Verweis auf diesen Wert erstellt werden, der erfasst und aktualisiert werden kann. Dieser Ansatz ist hilfreich bei Effekten, die langlebige Vorgänge enthalten, deren Neuerstellung und Neustart teuer oder unmöglich sind.

Angenommen, Ihre Anwendung hat ein LandingScreen, das nach einiger Zeit verschwindet. Auch wenn LandingScreen neu zusammengesetzt wird, wartet der Effekt, dass einige Zeit wartet, und weist darauf hin, dass die verstrichene Zeit nicht neu gestartet werden sollte:

@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 erzeugen, der dem Lebenszyklus der Aufrufwebsite entspricht, wird eine sich nie ändernde Konstante wie Unit oder true als Parameter übergeben. Im Code oben wird LaunchedEffect(true) verwendet. Damit das Lambda onTimeout immer den letzten Wert enthält, mit dem LandingScreen neu zusammengesetzt wurde, muss onTimeout mit der Funktion rememberUpdatedState zusammengefasst werden. Das zurückgegebene State, im Code currentOnTimeout, sollte in diesem Effekt verwendet werden.

DisposableEffect: Effekte, die bereinigt werden müssen

Verwenden Sie DisposableEffect für Nebeneffekte, die nach dem Ändern der Schlüssel bereinigt werden müssen oder wenn die zusammensetzbare Funktion die Zusammensetzung verlässt. Wenn sich die DisposableEffect-Schlüssel ändern, muss die zusammensetzbare Funktion den aktuellen Effekt entsorgen (Bereinigen ausführen) und zurücksetzen, indem der Effekt noch einmal aufgerufen wird.

So können Sie beispielsweise Analyseereignisse basierend auf Lifecycle-Ereignissen mit einem LifecycleObserver senden. Wenn Sie diese Ereignisse in Compose beobachten möchten, verwenden Sie DisposableEffect, um den Beobachter bei Bedarf zu registrieren und seine 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 durch den Effekt das observer zum lifecycleOwner hinzugefügt. Wenn sich lifecycleOwner ändert, wird der Effekt verworfen und mit dem neuen lifecycleOwner neu gestartet.

Ein DisposableEffect muss als endgültige Anweisung in seinem Codeblock eine onDispose-Klausel enthalten. Andernfalls zeigt die IDE einen Build-Zeitfehler an.

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

Wenn Sie den Erstellungsstatus für Objekte freigeben möchten, die nicht von „Compose“ verwaltet werden, verwenden Sie die zusammensetzbare Funktion SideEffect. Die Verwendung eines SideEffect sorgt dafür, dass der Effekt nach jeder erfolgreichen Neuzusammensetzung wirksam wird. Andererseits ist es falsch, einen Effekt auszuführen, bevor eine erfolgreiche Neuzusammensetzung garantiert ist, was dann der Fall ist, wenn der Effekt direkt in eine zusammensetzbare Funktion geschrieben wird.

In Ihrer Analysebibliothek können Sie beispielsweise die Nutzerpopulation segmentieren, indem Sie allen nachfolgenden Analyseereignissen benutzerdefinierte Metadaten (in diesem Beispiel Nutzereigenschaften) hinzufügen. Wenn Sie den Nutzertyp des aktuellen Nutzers an Ihre Analysebibliothek kommunizieren möchten, verwenden Sie SideEffect, um seinen 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-Zustand in Erstellungsstatus konvertieren

produceState startet eine Koroutine, die auf die Komposition beschränkt ist und Werte in eine zurückgegebene State übertragen kann. Sie können damit den Nicht-Compose-Status in den Erstellungsstatus konvertieren. So können Sie beispielsweise einen externen abobasierten Status wie Flow, LiveData oder RxJava in die Zusammensetzung aufnehmen.

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

Obwohl produceState eine Koroutine erstellt, kann sie auch verwendet werden, um nicht anhaltende Datenquellen zu beobachten. Mit der Funktion awaitDispose können Sie das Abo für diese Quelle entfernen.

Das folgende Beispiel zeigt, wie mit produceState ein Image aus dem Netzwerk geladen wird. 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 Statusobjekte in einen anderen Status konvertieren

Bei der Erstellung erfolgt eine Neuzusammensetzung jedes Mal, wenn sich ein beobachtetes Statusobjekt oder eine zusammensetzbare Eingabe ändert. Ein Statusobjekt oder eine Statuseingabe kann sich häufiger ändern, als die UI 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 neu zusammensetzen müssen. Das ist häufig der Fall, wenn sich etwas häufig ändert, z. B. eine Scrollposition, die zusammensetzbare Funktion aber erst dann reagieren muss, wenn sie einen bestimmten Schwellenwert überschreitet. derivedStateOf erstellt ein neues Objekt für den Status „Compose“. Sie können sehen, dass es nur so oft aktualisiert wird, wie Sie benötigen. Auf diese Weise verhält er sich ähnlich wie der distinctUntilChanged()-Operator für Kotlin-Abläufe.

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 wird firstVisibleItemIndex jedes Mal geändert, wenn sich das erste sichtbare Element ändert. Beim Scrollen ändert sich der Wert zu 0, 1, 2, 3, 4, 5 usw. Die Neuzusammensetzung muss jedoch nur erfolgen, wenn der Wert größer als 0 ist. Diese Abweichung bei der Aktualisierungshäufigkeit bedeutet, dass dies ein guter Anwendungsfall für derivedStateOf ist.

Falsche Verwendung

Häufig wird fälschlicherweise angenommen, dass Sie beim Kombinieren von zwei Statusobjekten für die Zusammensetzung derivedStateOf verwenden sollten, weil Sie den Status „ableiten“. Dies ist jedoch reiner Aufwand und nicht erforderlich, wie im folgenden Snippet gezeigt wird:

// 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 erfolgt keine übermäßige Neuzusammensetzung und die Verwendung von derivedStateOf ist nicht erforderlich.

snapshotFlow: Status von „Compose“ in Abläufe konvertieren

Verwenden Sie snapshotFlow, um State<T>-Objekte in einen kalten Ablauf zu konvertieren. snapshotFlow führt beim Erfassen seinen Block aus und gibt das Ergebnis der darin gelesenen State-Objekte aus. Wenn eines der State-Objekte, die im snapshotFlow-Block gelesen werden, mutiert, gibt der Ablauf den neuen Wert an seinen Collector aus, wenn der neue Wert nicht dem vorherigen ausgegebenen Wert entspricht. Dieses Verhalten ähnelt dem von Flow.distinctUntilChanged.

Das folgende Beispiel zeigt einen Nebeneffekt, bei dem erfasst wird, wenn der Nutzer nach dem ersten Element in einer Liste zu Analytics scrollt:

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 der Leistung der Flow-Operatoren profitieren kann.

Effekte neu starten

Für einige Effekte in der Funktion „Compose“, z. B. LaunchedEffect, produceState oder DisposableEffect, ist eine variable Anzahl von Argumenten bzw. Schlüsseln erforderlich, mit denen der laufende Effekt abgebrochen und ein neues mit den neuen Schlüsseln gestartet wird.

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 Neustart des Effekts verwendeten Parameter nicht die richtigen Parameter sind:

  • Niedrigere Effekte für einen Neustart können zu Fehlern in Ihrer App führen.
  • Ein stärkerer Neustart könnte ineffizient sein.

Als Faustregel sollten änderbare und unveränderliche Variablen, die im Effektblock des Codes verwendet werden, als Parameter für die zusammensetzbare Funktion hinzugefügt werden. Abgesehen von diesen können weitere Parameter hinzugefügt werden, um einen Neustart des Effekts zu erzwingen. Wenn die Änderung einer Variablen nicht zu einem Neustart führt, sollte die Variable in rememberUpdatedState eingeschlossen werden. Wenn sich die Variable nie ändert, weil sie in eine remember ohne Schlüssel verpackt ist, müssen Sie die Variable nicht als Schlüssel übergeben.

Im oben gezeigten DisposableEffect-Code wird der Effekt als Parameter des lifecycleOwner in seinem Block verwendet, da jede Änderung an den Werten einen Neustart des Effekts auslösen sollte.

@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 werden nicht als DisposableEffect-Schlüssel benötigt, da sich ihr Wert in der Zusammensetzung aufgrund der Verwendung von rememberUpdatedState nie ändert. Wenn Sie lifecycleOwner nicht als Parameter übergeben und er sich ändert, wird HomeScreen neu zusammengesetzt, DisposableEffect aber nicht verworfen und neu gestartet. Das verursacht Probleme, 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 Anrufwebsite entspricht. Es gibt zulässige Anwendungsfälle dafür, wie das oben gezeigte LaunchedEffect-Beispiel. Zuvor sollten Sie jedoch noch einmal überlegen, ob das ist, was Sie brauchen.