Nebeneffekte in „Compose“

Ein Nebeneffekt ist eine Statusänderung der App, die außerhalb des Bereichs einer zusammensetzbaren Funktion erfolgt. Aufgrund des Lebenszyklus und der Eigenschaften von zusammensetzbaren Funktionen wie unvorhersehbaren Neuzusammensetzungen, der Ausführung von Neuzusammensetzungen von Zusammensetzungen in unterschiedlicher Reihenfolge oder Neuzusammensetzungen, die verworfen werden können, sollten Zusammensetzungen im Idealfall keine Nebeneffekte haben.

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

Zustand und Wirkung – Anwendungsfälle

Wie in der Dokumentation Thinking in Compose beschrieben, sollten zusammensetzbare Funktionen keine Nebenwirkungen haben. Wenn Sie Änderungen am Status der Anwendung vornehmen müssen, wie in der Dokumentation zur Statusverwaltung beschrieben, sollten Sie die Effect APIs so verwenden, dass diese Nebenwirkungen vorhersehbar ausgeführt werden.

Da sich in „Compose“ viele verschiedene Möglichkeiten bieten, können Effekte zu viel verwendet werden. Achten Sie darauf, dass Ihre Arbeit sich auf die Benutzeroberfläche bezieht und den unidirektionalen Datenfluss nicht unterbricht, wie in der Dokumentation zur Statusverwaltung erläutert.

LaunchedEffect: Beendigungsfunktionen im Bereich einer zusammensetzbaren Funktion ausführen

Mit der zusammensetzbaren Funktion LaunchedEffect können Sie Beendigungsfunktionen sicher innerhalb einer zusammensetzbaren Funktion aufrufen. Wenn LaunchedEffect in die Zusammensetzung eingeht, wird eine Koroutine mit dem als Parameter übergebenen Codeblock gestartet. Die Koroutine wird abgebrochen, wenn LaunchedEffect die Zusammensetzung 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 Unterbrechungsfunktion in einer neuen Koroutine gestartet.

Zum Beispiel wird ein Snackbar in einer Scaffold-Funktion mit der SnackbarHostState.showSnackbar-Funktion angezeigt, 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 obigen Code 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, wenn die Anweisung „false“ ist und LaunchedEffect in der Komposition enthalten war, diese entfernt. Daher wird die Koroutine abgebrochen.

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

Da LaunchedEffect eine zusammensetzbare Funktion ist, kann sie nur in anderen zusammensetzbaren Funktionen verwendet werden. Wenn Sie eine Koroutine außerhalb einer zusammensetzbaren Funktion starten möchten, aber auf einen bestimmten Bereich beschränkt ist, damit sie automatisch abgebrochen wird, sobald sie die Zusammensetzung verlässt, verwenden Sie rememberCoroutineScope. Verwenden Sie rememberCoroutineScope auch, 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 der sie aufgerufen wird. Der Bereich wird abgebrochen, wenn der Aufruf die Komposition verlässt.

Entsprechend dem vorherigen Beispiel kannst du diesen Code verwenden, um ein 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: Verweist auf einen Wert in einem Effekt, der nicht neu gestartet werden sollte, wenn sich der Wert ändert

LaunchedEffect wird neu gestartet, wenn sich einer der Schlüsselparameter ändert. In einigen Situationen kann es jedoch sinnvoll sein, einen Wert für den Effekt zu erfassen, der bei einer Änderung 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 eignet sich für Effekte mit langlebigen Vorgängen, deren Neuerstellung und Neustart kostspielig oder zu kostspielig sein kann.

Angenommen, Ihre Anwendung hat ein LandingScreen, das nach einiger Zeit verschwindet. Selbst wenn LandingScreen neu zusammengesetzt wird, wird der Effekt, der eine Weile wartet und benachrichtigt, dass die vergangene 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 ständig ändernde Konstante wie Unit oder true als Parameter übergeben. Im Code oben wird LaunchedEffect(true) verwendet. Damit onTimeout Lambda immer den letzten Wert enthält, mit dem LandingScreen neu zusammengesetzt wurde, muss onTimeout mit der rememberUpdatedState-Funktion zusammengefasst werden. Der zurückgegebene State (currentOnTimeout im Code) sollte dabei verwendet werden.

DisposableEffect: Effekte, die bereinigt werden müssen

Für Nebeneffekte, die nach einer Änderung der Schlüssel oder wenn die zusammensetzbare Funktion die Komposition verlässt, bereinigt werden müssen, verwende DisposableEffect. Wenn sich die DisposableEffect-Schlüssel ändern, muss die zusammensetzbare Funktion ihren aktuellen Effekt beseitigen, d. h. bereinigen, und zurückgesetzt werden, indem der Effekt noch einmal aufgerufen wird.

Beispielsweise können Sie mithilfe von LifecycleObserver Analyseereignisse senden, die auf Lifecycle-Ereignissen basieren. 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 observer zum lifecycleOwner hinzugefügt. Wenn sich lifecycleOwner ändert, wird der Effekt entfernt und mit der neuen lifecycleOwner neu gestartet.

Eine DisposableEffect muss eine onDispose-Klausel als letzte Anweisung in ihrem Codeblock 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 durch die Zusammensetzung verwaltet werden, verwenden Sie die zusammensetzbare Funktion SideEffect. Die Verwendung einer SideEffect garantiert, dass der Effekt nach jeder erfolgreichen Neuzusammensetzung wirksam wird. Andererseits ist es falsch, einen Effekt auszuführen, bevor eine erfolgreiche Neuzusammensetzung garantiert ist. Dies ist der Fall, wenn der Effekt direkt in eine zusammensetzbare Funktion geschrieben wird.

