Fases do Jetpack Compose

Como a maioria dos outros kits de ferramentas de interface, o Compose renderiza um frame por várias fases diferentes. Por exemplo, o sistema de visualização do Android tem três fases principais: medição, layout e exibição. O Compose é parecido, mas tem outra fase importante chamada composição no início.

A documentação do Compose descreve a composição em Trabalhando com o Compose e Estado e Jetpack Compose.

As três fases de um frame

O Compose tem três fases principais:

  1. Composição: qual interface será exibida. O Compose executa funções que podem ser compostas e cria uma descrição da interface.
  2. Layout: onde a interface será colocada. Esta fase consiste em duas etapas: medição e posicionamento. Os elementos do layout são medidos e colocados, assim como todos os elementos filhos, em coordenadas 2D para cada nó na árvore de layout.
  3. Exibição: como a IU será renderizada. Os elementos da interface são exibidos em uma tela, geralmente do dispositivo.
As três fases em que o Compose transforma dados em interface (em ordem: dados, composição, layout, desenho, interface).
Figura 1. As três fases em que o Compose transforma dados em elementos da interface.

Geralmente, a ordem dessas fases é a mesma, permitindo que os dados fluam em uma única direção da composição ao layout e à exibição para produzir um frame. Esse processo é conhecido como fluxo de dados unidirecional. BoxWithConstraints, LazyColumn e LazyRow são exceções importantes em que a composição dos elementos filhos depende da fase de layout dos pais.

Conceitualmente, cada uma dessas fases acontece para cada frame. No entanto, para otimizar o desempenho, o Compose evita repetir trabalhos que calculariam os mesmos resultados das mesmas entradas em todas essas fases. Assim, o Compose vai ignorar a execução de uma função combinável se puder reutilizar um resultado anterior. A interface do Compose não vai recriar o layout nem exibir toda a árvore novamente se isso não for necessário. O Compose executa apenas a quantidade mínima de trabalho necessária para atualizar a interface. Essa otimização é possível graças ao monitoramento das leituras de estado nas diferentes fases.

Entender as fases

Nesta seção, descrevemos em mais detalhes como as três fases do Compose são executadas para elementos combináveis.

Composição

Na fase de composição, o ambiente de execução do Compose executa funções combináveis e gera uma estrutura de árvore que representa a interface. Essa árvore de IU consiste em nós de layout que contêm todas as informações necessárias para as próximas fases, conforme mostrado no vídeo a seguir:

Figura 2. A árvore que representa a IU criada na fase de composição.

Uma subseção do código e da árvore de IU é semelhante a esta:

Um snippet de código com cinco elementos que podem ser compostos e a árvore de interface resultante, com nós filhos se ramificando dos nós pais.
Figura 3. Uma subseção de uma árvore de IU com o código correspondente.

Nesses exemplos, cada função combinável no código é mapeada para um único nó de layout na árvore de IU. Em exemplos mais complexos, as funções de composição podem conter lógica e fluxo de controle e produzir uma árvore diferente de acordo com os estados.

Layout

Na fase de layout, o Compose usa a árvore de IU produzida na fase de composição como entrada. A coleção de nós de layout contém todas as informações necessárias para decidir o tamanho e o local de cada nó no espaço 2D.

Figura 4. A medição e o posicionamento de cada nó de layout na árvore da interface durante a fase de layout.

Durante a fase de layout, a árvore é percorrida usando o seguinte algoritmo de três etapas:

  1. Medir filhos: um nó mede os filhos, se houver.
  2. Decidir o próprio tamanho: com base nessas medições, um nó decide o próprio tamanho.
  3. Posicionar filhos: cada nó filho é posicionado em relação à própria posição de um nó.

No final dessa fase, cada nó de layout tem:

  • Uma width e uma height atribuídas.
  • Uma coordenada x, y em que ele precisa ser desenhado.

Relembre a árvore de UI da seção anterior:

Um snippet de código com cinco elementos combináveis e a árvore de interface resultante, com nós filhos se ramificando dos nós pais

