Effetti collaterali in Compose

Un effetto collaterale è una modifica dello stato dell'app che si verifica al di fuori dell'ambito di una funzione componibile. A causa del ciclo di vita e delle proprietà dei componibili, come le ricomposizioni imprevedibili, l'esecuzione di ricomposizioni di elementi componibili in ordini diversi o le ricomposizioni che possono essere scartate, gli elementi componibili dovrebbero essere privi di effetti collaterali.

Tuttavia, a volte gli effetti collaterali sono necessari, ad esempio, per attivare un evento una tantum come la visualizzazione di una snackbar o il passaggio a un'altra schermata in base a una determinata condizione di stato. Queste azioni dovrebbero essere richiamate da un ambiente controllato, consapevole del ciclo di vita dell'elemento componibile. In questa pagina, scoprirai le diverse API con effetto collaterale offerte da Jetpack Compose.

Casi d'uso degli stati e degli effetti

Come indicato nella documentazione di Thinking in Compose, i componibili non dovrebbero avere effetti collaterali. Quando devi apportare modifiche allo stato dell'app (come descritto nel documento Gestione dello stato), devi utilizzare le API Effect in modo che gli effetti collaterali vengano eseguiti in modo prevedibile.

Date le diverse possibilità che gli effetti si aprono in Compose, possono essere facilmente abusati. Assicurati che il lavoro che esegui al loro interno sia correlato all'interfaccia utente e non interrompa il flusso di dati unidirezionale, come spiegato nella documentazione sulla gestione dello stato.

LaunchedEffect: esegui le funzioni di sospensione nell'ambito di un componibile

Per chiamare le funzioni di sospensione in modo sicuro dall'interno di un componibile, utilizza l'elemento componibile LaunchedEffect. Quando LaunchedEffect entra nella composizione, avvia una coroutine con il blocco di codice trasmesso come parametro. La coroutine verrà annullata se LaunchedEffect lascia la composizione. Se LaunchedEffect viene ricomposto con chiavi diverse (consulta la sezione Effetti di riavvio di seguito), la coroutine esistente verrà annullata e la nuova funzione di sospensione verrà lanciata in una nuova.

Ad esempio, per mostrare una Snackbar in una Scaffold puoi utilizzare la funzione SnackbarHostState.showSnackbar, che è una funzione di sospensione.

@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 ->
        // ...
    }
}

Nel codice riportato sopra, una coroutine viene attivata se lo stato contiene un errore e viene annullata in caso contrario. Poiché il sito di chiamata LaunchedEffect si trova all'interno di un'istruzione if, quando l'affermazione è falsa, se LaunchedEffect si trovava nella composizione, verrà rimossa e, pertanto, la coroutine verrà annullata.

rememberCoroutineScope: ottieni un ambito sensibile alla composizione per lanciare una coroutine al di fuori di un componibile

Poiché LaunchedEffect è una funzione componibile, può essere utilizzata solo all'interno di altre funzioni componibili. Per avviare una coroutine al di fuori di un componibile, ma con ambito in modo che venga annullata automaticamente quando lascia la composizione, utilizza rememberCoroutineScope. Usa rememberCoroutineScope anche ogni volta che devi controllare manualmente il ciclo di vita di una o più coroutine, ad esempio annullando un'animazione quando si verifica un evento utente.

rememberCoroutineScope è una funzione componibile che restituisce un CoroutineScope associato al punto della composizione in cui è chiamato. L'ambito verrà annullato quando la chiamata abbandona la composizione.

Seguendo l'esempio precedente, potresti utilizzare questo codice per mostrare un Snackbar quando l'utente tocca un Button:

@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: fa riferimento a un valore che non deve essere riavviato se il valore cambia

LaunchedEffect si riavvia quando viene modificato uno dei parametri chiave. Tuttavia, in alcune situazioni potresti voler acquisire un valore nell'effetto che, se cambia, non vuoi che si riavvii. A questo scopo, è necessario utilizzare rememberUpdatedState per creare un riferimento a questo valore che possa essere acquisito e aggiornato. Questo approccio è utile per gli effetti che contengono operazioni di lunga durata che possono essere costose o proibitive da ricreare e riavviare.

