Ciclo de vida de funções que podem ser compostas

Nesta página, você aprenderá sobre o ciclo de vida de funções que podem ser compostas e como o Compose decide se elas precisam ser recompostas.

Visão geral do ciclo de vida

Como mencionado no artigo Como gerenciar a documentação sobre os estados, uma composição descreve a IU do app e é produzida pela execução de funções que podem ser compostas. Uma composição é uma estrutura em árvore de funções que podem ser compostas que descrevem a IU.

Quando o Jetpack Compose executa suas funções que podem ser compostas pela primeira vez, ele rastreia as funções chamadas durante a composição inicial para descrever a IU em uma composição. Depois, quando o estado do app mudar, o Jetpack Compose programará uma recomposição. A recomposição é quando o Jetpack Compose executa novamente as funções que podem ser compostas que tenham mudado em resposta a mudanças de estado e, em seguida, atualiza a composição para refletir essas mudanças.

Uma composição só pode ser produzida por uma composição inicial e atualizada por recomposição. A única maneira de modificar uma composição é pela recomposição.

Diagrama mostrando o ciclo de vida de uma função que pode ser composta

Figura 1. Ciclo de vida de uma função que pode ser composta na composição. A função entra na composição, é recomposta zero ou mais vezes e sai da composição.

A recomposição geralmente é acionada por uma mudança em um objeto State<T>. O Compose rastreia esses itens e executa todas as funções que podem ser compostas na Composição que lê esse State<T> específico e todas as funções chamadas que não podem ser ignorados.

Caso uma função que pode ser composta seja chamada várias vezes, diversas instâncias serão colocadas na Composição. Cada chamada tem um ciclo de vida próprio na composição.

@Composable
fun MyComposable() {
    Column {
        Text("Hello")
        Text("World")
    }
}

Diagrama mostrando a organização hierárquica dos elementos no snippet de código anterior

Figura 2. Representação de MyComposable na composição. Se uma função que pode ser composta for chamada várias vezes, várias instâncias serão colocadas na composição. Um elemento com uma cor diferente indica uma instância separada.

Anatomia de uma função que pode ser composta na composição

A instância de uma função que pode ser composta na composição é identificada pelo local de chamada. O compilador Compose considera que cada local de chamada é distinto. Chamar funções de vários locais de chamadas criará diversas instâncias da mesma função na composição.

Se, durante uma recomposição, uma função que pode ser composta chama funções diferentes das chamadas na composição anterior, o Compose identificará quais funções foram chamadas ou não. Para as funções que podem ser compostas chamadas em ambas as composições, o Compose evitará uma recomposição caso as entradas não tenham mudado.

Preservar a identidade é crucial para associar efeitos colaterais a funções que podem ser compostas, para que elas sejam concluídas corretamente, e não reiniciem para cada recomposição.

Veja o exemplo a seguir:

@Composable
fun LoginScreen(showError: Boolean) {
    if (showError) {
        LoginError()
    }
    LoginInput() // This call site affects where LoginInput is placed in Composition
}

@Composable
fun LoginInput() { /* ... */ }

No snippet de código acima, LoginScreen chamará condicionalmente o LoginError que pode ser composto e chamará sempre o LoginInput que pode ser composto. Cada chamada tem um local e posição de origem exclusivos, que o compilador usará para identificá-la.

Diagrama mostrando como ocorre a recomposição do código anterior se a sinalização showError mudar para &quot;true&quot;. O LoginError que pode ser composto é adicionado, mas não ocorre a recomposição das outras funções.

Figura 3. Representação de LoginScreen na composição quando o estado muda e uma recomposição ocorre. A mesma cor indica que não houve recomposição.

Embora LoginInput tenha passado de primeiro a ser chamado para segundo, a instância LoginInput será preservada após as recomposições. Além disso, como LoginInput não tem parâmetros que mudaram na recomposição, a chamada para LoginInput será ignorada pelo Compose.

Adicionar mais informações para ajudar na recomposição inteligente

Chamar uma função que pode ser composta várias vezes fará com que ela também seja adicionada várias vezes à composição. Ao chamar uma função do mesmo local de chamadas várias vezes, o Compose não recebe informações para identificar cada chamada de forma exclusiva. Assim, a ordem de execução é usada junto ao local da chamada para diferenciar as instâncias. Algumas vezes, esse comportamento é o suficiente. Mas, em alguns casos, ele pode causar um comportamento indesejado.

@Composable
fun MoviesScreen(movies: List<Movie>) {
    Column {
        for (movie in movies) {
            // MovieOverview composables are placed in Composition given its
            // index position in the for loop
            MovieOverview(movie)
        }
    }
}

No exemplo acima, o Compose usa a ordem de execução, além do local da chamada para diferenciar as instâncias na composição. Se um novo movie for adicionado ao fim da lista, o Compose poderá reutilizar as instâncias que já estão na composição, já que as posições na lista não mudaram e, portanto, a entrada movie é a mesma para essas instâncias.

Diagrama mostrando como ocorre a recomposição do código anterior se um novo elemento for adicionado ao fim da lista. A posição dos outros itens da lista não mudou, portanto, não ocorre a recomposição desses itens.

Figura 4. Representação de MoviesScreen na composição quando um novo elemento é adicionado ao fim da lista. Os MovieOverview que podem ser compostos na composição podem ser reutilizados. A mesma cor em MovieOverview indica que não ocorreu a recomposição da função.