Para essa árvore, o algoritmo funciona da seguinte maneira:

  1. O Row mede os filhos, Image e Column.
  2. O Image é medido. Ele não tem filhos, então decide o próprio tamanho e informa o tamanho de volta para o Row.
  3. Em seguida, o Column é medido. Ele mede os próprios filhos (dois elementos combináveis Text) primeiro.
  4. O primeiro Text é medido. Ele não tem filhos, então decide o próprio tamanho e informa o tamanho de volta para o Column.
    1. O segundo Text é medido. Ele não tem filhos, então decide o próprio tamanho e o informa à Column.
  5. O Column usa as medidas dos filhos para decidir o próprio tamanho. Ele usa a largura máxima do filho e a soma da altura dos filhos.
  6. O Column posiciona os filhos em relação a si mesmo, colocando-os um abaixo do outro verticalmente.
  7. O Row usa as medidas dos filhos para decidir o próprio tamanho. Ele usa a altura máxima do filho e a soma das larguras dos filhos. Em seguida, ele posiciona os filhos.

Observe que cada nó foi visitado apenas uma vez. O ambiente de execução do Compose requer apenas uma passagem pela árvore da IU para medir e posicionar todos os nós, o que melhora o desempenho. Quando o número de nós na árvore aumenta, o tempo gasto para percorrê-la aumenta de maneira linear. Por outro lado, se cada nó fosse visitado várias vezes, o tempo de travessia aumentaria exponencialmente.

Desenho

Na fase de desenho, a árvore é percorrida novamente de cima para baixo, e cada nó se desenha na tela por vez.

Figura 5. A fase de desenho mostra os pixels na tela.

Usando o exemplo anterior, o conteúdo da árvore é desenhado da seguinte maneira:

  1. O Row desenha qualquer conteúdo que possa ter, como uma cor de plano de fundo.
  2. O Image se desenha.
  3. O Column se desenha.
  4. O primeiro e o segundo Text se desenham, respectivamente.

Figura 6. Uma árvore de interface e a representação desenhada dela.

Leituras de estado

Quando você lê o value de um snapshot state durante uma das fases listadas anteriormente, o Compose monitora automaticamente o que ele estava fazendo durante a leitura do value. Esse monitoramento permite que o Compose execute o leitor novamente caso o value do estado mude e serve como a base da observabilidade de estado do Compose.

Geralmente, um estado é criado usando o método mutableStateOf() e pode ser acessado de duas maneiras: acessando diretamente a propriedade value ou usando um delegado de propriedade do Kotlin. Para mais informações, consulte Estado dos elementos que podem ser compostos. Neste guia, uma "leitura de estado" se refere a um desses métodos de acesso equivalentes.

// State read without property delegate.
val paddingState: MutableState<Dp> = remember { mutableStateOf(8.dp) }
Text(
    text = "Hello",
    modifier = Modifier.padding(paddingState.value)
)

// State read with property delegate.
var padding: Dp by remember { mutableStateOf(8.dp) }
Text(
    text = "Hello",
    modifier = Modifier.padding(padding)
)

As funções "getter" e "setter" são usadas no delegado de propriedade (link em inglês) para acessar e atualizar o value do estado. Essas funções são invocadas apenas quando a propriedade é referenciada como um valor, e não quando ela é criada. Por isso, as duas maneiras mencionadas são equivalentes.

Os blocos de código que podem ser executados novamente quando um estado lido é modificado são escopos de reinicialização. O Compose monitora as mudanças no estado value e reinicia os escopos em diferentes fases.

Leituras de estado em fases

Como já mencionado, há três fases principais no Compose e ele monitora qual estado é lido em cada uma delas. Isso permite que o Compose notifique apenas as fases específicas que precisam executar o trabalho para cada elemento afetado da interface.

As seções a seguir descrevem cada fase e o que acontece quando um valor de estado é lido como parte dela.

Fase 1: composição

As leituras de estado em uma função @Composable ou um bloco lambda afetam a composição e, possivelmente, as próximas fases. Quando o value do estado é modificado, o recompositor programa novas execuções em todas as funções combináveis, responsáveis por ler esse value. O ambiente de execução poderá ignorar algumas ou todas as funções que podem ser compostas se as entradas não tiverem mudado. Para mais informações, consulte Como ignorar caso as entradas não tenham mudado.

Dependendo do resultado da composição, a interface do Compose executará as fases de layout e exibição. Essas fases serão ignoradas se o conteúdo permanecer o mesmo, e nem o tamanho, nem o layout serão modificados.

