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 composabili, come ricostruzioni imprevedibili, esecuzione di ricostruzioni di composabili in ordini diversi o ricostruzioni che possono essere eliminate, idealmente i composabili dovrebbero essere privi di effetti collaterali.

Tuttavia, a volte sono necessari effetti collaterali, ad esempio per attivare un singolo evento come la visualizzazione di una barra di notifica o il passaggio a un'altra schermata in base a una determinata condizione di stato. Queste azioni devono essere chiamate da un ambiente controllato che sia a conoscenza del ciclo di vita del composable. In questa pagina scoprirai le diverse API con effetti collaterali offerte da Jetpack Compose.

Casi d'uso relativi a stato ed effetto

Come descritto nella documentazione Pensare in Compose, i composabili non devono avere effetti collaterali. Quando devi apportare modifiche allo stato dell'app (come descritto nella documentazione Gestione dello stato), devi utilizzare le API Effect in modo che gli effetti collaterali vengano eseguiti in modo prevedibile.

A causa delle diverse possibilità offerte dagli effetti in Scrittura, possono essere facilmente sovrautilizzati. Assicurati che il lavoro che svolgi in queste app sia correlato all'interfaccia utente e non interrompa il flusso di dati unidirezionale, come spiegato nella documentazione sulla gestione dello stato.

LaunchedEffect: esegui funzioni di sospensione nell'ambito di un composable

Per eseguire operazioni durante la vita di un componibile e avere la possibilità di chiamare funzioni di sospensione, utilizza il componibile LaunchedEffect. Quando LaunchedEffect entra nella composizione, avvia una coroutine con il blocco di codice passato come parametro. La coroutine verrà annullata se LaunchedEffect esce dalla composizione. Se LaunchedEffect viene riformata con chiavi diverse (vedi la sezione Effetti del riavvio di seguito), la coroutine esistente verrà annullata e la nuova funzione di sospensione verrà lanciata in una nuova coroutine.

Ad esempio, di seguito è riportata un'animazione che emette impulsi al valore alpha con un ritardo configurabile:

// 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)
    }
}

Nel codice riportato sopra, l'animazione utilizza la funzione di sospensione delay per attendere il tempo impostato. Quindi, anima in sequenza l'alpha da zero e viceversa utilizzando animateTo. L'operazione verrà ripetuta per tutta la durata del composable.

rememberCoroutineScope: ottieni un ambito sensibile alla composizione per avviare una coroutine all'esterno di un componente composable

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

rememberCoroutineScope è una funzione componibile che restituisce un CoroutineScope vincolato al punto della composizione in cui viene chiamata. L'ambito verrà annullato quando la chiamata esce dalla composizione.

Seguendo l'esempio precedente, puoi 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: fai riferimento a un valore in un effetto che non deve essere riavviato se il valore cambia

LaunchedEffect si riavvia quando uno dei parametri chiave cambia. Tuttavia, in alcune situazioni potresti voler acquisire un valore nell'effetto che, se cambia, non vuoi che l'effetto venga riavviato. Per farlo, è necessario utilizzare rememberUpdatedState per creare un riferimento a questo valore che può essere acquisito e aggiornato. Questo approccio è utile per gli effetti che contengono operazioni di lunga durata che potrebbero essere costose o proibitive da ricreare e riavviare.

Ad esempio, supponiamo che la tua app abbia un LandingScreen che scompare dopo un po' di tempo. Anche se LandingScreen viene ricomposto, l'effetto che attende un po' di tempo e comunica 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 di chiamata, viene passata come parametro una costante immutabile come Unit o true. Nel codice riportato sopra viene utilizzato LaunchedEffect(true). Per assicurarti che la funzione lambda onTimeout sempre contenga l'ultimo valore con cui è stato ricomposto LandingScreen, onTimeout deve essere racchiusa nella funzione rememberUpdatedState. I valori State, currentOnTimeout restituiti nel codice devono essere utilizzati nell'effetto.

DisposableEffect: effetti che richiedono la pulizia

Per gli effetti collaterali che devono essere ripuliti dopo la modifica delle chiavi o se il composable esce dalla composizione, utilizza DisposableEffect. Se le chiavi DisposableEffect cambiano, il composable deve rimuovere (eseguire la pulizia di) l'effetto corrente e reimpostarlo chiamando di nuovo l'effetto.

Ad esempio, potresti voler inviare eventi di analisi basati su eventi Lifecycle utilizzando un LifecycleObserver. Per ascoltare questi eventi in Scrittura, utilizza un DisposableEffect per registrare e annullare la registrazione dell'osservatore, se 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 DisposableEffect deve includere una clausola onDispose come istruzione finale nel suo blocco di codice. In caso contrario, l'IDE mostra un errore di compilazione.