Mithilfe der Analysebibliothek können Sie beispielsweise die Nutzerpopulation segmentieren, indem Sie allen nachfolgenden Analyseereignissen benutzerdefinierte Metadaten (in diesem Beispiel „Nutzereigenschaften“) hinzufügen. Verwenden Sie SideEffect, um den Wert des aktuellen Nutzers zu aktualisieren und der Analysebibliothek den Nutzertyp des aktuellen Nutzers mitzuteilen.

@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: Status ohne Erstellung in einen Erstellungsstatus umwandeln

produceState startet eine Koroutine für die Zusammensetzung, die Werte in ein zurückgegebenes State übertragen kann. Damit können Sie den Status ohne Erstellung in den Erstellungsstatus umwandeln, z. B. externe, abogesteuerte Status wie Flow, LiveData oder RxJava in die Komposition.

Der Producer wird gestartet, wenn produceState die Komposition betritt, und wird abgebrochen, wenn er die Komposition verlässt. Der zurückgegebene State führt zu einer Zusammenführung. Wenn Sie denselben Wert festlegen, wird keine Neuzusammensetzung ausgelöst.

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

Das folgende Beispiel zeigt, wie Sie mit produceState ein Image 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: Konvertiert ein oder mehrere Zustandsobjekte in einen anderen Status

Bei der Zusammensetzung erfolgt jedes Mal eine Neuzusammensetzung, 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 Ihre Eingaben für eine zusammensetzbare Funktion häufiger ändern, als Sie sie neu zusammensetzen müssen. Dies tritt häufig auf, wenn sich etwas häufig ändert, z. B. bei einer Scrollposition, aber die zusammensetzbare Funktion muss erst darauf reagieren, wenn sie einen bestimmten Grenzwert überschreitet. Mit derivedStateOf wird ein neues Erstellungsstatusobjekt erstellt, das nur so oft aktualisiert wird, wie Sie benötigen. Auf diese Weise funktioniert sie ähnlich wie der Kotlin-Operator distinctUntilChanged() für 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 ändert sich firstVisibleItemIndex jedes Mal, wenn sich das erste sichtbare Element ändert. Wenn Sie 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

Ein häufiger Fehler besteht darin, anzunehmen, dass Sie beim Kombinieren von zwei Compose-Zustandsobjekten derivedStateOf verwenden sollten, da Sie einen "Ableiten des Zustands" vornehmen. Wie im folgenden Snippet gezeigt, ist dies jedoch reiner Aufwand und nicht erforderlich:

// 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: Zustand von „Compose“ in Abläufe konvertieren

Verwenden Sie snapshotFlow, um State<T>-Objekte in einen kalten Ablauf zu konvertieren. snapshotFlow führt seinen Block aus, wenn er erfasst wird, und gibt das Ergebnis der darin gelesenen State-Objekte aus. Wenn eines der im snapshotFlow-Block gelesenen State-Objekte mutiert, gibt der Ablauf den neuen Wert an seinen Collector aus, wenn der neue Wert nicht gleich dem vorherigen ausgegebenen Wert ist (dieses Verhalten ähnelt dem von Flow.distinctUntilChanged).

Das folgende Beispiel zeigt einen Nebeneffekt, der erfasst, wenn der Nutzer in Analytics über das erste Element in einer Liste scrollt:

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 von der Leistungsfähigkeit der Flow-Operatoren profitieren kann.

Effekte neu starten

Einige Effekte in Compose, z. B. LaunchedEffect, produceState oder DisposableEffect, erfordern eine variable Anzahl von Argumenten oder Schlüsseln, mit denen der laufende Effekt abgebrochen und ein neuer mit den neuen Schlüsseln gestartet wird.

Die typische Form für diese APIs lautet:

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 ein Neustart weniger Auswirkungen hat, als er sollte, können Fehler in der App auftreten.
  • Ein Neustart kann ineffizient sein, als sie eigentlich sein sollten.

Als Faustregel gilt, dass änderbare und unveränderliche Variablen, die im Effektblock des Codes verwendet werden, dem zusammensetzbaren Effekt als Parameter hinzugefügt werden sollten. Darüber hinaus können weitere Parameter hinzugefügt werden, um einen Neustart des Effekts zu erzwingen. Wenn die Änderung einer Variablen den Effekt nicht neu starten soll, sollte die Variable in rememberUpdatedState eingeschlossen sein. 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 für die Wirkung übergeben.

Im oben gezeigten DisposableEffect-Code übernimmt der Effekt als Parameter von lifecycleOwner, der in seinem Block verwendet wird, da jede Änderung an ihnen einen Neustart des Effekts zur Folge haben 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 dieser sich ändert, wird HomeScreen neu zusammengesetzt, aber DisposableEffect wird nicht entfernt und neu gestartet. Dies führt zu Problemen, da ab diesem Zeitpunkt die falsche lifecycleOwner verwendet wird.

Konstanten als Schlüssel

Sie können eine Konstante wie true als Auswirkungsschlüssel verwenden, damit sie dem Lebenszyklus der Aufrufwebsite folgt. Dafür gibt es gültige Anwendungsfälle wie das LaunchedEffect-Beispiel oben. Überlegen Sie sich jedoch vorher genau, ob das wirklich nötig ist.