Fases do Jetpack Compose

Como a maioria dos outros kits de ferramentas de interface, 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 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.
Uma imagem das 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 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.

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

Esta seção descreve 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 que podem ser compostas e gera uma estrutura de árvore que representa a interface. Essa árvore da interface 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 interface criada na fase de composição.

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

Um snippet de código com cinco elementos combináveis e a árvore de interface resultante, com nós filhos ramificando os nós pais.
Figura 3. Uma subseção de uma árvore da interface com o código correspondente.

Nesses exemplos, cada função combinável no código é mapeada para um único nó de layout na árvore da interface. Em exemplos mais complexos, os elementos combináveis podem conter lógica e fluxo de controle e produzir uma árvore diferente com diferentes estados.

Layout

Na fase de layout, o Compose usa a árvore da interface 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 sobre o tamanho e a localização 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 algoritmo de três etapas abaixo:

  1. Medir filhos: um nó mede os filhos, se houver algum.
  2. Decidir o próprio tamanho: com base nessas medidas, um nó decide seu próprio tamanho.
  3. Colocar filhos: cada nó filho é colocado 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 onde deve ser desenhada.

Lembre-se da árvore da interface da seção anterior:

Um snippet de código com cinco elementos combináveis e a árvore de interface resultante, com nós filhos ramificando os 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. Como não tem filhos, ele decide o próprio tamanho e informa o tamanho ao Row.
  3. O Column é medido em seguida. Ela mede os próprios filhos (dois elementos combináveis Text) primeiro.
  4. A primeira Text é medida. Como não tem filhos, ele decide o próprio tamanho e informa o tamanho de volta ao Column.
    1. A segunda Text é medida. Como não tem filhos, ele decide o próprio tamanho e o informa ao Column.
  5. O Column usa as medidas dos filhos para decidir o próprio tamanho. Ele usa a largura máxima dos filhos e a soma da altura deles.
  6. O Column coloca os filhos em relação a si mesmo, colocando-os abaixo um do outro na vertical.
  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 deles. Em seguida, ela posiciona os filhos.

Cada nó foi visitado apenas uma vez. O ambiente de execução do Compose requer apenas uma transmissão pela árvore da interface para medir e posicionar todos os nós, o que melhora o desempenho. Quando o número de nós da árvore aumenta, o tempo gasto que a percorrem aumenta de maneira linear. Por outro lado, se cada nó for visitado várias vezes, o tempo de travessia vai aumentar exponencialmente.

Desenho

Na fase de exibição, a árvore é percorrida novamente de cima para baixo, e cada nó é desenhado na tela por vez.

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

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

  1. A Row desenha qualquer conteúdo que possa ter, como uma cor de plano de fundo.
  2. A Image é desenhada a si mesma.
  3. A Column é desenhada a si mesma.
  4. A primeira e a segunda Text são desenhadas, respectivamente.

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

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 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 interface.

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

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

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.

Vamos conferir 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 interface.

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 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 interface para o usuário.

Vamos analisar cada frame para saber 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 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.