wandres.dev
COMPOSE: FUNDAMENTOS · composables y recomposición

Las tres fases: composición, layout y dibujo

El fotograma de Compose desmontado en sus tres etapas: qué produce cada una, por qué el layout se resuelve en una sola pasada con restricciones que bajan y tamaños que suben, cómo se saltan fases enteras cuando un valor se lee más tarde, y por qué saber en qué fase trabajas decide el rendimiento.

⏱ 19 min

Compose no pinta la pantalla de una vez: la construye en tres etapas encadenadas, y cada una produce exactamente una cosa que la siguiente consume. La composición decide qué hay que mostrar, el layout decide dónde y con qué tamaño, y el dibujo emite las órdenes gráficas. Parece una división administrativa hasta que descubres que un fotograma puede recorrer solo la última etapa, o las dos últimas, saltándose las anteriores por completo. A partir de ahí, la pregunta que determina si una animación va fluida o arrastra deja de ser cuánto código escribes y pasa a ser en qué fase se lee el valor que cambia.

🎯 Al terminar esta lección sabrás
  • Identificar qué produce cada fase y qué recibe de la anterior.
  • Explicar la pasada única del layout: restricciones que bajan, tamaños que suben.
  • Reconocer que una fase puede ejecutarse sin que se ejecuten las anteriores.
  • Elegir la fase correcta para leer un valor que cambia en cada fotograma.

Qué produce cada fase

La composición ejecuta tus funciones componibles y produce un árbol de nodos de layout. Es la única fase donde interviene tu código Kotlin de descripción, y su salida no contiene ni una sola coordenada: solo la estructura de qué elementos existen, qué modificadores llevan y qué relación de anidamiento tienen. Un nodo recién compuesto no sabe dónde está ni cuánto mide.

El layout recorre ese árbol y resuelve la geometría. Cada nodo se mide dentro de unas restricciones que recibe de su padre, comunica el tamaño que ha decidido y luego coloca a sus hijos en posiciones concretas. Su salida es el mismo árbol, ahora con tamaño y posición asignados a cada nodo.

El dibujo recorre el árbol ya medido y colocado, y emite las órdenes de dibujo sobre el lienzo. Aquí no se decide nada estructural: se traduce geometría y color en operaciones gráficas que terminan en la GPU. Es la única fase que produce píxeles.

flowchart LR
A[Composicion] -->|arbol de nodos| B[Layout]
B -->|tamano y posicion| C[Dibujo]
C --> D[Fotograma en pantalla]
B -.reejecucion sin componer.-> C
style A fill:#a6e3a1,color:#11111b
style B fill:#f9e2af,color:#11111b
style C fill:#89b4fa,color:#11111b

Que la composición no produzca coordenadas es una restricción deliberada, no una carencia. Es lo que permite que la misma función describa correctamente un móvil estrecho, una tableta y un plegable abierto: como no puede preguntar cuánto espacio hay mientras describe, no puede tomar decisiones que aten la descripción a un tamaño concreto. Toda la adaptabilidad geométrica se resuelve más tarde, en la fase que sí conoce las restricciones.

📝
No todas las fases se ejecutan en cada fotograma

Un fotograma puede recorrer las tres fases, las dos últimas o solo la última. Lo habitual en una interfaz en reposo es que no se ejecute ninguna: si nada ha cambiado, no hay nada que recomponer, medir ni volver a dibujar, y el sistema reutiliza el resultado anterior.

El orden es estricto y de sentido único, con una excepción muy acotada: durante el layout, un nodo puede consultar el resultado de la medida de otro para decidir la suya, algo que el sistema de vistas resolvía con pasadas repetidas y que aquí queda confinado a los llamados intrínsecos. Fuera de eso, ninguna fase puede pedir información a la siguiente. Un nodo no puede decidir en composición qué anchura va a tener, porque esa anchura aún no existe.

El layout se resuelve en una sola pasada

Aquí está la diferencia de fondo con el sistema de vistas clásico. En aquel modelo, onMeasure podía llamarse varias veces por nodo y por fotograma: un RelativeLayout medía dos veces por diseño, y anidar unos cuantos producía un crecimiento exponencial de mediciones que era la causa habitual de los fotogramas perdidos en jerarquías profundas.

Compose garantiza que cada nodo se mide una sola vez por pasada de layout. Lo consigue con un protocolo de dos direcciones bien definido: las restricciones descienden del padre al hijo y los tamaños ascienden del hijo al padre.

