Gravação para trás

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

Três fases progressivas: 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 de interface.
  2. Layout: mede e posiciona os filhos.
  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 automaticamente registra uma dependência entre esse estado e a fase correspondente.


O que faz uma gravação ser "para trás"?

Uma gravação regressiva ocorre sempre que um estado é modificado em uma fase posterior (ou em um escopo downstream, ou seja, um escopo combinável executado posteriormente em ordem na mesma transmissão de composição) do que onde foi lido, forçando o Compose a fazer a recomposição agendando uma fase ou um elemento combinável anterior para ser executado novamente. Uma gravação para trás é um loop de recomposição não otimizado.

Exemplo de gravação de volta nas fases do loop de recomposição
Figura 2. Exemplo de gravação de volta nas fases do loop de recomposição

Consequências das gravações invertidas

As gravações regressivas 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 descartados: uma gravação para trás força o Compose a executar transmissões 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 para trás para resolver as dimensões ou o estado final, o primeiro frame será renderizado com dados inválidos, padrão ou não definidos (como tamanho zero ou um deslocamento incorreto). Isso causa um efeito visual de estouro ou layout piscando quando o segundo frame é renderizado.
  • Loops de recomposição infinita: se uma mudança de estado alterar o dimensionamento do layout e o dimensionamento do layout gravar continuamente um novo valor no estado, você poderá criar um loop de frames infinito em que a tela se recompõe constantemente a cada frame sem nunca estabilizar.

Fluxo de encaminhamento por fases

As mudanças de estado sempre devem fluir para frente pelas fases:

Fase de leitura Escrever contexto Aceitável? Por quê?
Layout (Modifier.offset { }) Composição Sim A composição atualiza o estado → o layout lê mais tarde no mesmo frame sem recompor.
Concurso (graphicsLayer { }, drawBehind { }) Composição Sim A composição atualiza o estado → o desenho o lê na fase final. A composição e o layout são ignorados completamente.
Desenhar Layout Sim O layout atualiza o estado, e o desenho o lê, fluxo válido.
Composição Callback de evento (onClick, onValueChange) que impulsiona a mudança de estado. Observação: callbacks de layout não são considerados eventos. Sim Um evento muda o estado usado para acionar a composição. Se o evento acontecer fora do frame (não em Composição, Layout ou Desenho), isso será 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. Evite fazer isso 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 invertida Escrever no estado em uma posição que está mais adiante no estado de leitura causa um loop de nova medição.
Composição Layout (onSizeChanged, LayoutModifier) Não: escrita ao contrário O layout invalida o loop de composição → recomposição.
Composição Concurso (drawWithContent, Canvas) Não - gravação 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 retroativas no Compose e como resolvê-las.

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

  • O que acontece: a composição lê componentHeight para determinar qual interface emitir. Mais tarde no frame, a fase de layout mede ou posiciona 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 depois que a medição do layout é concluída. Se o valor do estado atualizado se estabilizar na próxima transmissão, a recomposição poderá ser interrompida após um frame extra. No entanto, se o novo valor continuar alterando o tamanho, isso resultará em um loop de frames infinito. Além disso, onGloballyPositioned é executado após o layout e o posicionamento, tornando as gravações de estado ainda mais suscetíveis a loops contínuos de recomposição e redefinição de layout 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)
        }
    }
}

De trás para frente: leitura em Composição e escrita em Desenho

  • O que acontece: o estado é lido no corpo combinável (fase de composição), mas sofre mutação em Modifier.drawWithContent, Modifier.drawBehind ou Canvas (fase de desenho).
  • 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 e 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 combinável 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 para o estado em onGloballyPositioned, onSizeChanged 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 tamanhos de layout forem necessários apenas para desenho personalizado, 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 do tamanho do hoist ao nível da janela. A ramificação da composição ocorre em classes de tamanho de janela antes da medição local.
    • 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.
    • Usar subcomposição:use BoxWithConstraints ou SubcomposeLayout quando os elementos combináveis filhos precisarem ramificar com base na largura ou altura local. Use com cuidado: a subcomposição tem um custo de performance 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 layout visível, instabilidade e riscos de loops infinitos.
  2. Não faça mutações no estado depois que ele for lido pela primeira vez na composição:
    • Embora seja possível gravar com segurança 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 lido anteriormente em uma composição. É recomendável usar rememberUpdatedState quando essa necessidade de gravação de estado na composição surgir. Escrever em um estado durante a composição de outra forma geralmente é um sinal de um efeito ausente ou um estado ou elemento combinável projetado de maneira 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 interface não devem 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 mutações de estados 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 eventos (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 só invalidem a Fase 2 ou 3, pulando completamente a Fase 1 (Composição).