Gravação para trás

O Compose executa um frame em três fases estritamente ordenadas e de fluxo progressivo:

Três fases de fluxo contínuo: 1. Composição, 2. Layout, 3. Desenho
Figura 1. As três fases de um frame do Compose
  1. Composição: executa funções @Composable para criar e atualizar a árvore da interface.
  2. Layout: mede os filhos e os posiciona.
  3. Desenho: emite comandos de desenho de tela para renderizar pixels na tela.

Sempre que um State do Compose é lido durante qualquer fase, o Compose registra automaticamente uma dependência entre esse estado e a fase correspondente.


O que torna uma gravação "reversa"?

Uma gravação reversa ocorre sempre que um estado é modificado em uma fase posterior (ou em um escopo downstream, ou seja, um escopo de composição executado posteriormente em ordem na mesma passagem de composição) em relação ao local em que foi lido, forçando o Compose a fazer a recomposição programando uma fase anterior ou um elemento de composição para ser executado novamente. Uma gravação reversa é um loop de recomposição não otimizado.

Exemplo de gravação reversa pelas fases do loop de recomposição
Figura 2. Exemplo de gravação reversa pelas fases do loop de recomposição

Consequências das gravações inversas

As gravações reversas não são necessariamente ruins e nem sempre causam falhas, mas são ineficientes e podem prejudicar o desempenho do app de várias maneiras:

  • Renderização de frames extras e frames perdidos: uma gravação reversa força o Compose a executar passagens de composição redundantes em frames consecutivos, desperdiçando recursos de CPU e GPU e possivelmente causando instabilidade.
  • Problemas de correção do primeiro frame: se o componente exigir uma gravação reversa para resolver as dimensões ou o estado final, o primeiro frame será renderizado com dados inválidos, padrão ou não resolvidos, como tamanho zero ou deslocamento incorreto. Isso causa um pop-up visual visível ou uma oscilação de layout quando o segundo frame é renderizado.
  • Loops de recomposição infinitos: se uma mudança de estado alterar o dimensionamento do layout, e o dimensionamento do layout gravar continuamente um novo valor no estado, você corre o risco de criar um loop de frame infinito em que a tela se recompõe constantemente a cada frame sem nunca se estabilizar.

Fluxo de encaminhamento pelas fases

As mudanças de estado precisam sempre seguir em frente pelas fases:

Fase de leitura Contexto de gravação Aceitável? Por quê
Layout (Modifier.offset { }) Composição Sim A composição atualiza o estado → o layout o lê mais tarde no mesmo frame sem recomposição.
Empate (graphicsLayer { }, drawBehind { }) Composição Sim A composição atualiza o estado → O desenho o lê na fase final. As fases de composição e layout são ignoradas.
Desenhar Layout Sim O layout atualiza o estado, e o desenho o lê, fluxo válido.
Composição Callback de evento (onClick, onValueChange) que gera uma mudança de estado. Observação: os callbacks de layout não contam como eventos. Sim Um evento altera o estado usado para gerar a composição. Se o evento acontecer fora do frame (não em Composition, Layout ou Draw), isso é válido.
Composição Corrotina (LaunchedEffect) Sim, com cuidado Atualiza o estado de forma assíncrona em resposta ao ciclo de vida/eventos. As gravações de efeitos podem ser válidas, mas podem indicar uma camada de estado ineficiente. Elas devem ser evitadas sempre que possível.
Posição (no layout) Medição (no layout) Sim No Layout, é aceitável atualizar o estado e ler esse estado no posicionamento.
Medição (no layout) Posição (no layout) Não, gravação reversa A gravação no estado no posicionamento que está mais adiante no estado de leitura causa um loop de nova medição.
Composição Layout (onSizeChanged, LayoutModifier) Não: gravação para trás O layout invalida o loop de composição → recomposição.
Composição Empate (drawWithContent, Canvas) Não - Escrita invertida O desenho invalida a composição → loop de recomposição.

Combinações de fases: para trás x para frente

Confira a seguir exemplos de gravações reversas no Compose e como resolvê-las.

Para trás: leitura na composição, gravação no layout

  • O que acontece: a composição lê componentHeight para determinar qual interface emitir. Mais adiante no frame, a fase de layout mede ou posiciona as visualizações e grava um novo valor em componentHeight (por exemplo, usando onSizeChanged, onGloballyPositioned ou LayoutModifier personalizado).
  • Resultado: modificar componentHeight no layout invalida a fase de composição que acabou de ser concluída. O onSizeChanged informa o tamanho após a conclusão da transmissão de medição do layout. Se o valor do estado atualizado se estabilizar na próxima passagem, a recomposição poderá ser interrompida após um frame extra. No entanto, se o novo valor continuar alterando o tamanho, isso vai resultar em um loop de frame infinito. Além disso, o onGloballyPositioned é executado após o layout e o posicionamento, tornando as gravações de estado dentro dele ainda mais suscetíveis a loops contínuos de recomposição e relayout em frames consecutivos.

// ❌ BAD: Read in Composition, Written in Layout (onSizeChanged)
@Composable
fun BadAspectRatioImage(painter: Painter) {
    var calculatedHeight by remember { mutableStateOf(0.dp) }
    val density = LocalDensity.current

    // State read during COMPOSITION:
    Image(
        painter = painter,
        contentDescription = "Dynamic Image",
        modifier = Modifier
            .fillMaxWidth()
            .height(calculatedHeight)
            .onSizeChanged { size ->
                // State write during LAYOUT phase!
                // Triggers backwards write and recomposition pass
                val aspectRatio = 16f / 9f
                val widthDp = with(density) { size.width.toDp() }
                calculatedHeight = widthDp / aspectRatio
            }
    )
}

