wandres.dev
COMPOSE: LISTAS · Lazy y rendimiento

LazyColumn y LazyRow: qué se compone de verdad

Una lista perezosa no es un contenedor con scroll: es un mecanismo de composición bajo demanda que solo materializa los elementos visibles y unos pocos de reserva. Esta lección separa con precisión el modelo de un Column con scroll —que compone, mide y coloca a todos sus hijos siempre— del modelo de LazyColumn, explica que el bloque de contenido no es una función componible sino un constructor de intervalos que se ejecuta con antelación, describe cómo cada elemento visible obtiene su propia composición hija con su propio ámbito de recomposición, y establece el criterio para decidir cuándo la maquinaria perezosa compensa y cuándo su sobrecoste no se justifica.

⏱ 18 min

Casi todo el mundo llega a LazyColumn creyendo que es un Column que además hace scroll, y con esa creencia se escriben listas que funcionan hasta que dejan de hacerlo. La diferencia entre ambos no es de comodidad ni de sintaxis: es una diferencia de modelo de ejecución. Un Column con scroll ejecuta el código de todos sus hijos, los mide todos y los coloca todos, y luego se limita a mover el resultado y recortar lo que sobresale. Una lista perezosa nunca llega a ejecutar el código de lo que no se ve. Esa asimetría explica el consumo de memoria, la latencia de apertura, el motivo por el que ciertos anidamientos revientan con una excepción y hasta la razón de que la posición del scroll se exprese en índices y no en píxeles. Entender qué se compone realmente es el requisito previo para todo lo demás en este nivel.

🎯 Al terminar esta lección sabrás
  • Contrastar el modelo de ejecución de un Column con scroll frente al de una lista perezosa.
  • Entender que el bloque de contenido no es componible sino un constructor de intervalos.
  • Describir cómo cada elemento visible obtiene su composición hija y su ámbito de recomposición.
  • Establecer el criterio para decidir cuándo una lista perezosa compensa y cuándo estorba.

El contenedor con scroll y su coste

Un Column al que se le añade Modifier.verticalScroll sigue siendo un Column. El modificador no cambia nada del ciclo de composición: se limita a interceptar los gestos, a permitir que el contenido se mida con una altura máxima infinita y a desplazar el resultado dibujado. El árbol completo se compone, se mide y se coloca en la primera pasada, exista o no la posibilidad de que el usuario llegue a verlo.

Para veinte filas eso es irrelevante y a menudo preferible. Para dos mil es una catástrofe silenciosa: dos mil composiciones, dos mil pasadas de medida, dos mil nodos vivos en memoria y un tiempo de apertura que crece linealmente con los datos. El síntoma clásico es una pantalla que tarda en aparecer pero luego va perfecta, porque todo el trabajo se pagó de golpe al principio.

// Todo se compone, se mide y se coloca aunque solo se vean seis filas.
Column(modifier = Modifier.verticalScroll(rememberScrollState())) {
    articulos.forEach { articulo -> FilaArticulo(articulo) }
}

La lista perezosa invierte el reparto: compone únicamente los elementos que caben en la ventana visible más un pequeño margen de reserva, y descarta las composiciones de los que salen por el extremo contrario. El coste deja de depender del tamaño de los datos y pasa a depender del tamaño de la pantalla, que es una constante. Esta es la propiedad que buscamos, y todo lo demás son consecuencias suyas.

LazyColumn(
    contentPadding = PaddingValues(vertical = 12.dp),
    verticalArrangement = Arrangement.spacedBy(8.dp),
) {
    item { Cabecera() }
    items(articulos) { articulo -> FilaArticulo(articulo) }
    item { PieDeLista() }
}

Conviene fijarse en dos parámetros de esa llamada porque encapsulan decisiones que en un contenedor normal se resolverían con modificadores y aquí no pueden. El relleno de contenido separa el contenido de los bordes sin recortarlo: el primer elemento arranca desplazado, pero al desplazarse puede ocupar toda la ventana, que es exactamente lo que se quiere bajo una barra superior. Un modificador de relleno aplicado a la lista haría lo contrario, reducir la ventana y cortar el contenido antes. Y la disposición con separación declara el hueco entre elementos en un solo sitio, en vez de repartir márgenes por cada fila que luego se duplican o se pierden en los extremos.

Hay una consecuencia inmediata y poco intuitiva: la lista no conoce su altura total. Como nunca ha medido lo que no ha compuesto, no puede saber cuánto ocupa el contenido completo. De ahí que la posición del scroll se exprese como un índice de elemento más un desplazamiento dentro de él, y no como un número de píxeles absoluto. Toda la API de estado que veremos más adelante hereda esa limitación, que en realidad es el precio honesto de no haber hecho el trabajo.

⚠️
El anidamiento que revienta

