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 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 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 App vornehmen müssen (wie in der Dokumentation zum Verwalten des Status beschrieben), sollten Sie die Effect APIs verwenden, damit 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: Ausführen von Suspend-Funktionen im Rahmen einer komponierbaren Funktion

Wenn Sie während der Lebensdauer eines Composeables Aufgaben ausführen und Suspend-Funktionen aufrufen möchten, verwenden Sie das Composeable LaunchedEffect. Wenn LaunchedEffect die Komposition betritt, wird eine Coroutine mit dem Codeblock gestartet, der als Parameter übergeben wird. Die Koroutine wird abgebrochen, wenn LaunchedEffect die Zusammensetzung 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 kombinierbare Funktion ist, kann sie nur in anderen kombinierbaren 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 Tasks manuell steuern müssen, z. B. wenn eine Animation abgebrochen werden soll, wenn ein Nutzerereignis auftritt.

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.

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 wichtigen Parameter ä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 muss mit rememberUpdatedState eine Referenz auf diesen Wert erstellt werden, die erfasst und aktualisiert werden kann. Dieser Ansatz ist hilfreich für Effekte, die langlebige Vorgänge enthalten, deren Neuerstellung und Neustart teuer oder unzumutbar sein können.

Angenommen, Ihre App enthält eine LandingScreen, die nach einiger Zeit verschwindet. Selbst wenn LandingScreen neu zusammengesetzt wird, wird der Effekt, der eine Zeit wartet und benachrichtigt, dass die vergangene Zeit nicht neu gestartet werden soll:

@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 Code oben 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.

Beispielsweise können Sie mithilfe von LifecycleObserver Analyseereignisse senden, die auf Lifecycle-Ereignissen basieren. 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 als letzte Anweisung in seinem Codeblock eine onDispose-Klausel 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: Nicht-Compose-Status in Compose-Status 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.

Auch wenn produceState eine Coroutine erstellt, kann sie auch zum Beobachten von nicht angehaltenen 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: Ein oder mehrere Statusobjekte in einen anderen Status umwandeln

Bei der Zusammensetzung erfolgt jedes Mal eine Neuzusammensetzung, wenn sich ein beobachtetes Zustandsobjekt 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 ein Composeable häufiger ändern, als es neu zusammengesetzt werden muss. Das ist oft der Fall, wenn sich etwas häufig ändert, z. B. eine Scrollposition, das Composed Element aber erst reagieren muss, wenn ein bestimmter Grenzwert überschritten wird. derivedStateOf erstellt ein neues „Compose State“-Objekt, das Sie beobachten können und das nur so oft aktualisiert wird, wie Sie es 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. 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 ist die Annahme, dass Sie beim Kombinieren von zwei Compose-Statusobjekten derivedStateOf verwenden sollten, weil Sie den Status „ableiten“. 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 findet keine übermäßige Neuzusammensetzung statt und die Verwendung von derivedStateOf ist nicht erforderlich.

snapshotFlow: Compose-Status in Abläufe umwandeln

Verwenden Sie snapshotFlow, um State<T>-Objekte in einen kalten Ablauf umzuwandeln. 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).

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 ein Neustart weniger Auswirkungen hat, als er sollte, können Fehler in der App auftreten.
  • 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 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 eine remember ohne Schlüssel verpackt ist, müssen Sie die Variable nicht als Schlüssel für die Wirkung übergeben.

Im obigen DisposableEffect-Code nimmt der Effekt als Parameter den lifecycleOwner aus seinem Block an, 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 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 sich dieser ändert, wird HomeScreen neu erstellt, aber DisposableEffect wird nicht verworfen und neu gestartet. Das führt zu Problemen, da ab diesem Punkt 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 genau, ob das wirklich nötig ist.