Escritura hacia atrás

Compose ejecuta un fotograma en tres fases estrictamente ordenadas y secuenciales:

Tres fases que fluyen hacia adelante: 1. Composición, 2. Diseño, 3. Dibujo
Figura 1: Las tres fases de un fotograma de Compose
  1. Composición: Ejecuta funciones de @Composable para compilar y actualizar el árbol de IU.
  2. Diseño: Mide los elementos secundarios y, luego, los coloca.
  3. Dibujo: Emite comandos de dibujo del lienzo para renderizar píxeles en la pantalla.

Cada vez que se lee un State de Compose durante cualquier fase, Compose registra automáticamente una dependencia entre ese estado y la fase correspondiente.


¿Qué hace que una escritura sea "hacia atrás"?

Una escritura hacia atrás ocurre cuando se modifica un estado en una fase posterior (o en un alcance de nivel inferior, es decir, un alcance de componibilidad que se ejecuta más tarde en orden dentro del mismo pase de composición) que en el que se leyó, lo que obliga a Compose a recomponer programando una fase o un elemento componible anterior para que se ejecute de nuevo. Una escritura hacia atrás es un bucle de recomposición no optimizado.

Ejemplo de escritura hacia atrás a través de las fases del bucle de recomposición
Figura 2: Ejemplo de escritura hacia atrás a través de las fases del bucle de recomposición fases

Consecuencias de las escrituras hacia atrás

Las escrituras hacia atrás no son necesariamente algo malo y no siempre provocan fallas, pero son ineficientes y pueden perjudicar el rendimiento de la app de varias maneras:

  • Renderización de fotogramas adicionales y fotogramas perdidos: Una escritura hacia atrás obliga a Compose a ejecutar pases de composición redundantes en fotogramas consecutivos, lo que desperdicia recursos de CPU y GPU, y puede provocar tirones.
  • Problemas de corrección del primer fotograma: Si tu componente requiere una escritura hacia atrás para resolver sus dimensiones o estado finales, el primer fotograma se renderiza con datos no válidos, predeterminados o no establecidos (como tamaño cero o una compensación incorrecta). Esto provoca un parpadeo visual o de diseño visible cuando se renderiza el segundo fotograma.
  • Bucles de recomposición infinitos: Si un cambio de estado altera el tamaño del diseño y el tamaño del diseño escribe continuamente un valor nuevo en el estado, puedes correr el riesgo de crear un bucle de fotogramas infinito en el que la pantalla se recompone constantemente en cada fotograma sin estabilizarse nunca.

Flujo hacia adelante a través de las fases

Los cambios de estado siempre deben fluir hacia adelante a través de las fases:

Fase de lectura Contexto de escritura ¿Es aceptable? Por qué
Diseño (Modifier.offset { }) Composición La composición actualiza el estado → El diseño lo lee más tarde en el mismo fotograma sin recomponerse.
Sorteo (graphicsLayer { }, drawBehind { }) Composición La composición actualiza el estado → Draw lo lee en la fase final. Se omiten por completo la composición y el diseño.
Dibujar Diseño El diseño actualiza el estado y el dibujo lo lee, flujo válido.
Composición Devolución de llamada de evento (onClick, onValueChange) que genera el cambio de estado. Nota: Las devoluciones de llamada de diseño no se consideran eventos. Un evento muta el estado que se usa para impulsar la composición. Si el evento ocurre fuera del encuadre (no en Composición, Diseño o Dibujo), es válido.
Composición Corrutina (LaunchedEffect) Sí, con precaución Actualiza el estado de forma asíncrona en respuesta a eventos o ciclos de vida. Las escrituras de los efectos pueden ser válidas, pero pueden apuntar a una estratificación de estado ineficiente. Se deben evitar siempre que sea posible.
Posición (en el diseño) Medir (en el diseño) En Layout, es aceptable actualizar el estado y, luego, leerlo en la colocación.
Medir (en el diseño) Posición (en el diseño) No: Escritura hacia atrás Escribir en el estado en la posición que luego se encuentra más adelante en el estado de lectura provoca un bucle de nueva medición.
Composición Diseño (onSizeChanged, LayoutModifier) No: Escritura hacia atrás El diseño invalida el bucle de composición → recomposición.
Composición Sorteo (drawWithContent, Canvas) No: Escritura hacia atrás Draw invalida el bucle de composición → recomposición.

Combinaciones de fases: hacia atrás y hacia adelante

A continuación, se muestran ejemplos de escrituras hacia atrás en Compose y cómo puedes resolverlas.