Colocar un LazyColumn dentro de un contenedor con scroll vertical produce una excepción en tiempo de ejecución. El contenedor exterior ofrece a su hijo una altura máxima infinita para poder desplazarlo, y una lista perezosa necesita una altura acotada para saber cuántos elementos caben; sin ese dato, su premisa desaparece. La solución nunca es forzar una altura fija arbitraria: es aplanar la jerarquía en una sola lista perezosa que combine bloques item y items. Anidar en ejes cruzados, en cambio, es perfectamente legal: un LazyRow dentro de un LazyColumn funciona porque la restricción horizontal sí está acotada.

El bloque de contenido no es componible

Aquí está el detalle que casi nadie ve y que reordena la comprensión de todo lo demás. La lambda que se le pasa a LazyColumn no está anotada como componible: es un receptor de tipo LazyListScope que se ejecuta de forma anticipada y completa, antes de componer nada, con el único fin de construir una descripción del contenido.

Esa descripción es una lista de intervalos. Cada llamada a item añade un intervalo de longitud uno; cada llamada a items añade un intervalo de longitud igual a la colección. Cada intervalo guarda tres funciones: la que produce la clave de un índice, la que produce el tipo de contenido de un índice, y la que produce la interfaz de ese índice. Esta última sí es componible, pero no se invoca en ese momento: se guarda para invocarla cuando el elemento entre en pantalla.

flowchart TD
BLOQUE[El bloque LazyListScope se ejecuta entero y rapido] --> INT[Construye la lista de intervalos con claves y tipos]
INT --> TOTAL[La lista ya sabe cuantos elementos hay sin componer ninguno]
TOTAL --> VIS[El layout decide que indices caben en la ventana]
VIS --> SUB[Solo esos indices ejecutan su lambda componible]
SUB --> POOL[Los que salen liberan su composicion o quedan en reserva]
style SUB fill:#a6e3a1,color:#11111b
style POOL fill:#f9e2af,color:#11111b

De aquí se derivan tres reglas prácticas que suelen enunciarse sueltas y que en realidad son el mismo teorema. Primera: en el bloque de contenido no se puede llamar a funciones componibles fuera de un item, porque ese código no vive en una composición. Segunda: recorrer la colección con forEach y llamar a item en cada vuelta funciona, pero convierte una colección de mil elementos en mil intervalos en lugar de uno, y ese trabajo sí es proporcional a los datos. Tercera: cualquier cálculo caro escrito directamente en el bloque —ordenar, filtrar, agrupar— se paga entero en cada reconstrucción del bloque, y por tanto pertenece al modelo de datos, no a la interfaz.

// Mal: mil intervalos y un filtrado en cada reconstruccion del bloque.
LazyColumn {
    articulos.filter { it.visible }.forEach { a -> item { FilaArticulo(a) } }
}

// Bien: un solo intervalo, y el filtrado resuelto antes de llegar aqui.
LazyColumn {
    items(articulosVisibles) { a -> FilaArticulo(a) }
}

El resto del vocabulario del ámbito se entiende mejor con esta imagen en la cabeza. La variante indexada añade un intervalo idéntico que además entrega la posición a la lambda componible, útil para alternar fondos o numerar. Las cabeceras pegajosas declaran un intervalo cuyo primer elemento debe permanecer anclado en el borde mientras su sección siga en pantalla, lo que obliga al layout a colocar ese elemento fuera de su sitio natural y a componerlo aunque su posición real ya haya pasado de largo. Y como el bloque describe el contenido y no lo ejecuta, se puede factorizar en funciones de extensión del ámbito y componer listas grandes a partir de piezas reutilizables sin coste alguno.

// Una seccion reutilizable: no es componible, es constructora de intervalos.
fun LazyListScope.seccionArticulos(titulo: String, datos: List<Articulo>) {
    item { CabeceraSeccion(titulo) }
    items(datos, key = { it.id }) { a -> FilaArticulo(a) }
}

La vida de un elemento visible

Cuando el layout determina que el índice quince entra en la ventana, invoca la lambda componible de ese índice dentro de una composición hija que la lista gestiona por su cuenta. Cada elemento tiene así su propio ámbito de recomposición, independiente de sus vecinos: si cambian los datos del elemento quince, el dieciséis no se entera. Esta granularidad es la que hace que una lista bien construida actualice una fila sin tocar las demás.

Cuando ese mismo elemento sale por completo de la ventana visible y del margen de reserva, su composición se desecha o, si el mecanismo de reutilización lo permite, se conserva en un depósito para volver a usar su estructura con otros datos. Las dos consecuencias importantes son simétricas y hay que tenerlas presentes desde el primer día. Cualquier estado creado con remember dentro de un elemento muere al salir de pantalla y vuelve a nacer con su valor inicial al entrar; para que sobreviva hay que elevarlo o guardarlo con rememberSaveable. Y cualquier efecto lanzado dentro de un elemento se cancela al salir, lo que es deseable para cargar una imagen y desastroso para enviar una analítica.

@Composable
fun FilaArticulo(articulo: Articulo) {
    // Muere al salir de pantalla: vuelve a false al reentrar.
    var expandida by remember { mutableStateOf(false) }

    // Sobrevive porque se archiva asociado a la clave del elemento.
    var favorito by rememberSaveable { mutableStateOf(false) }
}

