La pasada única: constraints, medida y colocación
El sistema de layout de Compose descansa sobre una regla que parece una limitación y es en realidad una garantía de coste: cada nodo del árbol se mide exactamente una vez por fotograma. Esta lección desmonta el protocolo completo — el padre baja constraints, el hijo devuelve un tamaño, el padre lo coloca —, explica por qué el sistema de vistas clásico tenía que medir dos veces y cómo eso degeneraba en un coste exponencial con la profundidad, y separa con precisión la fase de medida de la fase de colocación, una distinción de la que dependen la mitad de las optimizaciones que verás en el resto del nivel.
Todo el sistema de layout de Compose cabe en una frase de tres verbos: el padre pasa Constraints, el hijo se mide, el padre lo coloca. Lo que hace interesante a esa frase no es lo que dice sino lo que prohíbe. En cuanto un hijo ha devuelto su tamaño, el padre no puede pedirle que se vuelva a medir con otras condiciones: si lo intenta, el runtime lanza una excepción en el acto. Esa prohibición no es una carencia de la API ni una simplificación pendiente de resolver. Es la decisión de diseño que convierte el layout de una operación con coste potencialmente exponencial en una travesía lineal del árbol, y es el motivo por el que existen los intrínsecos y SubcomposeLayout, que son las dos escapatorias reguladas que estudiarás al final del nivel.
- Reconstruir el protocolo completo de una pasada de layout y el papel de cada actor.
- Entender qué son exactamente unos
Constraintsy qué información viaja en cada dirección. - Explicar por qué medir dos veces está prohibido y qué coste evita esa prohibición.
- Separar la fase de medida de la fase de colocación y aprovechar la diferencia.
El protocolo, verbo a verbo
Una pasada de layout recorre el árbol de nodos desde la raíz. Cada nodo recibe de su padre unos Constraints, que no son un tamaño sino un rectángulo de tamaños admisibles: cuatro enteros en píxeles que expresan anchura mínima, anchura máxima, altura mínima y altura máxima. Un hijo puede elegir cualquier tamaño dentro de esa caja, y solo dentro de ella; si devuelve algo fuera de rango, el padre lo verá recortado a los límites que él mismo impuso.
Esa estructura admite tres formas que conviene nombrar porque aparecen constantemente. Unos Constraints son fijos cuando el mínimo coincide con el máximo en un eje: el hijo no elige nada, se le está dictando la medida, y eso es lo que produce un modificador de tamaño exacto. Son acotados cuando hay un máximo finito pero el mínimo es cero: el hijo elige libremente hasta un techo, el caso más común. Y son no acotados cuando el máximo en un eje es el valor de infinito: ocurre dentro de un contenedor desplazable, donde el eje de desplazamiento no tiene techo porque el contenido puede crecer cuanto quiera.
En sentido contrario solo sube un dato: el tamaño elegido, encapsulado en un Placeable. El Placeable es la prueba material de que ese hijo ya fue medido; expone su anchura y su altura, y guarda dentro la instrucción de dónde dibujarse cuando se lo coloque. El padre acumula los Placeable de todos sus hijos, decide con esa información su propio tamaño —siempre dentro de los Constraints que él recibió— y termina colocando cada uno en unas coordenadas relativas a su propio origen.
Constraints fijos
Mínimo igual a máximo en un eje. El hijo no elige: se le dicta la medida. Es lo que produce un modificador de tamaño exacto y lo que un contenedor entrega a un hijo con peso.
Constraints acotados
Mínimo cero y máximo finito. El hijo elige lo que quiera hasta el techo. Es el caso normal y el que hace que la mayoría de composables se ajusten a su contenido.
Constraints sin tope
Máximo infinito en un eje. Aparece dentro de contenedores desplazables. Ningún hijo debe intentar ocupar todo el espacio disponible ahí, porque no lo hay.
Placeable
El recibo de una medida ya consumida. Contiene el tamaño elegido y la orden de colocación pendiente. Su mera existencia certifica que ese hijo ya no se puede volver a medir.
Hay una asimetría en el protocolo que conviene tener presente desde el principio: hacia abajo viaja un rango y hacia arriba viaja un valor. El padre no sabe qué habría hecho el hijo con otras condiciones, solo qué hizo con las que le dio; y el hijo no sabe por qué recibió ese rango ni qué hará el padre con su respuesta. Toda la información que cruza la frontera cabe en esos dos objetos, y esa parquedad deliberada es lo que permite que cualquier subárbol funcione dentro de cualquier contenedor sin acuerdos previos.
flowchart TD A[Padre recibe constraints de su propio padre] --> B[Calcula constraints para cada hijo] B --> C[Cada hijo se mide una sola vez] C --> D[Cada hijo devuelve un placeable con su tamano] D --> E[El padre elige su tamano dentro de sus constraints] E --> F[El padre coloca cada placeable en coordenadas propias] F --> G[El tamano del padre sube al nivel superior] style C fill:#a6e3a1,color:#11111b style F fill:#89b4fa,color:#11111b
La cadena de modificadores participa del mismo protocolo y esto explica por qué el orden importa tanto. Cada modificador de layout es un nodo más del árbol, intercalado entre el padre y el composable al que se aplica. Los Constraints descienden por la cadena de izquierda a derecha, transformándose en cada eslabón, y los tamaños vuelven a subir de derecha a izquierda. Un relleno aplicado antes de un tamaño y uno aplicado después producen resultados distintos no por un capricho de la API, sino porque el primero reduce el techo que recibe el siguiente eslabón y el segundo se aplica sobre un tamaño ya decidido.
// La misma pareja de modificadores en dos ordenes distintos
// no describe dos estilos: describe dos arboles distintos.
Modifier
.padding(16.dp) // recorta 32.dp del techo que baja
.size(100.dp) // el hijo se mide con 100.dp fijos dentro de ese hueco
Modifier
.size(100.dp) // el nodo mide 100.dp
.padding(16.dp) // y su contenido recibe 68.dp de techo
Los cuatro números son enteros en píxeles, no unidades independientes de densidad: la conversión ocurre en el borde, cuando un modificador traduce lo que escribiste a píxeles del dispositivo. Y se empaquetan en un único valor de sesenta y cuatro bits repartiendo los bits disponibles entre las cuatro cifras, lo que impone un techo práctico a las dimensiones representables y explica por qué el infinito es un valor centinela y no una cantidad enorme. Comprobar si un eje está acotado antes de operar con su máximo no es una precaución teórica: sumar algo a un máximo infinito produce basura silenciosa.
Una consecuencia de que los modificadores sean nodos del árbol es que la pregunta de qué tamaño tiene un composable está mal formulada. Lo que tiene tamaño es cada eslabón de la cadena, y pueden diferir entre sí: un nodo con relleno mide más que el contenido que envuelve, y un nodo con un tamaño impuesto puede medir menos que lo que hay dentro, que quedará desbordado. Cuando el resultado en pantalla no coincide con lo esperado, el error casi nunca está en el composable sino en el punto de la cadena donde los Constraints dejaron de ser los que creías.
Por qué está prohibido medir dos veces
El sistema de vistas clásico de Android permitía llamar al método de medida de un hijo tantas veces como hiciera falta, y la biblioteca de contenedores lo aprovechaba sin pudor. Un contenedor lineal con pesos medía dos veces: la primera para descubrir cuánto ocupaban los hijos sin peso, la segunda para repartir el sobrante entre los que sí lo tenían. Un contenedor relativo medía dos veces para resolver dependencias horizontales y verticales. Cada uno de esos contenedores, aislado, tenía un coste perfectamente razonable.
El problema aparecía al anidarlos. Si un contenedor mide dos veces a cada hijo y ese hijo es a su vez un contenedor que mide dos veces a los suyos, el número de medidas efectivas se duplica en cada nivel de profundidad. Diez niveles de anidamiento con dobles medidas encadenadas son mil veinticuatro medidas de las hojas para pintar un solo fotograma. Ese crecimiento exponencial es la razón técnica de fondo por la que las jerarquías profundas se volvían lentas y por la que se popularizaron los contenedores planos con restricciones explícitas, que existían precisamente para aplanar el árbol y contener la explosión.
Compose corta ese nudo por la vía radical: cada Measurable acepta una única llamada de medida por pasada. La segunda no devuelve un resultado erróneo ni recalcula en silencio, lanza una excepción. Con esa regla, el coste del layout es proporcional al número de nodos y a nada más, sea cual sea la profundidad. Un árbol de mil nodos cuesta mil medidas, siempre, y esa previsibilidad es lo que permite anidar composables con la libertad con la que se anidan funciones.
El límite no se cuenta por composable sino por instancia de Measurable dentro de la pasada. Un Layout propio que recorra su lista de hijos dos veces —una para calcular un total y otra para medir de verdad— falla, aunque la intención fuera inocente. La forma correcta es un único recorrido que mida y acumule a la vez, guardando los Placeable en una lista. Si de verdad necesitas conocer un tamaño antes de decidir cómo medir, ya no estás en el caso ordinario: estás en el territorio de los intrínsecos o de la subcomposición, y ambos tienen un precio que se estudia más adelante.
El corolario menos obvio es que la regla también acota el peor caso de una recomposición. Cuando un estado cambia y obliga a remedir, el trabajo afectado es proporcional al número de nodos invalidados, no a alguna potencia de la profundidad del subárbol. Eso permite razonar sobre el coste de una pantalla mirando su árbol una sola vez, sin tener que auditar qué contenedor de la cadena tiene la costumbre de medir dos veces a los suyos. En un sistema con dobles medidas, el mismo componente insertado dos niveles más abajo cuesta cuatro veces más; en Compose cuesta exactamente lo mismo, y esa invariancia es lo que hace viable extraer y reutilizar composables sin pensar en su ubicación.
Conviene notar qué se pierde con esta regla, porque es real. Un padre no puede decir a un hijo dime cuánto ocuparías y ahora, a la vista de eso, mídete de otra manera. Esa consulta de tanteo es exactamente lo que resuelve el mecanismo de intrínsecos, que no es una segunda medida sino una pregunta separada con su propio coste. Y la situación más extrema —necesito saber el espacio disponible para decidir qué composables existen— no se resuelve midiendo distinto, sino aplazando la composición misma hasta la fase de layout, que es lo que hace SubcomposeLayout.
Medir y colocar son dos fases, no una
El detalle que más rendimiento regala de todo el nivel es que la medida y la colocación no ocurren en el mismo momento. Cuando un nodo termina de medirse, devuelve un objeto que contiene su tamaño y un bloque de colocación todavía sin ejecutar. Ese bloque se ejecuta después, en una pasada de colocación separada, y esa separación tiene una consecuencia enorme: es posible volver a colocar un árbol sin volver a medirlo.
// El bloque de colocacion es una lambda diferida:
// se ejecuta en la fase de placement, no durante la medida.
Layout(content = contenido) { measurables, constraints ->
val placeables = measurables.map { it.measure(constraints) }
val ancho = placeables.maxOf { it.width }
val alto = placeables.sumOf { it.height }
layout(ancho, alto) { // aqui todavia no se ha colocado nada
var y = 0
placeables.forEach { p ->
p.placeRelative(x = 0, y = y) // esto corre en la fase siguiente
y += p.height
}
}
}
Conviene subrayar que el bloque de colocación es una clausura sobre las variables locales de la medida: los Placeable que capturaste siguen ahí, con sus tamaños ya decididos, y el bloque puede leer estado que solo exista en ese instante. Eso es lo que permite escribir animaciones de posición sin recalcular ningún tamaño, y también lo que obliga a no meter en ese bloque cálculos pesados: se ejecuta más veces que la medida, no menos.
De esa asimetría nace una distinción práctica que aparece en toda la biblioteca. Los modificadores que aceptan una lambda para calcular un desplazamiento leen su estado dentro del bloque de colocación, no durante la medida; cuando ese estado cambia, se invalida únicamente la colocación y el nodo se recoloca sin remedirse. Los que aceptan un valor directo leen el estado durante la composición y arrastran, por tanto, una recomposición y una remedida completas. Una animación de desplazamiento escrita de la primera forma cuesta una fracción de la segunda, y la diferencia crece con el tamaño del subárbol afectado.
La separación explica además un comportamiento que desconcierta la primera vez que se observa: un contenedor puede colocar a un hijo fuera de sus propios límites, o colocarlo dos veces, o no colocarlo en absoluto. Nada de eso viola el protocolo, porque colocar es una operación independiente de medir. Lo único que el sistema exige es que todo Placeable obtenido acabe colocado alguna vez si se quiere que aparezca en pantalla; un hijo medido y nunca colocado ocupa su hueco en el cálculo del tamaño del padre y no se dibuja, que es exactamente el mecanismo con el que se implementan las transiciones de sustitución.
Merece la pena fijar el vocabulario completo, porque las tres fases de Compose se confunden con frecuencia. La composición decide qué composables existen y con qué parámetros. El layout decide, para cada nodo que exista, qué tamaño tiene y dónde va, y se subdivide en la medida y la colocación que acabamos de separar. El dibujo emite las órdenes de pintado. Cada fase puede invalidarse por separado, y saber en cuál de ellas se lee un estado es la única forma de predecir el coste de un cambio.
Esa independencia entre fases tiene una lectura operativa que conviene adoptar cuanto antes como hábito de diseño. Cuando un valor cambia muchas veces por segundo —una posición animada, un desplazamiento, un progreso—, la pregunta no es cómo hacer que la recomposición sea barata, sino cómo evitar que ese valor se lea en una fase temprana. Un dato leído en composición arrastra las tres fases; leído en medida, arrastra dos; leído en colocación, solo una; leído en dibujo, ninguna de las anteriores. Diferir la lectura hacia la fase más tardía posible es, casi siempre, la optimización de mayor rendimiento por línea escrita que existe en Compose, y no requiere tocar la estructura del árbol.
Hay un último matiz sobre el orden que evita un malentendido frecuente. Que la colocación sea posterior no significa que sea una segunda travesía completa del árbol equivalente a la medida: recorre los mismos nodos, pero sin construir nada ni negociar tamaños, ejecutando lambdas ya preparadas. Es sensiblemente más barata, y por eso el sistema puede permitirse repetirla sola muchas veces entre dos medidas sin que el coste se note.
Al colocar hay dos verbos casi idénticos. Uno coloca en coordenadas absolutas dentro del padre; el otro espeja horizontalmente las posiciones cuando la dirección de lectura del entorno es de derecha a izquierda. Salvo que estés construyendo algo deliberadamente asimétrico —una barra de progreso que siempre crece hacia el mismo lado, por ejemplo—, la variante relativa es la correcta, y usarla por defecto te ahorra descubrir en pruebas de localización que tu contenedor propio es el único elemento de la pantalla que no se ha espejado.
La tentación al conocer la pasada única es leerla como una restricción que habrá que sortear, y casi todos los contenedores mal escritos nacen de ahí. Vista de cerca es lo contrario: es un contrato que reparte responsabilidades de forma que ninguna de las dos partes pueda arruinar a la otra. El padre promete que los Constraints que baja son definitivos, y con esa promesa el hijo puede decidir su tamaño de una vez, sin reservas ni tanteos. El hijo promete que el tamaño que sube es final, y con esa promesa el padre puede colocar sin miedo a que el suelo se mueva bajo sus pies. Ninguno de los dos necesita conocer al otro, y de esa ignorancia mutua sale la composabilidad real del sistema: puedes meter cualquier subárbol dentro de cualquier contenedor sin que el resultado dependa de cuántas veces el contenedor tuviera la costumbre de medir. El coste lineal es la consecuencia visible; la invariante de composición es la conquista de fondo. Cuando en tu propio contenedor sientas la necesidad de medir dos veces, la pregunta útil no es cómo esquivar la excepción, sino cuál de las dos promesas estabas intentando romper. Casi siempre la respuesta es que querías información que el protocolo no transporta hacia arriba antes de tiempo, y para eso el sistema ofrece dos puertas —los intrínsecos y la subcomposición— que no rompen el contrato: lo pagan. Esa es la diferencia entre una escapatoria y una fuga, y es también la razón de que ambas puertas sean caras a propósito. Un mecanismo que hiciera gratis lo que la regla prohíbe habría devuelto al sistema entero al régimen exponencial del que salió.
- Escribe un
Layoutpropio que mida a sus hijos con losConstraintsque recibe y coloque cada uno debajo del anterior. Añade una traza dentro del bloque de medida y otra dentro del bloque de colocación, y observa cuántas veces se ejecuta cada una durante una animación de desplazamiento. - Intenta deliberadamente medir un mismo
Measurabledos veces y lee el mensaje exacto de la excepción. Guárdalo: es el error que más veces reconocerás a partir de ahora. - Coloca el mismo par de modificadores de relleno y tamaño en los dos órdenes posibles dentro de un contenedor de fondo coloreado y explica el resultado en términos de qué
Constraintsrecibe cada eslabón. - Mete tu contenedor dentro de un contenedor con desplazamiento vertical y comprueba qué le llega como altura máxima. Decide qué debe hacer tu contenedor cuando ese valor es infinito y escríbelo.
- Sustituye una animación de desplazamiento escrita con el modificador de valor directo por la variante con lambda y mide la diferencia en número de medidas por fotograma.