// ✅ GOOD: Measure and calculate aspect ratio height in Phase 2 (Layout) without recomposition
@Composable
fun GoodAspectRatioImage(
    painter: Painter,
    aspectRatio: Float = 16f / 9f,
    modifier: Modifier = Modifier
) {
    Layout(
        content = {
            Image(
                painter = painter,
                contentDescription = "Dynamic Image"
            )
        },
        modifier = modifier
    ) { measurables, constraints ->
        val width = constraints.maxWidth
        val height = (width / aspectRatio).toInt() // Illustrative, you can use Modifier.aspectRatio()
        val imageConstraints = constraints.copy(
            minWidth = width,
            maxWidth = width,
            minHeight = height,
            maxHeight = height
        )
        val placeable = measurables.first().measure(imageConstraints)
        layout(width, height) {
            placeable.placeRelative(0, 0)
        }
    }
}

Inverso: leitura em Composition, gravação em Drawing

  • O que acontece: o estado é lido no corpo do elemento combinável (fase de composição), mas sofre mutação dentro de Modifier.drawWithContent, Modifier.drawBehind ou Canvas (fase de exibição).
  • Resultado: a fase de desenho muda o estado → composição invalidada → loop infinito.

// ❌ BAD: Read in Composition, Written in Draw ()
@Composable
fun BadBackwardsWriteDraw() {
    var componentHeight by remember { mutableStateOf(0.dp) }
    // State read during COMPOSITION:
    Text(
        text = "Height is: $componentHeight",
        modifier = Modifier.drawBehind {
            // State write during the DRAW phase!
            // Invalidates Composition -> triggers recomposition loop!
            componentHeight = size.height.dp
        }
    )
}

Para trás: leitura na composição, gravação na composição (mesma fase)

  • O que acontece: leitura de count na função combinável e modificação de count diretamente em outro slot de conteúdo de elementos combináveis após a leitura.
  • Resultado: o sistema de snapshot registra a leitura e a gravação subsequente na mesma transmissão de composição, invalidando imediatamente o escopo atual.

// ❌ BAD: Direct write in Composable body after read
@Composable
fun BadCounter() {
    var count by remember { mutableIntStateOf(0) }
    Text("Count: $count") // State read in Composition
    Button(onClick = {}) {
        count++ // State write in Composition (Backwards write!)
    }
}
// Acceptable - but error-prone as someone may add a read before the write : Direct write in Composable body before read
@Composable
fun OkCounter() {
    var count by remember { mutableIntStateOf(0) }
    Button(onClick = {}) {
        count++ // State  write in Composition
    }
    Text("Count: $count") // State read in Composition
}


Regras principais para evitar gravações inversas

  1. Não grave no estado em onGloballyPositioned, onSizeChanged, onPlaced, onLayoutRectChanged ou LayoutModifier se esse estado for lido na composição, porque isso causa o problema de correção do primeiro frame.
    • Se as coordenadas ou os tamanhos do layout forem necessários apenas para desenhos personalizados, leia-os diretamente na fase de desenho ou layout (por exemplo, usando Modifier.drawWithCache ou Modifier.layout).
    • Para dimensionamento no nível da janela (WindowWidthSizeClass): eleve a observação de tamanho para o nível da janela. A composição se ramifica em classes de tamanho de janela antes que a medição local ocorra.
    • Mantenha a composição uniforme:use um único layout personalizado ou componentes como FlowRow ou LazyVerticalGrid que ajustam a medição e o posicionamento durante a Fase 2 sem recompor ou alterar o estado usado na composição.
    • Use a subcomposição:use BoxWithConstraints ou SubcomposeLayout quando as composições filhas precisarem se ramificar com base na largura ou altura local. Tenha cuidado: a subcomposição tem um custo de desempenho e geralmente pode ser evitada.
    • Como último recurso:permita que o primeiro frame esteja errado, armazenando o tamanho em onSizeChanged para acionar uma segunda recomposição de frame. Isso causa pop-ups de layout visíveis, instabilidade e riscos de loops infinitos.
  2. Não mude o estado depois que ele for lido pela primeira vez na composição:
    • Embora seja seguro gravar em objetos MutableState durante a composição fora de um SideEffect, tome cuidado especial para garantir que você não grave em um estado que possa ter sido lido anteriormente na composição. É recomendável usar rememberUpdatedState quando essa necessidade de uma gravação de estado na composição surgir. Gravar em um estado durante a composição de outra maneira geralmente é um sinal de um efeito ausente ou de um estado ou elemento combinável projetado de forma inadequada. Lembre-se de que a composição é otimista e sempre é executada com o valor mais recente de um estado. Portanto, talvez você não veja todas as mudanças de estado nas recomposições. As atualizações da IU não podem ser usadas como uma forma de processar eventos únicos, o que torna incomum que o valor de um estado precise ser atualizado como resultado da recomposição.
    • Evite modificar estados que são observados fora da composição (por exemplo, campos ViewModel ou flags isVisible). As gravações de estado que afetam a composição pertencem a lambdas de evento (onClick), corrotinas (LaunchedEffect) ou efeitos colaterais (SideEffect). rememberUpdatedState é uma exceção, já que foi projetado para mudar o estado usado apenas no corpo de @Composable.
  3. Adie as leituras de estado para a fase mais recente possível:
    • A leitura de estados em Draw (Modifier.graphicsLayer { alpha = ... }) ou Layout (Modifier.offset { IntOffset(...) }) garante que as mudanças invalidem apenas a Fase 2 ou 3, ignorando completamente a Fase 1 (Composição).