@Composable
fun CajaMedida(modifier: Modifier = Modifier, contenido: @Composable () -> Unit) {
    Layout(content = contenido, modifier = modifier) { medibles, restricciones ->
        // 1. medir cada hijo dentro de las restricciones recibidas
        val colocables = medibles.map { it.measure(restricciones) }

        val ancho = colocables.maxOfOrNull { it.width } ?: 0
        val alto = colocables.sumOf { it.height }

        // 2. declarar el tamano propio y colocar a los hijos
        layout(ancho, alto) {
            var y = 0
            colocables.forEach { colocable ->
                colocable.placeRelative(x = 0, y = y)
                y += colocable.height
            }
        }
    }
}

Ese bloque es todo el protocolo. Recibes una lista de hijos medibles y un objeto de restricciones con anchura y altura mínimas y máximas; mides a cada hijo exactamente una vez, obteniendo un colocable que ya conoce su tamaño; declaras el tamaño propio, que debe respetar las restricciones que te dieron; y en el bloque de colocación asignas posiciones. Medir dos veces al mismo hijo lanza una excepción en tiempo de ejecución, porque el modelo entero descansa en esa garantía.

La consecuencia práctica es que la profundidad del árbol deja de ser cara. Anidar veinte contenedores en Compose cuesta veinte medidas, no dos elevado a veinte, y por eso el estilo idiomático se permite envolver sin miedo lo que en XML había que aplanar a mano con un ConstraintLayout gigante.

El precio de esa garantía es que un nodo no puede decidir su medida en función de la medida de sus hijos si antes necesita saber el resultado de otro hijo. Para ese caso existe la válvula de escape de los intrínsecos: preguntar cuánto mediría un hijo con una anchura o altura hipotética, sin medirlo de verdad. La consulta es real y tiene coste, porque obliga al subárbol a recorrerse una vez más, así que la regla es tratarla como una excepción justificada y no como una herramienta cotidiana. La otra vía, cuando el contenido depende del espacio disponible, es un contenedor que expone las restricciones recibidas a su contenido, de modo que la decisión ocurra ya dentro de la fase de layout y no antes.

También merece la pena leer bien las restricciones. Cada una trae anchura y altura mínimas y máximas, y el máximo puede ser infinito: eso es lo que ocurre en el eje de desplazamiento de una columna desplazable, donde no existe un límite superior de altura. Un hijo que intente ocupar todo el espacio disponible en un eje sin límite superior está pidiendo un tamaño infinito, y ese es el origen de buena parte de los errores de medida que uno se encuentra al anidar contenedores desplazables.

ℹ️
La colocación es más barata que la medida

El bloque de colocación se ejecuta aparte del de medida, y esa separación no es estética. Un cambio que solo altera posiciones —desplazar, ordenar, animar un offset— puede reejecutar únicamente la colocación sin volver a medir a nadie. Por eso las variantes de modificadores que reciben una lambda posponen su lectura hasta ese momento.

Saltarse fases

La regla que gobierna el rendimiento en Compose se enuncia en una línea: el momento en que se lee un valor cambiante determina qué fases se reejecutan cuando ese valor cambia. Si lo lees en el cuerpo de la función componible, cualquier cambio obliga a recomponer, y tras recomponer hay que medir y dibujar de nuevo: las tres fases. Si lo lees dentro de la lambda de un modificador que se evalúa en layout, la composición se salta y solo se rehacen dos. Y si lo lees en un ámbito de dibujo, se saltan las dos primeras.

// Tres fases en cada fotograma: el valor se lee al componer
Etiqueta(modifier = Modifier.offset(x = desplazamiento.dp))

// Dos fases: la lectura se pospone al layout
Etiqueta(modifier = Modifier.offset { IntOffset(desplazamiento.roundToPx(), 0) })

// Una fase: la lectura ocurre al dibujar
Etiqueta(modifier = Modifier.graphicsLayer { translationX = desplazamiento })

Las tres líneas producen el mismo movimiento en pantalla y tienen costes muy distintos. Con una animación a sesenta fotogramas por segundo, la primera reejecuta tu código de descripción sesenta veces por segundo, con toda la maquinaria de recomposición detrás; la tercera solo actualiza una propiedad de la capa gráfica y deja intactos el árbol y la geometría. En una lista larga o en un elemento con muchos hijos, esa diferencia es la que separa una animación suave de un tirón visible.

Conviene precisar que esto no es una invitación a mover todo al dibujo. Un desplazamiento aplicado en la capa gráfica mueve píxeles y nada más: el elemento sigue midiendo y ocupando el sitio que ocupaba, y su área sensible al tacto no viaja con él. Si lo que necesitas es que el resto del contenido se reorganice alrededor, la decisión pertenece a la fase de layout y no hay atajo posible. La regla completa es leer lo más tarde posible dentro de la fase que de verdad corresponde al efecto que buscas.

