Cómo funciona LazyColumn internamente
LazyColumn solo compone y dibuja los items visibles en pantalla más un pequeño buffer fuera de pantalla. Cuando el usuario scrollea, los items que salen de pantalla se reciclan — sus composables se reutilizan para los items nuevos que entran.
Este reciclado es lo que hace a LazyColumn eficiente en memoria. Pero también es la fuente de los errores más comunes: si LazyColumn no puede determinar correctamente qué item es cuál, el reciclado lleva al estado incorrecto al composable incorrecto.
keys — el error con más impacto
Sin key, LazyColumn identifica cada item por su posición en la lista. Si la lista se reordena, inserta o elimina items, Compose asume que el item en la posición 0 sigue siendo el mismo item de antes — y le asigna el estado del item anterior. El resultado: estados mezclados, animaciones incorrectas y recomposiciones innecesarias de toda la lista.
// ❌ Sin key — identificación por posición
LazyColumn {
items(productos) { producto ->
ProductoCard(producto)
}
}
// Si se inserta un item al principio de la lista:
// → Compose ve que "el item en posición 0 cambió" → recompone TODOS los items
// → Los estados locales (expansión, selección) quedan en las posiciones viejas
// → Las animaciones de inserción/eliminación no funcionan
// ✓ Con key — identificación estable
LazyColumn {
items(
items = productos,
key = { it.id } // identificador único y estable
) { producto ->
ProductoCard(producto)
}
}
// Si se inserta un item al principio:
// → Compose sabe exactamente qué item es cuál
// → Solo recompone el item nuevo
// → Los estados locales siguen en el item correcto
// → Animaciones funcionan automáticamente con Modifier.animateItem()
// Listas con múltiples tipos — key única por tipo para evitar colisiones:
sealed class ItemFeed {
data class Post(val post: Post) : ItemFeed()
data class Ad(val ad: Ad) : ItemFeed()
data class Header(val titulo: String) : ItemFeed()
}
LazyColumn {
items(
items = feedItems,
key = { item ->
when (item) {
is ItemFeed.Post -> "post_${item.post.id}"
is ItemFeed.Ad -> "ad_${item.ad.id}"
is ItemFeed.Header -> "header_${item.titulo}"
}
}
) { item -> /* ... */ }
}
// Las keys deben ser únicas GLOBALMENTE en la lista, no solo por tipo
La key debe ser estable entre recomposicionesNo uses el índice como key (key = { index, _ -> index }) — eso es equivalente a no tener key. No uses objetos que se crean en la composición (key = { it.hashCode() } en clases con identidad de objeto). La key debe ser un valor que identifica el item de forma permanente: su ID de base de datos, su URL, su slug.
contentType — reciclado entre tipos distintos
Cuando la lista tiene items de tipos visuales diferentes (cards, headers, banners), contentType le dice a Compose qué composables puede reutilizar entre sí durante el reciclado. Sin esto, puede intentar reutilizar un header como una card con resultados incorrectos.
// ❌ Sin contentType — Compose puede intentar reciclar un Header como una Card
LazyColumn {
items(feedItems, key = { /* ... */ }) { item ->
when (item) {
is ItemFeed.Post -> PostCard(item.post)
is ItemFeed.Ad -> AdBanner(item.ad)
is ItemFeed.Header -> SectionHeader(item.titulo)
}
}
}
// ✓ Con contentType — solo se reciclan composables del mismo tipo
LazyColumn {
items(
items = feedItems,
key = { item -> when (item) {
is ItemFeed.Post -> "post_${item.post.id}"
is ItemFeed.Ad -> "ad_${item.ad.id}"
is ItemFeed.Header -> "header_${item.titulo}"
}},
contentType = { item -> item::class } // el tipo de la sealed class
) { item ->
when (item) {
is ItemFeed.Post -> PostCard(item.post)
is ItemFeed.Ad -> AdBanner(item.ad)
is ItemFeed.Header -> SectionHeader(item.titulo)
}
}
}
Recomposición innecesaria de items
Cada vez que el ViewModel emite un nuevo estado, todos los items visibles son candidatos a recomposición. Si los parámetros del item no cambiaron, Compose los saltea — pero solo si puede determinarlo con equals(). Los tipos inestables fuerzan la recomposición aunque el contenido sea idéntico.
// ❌ List<T> como parámetro → siempre inestable → recompone en cada emisión
@Composable
fun ProductoCard(etiquetas: List<String>) { /* ... */ }
// Cada vez que el ViewModel emite cualquier cambio de estado,
// ProductoCard recompone aunque las etiquetas no hayan cambiado
// ✓ ImmutableList → estable → Compose puede saltear la recomposición
@Composable
fun ProductoCard(etiquetas: ImmutableList<String>) { /* ... */ }
// O anotar el wrapper como @Stable:
@Stable
data class ProductoUiModel(
val id: Int,
val nombre: String,
val etiquetas: List<String> // la List inestable queda encapsulada
)
// Al estar dentro de una clase @Stable, Compose confía en que equals() es correcto
// ❌ Lambda que captura el item y se recrea en cada recomposición del padre
LazyColumn {
items(productos, key = { it.id }) { producto ->
ProductoCard(
producto = producto,
onClick = { viewModel.seleccionar(producto.id) } // nueva lambda siempre
)
}
}
// ✓ Pasar el callback estable desde afuera
LazyColumn {
items(productos, key = { it.id }) { producto ->
ProductoCard(
producto = producto,
onSeleccionar = onProductoSeleccionado // referencia estable al callback
)
}
}
Estado local en items — el problema del reciclado
El estado local con remember dentro de un item de LazyColumn sobrevive al reciclado — pero se reasigna al nuevo item que ocupa esa posición. Sin key, el estado "expansión = true" del item que scrolleó fuera de pantalla aparece en el item nuevo que entró.
// ❌ Estado local sin key — el estado se "transfiere" al item en la misma posición
LazyColumn {
items(comentarios) { comentario -> // sin key
var expandido by remember { mutableStateOf(false) } // estado local
ComentarioCard(comentario, expandido, onExpandir = { expandido = !expandido })
}
}
// Si el usuario expande el item en posición 3 y scrollea,
// cuando el item de posición 3 vuelve a la pantalla (con un comentario diferente),
// aparece expandido — el estado pertenecía a la posición, no al item
// ✓ Con key — el estado sigue al item correcto
LazyColumn {
items(comentarios, key = { it.id }) { comentario ->
var expandido by remember { mutableStateOf(false) }
ComentarioCard(comentario, expandido, onExpandir = { expandido = !expandido })
}
}
// Para estado que debe persistir entre scrolls (selección, leído/no leído):
// NO usar remember local — subirlo al ViewModel
class ComentariosViewModel : ViewModel() {
private val _expandidos = mutableStateSetOf<Int>() // IDs expandidos
fun toggleExpandido(id: Int) {
if (id in _expandidos) _expandidos.remove(id) else _expandidos.add(id)
}
fun isExpandido(id: Int) = id in _expandidos
}
Listas anidadas — el error que paraliza el scroll
Una LazyRow dentro de una LazyColumn es un patrón muy común (estilo Netflix: filas horizontales dentro de una lista vertical). El error más frecuente: la LazyRow pierde su posición de scroll cuando scrollea la LazyColumn.
// ❌ LazyRow que pierde el scroll al salir y volver a pantalla
LazyColumn {
items(categorias, key = { it.id }) { categoria ->
Text(categoria.nombre)
LazyRow { // el estado de scroll se pierde cuando esta fila sale de pantalla
items(categoria.items, key = { it.id }) { item ->
ItemCard(item)
}
}
}
}
// ✓ Persistir el estado de scroll de cada LazyRow:
LazyColumn {
items(categorias, key = { it.id }) { categoria ->
val scrollState = rememberLazyListState()
// rememberLazyListState con key = categoria.id persiste el scroll
// mientras el item esté en memoria (dentro del buffer de LazyColumn)
Text(categoria.nombre)
LazyRow(state = scrollState) {
items(categoria.items, key = { it.id }) { item ->
ItemCard(item)
}
}
}
}
// Para persistir el scroll entre reinstanciaciones más agresivas:
// Guardar y restaurar la posición en el ViewModel
class FeedViewModel : ViewModel() {
private val scrollPositions = mutableMapOf<Int, Int>() // categoriaId → firstVisibleIndex
fun getScrollPosition(categoriaId: Int) = scrollPositions[categoriaId] ?: 0
fun saveScrollPosition(categoriaId: Int, index: Int) {
scrollPositions[categoriaId] = index
}
}
Column + LazyColumn anidados rompen el scrollOtro error frecuente: poner una LazyColumn dentro de una Column scrolleable (o dentro de un NestedScrollView). LazyColumn necesita saber su altura para saber cuántos items mostrar — si está dentro de algo de altura infinita, intenta componer TODOS los items a la vez, derrotando todo el propósito de lazy. Si necesitás combinar contenido estático con una lista, usá LazyColumn con item { } para el contenido estático y items { } para la lista.
// ❌ LazyColumn dentro de Column scrolleable — compone TODOS los items
Column(modifier = Modifier.verticalScroll(rememberScrollState())) {
HeaderSection()
LazyColumn { // altura ilimitada → compone todo → no lazy
items(listaLarga) { item -> ItemCard(item) }
}
}
// ✓ Todo en una sola LazyColumn con items mixtos
LazyColumn {
item { HeaderSection() } // contenido estático como item
item { SubHeaderSection() }
items(listaLarga, key = { it.id }) { item ->
ItemCard(item)
}
item { FooterSection() }
}
items vs itemsIndexed — cuándo cada uno
// items — cuando no necesitás el índice (la mayoría de los casos)
LazyColumn {
items(productos, key = { it.id }) { producto ->
ProductoCard(producto)
}
}
// itemsIndexed — cuando el índice es parte de la lógica de presentación
LazyColumn {
itemsIndexed(
items = productos,
key = { _, producto -> producto.id } // el key usa el item, no el índice
) { index, producto ->
ProductoCard(
producto = producto,
posicion = index + 1, // "1 de 50", "2 de 50"...
mostrarDivisor = index < productos.lastIndex // no mostrar divisor en el último
)
}
}
// ❌ Usar itemsIndexed y pasar el índice como key:
itemsIndexed(productos) { index, producto ->
// key = { index, _ -> index } ← NUNCA: el índice como key es equivalente a no tener key
}
Allocations en la composición de items
// ❌ Crear objetos costosos dentro del composable del item — se ejecuta por cada recomposición
LazyColumn {
items(transacciones, key = { it.id }) { transaccion ->
val formato = NumberFormat.getCurrencyInstance(Locale("es", "AR")) // nuevo objeto siempre
val monto = formato.format(transaccion.monto)
TransaccionCard(monto = monto, transaccion = transaccion)
}
}
// ✓ Cachear con remember — se crea solo cuando cambia la dependencia
LazyColumn {
items(transacciones, key = { it.id }) { transaccion ->
// El NumberFormat se crea UNA SOLA VEZ y se reutiliza
val formato = remember { NumberFormat.getCurrencyInstance(Locale("es", "AR")) }
val monto = remember(transaccion.monto) { formato.format(transaccion.monto) }
TransaccionCard(monto = monto, transaccion = transaccion)
}
}
// ✓ Aún mejor: mover el formateo al modelo o al ViewModel
data class TransaccionUiModel(
val id: Int,
val montoFormateado: String, // ya formateado antes de llegar al composable
val fecha: String
)
LazyColumn {
items(transacciones, key = { it.id }) { transaccion ->
// No hay formateo en el composable — solo presentación
TransaccionCard(transaccion = transaccion)
}
}
Cómo medir la performance de LazyColumn
# 1. Layout Inspector con recomposition counts
# Android Studio → View → Tool Windows → Layout Inspector
# Activar "Show recomposition counts"
# Scrollear la lista — un item que se recompone en cada frame está mal
# 2. System Trace — detectar janky frames
# Android Studio → Profiler → CPU → System Trace
# Los frames que tardan más de 16ms (o 8ms en dispositivos de 120Hz) se muestran en rojo
# Expandir el Main thread en esos frames para ver qué composable los causó
# 3. Compose compiler report — detectar tipos inestables
# build.gradle:
# kotlinOptions {
# freeCompilerArgs += listOf(
# "-P", "plugin:androidx.compose.compiler.plugins.kotlin:reportsDestination=/tmp/compose",
# "-P", "plugin:androidx.compose.compiler.plugins.kotlin:metricsDestination=/tmp/compose"
# )
# }
# ./gradlew assembleRelease
# Ver /tmp/compose/app_release-composables.txt
# Buscar "unstable" en los parámetros de los composables de los items
# 4. Método simple — contar con un log
@Composable
fun ProductoCard(producto: Producto) {
// Solo en debug:
if (BuildConfig.DEBUG) {
SideEffect { Log.d("Recompose", "ProductoCard recompuesto: ${producto.id}") }
}
// Si ves este log demasiadas veces durante un scroll suave → problema de estabilidad
}