Ad esempio, supponi che la tua app abbia un valore LandingScreen che scompare dopo un po' di tempo. Anche se LandingScreen viene ricomposto, l'effetto che attende un po' di tempo e avvisa che il tempo trascorso non deve essere riavviato:

@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 */
}

Per creare un effetto che corrisponda al ciclo di vita del sito della chiamata, viene passata come parametro una costante in continuo cambiamento, come Unit o true. Nel codice riportato sopra, viene utilizzato LaunchedEffect(true). Per assicurarti che la lambda onTimeout contenga sempre il valore più recente con cui è stata ricomposta LandingScreen, devi racchiudere onTimeout nella funzione rememberUpdatedState. Il valore State restituito, currentOnTimeout nel codice, deve essere utilizzato nell'effetto.

DisposableEffect: effetti che richiedono pulizia

Per gli effetti collaterali che devono essere puliti dopo la modifica dei tasti o se il componibile lascia la composizione, utilizza DisposableEffect. Se i tasti DisposableEffect cambiano, il componibile deve smaltire (eseguire la pulizia) l'effetto attuale e resettarlo richiamando di nuovo l'effetto.

Ad esempio, potresti voler inviare eventi di analisi in base agli eventi Lifecycle utilizzando un LifecycleObserver. Per ascoltare questi eventi in Compose, utilizza un DisposableEffect per registrare e annullare la registrazione dell'osservatore quando necessario.

@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 */
}

Nel codice riportato sopra, l'effetto aggiungerà observer a lifecycleOwner. Se lifecycleOwner cambia, l'effetto viene eliminato e riavviato con il nuovo lifecycleOwner.

Un elemento DisposableEffect deve includere una clausola onDispose come istruzione finale nel blocco di codice. In caso contrario, l'IDE mostra un errore in fase di build.

SideEffect: pubblica lo stato di Compose in un codice non in scrittura

Per condividere lo stato della Scrittura con oggetti non gestiti da Compose, utilizza l'elemento componibile SideEffect. L'utilizzo di un SideEffect garantisce che l'effetto venga eseguito dopo ogni ricomposizione riuscita. D'altra parte, è errato eseguire un effetto prima che sia garantita una ricomposizione riuscita, il che è il caso in cui si scrive l'effetto direttamente in un componibile.

Ad esempio, la libreria di analisi potrebbe consentirti di segmentare la popolazione di utenti collegando i metadati personalizzati ("proprietà utente" in questo esempio) a tutti i successivi eventi di analisi. Per comunicare il tipo di utente dell'utente corrente alla libreria di analisi, utilizza SideEffect per aggiornarne il valore.

@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: converti lo stato non di Compose nello stato di Compose

produceState avvia una coroutine con ambito alla composizione che può inviare valori in una State restituita. Utilizzalo per convertire lo stato non Scrittura nello stato Scrittura, ad esempio portando nella Composizione uno stato esterno basato sull'abbonamento, come Flow, LiveData o RxJava.

Il produttore viene lanciato quando produceState entra nella composizione e verrà annullato quando esce dalla composizione. Il valore State restituito si confonde; l'impostazione dello stesso valore non attiva una ricomposizione.

Anche se produceState crea una coroutine, può essere utilizzato anche per osservare le fonti di dati non sospese. Per rimuovere l'abbonamento a quell'origine, utilizza la funzione awaitDispose.

L'esempio seguente mostra come utilizzare produceState per caricare un'immagine dalla rete. La funzione componibile loadNetworkImage restituisce un State che può essere utilizzato in altri componibili.

@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: converti uno o più oggetti di stato in un altro stato

In Compose, la ricomposizione si verifica ogni volta che un oggetto di stato osservato o un input componibile cambia. Un input o un oggetto di stato potrebbero cambiare più spesso di quanto l'aggiornamento della UI debba effettivamente essere aggiornato, causando una ricomposizione non necessaria.

Dovresti usare la funzione derivedStateOf quando gli input in un componibile cambiano più spesso del necessario per ricomporre. Questo si verifica spesso quando qualcosa cambia spesso, ad esempio una posizione di scorrimento, ma il componibile deve reagire solo quando supera una determinata soglia. derivedStateOf crea un nuovo oggetto di stato Compose che puoi osservare e si aggiorna solo quanto necessario. In questo modo, agisce in modo simile all'operatore Kotlin Flows distinctUntilChanged().

