wandres.dev
COMPOSE: LISTAS · Lazy y rendimiento

LazyListState: leer, controlar y paginar

El estado de una lista perezosa es una fuente de datos de altísima frecuencia: cambia en cada fotograma mientras el dedo se mueve. Leerlo ingenuamente dentro de una composición convierte cada gesto en decenas de recomposiciones inútiles. Esta lección expone la superficie de LazyListState y su información de layout, explica el patrón de derivación que reduce una señal continua a una señal discreta, muestra cómo convertir el estado en un flujo de eventos para disparar la carga de la siguiente página exactamente una vez, y describe el control programático del scroll con sus funciones suspendidas.

⏱ 18 min

Una lista perezosa expone su posición a través de un objeto observable, y ese objeto tiene una propiedad que lo hace distinto de casi todo el estado que manejas: cambia sesenta o ciento veinte veces por segundo mientras el usuario arrastra. Cualquier código que lo lea dentro de una composición se convierte, sin quererlo, en código que se ejecuta en cada fotograma del gesto, y lo hace precisamente en el momento en que el presupuesto de tiempo es más escaso. El problema no se resuelve leyendo menos, porque la información hace falta; se resuelve leyendo en el sitio correcto y con la granularidad correcta. Esta lección trata de eso: de la diferencia entre observar una señal continua y observar los pocos instantes en que esa señal cruza un umbral que te importa. Es también la lección donde aparece la paginación manual, que es el caso donde ambos errores —recomponer de más y disparar de más— se pagan al mismo tiempo.

🎯 Al terminar esta lección sabrás
  • Conocer la superficie de LazyListState y la información de layout que expone.
  • Aplicar la derivación de estado para convertir una señal continua en una discreta.
  • Convertir el estado en un flujo de eventos para paginar exactamente una vez por umbral.
  • Controlar el scroll de forma programática con las funciones suspendidas de desplazamiento.

Qué expone el estado de la lista

El objeto de estado se crea con rememberLazyListState y se puede elevar al nivel que lo necesite. Internamente usa el mecanismo de guardado, de modo que la posición sobrevive a una rotación y a la muerte del proceso, archivada como índice del primer elemento visible más su desplazamiento en píxeles.

Ese detalle de la restauración esconde una limitación que conviene conocer: se archiva un índice, no una identidad. Si al volver a la pantalla la lista trae datos nuevos por delante, el índice guardado apunta ahora a otro elemento, y el usuario aparece en un sitio que no es donde lo dejó. Cuando eso importa —un hilo de mensajes, un lector de artículos— la posición hay que expresarla en términos del dominio y restaurarla buscando la clave, no confiando en el número.

Su superficie se divide en dos familias muy distintas. Por un lado, unas pocas propiedades baratas y de baja frecuencia: si hay un desplazamiento en curso, y si es posible seguir desplazándose hacia delante o hacia atrás. Por otro, la información completa de layout, que describe qué elementos están colocados ahora mismo, con qué índice, qué clave, qué desplazamiento y qué tamaño, además de las medidas de la ventana y del relleno.

val estado = rememberLazyListState()

LazyColumn(state = estado) {
    items(articulos, key = { it.id }) { articulo -> FilaArticulo(articulo) }
}

Sobre la elevación conviene un matiz que no siempre se dice. El objeto de estado está atado al layout que lo usa y no debe guardarse en un modelo de vista, porque contiene referencias a la medida actual y su ciclo de vida es el de la composición. Lo que sí se eleva legítimamente es al composable padre, cuando este necesita un botón de volver arriba o coordinar dos listas. Si lo que quieres es recordar una posición a través de una navegación completa, guarda un identificador de dominio en el modelo y usa el salto instantáneo al volver, en lugar de intentar conservar el objeto de estado.

La distinción importa porque el coste de leer una y otra familia no se parece. Las propiedades booleanas cambian pocas veces por gesto y leerlas en composición es aceptable. La información de layout se recalcula en cada pasada de medida, y leerla en composición ata tu función a la frecuencia del scroll. El primer índice visible está en un punto intermedio: cambia una vez por fila que cruza el borde, lo que ya es bastante menos que cada fotograma pero sigue siendo mucho más de lo que la mayoría de las interfaces necesitan.

⚠️
Leer alto y leer bajo no cuestan lo mismo

Leer una propiedad de scroll en el cuerpo de una función componible la suscribe a esa propiedad y la invalida en cada cambio. Si lo que haces con el valor solo afecta al dibujo —una opacidad, una traslación, una sombra— hay una vía mucho más barata: pasarlo como lambda a un modificador que lo lea en la fase de layout o de dibujo. Así el valor cambia sin que ninguna composición se invalide, y el trabajo por fotograma se reduce a lo que realmente hay que rehacer.

Derivar para reducir la frecuencia

El patrón central de esta lección es simple de enunciar y sorprendentemente fácil de olvidar: cuando una entrada cambia mucho más a menudo que la salida que te interesa, envuelve el cálculo en una derivación para que solo notifique cuando la salida cambie de verdad.

