Efeitos colaterais no Compose

Um efeito colateral é uma mudança no estado do app que acontece fora do escopo de uma função de composição. Devido ao ciclo de vida e às propriedades das funções de composição (como as recomposições imprevisíveis, a execução de recomposições de elementos de composição em diferentes ordens ou as recomposições que podem ser descartadas), elas precisam ser livres de efeitos colaterais.

No entanto, às vezes, os efeitos colaterais são necessários. Por exemplo, para acionar um evento único a fim de mostrar uma snackbar ou para navegar para outra tela devido a determinada condição de estado. Essas ações precisam ser chamadas em um ambiente controlado, compatível com o ciclo de vida das funções de composição. Nesta página, você vai aprender sobre as diferentes APIs de efeitos colaterais oferecidas pelo Jetpack Compose.

Casos de uso de estado e efeito

Conforme visto na documentação Trabalhando com o Compose, as funções que podem ser compostas precisam ser livres de efeitos colaterais. Quando você precisar fazer mudanças no estado do app, conforme descrito no documento Como gerenciar a documentação sobre os estados, use as APIs Effect para que esses efeitos colaterais sejam executados de forma previsível.

Devido às diferentes possibilidades oferecidas por efeitos no Compose, eles podem ser usados de forma excessiva. Confira se o trabalho que você realiza nesses efeitos é relacionado à IU e não quebra o fluxo de dados unidirecional, conforme explicado no artigo Como gerenciar a documentação sobre os estados.

LaunchedEffect: executa funções de suspensão no escopo de um elemento combinável.

Para chamar funções de suspensão de forma segura em uma função de composição, use o LaunchedEffect de composição. Quando LaunchedEffect entra na composição, ele inicia uma corrotina com o bloco de código transmitido como um parâmetro. A corrotina será cancelada se LaunchedEffect sair da composição. Se LaunchedEffect for recomposto com chaves diferentes (consulte a seção Como reiniciar efeitos abaixo), a corrotina existente será cancelada e a nova função de suspensão será iniciada em uma nova corrotina.

Por exemplo, a ação de mostrar uma Snackbar em um Scaffold é realizada pela função SnackbarHostState.showSnackbar, que é uma função de suspensão.

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

No código acima, uma corrotina será acionada caso o estado contenha um erro e será cancelada quando isso não ocorrer. Como o local de chamadas LaunchedEffect está dentro de uma instrução if, quando a instrução for falsa, se LaunchedEffect estiver na composição, ele será removido e, portanto, a corrotina será cancelada.

rememberCoroutineScope: receber um escopo com reconhecimento de composição para iniciar uma corrotina fora de um elemento combinável.

Como LaunchedEffect é uma função que pode ser composta, ela só pode ser usada dentro de outras funções desse tipo. Para iniciar uma corrotina fora de uma função que pode ser composta, mas com escopo para que ela seja automaticamente cancelada ao sair da composição, use rememberCoroutineScope. Além disso, use rememberCoroutineScope sempre que precisar controlar o ciclo de vida de uma ou mais corrotinas manualmente, por exemplo, para cancelar uma animação quando um evento de usuário ocorrer.

A rememberCoroutineScope é uma função que pode ser composta que retorna um CoroutineScope vinculado ao ponto da composição em que ele é chamado. O escopo será cancelado quando a chamada sair da composição.

Seguindo o exemplo anterior, esse código poderia ser usado para exibir uma Snackbar quando o usuário toca em um 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: faz referência a um valor em um efeito que não pode ser reiniciado se o valor mudar.

O LaunchedEffect é reiniciado quando um dos parâmetros de chave muda. No entanto, em algumas situações, você pode querer capturar um valor que, se modificado, faria com que o efeito fosse reiniciado, e é possível que você não queira que isso aconteça. Para fazer isso, é necessário usar rememberUpdatedState para criar uma referência a esse valor que possa ser capturada e atualizada. Essa abordagem é útil para efeitos que contêm operações de longa duração que podem ser caras ou proibitivas para recriar e reiniciar.