var padding by remember { mutableStateOf(8.dp) }
Text(
    text = "Hello",
    // The `padding` state is read in the composition phase
    // when the modifier is constructed.
    // Changes in `padding` will invoke recomposition.
    modifier = Modifier.padding(padding)
)

Fase 2: layout

A fase de layout consiste em duas etapas: medição e posicionamento. A etapa de medição executa a lambda de medida transmitida ao elemento combinável Layout, ao método MeasureScope.measure da interface LayoutModifier, entre outros. A etapa de posição executa o bloco de posicionamento da função layout, o bloco lambda de Modifier.offset { … } e funções semelhantes.

As leituras de estado durante cada uma dessas etapas afetam o layout e, possivelmente, a fase de exibição. Quando o value do estado é modificado, a interface do Compose programa a fase de layout. Ela também executa a fase de exibição quando o tamanho ou a posição do estado são modificados.

var offsetX by remember { mutableStateOf(8.dp) }
Text(
    text = "Hello",
    modifier = Modifier.offset {
        // The `offsetX` state is read in the placement step
        // of the layout phase when the offset is calculated.
        // Changes in `offsetX` restart the layout.
        IntOffset(offsetX.roundToPx(), 0)
    }
)

Fase 3: exibição

As leituras de estado durante o código de exibição afetam a fase de exibição. Exemplos comuns incluem os métodos Canvas(), Modifier.drawBehind e Modifier.drawWithContent. Quando o value do estado é modificado, a interface do Compose executa apenas a fase de exibição.

var color by remember { mutableStateOf(Color.Red) }
Canvas(modifier = modifier) {
    // The `color` state is read in the drawing phase
    // when the canvas is rendered.
    // Changes in `color` restart the drawing.
    drawRect(color)
}

Diagrama mostrando que uma leitura de estado durante a fase de exibição só aciona a execução dessa fase novamente.

Otimizar leituras de estado

Como o Compose realiza o monitoramento das leituras de estado localizadas, é possível minimizar a quantidade de trabalho realizado na leitura de cada estado em uma fase adequada.

Veja o exemplo a seguir. Este exemplo tem uma Image() que usa o modificador de deslocamento para deslocar a posição final do layout, resultando em um efeito paralaxe quando o usuário rola a tela.

