El problema: listas que no caben en memoria
Antes de escribir una sola línea de Paging conviene entender con precisión qué problema resuelve, porque casi todo el mundo cree que resuelve uno distinto del que resuelve. Esta lección hace la aritmética de una lista grande, separa las tres capas donde el volumen duele —red, memoria y composición—, delimita exactamente qué garantías ofrece la librería y cuáles no, y establece el criterio para decidir si tu caso la necesita o si estás a punto de pagar una complejidad considerable a cambio de nada.
Hay una clase de decisión de arquitectura que se toma implícitamente, sin que nadie la formule, el día en que alguien escribe una consulta que devuelve una lista completa. Funciona con las cien filas de la base de datos de desarrollo, funciona con las mil del entorno de pruebas, y falla en producción tres meses después, cuando un usuario concreto acumula cuarenta mil registros y su aplicación tarda once segundos en abrir una pantalla o muere directamente sin dejar rastro útil. Lo interesante de ese fallo es que no es un error de programación en ningún sentido convencional: el código es correcto, los tests pasan, la lógica es impecable. Lo que falla es un supuesto no escrito —que el conjunto de datos cabe— y los supuestos no escritos son los que sobreviven a todas las revisiones. La paginación es la respuesta disciplinada a ese supuesto, y como toda disciplina tiene un coste que solo merece la pena pagar cuando el problema es real. Esta lección trata precisamente de saber cuándo lo es.
- Cuantificar el coste de memoria, red y latencia de cargar un conjunto de datos completo.
- Distinguir las tres capas donde el volumen se paga y entender que las listas perezosas solo cubren una.
- Delimitar el contrato de la librería de paginación: qué garantiza, qué automatiza y qué deja en tus manos.
- Decidir con criterio si un caso concreto justifica la complejidad de introducir
Paging.
La aritmética que nadie hace
La conversación sobre paginación suele empezar mal porque se plantea como una cuestión de estilo o de buenas prácticas, y en ese registro cualquiera puede tener razón. Planteada como una cuestión de presupuesto, en cambio, se resuelve sola: hay una cantidad finita de memoria, de ancho de banda y de tiempo antes del primer píxel, y una lista completa las consume en proporción al tamaño del conjunto, que es una variable que tú no controlas.
Empecemos por los números, porque la intuición engaña sistemáticamente en este terreno. Supón un objeto de dominio modesto: un identificador, un título, un resumen de doscientos caracteres, una URL de imagen, una marca de tiempo y tres campos numéricos. En la máquina virtual de Android, con las cabeceras de objeto, los punteros y la representación de las cadenas, ese objeto ocupa del orden de seiscientos u ochocientos bytes reales. Cuarenta mil de esos objetos son unos treinta megabytes de datos vivos, a los que hay que sumar el documento JSON del que salieron, los objetos intermedios del deserializador y la lista que los contiene.
Treinta megabytes no suenan alarmantes hasta que se recuerda cuál es el presupuesto real. Un dispositivo de gama media asigna a cada aplicación un montículo del orden de ciento veintiocho o doscientos cincuenta y seis megabytes, del que una parte considerable ya está ocupada por el propio marco de trabajo, por las imágenes en caché y por la maquinaria de la interfaz. Cargar un tercio del presupuesto disponible en una sola lista no es un fallo inmediato, sino algo peor: es una aplicación que funciona bien en tu dispositivo de desarrollo y que en manos del usuario con más datos —que suele ser también el más valioso— sufre expulsiones frecuentes del proceso, pausas largas del recolector de basura y un cierre inesperado ocasional que ningún informe explica bien.
Los datos de desarrollo son siempre pequeños, limpios y recientes. Los de producción son grandes, sucios y antiguos. La distribución del tamaño de las colecciones por usuario no es normal sino de cola larga: la mediana puede ser de doscientos elementos mientras el percentil noventa y nueve supera los cincuenta mil. Si tu decisión de arquitectura solo contempla la mediana, has diseñado para el usuario que menos te importa perder.
Conviene además recordar que el montículo no es el único recinto que se llena. Cada cadena de esos cuarenta mil objetos es un objeto más con su propia cabecera, la deserialización crea estructuras intermedias que viven hasta la siguiente recolección, y la propia lista mantiene un vector de referencias que se redimensiona copiando. El pico de memoria durante la carga es sensiblemente mayor que el estado estable posterior, y es en ese pico donde ocurren los cierres inesperados: la aplicación no muere porque los datos ocupen mucho, sino porque durante un instante ocupan casi el doble.
A la memoria hay que sumarle la latencia, que es lo que el usuario percibe. Una respuesta de red con cuarenta mil elementos serializados son varios megabytes que hay que descargar, deserializar e insertar. Aunque la conexión sea buena, el tiempo hasta el primer píxel útil se mide en segundos, y ese tiempo se paga entero antes de mostrar nada, porque la pantalla no puede pintar hasta que la lista esté completa. La paginación invierte esa relación: el usuario ve las primeras veinte filas en unos cientos de milisegundos y el resto llega mientras lee.
Y hay un tercer coste, menos visible y a menudo el más caro: el ancho de banda desperdiciado. La distribución del consumo de listas también es de cola larga en la otra dirección. La mayoría de los usuarios mira las primeras dos o tres pantallas y se va. Descargar cuarenta mil elementos para mostrar veinte es, en términos de datos móviles y de batería, un desperdicio de tres órdenes de magnitud que además se repite en cada apertura.
El código que produce todo esto es inofensivo a la vista, y esa es precisamente la razón de que sobreviva a las revisiones. Nadie escribe nunca una línea que diga carga treinta megabytes.
// funciona perfectamente durante dos anos
suspend fun cargarArticulos(): List<Articulo> = api.todosLosArticulos()
@Query("SELECT * FROM articulos ORDER BY publicado DESC")
suspend fun todos(): List<ArticuloEntity>
El tipo de retorno es el problema, y lo es de forma silenciosa: una lista completa es una afirmación de que el conjunto cabe, hecha en la firma de la función y por tanto propagada a todo lo que la use. Cambiarla más adelante no es un ajuste local sino una modificación que atraviesa el repositorio, el modelo de vista y la pantalla, que es exactamente la razón por la que estas migraciones se posponen hasta que el problema ya está en producción.
Hay un último factor que agrava los tres anteriores y que se olvida al razonar sobre un solo dispositivo: la asimetría entre tu equipo y el parque real. Tu teléfono de desarrollo tiene la memoria más generosa, la conexión más estable y la cuenta de usuario más limpia de todo el conjunto de personas que ejecutarán tu código. Cada una de esas tres ventajas oculta exactamente uno de los tres costes, y las tres se acumulan en la misma dirección. Por eso el problema del volumen es sistemáticamente invisible para quien lo introduce y perfectamente visible para quien lo sufre, que es la misma estructura de incentivos que hace que casi ninguna aplicación esté paginada hasta que alguien se queja.
Las tres capas donde duele el volumen
El error conceptual más extendido consiste en creer que el problema ya está resuelto porque se usa una lista perezosa. Conviene separar con nitidez las tres capas independientes en las que el volumen se manifiesta, porque cada una tiene su propia solución y resolver una no resuelve las otras. La confusión es comprensible: las tres se refieren a la misma lista y las tres hablan de mostrar solo una parte. Pero la parte a la que se refiere cada una es distinta, y las técnicas que las resuelven no se sustituyen entre sí.
Capa de transporte
Cuántos bytes viajan por la red y cuánto tarda la primera respuesta útil. Se resuelve pidiendo páginas al servidor, nunca conjuntos completos.
Capa de memoria
Cuántos objetos de dominio están vivos simultáneamente en el montículo. Se resuelve manteniendo una ventana deslizante y liberando lo que queda lejos.
Capa de composicion
Cuántos nodos de interfaz existen a la vez. Es lo único que resuelve una lista perezosa por sí sola.
Una LazyColumn alimentada con una lista de cuarenta mil elementos compone únicamente los veinte visibles, y en ese sentido su promesa se cumple. Pero los cuarenta mil objetos de dominio siguen en memoria, los cuarenta mil viajaron por la red y los cuarenta mil se deserializaron antes de pintar nada. La pereza de la interfaz es real y valiosa, y es exactamente ortogonal al problema de los datos. Confundirlas es el equivalente a creer que una ventana pequeña reduce el tamaño de la habitación.
El mismo razonamiento se aplica a la base de datos local, donde el error es todavía más frecuente porque leer de disco parece gratis. Una consulta de Room que devuelve una lista completa materializa todas las filas en objetos antes de entregar nada, y lo hace en un hilo que a menudo bloquea a quien espera. Que el origen sea local abarata el transporte pero no la memoria ni el tiempo de deserialización, de modo que dos de las tres capas siguen exactamente igual de rotas.
Merece la pena observar que las tres capas se pagan en momentos distintos, y esa asincronía es la que dificulta el diagnóstico. El coste de transporte se paga al abrir la pantalla y se percibe como lentitud de arranque. El de memoria se paga a lo largo de la sesión y se percibe como una degradación difusa, cierres inesperados y pérdida de estado al volver de segundo plano. El de composición se paga en cada fotograma y se percibe como tirones al desplazar. Tres síntomas que nadie relaciona entre sí, un solo origen. Por eso el orden correcto de intervención no es empezar por lo que más se nota, sino por lo que está más arriba en la cadena: acotar el transporte reduce automáticamente la presión de las otras dos, mientras que optimizar la composición no alivia ninguna de las anteriores.
Existe también una cuarta capa, menos citada y a veces determinante: la de las imágenes. Una lista de cuarenta mil filas con miniatura no carga cuarenta mil imágenes, porque las librerías de imágenes son perezosas y están acotadas por su propia caché, pero sí genera cuarenta mil peticiones potenciales y una presión de caché que expulsa continuamente lo que acaba de entrar. Acotar la ventana de datos acota también esa presión, y en dispositivos modestos la mejora percibida procede más de ahí que de la memoria del dominio.
Una consecuencia práctica de esta separación es que existen aplicaciones que hacen bien las tres capas y aplicaciones que hacen bien la tercera y creen haber hecho las tres. La forma de distinguirlas no es leer el código de la pantalla, sino mirar el tipo que viaja desde el repositorio: si es una colección completa, las dos primeras capas están sin resolver por muy perezosa que sea la lista que la consume.
flowchart LR
RED[Red o base de datos] --> CAPA1{Se piden paginas}
CAPA1 -->|No| T1[Megabytes descargados y segundos de espera]
CAPA1 -->|Si| MEM{Se libera lo lejano}
MEM -->|No| T2[El monticulo crece sin limite]
MEM -->|Si| UI{La lista es perezosa}
UI -->|No| T3[Miles de nodos compuestos]
UI -->|Si| OK[Ventana acotada en las tres capas]
style OK fill:#a6e3a1,color:#11111b
style T1 fill:#f38ba8,color:#11111b
style T2 fill:#f38ba8,color:#11111b
style T3 fill:#f38ba8,color:#11111bQué resuelve Paging y a qué precio
La librería de paginación de Jetpack ataca las dos primeras capas y se integra con la tercera. Su contribución real no es la idea de pedir páginas, que cualquiera implementa en una tarde, sino la gestión correcta de la docena de casos límite que aparecen después y que casi nadie implementa bien a la primera.
Esa distinción merece énfasis porque explica por qué tantos equipos abandonan su implementación propia después de un trimestre. Pedir la página siguiente al llegar al final es una tarde de trabajo. Impedir que se pidan tres veces seguidas, no perder la posición al rotar, no recargar desde cero al volver de segundo plano, distinguir el error del final de la lista, ofrecer reintento sin perder lo cargado, liberar memoria de páginas lejanas sin romper el desplazamiento y reconstruir la lista cuando los datos cambian debajo son, cada uno, otra tarde de trabajo y una fuente permanente de informes de error. La librería no vende la idea; vende las siete tardes siguientes.
Lo que aporta se puede enumerar con honestidad. Mantiene una ventana deslizante de páginas cargadas y descarta las que quedan suficientemente lejos, con lo que el consumo de memoria deja de crecer con el tiempo de sesión. Dispara la carga de la página siguiente exactamente una vez por umbral, sin las ráfagas de peticiones duplicadas que produce la detección ingenua. Expone un estado de carga por extremo, distinguiendo la carga inicial de la carga hacia delante y hacia atrás, con sus errores y su operación de reintento. Sobrevive a los cambios de configuración conservando la posición y las páginas ya cargadas. Y ofrece un mecanismo de invalidación coherente, de modo que cuando los datos subyacentes cambian la lista se reconstruye sin que el usuario pierda el sitio.
El paquete se compone de tres piezas conceptuales que veremos una a una en las lecciones siguientes. PagingSource sabe cargar una página dada una clave. Pager orquesta las cargas y produce un flujo de PagingData. RemoteMediator coordina una fuente local y una remota cuando quieres que la base de datos sea la única fuente de verdad. Todo lo demás son transformaciones y adaptadores alrededor de esas tres.
El precio también hay que enunciarlo sin adornos. El PagingData no es una lista: no se puede indexar, no se puede recorrer, no tiene tamaño y no se puede comparar con otro. Esa opacidad es deliberada —es lo que permite que la ventana deslice sin que nadie sostenga referencias— pero rompe todos los reflejos adquiridos. Las transformaciones son perezosas y se aplican por elemento a medida que se presenta, lo que hace que cualquier operación que necesite ver el conjunto completo, como ordenar o agrupar globalmente, sea imposible en esta capa y deba resolverse antes, en la consulta o en el servidor. Y la superficie de estados a manejar en la interfaz se multiplica: ya no hay cargando y cargado, sino carga inicial, anexado, prefijado, refresco, error por extremo y fin de paginación, cada uno con su representación visual.
Hay además un coste organizativo que rara vez se contabiliza y que conviene anticipar. La paginación atraviesa todas las capas: cambia la firma del servicio de red, cambia el objeto de acceso a datos, cambia el modelo de vista y cambia la pantalla. No es una pieza que se pueda encapsular en el repositorio y ocultar al resto, porque la pereza y el estado de carga por extremo tienen que llegar hasta la interfaz para ser útiles. Cualquier intento de esconderla detrás de una fachada que devuelva una lista normal reintroduce exactamente el problema que se quería resolver.
También conviene ser exacto sobre lo que la librería no hace, porque la expectativa contraria produce decepciones caras. No sincroniza: no sabe fusionar cambios locales con remotos ni resolver conflictos de escritura. No busca: un término de búsqueda cambia el conjunto y obliga a reconstruir el flujo entero, no a filtrar el existente. No ordena: el orden pertenece a la consulta o al servidor. No garantiza coherencia entre páginas: si el conjunto cambia en el servidor mientras navegas, verás duplicados o ausencias, y evitarlo es responsabilidad del diseño de las claves. Y no reduce por sí sola el número de peticiones totales; en un usuario que recorra la lista entera hará más peticiones, no menos. Lo que cambia es que ese usuario es raro y el que mira dos pantallas es la norma.
Si en tu codigo actual existe alguna variante de cargar los primeros doscientos y ya veremos, ya has decidido paginar; simplemente lo has hecho con un limite arbitrario, sin forma de continuar y sin decirselo al usuario. Ese recorte silencioso es peor que cualquiera de las dos alternativas honestas, porque produce una aplicación que oculta datos sin que nadie sepa que lo hace.
Una advertencia sobre la interfaz que conviene hacer aquí y no más tarde: paginar cambia lo que el usuario puede saber de la lista. Deja de haber un total fiable, la barra de desplazamiento deja de representar la proporción recorrida y la búsqueda local deja de ser posible. Esas tres pérdidas son visibles y hay que compensarlas conscientemente —con un contador que venga del servidor, con una barra que no mienta sobre lo que no conoce, con una búsqueda que se resuelva en la fuente— o el usuario percibirá la aplicación como menos capaz aunque técnicamente sea más sólida.
Cuándo no usar Paging
La decisión correcta depende de dos variables: el tamaño esperado del conjunto en el percentil alto y su tasa de crecimiento. Si el conjunto está acotado por diseño —los ajustes de la aplicación, los países del mundo, las categorías de un catálogo, los participantes de una conversación de grupo— introducir paginación es añadir complejidad sin beneficio, y además impide operaciones perfectamente legítimas como buscar en memoria u ordenar localmente. Pagar la opacidad del PagingData a cambio de nada es un mal negocio que además contamina las pruebas y la depuración de esa pantalla para siempre.
El razonamiento correcto no es sobre el número de hoy sino sobre la función que lo genera. Si el tamaño depende del tiempo de uso, del número de acciones del usuario o de la actividad de otras personas, crece sin techo y el número actual es irrelevante. Si depende de una decisión de producto o de una realidad del mundo —los idiomas soportados, las provincias de un país, los métodos de pago— tiene un techo conocido y estable. Formular la pregunta así elimina casi todas las discusiones, porque convierte una estimación en una propiedad estructural.
La regla práctica que mejor funciona tiene dos partes. La primera: si el conjunto puede crecer sin límite con el uso, pagina desde el primer día, aunque hoy tenga cuarenta filas, porque migrar una pantalla ya construida a paginación es bastante más caro que nacer paginada. La segunda: si el conjunto está acotado por la naturaleza del dominio y ese límite es de unos pocos miles, no pagines, y documenta el límite donde se pueda leer.
Existe además un caso intermedio que conviene reconocer, porque es donde más se equivoca la gente: el conjunto es grande pero el acceso no es secuencial. Un mapa de puntos, un calendario al que se salta por fechas, un editor que necesita el documento entero. Ahí el modelo de páginas consecutivas no encaja bien y la solución correcta suele ser una consulta acotada por la ventana visible, no una paginación clásica. Paging supone que el usuario avanza; cuando esa suposición no se cumple, la librería trabaja en tu contra.
Hay un cuarto caso que merece mención aparte porque es frecuente y tiene una salida sencilla: el conjunto es grande pero el usuario nunca lo recorre, sino que lo consulta. Un catálogo de treinta mil productos del que cada persona ve cuatro después de buscar no necesita paginación de recorrido; necesita búsqueda del lado del servidor con un límite de resultados. La diferencia es que en un caso el usuario pide más y en el otro pide otra cosa, y confundirlas lleva a construir maquinaria de páginas para una interacción que en realidad es una consulta puntual.
Y conviene desactivar de antemano el argumento que aparece siempre en estas discusiones: que paginar es una optimización prematura. No lo es, por una razón precisa. Una optimización prematura consiste en complicar el código para mejorar un rendimiento que aún no se ha medido; paginar consiste en elegir un contrato de datos distinto, y los contratos se eligen al principio porque cambiarlos después obliga a tocar todas las capas. La prematuridad se predica de las optimizaciones locales y reversibles, no de las decisiones estructurales e irreversibles, y confundir ambas categorías es lo que produce las migraciones dolorosas del tercer año.
Las lecciones siguientes construyen el patrón completo en orden: primero la fuente de páginas y su sistema de claves, después el consumo en la interfaz con sus estados, luego la coordinación entre base de datos local y red, y por último las transformaciones y las pruebas. Antes de escribir la primera línea, sin embargo, conviene tener contestada la pregunta de esta lección, porque es la única que no se puede corregir después con más código.
Merece la pena detenerse en la naturaleza del cambio conceptual que se produce al paginar, porque es mucho mayor de lo que sugiere el aspecto del código y explica por qué la transición resulta incómoda incluso a programadores experimentados. Casi todo lo que aprendemos a hacer con colecciones descansa en una premisa silenciosa: que la colección cabe. Que podemos preguntarle su tamaño, recorrerla dos veces, ordenarla, buscarla, compararla con otra. Esa premisa es tan cotidiana que ni siquiera la registramos como premisa; es simplemente lo que una lista es. Paginar consiste en retirarla, y al retirarla se derrumba de golpe una familia entera de operaciones que dábamos por gratuitas. No hay tamaño porque el tamaño vive en el servidor y cambia mientras lo miras. No hay recorrido completo porque recorrerlo obligaría a materializar lo que precisamente no queremos materializar. No hay igualdad estructural porque dos flujos de datos paginados no son dos valores, sino dos procesos. Lo que queda en su lugar es algo distinto y más humilde: una ventana móvil sobre un conjunto potencialmente infinito, del que solo conoces con certeza lo que estás mirando ahora mismo y una vaga promesa de que hay más en ambas direcciones. Esa es exactamente la relación que un programa tiene con el mundo real, y ahí está el punto profundo: la lista completa en memoria nunca fue la representación fiel de los datos, sino una simplificación que funcionaba mientras los datos fueran pequeños. La paginación no añade complejidad artificial al modelo; retira una simplificación que había dejado de ser cierta. Por eso duele tanto y por eso es correcta. Y por eso también el criterio para adoptarla no es técnico sino epistemológico: la pregunta que hay que hacerse no es cuánto pesa la lista hoy, sino si tu programa tiene derecho a seguir creyendo que puede conocer el conjunto entero.
- Elige la pantalla con la lista más larga de tu aplicación y estima el tamaño en bytes de un elemento de dominio contando cabeceras, punteros y cadenas.
- Genera un conjunto sintético en el percentil noventa y nueve de tus usuarios reales y mide el montículo con el perfilador antes y después de cargarlo.
- Cronometra el tiempo desde la petición hasta el primer píxel útil con ese conjunto y con uno de veinte elementos; anota la diferencia.
- Clasifica todas las listas de tu aplicación en acotadas por diseño, de crecimiento ilimitado y de acceso no secuencial, y justifica cada clasificación.
- Para una lista acotada, escribe dónde queda documentado el límite y qué ocurre el día que alguien lo supere.