Hacia atrás: Lectura en Composición, escritura en Diseño

  • Qué sucede: La composición lee componentHeight para determinar qué IU emitir. Más adelante en el fotograma, la fase de diseño mide o coloca vistas y escribe un valor nuevo en componentHeight (por ejemplo, con onSizeChanged, onGloballyPositioned o LayoutModifier personalizado).
  • Resultado: La modificación de componentHeight en Layout invalida la fase de composición que se acaba de completar. Ten en cuenta que onSizeChanged informa el tamaño después de que se completa el paso de medición del diseño. Si el valor de estado actualizado se estabiliza en el siguiente paso, la recomposición puede detenerse después de un fotograma adicional. Sin embargo, si el nuevo valor sigue alterando el tamaño, se produce un bucle de fotogramas infinito. Además, onGloballyPositioned se ejecuta después del diseño y la colocación, lo que hace que las escrituras de estado dentro de él sean aún más susceptibles a los bucles continuos de recomposición y rediseño en fotogramas 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)
        }
    }
}

Hacia atrás: Lectura en Composición y escritura en Dibujo

  • Qué sucede: El estado se lee en el cuerpo de Composable (fase de composición), pero se muta dentro de Modifier.drawWithContent, Modifier.drawBehind o Canvas (fase de dibujo).
  • Resultado: La fase de dibujo muta el estado → Se invalida la composición → Bucle sin fin.

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

Hacia atrás: Lectura en Composición, escritura en Composición (misma fase)

  • Qué sucede: Se lee count en la función de componibilidad y se modifica count directamente en otra ranura de contenido componible después de leerlo.
  • Resultado: El sistema de instantáneas registra la lectura y la escritura posterior en el mismo pase de composición, lo que invalida de inmediato el alcance actual.

// ❌ 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
}


Reglas clave para evitar las escrituras hacia atrás

  1. No escribas en el estado de onGloballyPositioned, onSizeChanged o LayoutModifier si ese estado se lee en Composition, ya que esto causa el problema de corrección del primer fotograma.
    • Si solo se necesitan coordenadas o tamaños de diseño para el dibujo personalizado, léelos directamente en la fase de diseño o dibujo (por ejemplo, con Modifier.drawWithCache o Modifier.layout).
    • Para el ajuste de tamaño a nivel de la ventana (WindowWidthSizeClass): Eleva la observación del tamaño del elevador al nivel de la ventana. La composición se bifurca en clases de tamaño de ventana antes de que se produzca la medición local.
    • Mantén la composición uniforme: Usa un solo diseño personalizado o componentes como FlowRow o LazyVerticalGrid que ajusten la medición y la colocación durante la fase 2 sin volver a componer ni alterar el estado que se usa en la composición.
    • Usa subcomposición: Usa BoxWithConstraints o SubcomposeLayout cuando los elementos componibles secundarios deban bifurcarse según el ancho o la altura locales. Ten cuidado: La subcomposición conlleva un costo de rendimiento y, por lo general, se puede evitar.
    • Como último recurso: Permite que el primer fotograma sea incorrecto y almacena el tamaño en onSizeChanged para activar una segunda recomposición del fotograma. Esto provoca un cambio repentino visible en el diseño, tirones y riesgos de bucles infinitos.
  2. No modifiques el estado después de que se lea por primera vez en la composición:
    • Aunque puedes escribir de forma segura en objetos MutableState durante la composición fuera de un SideEffect, ten especial cuidado para asegurarte de no escribir en un estado que hayas leído previamente en la composición. Se recomienda usar rememberUpdatedState cuando surge esta necesidad de escribir un estado en la composición. Escribir en un estado durante la composición de otra manera suele ser un signo de un efecto faltante o de un estado o elemento componible diseñado de forma inadecuada. Recuerda que la composición es optimista y siempre se ejecuta con el valor más reciente de un estado, por lo que es posible que no veas todos los cambios de estado en las recomposiciones. Las actualizaciones de la IU no deben usarse como una forma de controlar eventos únicos, lo que hace que sea poco común que el valor de un estado requiera actualizarse como resultado de una recomposición.
    • Evita mutar estados que se observan fuera de la composición (por ejemplo, campos ViewModel o marcas isVisible). Las escrituras de estado que afectan la composición pertenecen a lambdas de eventos (onClick), corrutinas (LaunchedEffect) o efectos secundarios (SideEffect). rememberUpdatedState es una excepción, ya que está diseñado para mutar el estado que solo se usa en el cuerpo de @Composable.
  3. Aplazar las lecturas de estado hasta la fase más tardía posible:
    • La lectura de estados en Draw (Modifier.graphicsLayer { alpha = ... }) o Layout (Modifier.offset { IntOffset(...) }) garantiza que los cambios solo invaliden las fases 2 o 3, y se omita por completo la fase 1 (Composición).