Box {
    val listState = rememberLazyListState()

    Image(
        // ...
        // Non-optimal implementation!
        Modifier.offset(
            with(LocalDensity.current) {
                // State read of firstVisibleItemScrollOffset in composition
                (listState.firstVisibleItemScrollOffset / 2).toDp()
            }
        )
    )

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

Esse código funciona, mas tem um desempenho fraco. Como já mencionado, o código lê o value do estado firstVisibleItemScrollOffset e o transmite para a função Modifier.offset(offset: Dp). Conforme o usuário rola a tela, o value do firstVisibleItemScrollOffset muda. Como você aprendeu, o Compose monitora todas as leituras de estado para poder reiniciar (invocar novamente) o código de leitura, que, neste exemplo, é o conteúdo da Box.

Esse é um exemplo de leitura de um estado na fase de composição. Isso não é necessariamente ruim, já que é, na verdade, a base da recomposição, permitindo que mudanças de dados emitam uma nova interface.

Importante: esse exemplo não é ideal porque todo evento de rolagem faz com que o conteúdo que pode ser composto seja reavaliado, medido, colocado no layout e, por fim, exibido. Você aciona a fase do Compose em cada rolagem, mesmo que o conteúdo mostrado não tenha mudado, apenas a posição dele. É possível otimizar a leitura do estado para acionar novamente apenas a fase de layout.

Deslocamento com lambda

Há outra versão do modificador de deslocamento disponível: Modifier.offset(offset: Density.() -> IntOffset).

Essa versão usa um parâmetro lambda em que o deslocamento resultante é retornado pelo bloco lambda. Atualize o código para usá-lo:

Box {
    val listState = rememberLazyListState()

    Image(
        // ...
        Modifier.offset {
            // State read of firstVisibleItemScrollOffset in Layout
            IntOffset(x = 0, y = listState.firstVisibleItemScrollOffset / 2)
        }
    )

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

Por que o desempenho ficou melhor? O bloco lambda fornecido ao modificador é invocado durante a fase de layout, especificamente durante a etapa de posicionamento, ou seja, o estado firstVisibleItemScrollOffset não é mais lido durante a composição. Como o Compose monitora o momento de leitura do estado, essa mudança significa que, se o value do firstVisibleItemScrollOffset for modificado, o Compose precisará reiniciar apenas as fases de layout e exibição.

Obviamente, muitas vezes é necessário ler os estados na fase de composição. Mesmo assim, há casos em que é possível minimizar o número de recomposições ao filtrar as mudanças de estado. Para mais informações, consulte derivedStateOf: converter um ou vários objetos de estado em outro estado.

Repetição de recomposição (dependência da fase cíclica)

Este guia mencionou anteriormente que as fases do Compose são sempre invocadas na mesma ordem e que não há como voltar no mesmo frame. No entanto, isso não impede que apps entrem em repetições de composição em frames diferentes. Por exemplo,

Box {
    var imageHeightPx by remember { mutableIntStateOf(0) }

    Image(
        painter = painterResource(R.drawable.rectangle),
        contentDescription = "I'm above the text",
        modifier = Modifier
            .fillMaxWidth()
            .onSizeChanged { size ->
                // Don't do this
                imageHeightPx = size.height
            }
    )

    Text(
        text = "I'm below the image",
        modifier = Modifier.padding(
            top = with(LocalDensity.current) { imageHeightPx.toDp() }
        )
    )
}

Este exemplo implementa uma coluna vertical, com a imagem na parte de cima e o texto abaixo dela. Ele usa Modifier.onSizeChanged() para saber o tamanho resolvido da imagem e, em seguida, usa Modifier.padding() no texto para o deslocar para baixo. A conversão não natural de Px para Dp já indica que o código tem um problema.

O problema com esse exemplo é que o código não chega ao layout "final" em um único frame. O código depende de vários frames, executando trabalhos desnecessários e resultando em saltos da interface para o usuário.

Composição do primeiro frame

Durante a fase de composição do primeiro frame, imageHeightPx é inicialmente 0. Consequentemente, o código fornece o texto com Modifier.padding(top = 0). A fase de layout subsequente invoca o callback do modificador onSizeChanged, que atualiza imageHeightPx para a altura real da imagem. O Compose programa uma recomposição para o próximo frame. No entanto, durante a fase de desenho atual, o texto é renderizado com um padding de 0, já que o valor atualizado de imageHeightPx ainda não foi refletido.

Composição do segundo frame

O Compose inicia o segundo frame, acionado pela mudança no valor de imageHeightPx. Na fase de composição desse frame, o estado é lido no bloco de conteúdo Box. O texto agora tem um padding que corresponde exatamente à altura da imagem. Durante a fase de layout, imageHeightPx é definido novamente. No entanto, nenhuma outra recomposição é programada porque o valor permanece consistente.

Diagrama que mostra um loop de recomposição em que uma mudança de tamanho na fase de layout aciona uma recomposição, o que faz com que o layout aconteça novamente.

O exemplo pode parecer complicado, mas tome cuidado com este padrão geral:

  • Modifier.onSizeChanged(), onGloballyPositioned() ou algumas outras operações de layout
  • Atualização de alguns estados
  • Uso desse estado como entrada para um modificador de layout (padding(), height() ou semelhante)
  • Possível repetição

Para corrigir o exemplo acima, use os primitivos de layout adequados. O exemplo anterior pode ser implementado usando uma Column(), mas talvez você encontre um exemplo mais complexo que exija elementos personalizados, em que a criação de um layout personalizado é necessária. Para mais informações, consulte o guia Layouts personalizados.

O princípio geral é a presença de uma única fonte de verdade para vários elementos da interface que precisam ser medidos e posicionados entre si. O uso de um primitivo de layout adequado ou a criação de um layout personalizado significa que o elemento pai compartilhado mínimo serve como a fonte de verdade que pode coordenar a relação entre vários elementos. A introdução de um estado dinâmico viola esse princípio.

Para mais informações sobre loops de recomposição e como evitar a gravação no estado em várias fases, consulte Gravações anteriores no Compose.