Nebeneffekte in „Compose“

Ein Nebeneffekt ist eine Änderung des App-Status, die außerhalb des Gültigkeitsbereichs einer kompositionsfähigen Funktion erfolgt. Aufgrund des Lebenszyklus und der Eigenschaften von Composeables wie unvorhersehbare Neuzusammensetzungen, die Ausführung von Neuzusammensetzungen von Composeables in unterschiedlichen Reihenfolgen oder Neuzusammensetzungen, die verworfen werden können, sollten Composeables idealerweise ohne Nebenwirkungen sein.

Manchmal sind jedoch Nebenwirkungen erforderlich, z. B. um ein einmaliges Ereignis auszulösen, wie das Einblenden einer Snackbar oder das Wechseln zu einem anderen Bildschirm bei einem bestimmten Status. Diese Aktionen sollten aus einer kontrollierten Umgebung aufgerufen werden, die den Lebenszyklus des Composeable kennt. Auf dieser Seite erfahren Sie mehr über die verschiedenen Side-Effect APIs, die Jetpack Compose bietet.

Zustand und Wirkung – Anwendungsfälle

Wie in der Dokumentation Mit Compose denken beschrieben, sollten Compose-Elemente 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.

Aufgrund der verschiedenen Möglichkeiten, die sich in Compose bieten, können sie leicht überstrapaziert werden. Achten Sie darauf, dass die darin ausgeführten Arbeiten sich auf die Benutzeroberfläche beziehen und den einseitigen Datenfluss nicht unterbrechen, wie in der Dokumentation zum Verwalten des Status erläutert.

LaunchedEffect: Beendigungsfunktionen im Bereich einer zusammensetzbaren Funktion ausführen

Wenn Sie über die gesamte Lebensdauer einer zusammensetzbaren Funktion arbeiten und Sperrungsfunktionen aufrufen möchten, verwenden Sie die zusammensetzbare Funktion LaunchedEffect. Wenn LaunchedEffect in die Zusammensetzung eintritt, startet es eine Koroutine mit dem als Parameter übergebenen Codeblock. Die Coroutine wird abgebrochen, wenn LaunchedEffect die Komposition verlässt. Wenn LaunchedEffect mit anderen Schlüsseln neu zusammengesetzt wird (siehe Abschnitt Neustart von Effekten unten), wird die vorhandene Coroutine abgebrochen und die neue Suspend-Funktion in einer neuen Coroutine gestartet.