Por exemplo, suponha que o app tenha uma LandingScreen que desaparece após determinado período. Mesmo que LandingScreen seja recomposta, o efeito que aguarda por um determinado período e avisa que o período passou não precisa ser reiniciado:

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

Para criar um efeito que corresponda ao ciclo de vida do local de chamada, uma constante que nunca muda (como Unit ou true) é transmitida como parâmetro. No código acima, LaunchedEffect(true) é usado. Para garantir que a lambda onTimeout sempre contenha o valor mais recente usado para recompor a LandingScreen, onTimeout precisa ser unido à função rememberUpdatedState. O State retornado, currentOnTimeout no código, será usado no efeito.

DisposableEffect: efeitos que exigem limpeza

Para efeitos colaterais que exigem a limpeza depois que as chaves mudam ou se a função que pode ser composta sai da composição, use DisposableEffect. Se as chaves DisposableEffect mudarem, a função que pode ser composta precisa descartar o efeito atual, ou seja, fazer a limpeza, e ser redefinida chamando o efeito novamente.

Por exemplo, pode ser necessário enviar eventos de análise com base em eventos do Lifecycle usando um LifecycleObserver. Para detectar esses eventos no Compose, use um DisposableEffect para registrar e cancelar o registro do observador quando necessário.

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

No código acima, o efeito adicionará o observer ao lifecycleOwner. Se o lifecycleOwner mudar, o efeito será descartado e reiniciado com o novo lifecycleOwner.

Um DisposableEffect precisa incluir uma cláusula onDispose como a instrução final no bloco de código. Caso contrário, o ambiente de desenvolvimento integrado exibirá um erro de tempo de compilação.

SideEffect: publica o estado do Compose em um código que não é dele.

Para compartilhar o estado do Compose com objetos não gerenciados pelo Compose, use o elemento combinável SideEffect. O uso de um SideEffect garante que o efeito seja executado após cada recomposição bem-sucedida. Por outro lado, não é correto executar um efeito antes que uma recomposição bem-sucedida seja garantida, o que é o caso ao gravar o efeito diretamente em um elemento combinável.

Por exemplo, sua biblioteca de análise pode permitir segmentar a população de usuários anexando metadados personalizados (nesse caso, "propriedades do usuário") a todos os eventos de análise subsequentes. Para comunicar o tipo de usuário atual à biblioteca de análise, use o SideEffect a fim de atualizar o valor da biblioteca.

@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: converte o estado que não é do Compose para o do Compose.

produceState inicia uma corrotina com escopo no Compose, que pode enviar valores para um State retornado. Use essa função para converter um estado externo em um estado do Compose, por exemplo, para usar um estado externo por assinatura, como Flow, LiveData ou RxJava, no Compose.

O produtor é iniciado quando produceState entra na composição e será cancelado quando ele sair da composição. O State é mesclado. Definir o mesmo valor não acionará uma recomposição.

Mesmo que o produceState crie uma corrotina, ele também pode ser usado para observar fontes de dados não suspensas. Para remover a assinatura dessa origem, use a função awaitDispose.

O exemplo a seguir mostra como usar produceState para carregar uma imagem da rede. A função que pode ser composta loadNetworkImage retorna um State que pode ser usado em outras funções desse tipo.

@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: converte um ou vários objetos de estado em outro estado.

No Compose, a recomposição ocorre sempre que um objeto de estado observado ou uma entrada de composição muda. Um objeto de estado ou uma entrada pode mudar com mais frequência do que a interface precisa ser atualizada, o que leva a uma recomposição desnecessária.

Use a função derivedStateOf quando as entradas em um elemento combinável mudarem com mais frequência do que o necessário para a recomposição. Isso geralmente ocorre quando algo muda com frequência, como uma posição de rolagem, mas o elemento combinável só precisa reagir a ele quando ultrapassa um determinado limite. derivedStateOf cria um novo objeto de estado do Compose que pode ser atualizado apenas o necessário. Dessa forma, ele age de forma semelhante ao operador distinctUntilChanged() (link em inglês) dos fluxos do Kotlin.