Hay una tercera consecuencia menos evidente y que explica bastantes fallos raros: como la composición de un elemento nace y muere sin relación con el ciclo de vida de la pantalla, ningún trabajo con valor de negocio debería vivir ahí dentro. Registrar que un elemento se ha visto, marcar un mensaje como leído o disparar una descarga son operaciones que se ejecutarán tantas veces como el usuario pase por delante, y que se cancelarán a medias si arrastra deprisa. La regla operativa es sencilla de recordar: dentro de un elemento solo pertenece el trabajo que carece de sentido cuando el elemento no se ve.

💡
Prefetch: el margen de reserva que no ves

Mientras el usuario arrastra, la lista compone anticipadamente el siguiente elemento en la dirección del gesto, aprovechando el tiempo libre del hilo principal entre fotogramas. Ese trabajo adelantado es lo que hace que el scroll parezca continuo. También significa que un elemento desproporcionadamente caro no solo tarda en aparecer: interrumpe fotogramas que se estaban dibujando bien, porque su composición anticipada se cuela en el presupuesto de tiempo de un fotograma ajeno.

Cuándo la pereza no compensa

La maquinaria perezosa no es gratis. Mantener intervalos, calcular visibilidad, subcomponer bajo demanda, gestionar el depósito de reutilización y anticipar elementos tiene un coste fijo que un Column sencillo no paga. Con pocos elementos, y sobre todo con elementos baratos, ese sobrecoste supera al ahorro.

El umbral exacto no se puede tabular porque depende del coste de cada elemento, pero el criterio cualitativo sí se puede enunciar: usa la lista perezosa cuando el número de elementos sea desconocido en tiempo de diseño. Una pantalla de ajustes tiene once secciones y siempre tendrá once; un formulario tiene los campos que tiene. Un feed, un historial o un catálogo tienen los que devuelva el servidor, que hoy son doce y mañana ocho mil. La pregunta correcta no es cuántos elementos hay ahora, sino si existe algún dato del mundo capaz de hacer crecer ese número sin que nadie toque el código.

📋

Usa lista perezosa

Cuando el número de elementos es grande, desconocido o crece con los datos del servidor, y cuando cada elemento tiene un coste de composición apreciable.

🧱

Usa Column con scroll

Cuando el contenido es un formulario, una pantalla de ajustes o un detalle: pocas secciones heterogéneas, todas necesarias, ninguna repetida.

🚫

No mezcles

Un contenedor con scroll no puede alojar una lista perezosa del mismo eje. Aplana la jerarquía en una sola lista con varios bloques en lugar de anidar.

↔️

LazyRow para el eje corto

Los carruseles horizontales dentro de una lista vertical son legales y frecuentes, pero cada uno es una lista completa con su propio estado y su propio coste.

La pereza no es una optimizacion: es un cambio de invariante

Conviene resistirse a leer LazyColumn como la versión rápida de Column, porque esa lectura es cómoda y falsa, y produce código que falla justo cuando importa. Lo que cambia entre ambos no es la velocidad sino la invariante sobre la que puedes razonar. En un Column puedes dar por hecho que todo lo que declaraste existe: existe su composición, existe su estado recordado, existe su nodo, y existe su medida. Sobre esa base es legítimo escribir un remember dentro de un hijo y confiar en él, o calcular la altura total, o suponer que un efecto se lanzó. En una lista perezosa ninguna de esas cosas es cierta, y no lo son por accidente sino por diseño: el ahorro procede exactamente de haber renunciado a esas garantías. Un elemento que no se ve no tiene estado, no ha lanzado efectos, no ha medido nada y no ocupa memoria; y en el momento en que empiezas a depender de que sí lo tenga, lo que has construido no es una lista lenta sino una lista incorrecta, que mostrará una casilla marcada en la fila equivocada o perderá lo que el usuario escribió al desplazarse. Por eso todas las lecciones que siguen —claves, tipos de contenido, estado del scroll, medición del jank— no son técnicas independientes: son las herramientas que devuelven, una a una y de forma explícita, las garantías que la pereza retiró. Quien entiende esto deja de preguntar cuál de los dos es mejor y empieza a preguntar qué invariantes necesita su pantalla, que es la única pregunta que tiene respuesta.

⚔️ Mide la diferencia con tus manos
  1. Construye la misma pantalla de mil filas dos veces, una con Column y scroll y otra con LazyColumn, y compara el tiempo hasta el primer fotograma útil.
  2. Pon un contador con remember dentro de cada fila, increméntalo, desplázate hasta el final y vuelve: explica qué versión conserva el valor y por qué.
  3. Anida deliberadamente un LazyColumn dentro de un Column con scroll, lee el mensaje de la excepción y reescribe la pantalla con una sola lista.
  4. Sustituye un items por un forEach con item dentro y razona qué se ha vuelto proporcional al tamaño de los datos.
  5. Enuncia con tus palabras qué garantías pierde un elemento cuando sale de la ventana visible.