Hier ist beispielsweise eine Animation, bei der der 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 { mutableStateOf(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 Funktion delay verwendet, um die festgelegte Zeitspanne zu warten. Anschließend wird der Alphawert mit animateTo nacheinander auf null und wieder zurück animiert. Dies wird während der gesamten Lebensdauer des Composeables wiederholt.

rememberCoroutineScope: einen zusammensetzungsbewussten Bereich abrufen, um eine Coroutine außerhalb eines Composeables zu starten

Da LaunchedEffect eine zusammensetzbare Funktion ist, kann sie nur in anderen zusammensetzbaren Funktionen verwendet werden. Wenn Sie eine Coroutine außerhalb eines Composeables starten möchten, sie aber so eingrenzen möchten, 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 Koroutinen manuell steuern müssen, z. B. um eine Animation abzubrechen, wenn ein Nutzerereignis eintritt.

rememberCoroutineScope ist eine kombinierbare 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önntest du mit diesem Code eine Snackbar anzeigen lassen, 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: 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 Fällen kann es jedoch sinnvoll sein, einen Wert in deinem Effekt zu erfassen, der den Effekt nicht neu starten soll, wenn er sich ändert. 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 App enthält eine LandingScreen, die nach einiger Zeit verschwindet. Auch wenn LandingScreen neu zusammengesetzt wird, sollte der Effekt, der eine Weile wartet und benachrichtigt, dass die Zeit verstrichen 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 als Parameter eine unveränderliche Konstante wie Unit oder true übergeben. Im obigen Code wird LaunchedEffect(true) verwendet. Damit die Lambda-Funktion onTimeout immer den neuesten Wert enthält, mit dem LandingScreen neu zusammengesetzt wurde, muss onTimeout in die Funktion rememberUpdatedState eingekapselt werden. Die zurückgegebenen State, currentOnTimeout im Code sollten im Effekt verwendet werden.

DisposableEffect: Effekte, die bereinigt werden müssen

Verwenden Sie DisposableEffect für Nebenwirkungen, die nach der Änderung der Schlüssel beseitigt werden müssen oder wenn das kompositionsfähige Element die Komposition verlässt. Wenn sich die DisposableEffect-Schlüssel ändern, muss das Composeable den aktuellen Effekt entfernen (bereinigen) und durch erneutes Aufrufen des Effekts zurücksetzen.

So können Sie beispielsweise Analytics-Ereignisse basierend auf Lifecycle-Ereignissen mithilfe eines LifecycleObserver senden. Wenn Sie in Compose auf diese Ereignisse warten möchten, verwenden Sie ein DisposableEffect, um den Beobachter bei Bedarf zu registrieren und wieder abzumelden.

@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 wird durch den Effekt die observer der lifecycleOwner hinzugefügt. Wenn sich lifecycleOwner ändert, wird der Effekt entfernt und mit dem neuen lifecycleOwner neu gestartet.

Ein DisposableEffect muss in seinem Codeblock eine onDispose-Klausel als letzte Anweisung enthalten. Andernfalls wird in der IDE ein Fehler bei der Buildzeit 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 den Befehl SideEffect composable. Wenn du einen SideEffect verwendest, wird der Effekt nach jeder erfolgreichen Neukomposition ausgeführt. Andererseits ist es falsch, einen Effekt auszuführen, bevor eine erfolgreiche Neuzusammensetzung garantiert ist. Das ist der Fall, wenn der Effekt direkt in ein Composeable geschrieben wird.

Mit Ihrer Analysebibliothek können Sie beispielsweise Ihre Nutzergruppe segmentieren, indem Sie allen nachfolgenden Analyseereignissen benutzerdefinierte Metadaten („Nutzereigenschaften“ in diesem Beispiel) zuordnen. Wenn Sie den Nutzertyp des aktuellen Nutzers an Ihre Analysebibliothek senden möchten, aktualisieren Sie den Wert mit SideEffect.

@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 coroutine, die auf die Komposition beschränkt ist und Werte in eine zurückgegebene State einfügen kann. Mit dieser Funktion können Sie einen nicht Compose-Status in einen Compose-Status umwandeln, z. B. externen abobasierten Status wie Flow, LiveData oder RxJava in die Komposition einbinden.

Der Producer wird gestartet, wenn produceState die Komposition betritt, und annulliert, wenn er sie verlässt. Der zurückgegebene State-Wert wird zusammengefasst. 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. Verwenden Sie die Funktion awaitDispose, 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 loadNetworkImage-Funktion gibt eine State zurück, die in anderen Composeables 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

In Compose 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 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. In dieser Hinsicht funktioniert er ähnlich wie der Operator distinctUntilChanged() in Kotlin Flows.

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 ändert sich der Wert in 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, weil Sie einen "Ableiten des Zustands" vornehmen. Dies ist jedoch reines Overhead 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 kommt es nicht zu einer übermäßigen Neuzusammensetzung 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 State-Objekte, die im snapshotFlow-Block gelesen werden, mutiert, gibt der Ablauf den neuen Wert an den entsprechenden Collector aus, wenn er sich von dem zuvor ausgegeben Wert unterscheidet. Dieses Verhalten ähnelt dem von Flow.distinctUntilChanged.

Im folgenden Beispiel wird ein Nebeneffekt gezeigt, bei dem erfasst wird, wenn der Nutzer in Analytics über den ersten Eintrag 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 umgewandelt, der von den Funktionen der Flow-Operatoren profitieren kann.

Effekte neu starten

Einige Effekte in Compose, z. B. LaunchedEffect, produceState oder DisposableEffect, nehmen eine variable Anzahl von Argumenten (Tasten) an, mit denen der laufende Effekt abgebrochen und ein neuer mit den neuen Tasten 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 Parameter, mit denen der Effekt neu gestartet wird, nicht richtig sind:

  • Wenn die Auswirkungen des Neustarts geringer sind als sie sein sollten, 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: Veränderliche und unveränderliche Variablen, die im Effektblock des Codes verwendet werden, sollten dem Effekt-Composable als Parameter hinzugefügt werden. Außerdem können weitere Parameter hinzugefügt werden, um den Effekt 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 sind als DisposableEffect-Schlüssel nicht erforderlich, da sich ihr Wert in der Komposition 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 Effektschlüssel verwenden, damit der Effekt dem Lebenszyklus der Aufrufstelle folgt. Es gibt jedoch durchaus gültige Anwendungsfälle, wie das Beispiel LaunchedEffect oben zeigt. Überlegen Sie sich jedoch vorher gut, ob Sie das wirklich brauchen.