El mismo razonamiento explica por qué ciertos modificadores existen en pares aparentemente redundantes. Modifier.background con un color fijo se resuelve al componer; Modifier.drawBehind recibe una lambda que se ejecuta en la fase de dibujo y puede leer valores cambiantes sin coste de composición. Modifier.padding con un valor fijo frente a la variante que calcula en layout obedece a la misma lógica. No son duplicados: son puntos de entrada a fases distintas.

🧩

Composición

Ejecuta tus funciones y produce el árbol de nodos. La fase más cara, porque arrastra a las otras dos.

📐

Layout

Mide una vez por nodo con restricciones que bajan y tamaños que suben, y después coloca.

🖌️

Dibujo

Traduce geometría y color en órdenes gráficas. La más barata y la más fácil de aislar.

🎯

Elegir fase

Un valor que cambia por fotograma debe leerse lo más tarde posible en la cadena.

Por qué importa saber dónde estás

Saber en qué fase trabajas resuelve además dudas que de otro modo parecen caprichos del framework. La razón de que no puedas consultar el tamaño de un elemento mientras lo describes es que en composición ese tamaño no existe todavía; para obtenerlo hay que esperar al layout, y por eso las herramientas que lo entregan lo hacen mediante devoluciones de llamada o modificadores que se evalúan más tarde. La razón de que dibujar fuera de los límites solo funcione si no se ha recortado es que el recorte es una decisión de la fase de dibujo, no de la geometría.

Explica igualmente por qué ciertas construcciones son caras aunque parezcan inocentes. Envolver un elemento en una capa gráfica con transparencia obliga a dibujarlo en un búfer aparte y a componerlo después, trabajo que ocurre entero en la tercera fase; una sombra pronunciada tiene un coste similar por el mismo motivo. Ninguna de las dos cosas se nota al mirar el código, porque el árbol y la geometría son idénticos: la diferencia vive en una fase que el código no menciona.

Sirve también para leer bien las herramientas de diagnóstico. Un contador de recomposiciones alto señala un problema en la primera fase; una traza con mucho tiempo en medida apunta a una jerarquía que se remide más de lo previsto, casi siempre por intrínsecos o por listas mal configuradas; y un tiempo alto en dibujo suele venir de sombras, transparencias o capas gráficas innecesarias. Sin el modelo de fases en la cabeza, esas tres patologías se parecen entre sí y se atacan a ciegas.

Las fases no son un detalle de implementación: son el contrato temporal de la interfaz

Detrás de la separación en tres etapas hay una decisión de diseño que se paga en todo lo demás. Cada fase está definida por lo que todavía no se sabe. En composición no se conoce ninguna dimensión, y esa ignorancia obligada es lo que permite que tu descripción sea independiente del dispositivo: la misma función sirve para un móvil estrecho y para un plegable abierto porque no puede preguntar por el ancho mientras describe. En layout ya se conocen las restricciones pero aún no hay píxeles, y esa ventana es donde vive toda la adaptabilidad geométrica. En dibujo se conoce todo y no queda ninguna decisión estructural que tomar, y por eso es la fase que se puede repetir sesenta veces por segundo sin despeinar al sistema. La progresión es de información creciente y libertad decreciente, y el motivo de que el orden sea de sentido único es que cualquier vuelta atrás introduciría un ciclo entre lo que se decide y lo que se sabe; el sistema de vistas permitía esas vueltas atrás con pasadas repetidas de medida, y el precio fue una complejidad de coste impredecible que hacía imposible razonar sobre el rendimiento de una jerarquía sin medirla. Compose sacrifica poder expresivo local —no puedes decidir tu contenido en función de tu tamaño sin salir del camino fácil— a cambio de una garantía global: cada nodo se mide una vez y el coste es lineal en el número de nodos. La consecuencia cultural es la que de verdad cambia cómo escribes: en cuanto sabes que tres fases se pueden ejecutar por separado, dejas de optimizar contando líneas de código y empiezas a optimizar preguntándote cuándo se lee este valor. Esa pregunta, y no la microoptimización del cuerpo de tus funciones, es donde está prácticamente todo el rendimiento de una interfaz en Compose.

⚔️ Medir las tres fases
  1. Anima un desplazamiento con las tres variantes del ejemplo y compara el resultado en el inspector de composición de Android Studio.
  2. Activa la superposición de recomposiciones y comprueba cuál de las tres tiñe el elemento en cada fotograma.
  3. Escribe un Layout propio que apile a sus hijos y provoca a propósito una segunda medida del mismo hijo para ver la excepción.
  4. Captura una traza de sistema durante un desplazamiento de lista e identifica el tiempo dedicado a componer, medir y dibujar.
  5. Sustituye un Modifier.background con color animado por Modifier.drawBehind y documenta la diferencia en la traza.