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 composable, come le ricomposizioni imprevedibili, l'esecuzione di ricomposizioni di composable in ordini diversi o le ricomposizioni che possono essere eliminate, i composable devono idealmente 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 per passare a un'altra schermata in base a una determinata condizione di stato. Queste azioni devono essere chiamate da un ambiente controllato che conosca il ciclo di vita del componente componibile. In questa pagina, scoprirai le diverse API per gli effetti collaterali offerte da Jetpack Compose.

Casi d'uso di stato ed effetto

Come descritto nella documentazione Thinking in Compose, i composable non devono 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 questi effetti collaterali vengano eseguiti in modo prevedibile.

A causa delle diverse possibilità che gli effetti aprono in Scrittura, possono essere facilmente usati in modo eccessivo. Assicurati che il lavoro che svolgi 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'attività componibile

Per eseguire operazioni durante il ciclo di vita di un elemento componibile e avere la possibilità di chiamare funzioni di sospensione, utilizza l'elemento 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 ricomposto con chiavi diverse (vedi la sezione Riavvio degli effetti di seguito), la coroutine esistente verrà annullata e la nuova funzione di sospensione verrà avviata in una nuova coroutine.

Ad esempio, ecco un'animazione che pulsa il valore alfa 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 precedente, l'animazione utilizza la funzione di sospensione delay per attendere il periodo di tempo impostato. Poi, anima in sequenza l'alpha a zero e di nuovo indietro usando animateTo. Questa operazione verrà ripetuta per l'intero ciclo di vita del componente componibile.

rememberCoroutineScope: ottenere un ambito sensibile alla composizione per avviare una coroutine al di fuori di un elemento 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 elemento componibile, ma con ambito in modo che venga annullata automaticamente una volta uscita dalla composizione, utilizza rememberCoroutineScope. Utilizza rememberCoroutineScope anche quando 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 viene chiamato. L'ambito verrà annullato quando la chiamata esce dalla 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 in un effetto che non deve essere riavviato se il valore cambia

LaunchedEffect viene riavviato 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 per un po' di tempo e notifica 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 trasmessa come parametro una costante che non cambia mai, ad esempio Unit o true. Nel codice sopra, viene utilizzato LaunchedEffect(true). Per assicurarti che la lambda onTimeout contenga sempre l'ultimo valore con cui è stato ricomposto LandingScreen, onTimeout deve essere racchiuso nella funzione rememberUpdatedState. State e currentOnTimeout restituiti nel codice devono essere utilizzati nell'effetto.

DisposableEffect: effetti che richiedono pulizia

Per gli effetti collaterali che devono essere puliti dopo la modifica delle chiavi o se il componente componibile esce dalla composizione, utilizza DisposableEffect. Se le chiavi DisposableEffect cambiano, il composable deve eliminare (eseguire la pulizia per) il suo effetto attuale e reimpostarlo chiamando di nuovo l'effetto.

Ad esempio, potresti voler inviare eventi Analytics basati su eventi Lifecycle utilizzando un LifecycleObserver. Per rilevare questi eventi in Compose, utilizza un DisposableEffect per registrare e annullare la registrazione dell'observer 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 precedente, 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 ultima istruzione nel blocco di codice. In caso contrario, l'IDE visualizza un errore in fase di compilazione.

SideEffect: pubblica lo stato di composizione nel codice non Compose

Per condividere lo stato di composizione con oggetti non gestiti dalla composizione, utilizza il composable 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 si verifica quando l'effetto viene scritto direttamente in un elemento componibile.

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

produceState avvia una coroutine con ambito nella composizione che può inserire valori in un State restituito. Utilizzalo per convertire lo stato non di composizione in stato di composizione, ad esempio per portare lo stato esterno basato su abbonamento, come Flow, LiveData o RxJava, nella composizione.

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 ricomposizione.

Anche se produceState crea una coroutine, può essere utilizzato anche per osservare origini dati non sospese. Per rimuovere l'iscrizione a questa 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 stato in un altro stato

In Compose, la ricomposizione si verifica ogni volta che cambia un oggetto di stato osservato o un input componibile. Un oggetto stato o un input potrebbe cambiare più spesso di quanto l'interfaccia utente debba effettivamente aggiornarsi, causando una ricomposizione non necessaria.

Devi utilizzare la funzione derivedStateOf quando gli input di un elemento componibile cambiano più spesso di quanto sia necessario ricomporre. Ciò si verifica spesso quando qualcosa cambia frequentemente, ad esempio una posizione di scorrimento, ma il composable deve reagire solo quando supera una determinata soglia. derivedStateOf crea un nuovo oggetto di stato Compose che puoi osservare e che si aggiorna solo in base alle tue esigenze. In questo modo, si comporta 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 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 è supporre che, quando combini due oggetti di stato Compose, devi utilizzare derivedStateOf perché stai "derivando lo stato". Tuttavia, questo è puramente overhead e non è necessario, come mostrato nel seguente snippet:

// 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 ricomposizione 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 freddo. snapshotFlow esegue il blocco quando viene raccolto ed emette il risultato degli oggetti State letti. Quando uno degli oggetti State letti all'interno del blocco snapshotFlow subisce una mutazione, il flusso emette il nuovo valore nel suo raccoglitore se il nuovo valore non è uguale al valore emesso in precedenza (questo comportamento è simile a quello di Flow.distinctUntilChanged).

Il seguente esempio mostra un effetto collaterale che registra quando l'utente scorre 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 precedente, listState.firstVisibleItemIndex viene convertito in un flusso che può sfruttare la potenza degli operatori di Flow.

Riavvio degli effetti

Alcuni effetti in Compose, 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.

Il modulo tipico per 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:

  • Il riavvio degli effetti meno del dovuto potrebbe causare bug nell'app.
  • Riavviare gli effetti più del dovuto potrebbe essere inefficiente.

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

Nel codice DisposableEffect mostrato sopra, l'effetto prende come parametro il lifecycleOwner utilizzato nel suo blocco, perché qualsiasi modifica apportata deve 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 viene ricomposto, ma DisposableEffect non viene eliminato e riavviato. Ciò causa problemi perché da quel momento in poi viene utilizzato il 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 bene e assicurati che sia quello che ti serve.