Utilizzo corretto

Lo snippet seguente mostra un caso d'uso appropriato per 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 questo snippet, firstVisibleItemIndex cambia ogni volta che il primo elemento visibile cambia. Mentre scorri, il valore diventa 0, 1, 2, 3, 4, 5 e così via. Tuttavia, la ricomposizione deve avvenire solo se il valore è maggiore di 0. Questa mancata corrispondenza nella frequenza di aggiornamento significa che questo è un buon caso d'uso per derivedStateOf.

Utilizzo non corretto

Un errore comune è presumere che, quando combini due oggetti di stato Compose, dovresti utilizzare derivedStateOf perché stai ottenendo lo "stato derivato". Tuttavia, questo è puramente overhead e non è obbligatorio, come mostrato nello snippet seguente:

// 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 questo snippet, l'aggiornamento fullName deve essere aggiornato con la stessa frequenza di firstName e lastName. Pertanto, non viene eseguita alcuna ricomposizione in eccesso e non è necessario utilizzare derivedStateOf.

snapshotFlow: converti lo stato di Compose in Flows

Utilizza snapshotFlow per convertire gli oggetti State<T> in un flusso a freddo. snapshotFlow esegue il blocco quando viene raccolto ed emette il risultato degli oggetti State letti al suo interno. Quando uno degli oggetti State letti all'interno del blocco snapshotFlow cambia, il Flow emette il nuovo valore al raccoglitore se il nuovo valore non è uguale al valore emesso precedente (questo comportamento è simile a quello di Flow.distinctUntilChanged).

L'esempio seguente mostra un effetto collaterale che registra quando l'utente scorre il primo elemento di un elenco su Analytics:

val listState = rememberLazyListState()

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

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

Nel codice riportato sopra, listState.firstVisibleItemIndex viene convertito in un Flow che può trarre vantaggio dalla potenza degli operatori di Flow.

Effetti di riavvio

Alcuni effetti in Compose, come LaunchedEffect, produceState o DisposableEffect, prevedono un numero variabile di argomenti e chiavi utilizzati per annullare l'effetto in esecuzione e avviarne uno nuovo con le nuove chiavi.

Il formato tipico di queste API è:

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

A causa delle sfumature di questo comportamento, possono verificarsi problemi se i parametri utilizzati per riavviare l'effetto non sono quelli corretti:

  • Il riavvio degli effetti meno previsto potrebbe causare bug nella tua app.
  • Gli effetti del riavvio potrebbero essere inefficienti.

Come regola generale, le variabili mutabili e immutabili utilizzate nel blocco degli effetti del codice devono essere aggiunte come parametri all'effetto componibile. Puoi aggiungere altri parametri per forzare il riavvio dell'effetto. Se la modifica di una variabile non deve causare il riavvio dell'effetto, la variabile deve essere aggregata in rememberUpdatedState. Se la variabile non cambia mai perché è aggregata in un remember senza chiavi, non è necessario passare la variabile come chiave all'effetto.

Nel codice DisposableEffect mostrato sopra, l'effetto assume come parametro lifecycleOwner utilizzato nel blocco, poiché qualsiasi modifica dovrebbe causare il riavvio dell'effetto.

@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 e currentOnStop non sono necessarie come chiavi DisposableEffect, poiché il loro valore non cambia mai in Composizione a causa dell'utilizzo di rememberUpdatedState. Se non passi come parametro lifecycleOwner e cambia, HomeScreen si ricompone, ma DisposableEffect non viene eliminato e riavviato. Questo causa problemi perché da quel momento in poi viene utilizzato il valore lifecycleOwner sbagliato.

Costanti come chiavi

Puoi utilizzare una costante come true come chiave effetto per seguire il ciclo di vita del sito della chiamata. Esistono casi d'uso validi, come l'esempio LaunchedEffect mostrato sopra. Tuttavia, prima di farlo, rifletti bene e assicurati che sia quello che ti serve.