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.

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. O Compose ignora a execução de uma função combinável se ele 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 porque o Compose monitora as 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 combináveis e exibe uma estrutura em árvore que representa a interface. Essa árvore de 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 IU criada na fase de composição.

Uma subseção da árvore de código e interface tem esta aparência:

Um snippet de código com cinco elementos combináveis e a árvore de interface resultante, com nós filhos ramificados a partir dos nós pais.
Figura 3. Uma subseção de uma árvore de 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 fluxo de lógica e de controle e produzir uma árvore diferente para estados diferentes.

Layout

Na fase de layout, o Compose usa a árvore de interface gerada 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 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 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. Colocar filhos: cada nó filho é colocado em relação à própria posição de um nó.

Ao final desta fase, cada nó de layout tem:

  • Uma largura e altura atribuídas
  • Uma coordenada x, y em que ele precisa ser desenhado

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 ramificados a partir 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 ele decide o próprio tamanho e informa o tamanho de volta para o Row.
  3. O Column é medido em seguida. Ele mede os próprios filhos (dois elementos combináveis Text) primeiro.
  4. A primeira Text é medida. Ele não tem filhos, então ele 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 ele decide o próprio tamanho e o informa de volta ao Column.
  5. O Column usa as medições filhas para decidir o próprio tamanho. Ele usa a largura máxima da filha e a soma da altura das filhas.
  6. O Column coloca os filhos em relação a si mesmo, colocando-os um abaixo do outro verticalmente.
  7. O Row usa as medições filhas para decidir o próprio tamanho. Ele usa a altura máxima da filha e a soma das larguras das filhas. Em seguida, ele coloca os filhos.

Observe que 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 na árvore aumenta, o tempo gasto na travessia aumenta de maneira linear. Por outro lado, se cada nó for acessado várias vezes, o tempo de travessia aumentará exponencialmente.

Desenho

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

Figura 5. A fase de exibição desenha 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 é desenhado.
  3. O Column é desenhado.
  4. O primeiro e o segundo Text são desenhados, respectivamente.

Figura 6. Uma árvore de interface e a representação 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.