El ejemplo canónico es el botón de volver arriba. Lo que necesita la interfaz no es el índice del primer elemento visible, sino un booleano que dice si ese índice pasó de tres. El índice cambia decenas de veces durante un gesto largo; el booleano cambia dos veces en toda la sesión.

val mostrarBoton by remember {
    derivedStateOf { estado.firstVisibleItemIndex > 3 }
}

if (mostrarBoton) { BotonVolverArriba(onClick = { /* ... */ }) }

Hay un detalle de escritura que se equivoca a menudo y que anula por completo el beneficio: el cálculo derivado debe crearse una sola vez, envuelto en un remember sin claves. Si se construye en cada composición, se pierde el valor cacheado anterior y con él la capacidad de saber si el resultado cambió, con lo que la derivación se convierte en un cálculo normal con contabilidad extra. Y las claves del remember deben omitirse precisamente porque las dependencias del cálculo ya se descubren solas al leerlas.

Sin la derivación, el bloque que lee el índice se invalida en cada fila que cruza el borde superior, y con ella todo lo que dependa de ese ámbito. Con la derivación, la lectura de alta frecuencia queda encerrada dentro del cálculo derivado y solo se propaga hacia fuera cuando el resultado cambia de valor. El mismo patrón sirve para bastante más que un botón. Elevar una barra superior cuando la lista ya no está arriba del todo, mostrar u ocultar una barra inferior según la dirección del gesto, activar un separador bajo la cabecera, decidir si mostrar el indicador de “hay más contenido”: todas son señales binarias derivadas de una magnitud continua, y todas se escriben igual. Reconocer la forma del problema es más valioso que memorizar el caso concreto.

La regla de aplicación es estricta en las dos direcciones: usarla cuando la entrada cambia más que la salida es una mejora clara; usarla cuando ambas cambian igual añade una capa de contabilidad que no ahorra nada y confunde a quien lea el código después.

flowchart TD
GESTO[El dedo arrastra y el desplazamiento cambia cada fotograma] --> ESTADO[LazyListState actualiza indice y desplazamiento]
ESTADO --> DER[Estado derivado calcula el booleano que importa]
DER --> Q{El resultado ha cambiado}
Q -->|No| NADA[No se notifica a nadie y no se recompone]
Q -->|Si| RECOMP[Se invalida solo quien lee el booleano]
style NADA fill:#a6e3a1,color:#11111b
style RECOMP fill:#f9e2af,color:#11111b

Detectar el final para paginar

La paginación manual necesita responder a una pregunta que parece igual pero no lo es: no si estamos cerca del final, sino cuándo acabamos de llegar cerca del final. Lo primero es un estado; lo segundo es un evento, y confundirlos produce el fallo clásico de disparar la misma petición de red muchas veces seguidas mientras la condición se mantiene cierta.

La forma correcta convierte el estado en un flujo, lo reduce a la condición de umbral, elimina las repeticiones consecutivas y solo entonces actúa. Al ejecutarse dentro de un efecto ligado al ciclo de vida de la composición, la recolección se cancela sola cuando la pantalla desaparece.

LaunchedEffect(estado) {
    snapshotFlow {
        val ultimo = estado.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0
        val total = estado.layoutInfo.totalItemsCount
        total > 0 && ultimo >= total - UMBRAL
    }
        .distinctUntilChanged()
        .filter { cerca -> cerca }
        .collect { viewModel.cargarSiguientePagina() }
}

Merece la pena detenerse en por qué esto no recompone nada. El bloque de instantánea se ejecuta fuera de la composición, dentro de una corrutina; sus lecturas se registran contra el sistema de instantáneas y no contra ningún ámbito de recomposición. La información de layout se consulta con la máxima frecuencia, sí, pero el resultado solo cruza la frontera hacia el resto del sistema cuando el booleano cambia. Es exactamente el mismo principio de la sección anterior aplicado a efectos en lugar de a interfaz.

Merece la pena contrastar esta versión con la que casi todo el mundo escribe primero, que consiste en lanzar la carga desde el cuerpo del último elemento de la lista. Esa variante parece más elegante y tiene dos defectos serios. El primero es que se dispara también durante la composición anticipada, es decir, antes de que el usuario haya llegado de verdad al final. El segundo es que el efecto se cancela y se relanza cada vez que ese elemento sale y entra de pantalla, de modo que un usuario que oscile arriba y abajo cerca del borde produce una ráfaga de peticiones. Sacar la detección fuera de los elementos resuelve ambas cosas de raíz, porque el estado de la lista existe con independencia de qué esté compuesto en cada instante.

El umbral tiene que elegirse con criterio y no copiarse. Demasiado pequeño y el usuario ve el final antes de que llegue la página siguiente; demasiado grande y se cargan páginas que nadie mirará, gastando datos y batería. Un valor sensato es del orden de una pantalla de elementos por delante, ajustado a la latencia real de la fuente de datos. Y la lógica de a partir de aquí pertenece al modelo, que debe ignorar la petición si ya hay una en vuelo o si no quedan más páginas: el efecto solo avisa, no decide.

💡
Cuando esto ya esta resuelto