SideEffect: pubblica lo stato di composizione nel codice non di composizione

Per condividere lo stato di composizione con oggetti non gestiti da compose, utilizza il composable SideEffect. L'utilizzo di un SideEffect garantisce che l'effetto venga eseguito dopo ogni ricomposizione riuscita. D'altra parte, non è corretto eseguire un effetto prima di garantire una ricompozione riuscita, come accade quando si scrive l'effetto direttamente in un composable.

Ad esempio, la tua libreria di analisi potrebbe consentirti di segmentare la popolazione di utenti collegando metadati personalizzati ("proprietà utente" in questo esempio) a tutti gli eventi di analisi successivi. Per comunicare il tipo di utente dell'utente corrente alla tua 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 composizione in stato di composizione

produceState avvia una coroutine limitata alla composizione che può inviare valori a un State restituito. Utilizzalo per convertire lo stato non Compose in stato Compose, ad esempio inserendo nello stato Composizione lo stato esterno basato sugli abbonamenti come Flow, LiveData o RxJava.

Il produttore viene avviato quando produceState entra nella composizione e viene annullato quando esce dalla composizione. Il valore State restituito viene unito; l'impostazione dello stesso valore non attiverà una ricostituzione.

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

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

@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 stato in un altro stato

In Compose, la ricompozione avviene ogni volta che un oggetto dello stato osservato o un input composable cambia. Un oggetto stato o un input potrebbe cambiare più spesso di quanto l'interfaccia utente debba effettivamente aggiornarsi, con conseguente ricompozione non necessaria.

Devi utilizzare la funzione derivedStateOf quando gli input di un composable cambiano più spesso di quanto sia necessario per eseguire la ricompozione. Questo accade spesso quando qualcosa cambia di frequente, ad esempio una posizione di scorrimento, ma il composable deve reagire solo quando supera una determinata soglia. derivedStateOf crea un nuovo oggetto dello stato Compose che puoi osservare e che si aggiorna solo in base alle tue esigenze. In questo modo, agisce in modo simile all'operatore distinctUntilChanged() di Kotlin Flows.

Utilizzo corretto

Il seguente snippet 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 cambia il primo elemento visibile. Man mano che scorri, il valore diventa 0, 1, 2, 3, 4, 5 e così via. Tuttavia, la ricompozione deve avvenire solo se il valore è maggiore di 0. Questa mancata corrispondenza nella frequenza di aggiornamento indica che si tratta di un buon caso d'uso per derivedStateOf.

Utilizzo non corretto

Un errore comune è assumere che, quando combini due oggetti dello stato di Compose, dovresti utilizzare derivedStateOf perché stai "derivando lo stato". Tuttavia, questo è puramente un overhead e non è necessario, 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, fullName deve essere aggiornato con la stessa frequenza di firstName e lastName. Pertanto, non si verifica alcuna ricostituzione in eccesso e l'utilizzo di derivedStateOf non è necessario.

snapshotFlow: converti lo stato di Compose in flussi

Utilizza snapshotFlow per convertire gli oggetti State<T> in un flusso a freddo. snapshotFlow esegue il proprio 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 subisce una mutazione, il flusso emette il nuovo valore al suo collettore se il nuovo valore non è uguale a il valore emesso in precedenza (questo comportamento è simile a quello di Flow.distinctUntilChanged).

Il seguente esempio mostra un effetto collaterale che registra quando l'utente scorri oltre il primo elemento di un elenco in 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 flusso che può sfruttare la potenza degli operatori di Flow.

Riavvio degli effetti

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

La forma tipica di queste API è:

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

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

  • Se riavvii gli effetti meno di quanto dovresti, potresti causare bug nella tua app.
  • Riavviare gli effetti più di quanto dovrebbe essere necessario potrebbe non essere efficiente.

Come regola generale, le variabili mutabili e immutabili utilizzate nel blocco dell'effetto del codice devono essere aggiunte come parametri al composable dell'effetto. Oltre a questi, si possono 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 racchiusa tra rememberUpdatedState. Se la variabile non cambia mai perché è racchiusa in un remember senza chiavi, non è necessario passarla come chiave all'effetto.

Nel codice DisposableEffect mostrato sopra, l'effetto prende come parametro il lifecycleOwner utilizzato nel relativo blocco, perché 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 necessari come chiavi DisposableEffect, perché il loro valore non cambia mai nella composizione a causa dell'utilizzo di rememberUpdatedState. Se non passi lifecycleOwner come parametro e questo cambia, HomeScreen si ricomporrà, ma DisposableEffect non verrà eliminato e riavviato. Ciò causa problemi perché da quel momento in poi viene utilizzato lifecycleOwner sbagliato.

Costanti come chiavi

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