No entanto, se a lista movies mudar adicionando itens ao topo ou ao meio, removendo ou reorganizando itens, ela causará uma recomposição em todas as chamadas MovieOverview cujo parâmetro de entrada mudou de posição na lista. Isso é extremamente importante se, por exemplo, MovieOverview buscar uma imagem de filmes usando um efeito colateral. Se a reposição ocorrer enquanto o efeito estiver em andamento, ela será cancelada e começará novamente.

@Composable
fun MovieOverview(movie: Movie) {
    Column {
        // Side effect explained later in the docs. If MovieOverview
        // recomposes, while fetching the image is in progress,
        // it is cancelled and restarted.
        val image = loadNetworkImage(movie.url)
        MovieHeader(image)

        /* ... */
    }
}

Diagrama mostrando como ocorre a recomposição do código anterior se um novo elemento for adicionado ao topo da lista. Todos os outros itens da lista mudam de posição e precisam ser recompostos.

Figura 5. Representação de MoviesScreen na composição quando um novo elemento é adicionado à lista. Os elementos MovieOverview que podem ser compostos não podem ser reutilizados e todos os efeitos colaterais são reiniciados. Uma cor diferente em MovieOverview indica que a função foi recomposta.

O ideal é que a identidade da instância MovieOverview seja considerada como vinculada à identidade do movie que é passada a ela. Se a lista de filmes for reordenada, o ideal será reorganizar as instâncias correspondentes na árvore de composição, em vez de recompor cada função MovieOverview em outra instância de filme. O Compose oferece uma maneira de informar ao ambiente de execução quais valores você quer usar para identificar uma determinada parte da árvore: a key que pode ser composta.

Ao envolver um bloco de código usando uma chamada à chave que pode ser composta com um ou mais valores transmitidos, esses valores serão combinados para identificar essa instância na composição. O valor de uma key não precisa ser globalmente exclusivo. Ele precisa ser único apenas entre as invocações de funções no local de chamada. Portanto, nesse exemplo, cada movie precisa ter uma key que seja única entre os movies. Não há problema se essa for a mesma key de outra função em outro local do app.

@Composable
fun MoviesScreen(movies: List<Movie>) {
    Column {
        for (movie in movies) {
            key(movie.id) { // Unique ID for this movie
                MovieOverview(movie)
            }
        }
    }
}

Com o exemplo acima, mesmo que os elementos na lista mudem, o Compose reconhece chamadas individuais para MovieOverview e pode reutilizá-las.

Diagrama mostrando como ocorre a recomposição do código anterior se um novo elemento for adicionado ao topo da lista. Como os itens da lista são identificados por chaves, o Compose sabe que não é necessário realizar a recomposição, mesmo que as posições tenham sido alteradas.

Figura 6. Representação de MoviesScreen na composição quando um novo elemento é adicionado à lista. Como as funções MovieOverview têm chaves exclusivas, o Compose reconhece quais instâncias de MovieOverview não mudaram e podem reutilizá-las. Os efeitos colaterais continuarão sendo executados.

Algumas funções têm compatibilidade integrada com a key. Por exemplo, LazyColumn aceita especificar uma key personalizada na DSL items.

@Composable
fun MoviesScreen(movies: List<Movie>) {
    LazyColumn {
        items(movies, key = { movie -> movie.id }) { movie ->
            MovieOverview(movie)
        }
    }
}

Como ignorar caso as entradas não tenham mudado

Se uma função já estiver em composição, ela poderá ignorar a recomposição caso todas as entradas sejam estáveis e não tenham mudado.

Um tipo estável precisa obedecer aos seguintes termos:

  • O resultado de equals para duas instâncias sempre será o mesmo para essas duas instâncias.
  • Se uma propriedade pública do tipo mudar, a composição será notificada.
  • Todos os tipos de propriedade pública também são estáveis.

Há alguns tipos comuns importantes que se enquadram nesses termos que serão tratados como estáveis pelo compilador do Compose, mesmo que não estejam explicitamente marcados como estáveis pela anotação @Stable:

  • Todos os tipos de valor primitivo: Boolean, Int, Long, Float, Char etc.
  • Strings
  • Todos os tipos de função (lambdas)

Todos esses tipos podem seguir os termos de estabilidade porque são imutáveis. Como os tipos imutáveis nunca mudam, eles nunca precisam notificar a composição sobre uma mudança. Por esse motivo, fica muito mais fácil seguir os termos.

Um tipo importante que é estável, mas é mutável é o tipo MutableState do Compose. Se um valor for mantido em um MutableState, o objeto de estado será considerado estável de forma geral, porque o Compose será notificado sobre qualquer mudança na propriedade .value do State.

Quando todos os tipos passados como parâmetros para uma função são estáveis, os valores são comparados para verificar se eles são iguais, com base na posição da função na árvore de IU. A recomposição será ignorada caso todos os valores tenham permanecido inalterados desde a chamada anterior.

O Compose só considera que um tipo é estável se for possível provar isso. Por exemplo, uma interface geralmente é tratada como não estável, assim como tipos com propriedades públicas mutáveis (cuja implementação pode ser imutável).

Caso o Compose não consiga concluir que um tipo é estável, mas você queira forçá-lo a tratar o tipo como estável, marque-o com a anotação @Stable.

// Marking the type as stable to favor skipping and smart recompositions.
@Stable
interface UiState<T : Result<T>> {
    val value: T?
    val exception: Throwable?

    val hasError: Boolean
        get() = exception != null
}

No snippet de código acima, como UiState é uma interface, geralmente o Compose poderia considerar que esse tipo não é estável. Ao adicionar a anotação @Stable, você informa ao Compose que esse tipo é estável, permitindo que ele priorize recomposições inteligentes. Isso também significa que o Compose tratará todas as implementações da interface como estáveis se ela for usada como o tipo de parâmetro.