La paginación manual es un ejercicio excelente para entender el mecanismo, pero en una aplicación real conviene apoyarse en la librería de paginación de Jetpack, que resuelve además el reintento, el estado de carga por extremo, la invalidación al cambiar el origen y la combinación con la base de datos local. Lo que no cambia es el razonamiento de esta lección: sigue habiendo un estado de altísima frecuencia y sigue siendo obligatorio no leerlo en composición.

Controlar el scroll

Mover la lista programáticamente se hace con funciones suspendidas, y esa firma no es un capricho: un desplazamiento animado dura en el tiempo, puede interrumpirse cuando el usuario toca la pantalla y debe cancelarse si la pantalla desaparece a mitad. Como son suspendidas, hay que llamarlas desde una corrutina, normalmente la que se obtiene con rememberCoroutineScope en respuesta a un gesto.

val alcance = rememberCoroutineScope()

BotonVolverArriba(onClick = {
    alcance.launch { estado.animateScrollToItem(index = 0) }
})

Hay dos variantes que conviene no confundir. El salto instantáneo coloca la lista en el índice indicado sin animación y es lo correcto al restaurar una posición o al aplicar un filtro. El desplazamiento animado recorre el camino y es lo correcto cuando el usuario debe percibir el movimiento. Ambas aceptan un desplazamiento adicional en píxeles para afinar la colocación por debajo de una cabecera fija.

El desplazamiento animado tiene un límite que sorprende la primera vez: no puede animar de forma continua hasta un índice muy lejano, porque las posiciones intermedias no se han medido nunca y no hay trayecto que recorrer. Lo que hace en ese caso es aproximarse a saltos usando estimaciones y animar solo el último tramo. La consecuencia práctica es que animar de un extremo a otro de una lista enorme no produce el efecto cinematográfico que uno imagina, y que en esos casos el salto instantáneo es más honesto y más barato.

Una última precaución sobre el ámbito de corrutina: debe obtenerse del entorno de composición y no crearse a mano, para que se cancele con la pantalla. Lanzar un desplazamiento animado desde un ámbito que sobrevive a la salida deja una corrutina intentando mover una lista que ya no existe, y ese es el tipo de fuga que no rompe nada visible pero se acumula.

📋

Propiedades baratas

Si hay desplazamiento en curso y si se puede seguir avanzando son señales de baja frecuencia y su lectura directa en composición es aceptable.

🔭

Informacion de layout

Describe los elementos colocados ahora mismo con índice, clave, tamaño y desplazamiento. Es la fuente más rica y la más cara de observar.

🧮

Estado derivado

Encierra la lectura de alta frecuencia y solo propaga el cambio cuando el resultado calculado cambia de verdad.

🌊

Flujo de instantaneas

Lleva el estado fuera de la composición para disparar efectos: la lectura es frecuente, la emisión no, y la cancelación es automática.

El scroll es una senal continua y tu interfaz es una maquina discreta

Detrás de todas las técnicas de esta lección hay una sola idea, y es la que separa una lista que va fina de una que va a tirones aunque su código parezca idéntico. El desplazamiento no es un evento: es una señal continua, un número real que varía sin interrupción mientras el dedo se mueve, y que el sistema muestrea a la frecuencia de la pantalla. Tu interfaz, en cambio, es una máquina discreta: sus estados son “el botón se ve o no se ve”, “cargamos página o no”, “la cabecera está elevada o plana”. Entre ambos mundos hay un abismo de frecuencia de dos o tres órdenes de magnitud, y todo el arte consiste en poner el conversor en el sitio correcto. Si dejas que la señal continua entre directamente en la composición, obligas a un sistema discreto a funcionar a la frecuencia de uno continuo, y el resultado es que recompones cientos de veces para producir dos cambios visibles: has convertido tu interfaz en un muestreador carísimo de un valor que ni siquiera necesitabas con esa resolución. Si en cambio colocas el conversor abajo —una derivación, un flujo de instantáneas, una lectura diferida a la fase de dibujo— la señal continua se queda donde debe estar, en la maquinaria de layout que ya está obligada a correr a esa frecuencia, y hacia arriba solo suben los pocos cruces de umbral que de verdad significan algo. Formulado así, deja de ser una lista de trucos y se convierte en un criterio que se aplica solo: ante cualquier estado que cambie más deprisa que las decisiones que alimenta, pregúntate dónde está el conversor. Si no lo encuentras, es que lo estás siendo tú, fotograma a fotograma.

⚔️ Instrumenta el scroll de una lista real
  1. Lee el índice del primer elemento visible directamente en una composición y cuenta las recomposiciones durante un gesto largo.
  2. Repite la medida envolviendo la lectura en un estado derivado y compara ambos recuentos.
  3. Implementa la detección de final con un flujo de instantáneas y comprueba con un registro que la carga se dispara una sola vez por umbral.
  4. Elimina la eliminación de repeticiones consecutivas y observa cuántas peticiones se lanzan; explica por qué.
  5. Añade un botón de volver arriba con desplazamiento animado y razona por qué la función tiene que ser suspendida.