Fases do Jetpack Compose

Como a maioria dos outros kits de ferramentas de IU, o Compose renderiza um frame por várias fases diferentes. Ao observar o sistema de visualização do Android, vamos que ele 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.

Veja uma descrição sobre composições nos nossos documentos do Compose, incluindo 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 IU será exibida. O Compose executa funções que podem ser compostas e cria uma descrição da IU.
  2. Layout: onde a IU 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 IU são exibidos em uma tela, geralmente do dispositivo.

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.

Podemos presumir com segurança que essas três fases acontecem virtualmente para cada frame. No entanto, para melhorar 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 que pode ser composta se puder reutilizar um resultado anterior. A IU do Compose não recriará o layout nem exibirá novamente toda a árvore se não for preciso fazer isso. O Compose executa apenas a quantidade mínima de trabalho necessária para atualizar a IU. Essa otimização é possível graças ao monitoramento das leituras de estado nas diferentes fases.

Leituras de estado

Quando você lê o valor do estado de um snapshot durante uma das fases listadas acima, o Compose monitora automaticamente o que ele estava fazendo durante a leitura. Esse monitoramento permite que o Compose execute o leitor novamente caso o valor 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 ver 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 valor do estado 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 IU.

Vamos analisar cada fase e descrever 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 valor do estado é modificado, o recompositor programa novas execuções em todas as funções que podem ser compostas responsáveis por ler esse valor. 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 ver mais informações, consulte Como ignorar caso as entradas não tenham mudado.

Dependendo do resultado da composição, a IU 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 Layout que pode ser composto, ao método MeasureScope.measure da interface LayoutModifier e assim por diante. A etapa de posição executa o bloco de posicionamento da função layout, o bloco lambda de Modifier.offset { … } e assim por diante.

As leituras de estado durante cada uma dessas etapas afetam o layout e, possivelmente, a fase de exibição. Quando o valor do estado é modificado, a IU 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.

Mais precisamente, a etapa de medição e a etapa de posicionamento têm escopos de reinicialização diferentes, ou seja, as leituras de estado na etapa de posição não invocam novamente a etapa de medição antes da hora. No entanto, essas duas etapas geralmente estão interligadas. Portanto, uma leitura de estado na etapa da posicionamento pode afetar outros escopos de reinicialização da etapa de medição.

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 valor do estado é modificado, a IU 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)
}

Como otimizar leituras de estado

À medida que o Compose realiza o monitoramento das leituras de estado localizadas, podemos minimizar a quantidade de trabalho realizado na leitura de cada estado em uma fase adequada.

Vejamos um exemplo. Aqui, temos 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 valor do estado firstVisibleItemScrollOffset e o transmite para a função Modifier.offset(offset: Dp). Conforme o usuário rola a tela, o valor de firstVisibleItemScrollOffset muda. Como sabemos, o Compose monitora todas as leituras de estado para poder reiniciar (invocar novamente) o código de leitura, que, no nosso exemplo, é o conteúdo da Box.

Esse é um exemplo de estado lido 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 IU.

Ainda assim, o exemplo não é ideal porque todo evento de rolagem fará com que o conteúdo que pode ser composto seja reavaliado e, em seguida, medido, colocado no layout e exibido. Estamos acionando a fase do Compose em cada rolagem, mesmo que o conteúdo exibido não tenha sido modificado, somente o local onde a exibição ocorre. É possível otimizar a leitura do estado para acionar novamente apenas a fase de layout.

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. Vamos atualizar nosso código para o usar:

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 valor firstVisibleItemScrollOffset for modificado, o Compose precisará reiniciar apenas as fases de layout e exibição.

O exemplo depende dos diferentes modificadores de deslocamento para otimizar o código resultante, mas a ideia geral é essa: tente localizar as leituras de estado na fase de nível mais baixo possível, permitindo que o Compose execute a quantidade mínima de trabalho.

Obviamente, muitas vezes é necessário ler os estados na fase de composição. Mesmo assim, há casos em que podemos minimizar o número de recomposições ao filtrar as mudanças de estado. Para ver 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)

Como mencionado, as fases do Compose são sempre invocadas na mesma ordem, e 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. Veja este exemplo:

Box {
    var imageHeightPx by remember { mutableStateOf(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() }
        )
    )
}

Nele, uma coluna vertical foi (mal) implementada, com a imagem na parte de cima e o texto abaixo dela. Estamos usando o método Modifier.onSizeChanged() para saber o tamanho resolvido da imagem e, em seguida, usamos 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 não chegamos ao layout "final" em um único frame. O código depende de vários frames, executando trabalhos desnecessários e resultando em saltos da IU para o usuário.

Vamos analisar cada frame para ver o que está acontecendo:

Na fase de composição do primeiro frame, imageHeightPx tem um valor de 0 e o texto é fornecido pelo método Modifier.padding(top = 0). Em seguida, na fase de layout, o callback do modificador onSizeChanged é chamado. Nesse momento, o valor imageHeightPx é atualizado para a altura real da imagem. O Compose programa a recomposição para o próximo frame. Na fase de exibição, o texto é renderizado com o padding de 0, já que a mudança no valor ainda não foi refletida.

Em seguida, o Compose inicia o segundo frame programado pela mudança de valor de imageHeightPx. O estado é lido no bloco de conteúdo da caixa e invocado na fase de composição. Dessa vez, o texto tem um padding correspondente à altura da imagem. Na fase de layout, o código define o valor de imageHeightPx novamente, mas nenhuma recomposição é programada, já que o valor permanece o mesmo.

No final, temos o padding desejado no texto, mas não é ideal gastar um frame extra para transmitir o valor do padding de volta a uma fase diferente, e isso resultará na produção de um frame com conteúdo sobreposto.

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. Ele pode ser implementado usando uma Column() simples, mas talvez você encontre um exemplo mais complexo que exige elementos personalizados, em que a criação de um layout personalizado é necessária. Para ver 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 IU 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.