Uso correto

O snippet a seguir mostra um caso de uso apropriado para 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()
        }
    }
}

Neste snippet, firstVisibleItemIndex muda sempre que o primeiro item visível é modificado. Conforme você rola a tela, o valor se torna 0, 1, 2, 3, 4, 5 etc. No entanto, a recomposição só vai precisar ocorrer se o valor for maior que 0. Essa incompatibilidade na frequência de atualização significa que esse é um bom caso de uso para derivedStateOf.

Uso incorreto

Um erro comum é supor que, ao combinar dois objetos de estado do Compose, você precisa usar derivedStateOf porque está "derivando o estado". No entanto, isso é puramente desnecessário e não é obrigatório, conforme mostrado no snippet a seguir:

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

Nesse snippet, fullName precisa ser atualizado com a mesma frequência que firstName e lastName. Portanto, não ocorre nenhuma recomposição excessiva e o uso de derivedStateOf não é necessário.

snapshotFlow: converte o estado do Compose em fluxos.

Use snapshotFlow para converter objetos State<T> em um fluxo frio. O snapshotFlow executa o próprio bloco quando coletado e emite o resultado dos objetos State lidos nele. Quando um dos objetos State lidos no bloco snapshotFlow é modificado, o fluxo emite o novo valor para o coletor se o novo valor não for igual ao emitido anteriormente. Esse comportamento é semelhante ao de Flow.distinctUntilChanged (links em inglês).

O exemplo a seguir mostra um efeito colateral que registra quando o usuário rola pelo primeiro item em uma lista para análise:

val listState = rememberLazyListState()

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

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

No código acima, listState.firstVisibleItemIndex é convertido em um fluxo que pode se beneficiar dos recursos dos operadores de fluxo.

Como reiniciar efeitos

Alguns efeitos no Compose, como LaunchedEffect, produceState ou DisposableEffect, recebem um número variável de argumentos e chaves, que são usados para cancelar o efeito de execução e iniciar um novo argumento com as novas chaves.

A forma típica dessas APIs é:

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

Devido às particularidades desse comportamento, podem ocorrer problemas se os parâmetros usados para reiniciar o efeito não forem os corretos:

  • Ter menos reiniciações de efeitos que o necessário pode causar bugs no app.
  • Ter mais reiniciações de efeitos que o necessário pode ser ineficiente.

Como regra geral, as variáveis mutáveis e imutáveis usadas no bloco de efeito do código precisam ser adicionadas como parâmetros à função do efeito. Além desses parâmetros, outros podem ser adicionados para que sejam forçados quando o efeito for reiniciado. Se a mudança de uma variável não fizer com que o efeito seja reiniciado, ela precisará ser envolvida no rememberUpdatedState. Se a variável nunca mudar porque está unida a um remember sem chaves, você não precisará transmitir a variável como uma chave para o efeito.

No código DisposableEffect mostrado acima, o efeito recebe o lifecycleOwner usado no bloco como um parâmetro, já que qualquer mudança faria com que o efeito fosse reiniciado.

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

Os elementos currentOnStart e currentOnStop não são necessários como chaves DisposableEffect, porque o valor nunca muda na composição devido ao uso do rememberUpdatedState. Se você não transmitir o lifecycleOwner como um parâmetro e ele mudar, o elemento HomeScreen será recomposto, mas o DisposableEffect não será descartado e reiniciado. Essa ação causa problemas porque faz com que o lifecycleOwner incorreto seja usado desse ponto em diante.

Constantes como chaves

Você pode usar uma constante, como true, como uma chave de efeito para fazê-la seguir o ciclo de vida do local de chamada. Existem casos de uso válidos dessa opção, como o exemplo LaunchedEffect mostrado acima. No entanto, pense duas vezes antes de fazer isso e confira se é o que você precisa.