Efeitos colaterais no Compose

Funções que podem ser compostas precisam ser livres de efeitos colaterais. No entanto, quando necessárias para mudar o estado do app, elas precisam ser chamadas em um ambiente controlado, compatível com o ciclo de vida dela. Nesta página, você 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: executar funções de suspensão no escopo de uma função que pode ser composta

Para chamar funções de suspensão de forma segura em uma função que pode ser composta, use o LaunchedEffect que pode ser composto. 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>>,
    scaffoldState: ScaffoldState = rememberScaffoldState()
) {

    // If the UI state contains an error, show snackbar
    if (state.hasError) {

        // `LaunchedEffect` will cancel and re-launch if
        // `scaffoldState.snackbarHostState` changes
        LaunchedEffect(scaffoldState.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.
            scaffoldState.snackbarHostState.showSnackbar(
                message = "Error message",
                actionLabel = "Retry message"
            )
        }
    }

    Scaffold(scaffoldState = scaffoldState) {
        /* ... */
    }
}

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: extrair um escopo compatível com a composição para iniciar uma corrotina fora de uma função que pode ser composta

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(scaffoldState: ScaffoldState = rememberScaffoldState()) {

    // Creates a CoroutineScope bound to the MoviesScreen's lifecycle
    val scope = rememberCoroutineScope()

    Scaffold(scaffoldState = scaffoldState) {
        Column {
            /* ... */
            Button(
                onClick = {
                    // Create a new coroutine in the event handler
                    // to show a snackbar
                    scope.launch {
                        scaffoldState.snackbarHostState
                            .showSnackbar("Something happened!")
                    }
                }
            ) {
                Text("Press me")
            }
        }
    }
}

rememberUpdatedState: referenciar 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: publicar estado do Compose em código que não é do Compose

Para compartilhar o estado do Compose com objetos não gerenciados pelo Compose, use o SideEffect que pode ser composto, que é invocado a cada recomposição concluída corretamente.

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 rememberAnalytics(user: User): FirebaseAnalytics {
    val analytics: FirebaseAnalytics = remember {
        /* ... */
    }

    // On every successful composition, update the analytics library with
    // the userType from the current User, ensuring that future analytics
    // events have this metadata attached
    SideEffect {
        analytics.setUserProperty("userType", user.userType)
    }
    return analytics
}

productState: converter um estado que não é do Compose em um estado 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
): 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 keys.
    return produceState(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: converter um ou vários objetos de estado em outro estado

Use derivedStateOf para calcular ou derivar um determinado estado de outros objetos de estado. O uso dessa função garante que o cálculo só ocorra quando um dos estados usados no cálculo mudar.

O exemplo a seguir mostra uma lista de Tarefas básicas, em que as tarefas com palavras-chave de alta prioridade definidas pelo usuário são exibidas primeiro:

@Composable
fun TodoList(
    highPriorityKeywords: List<String> = listOf("Review", "Unblock", "Compose")
) {
    val todoTasks = remember { mutableStateListOf<String>() }

    // Calculate high priority tasks only when the todoTasks or
    // highPriorityKeywords change, not on every recomposition
    val highPriorityTasks by remember(todoTasks, highPriorityKeywords) {
        derivedStateOf {
            todoTasks.filter { it.containsWord(highPriorityKeywords) }
        }
    }

    Box(Modifier.fillMaxSize()) {
        LazyColumn {
            items(highPriorityTasks) { /* ... */ }
            items(todoTasks) { /* ... */ }
        }
        /* Rest of the UI where users can add elements to the list */
    }
}

No código acima, derivedStateOf garante que sempre que todoTasks ou highPriorityKeywords mudarem, o cálculo de highPriorityTasks será executado e a IU será atualizada com o resultado. Como o filtro para calcular highPriorityTasks pode ser caro, ele só deve ser executado quando uma das listas mudar, e não a cada recomposição.

Além disso, uma atualização no estado gerada por derivedStateOf não faz com que a função que pode ser composta em que ele foi declarado seja recomposta. O Compose só recompõe funções desse tipo cujo estado retornado é lido, como dentro da LazyColumn no exemplo.

snapshotFlow: converter o estado do Compose em fluxos

Use snapshotFlow para converter objetos State<T> em um fluxo frio. 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 precisa ser unida ao 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 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)
        }
    }
}

currentOnStart e currentOnStop não são necessários como uma chave de DisposableEffect, porque o valor nunca muda na composição devido ao uso de rememberUpdatedState. Se o lifecycleOwner não for transmitido como um parâmetro e ele mudar, a HomeScreen será recomposta, mas o DisposableEffect não será descartado e reiniciado. Essa ação causa problemas, porque fará com que o lifecycleOwner errado 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.