wandres.dev
PAGING 3 · listas infinitas

Transformar y probar: mapear, separar, filtrar y testear

Las transformaciones sobre datos paginados obedecen a reglas distintas de las de una colección normal: son perezosas, se aplican por elemento a medida que se presenta y no pueden ver el conjunto completo. Esta lección recorre el mapeo, la inserción de separadores y el filtrado con su trampa característica del recuento, explica dónde colocar cada transformación respecto al anclaje en memoria, y cierra el nivel construyendo pruebas unitarias de una fuente de paginación y de las transformaciones que la acompañan.

⏱ 20 min

Un flujo de datos paginados admite transformaciones que se parecen mucho, en su escritura, a las de cualquier colección: mapear, filtrar, insertar elementos entre otros. El parecido es superficial y engañoso, y ahí está el interés de esta última lección del nivel. Las transformaciones de una colección se aplican una vez sobre un conjunto conocido y producen otro conjunto conocido; las de un flujo paginado se aplican perezosamente, elemento a elemento, cada vez que ese elemento se presenta en la interfaz, sobre un conjunto que nadie ha visto entero y cuyo tamaño nadie conoce. De esa diferencia se derivan todas las reglas prácticas que veremos: por qué mapear a un tipo pesado se paga muchas veces, por qué un separador tiene que ser un tipo del modelo y no un adorno de la vista, por qué filtrar en esta capa es casi siempre un error, y por qué el sitio donde colocas cada operación respecto al anclaje en memoria cambia cuántas veces se ejecuta. Y como todo esto ocurre lejos de la vista, cerraremos con lo único que lo mantiene honesto: pruebas.

🎯 Al terminar esta lección sabrás
  • Aplicar mapeo sobre PagingData entendiendo su pereza y su coste por presentación.
  • Insertar separadores modelando el tipo de lista como una jerarquía sellada.
  • Reconocer la trampa del filtrado en la capa de presentación y resolverla en la consulta.
  • Probar una PagingSource y sus transformaciones con pruebas unitarias deterministas.

Mapear: pereza, coste y ubicación

El mapeo transforma cada elemento y devuelve un nuevo flujo paginado. Su uso canónico es convertir entidades de base de datos en modelos de presentación, y conviene escribirlo con dos precauciones que no son obvias.

val articulos: Flow<PagingData<ArticuloUi>> = pager.flow
    .map { pagingData ->
        pagingData.map { entidad -> entidad.aModeloUi(formateador) }
    }
    .cachedIn(viewModelScope)

Los dos niveles de mapeo no son un despiste: el exterior opera sobre el flujo y el interior sobre los elementos dentro de cada PagingData. Confundirlos produce errores de tipos que se resuelven a ciegas y luego no se entienden.

La primera precaución es de coste. La transformación no se ejecuta una vez por elemento sino cada vez que ese elemento se presenta, lo que en la práctica significa varias veces a lo largo de una sesión de desplazamiento. Formatear una fecha, construir una cadena localizada o calcular un color son operaciones baratas y aceptables; parsear, comprimir, consultar la base de datos o acceder a disco dentro de un mapeo es un error de rendimiento que se manifiesta como tirones al desplazar y cuya causa cuesta encontrar.

La segunda es de ubicación. Todo lo que se coloca antes del anclaje en memoria se calcula una sola vez y se comparte entre consumidores; lo que se coloca después se recalcula para cada consumidor y en cada cambio de configuración. La regla es simple: las transformaciones estables van antes, y las que dependen de algo que cambia en la interfaz —el término de búsqueda resaltado, el elemento seleccionado, el modo de visualización— van después, precisamente porque deben recalcularse.

⚠️
Nunca uses el indice de la lista como identidad

Dentro de una transformación no existe una posición absoluta fiable: la ventana se mueve, las páginas se descartan y se recargan, y los marcadores de posición pueden estar activos. Cualquier lógica que dependa de saber que este elemento es el número cuarenta y dos producirá resultados distintos según por dónde haya entrado el usuario. La identidad tiene que venir del dominio.

Separadores: modelar antes que decorar

Insertar cabeceras de sección entre elementos es la transformación más interesante porque obliga a un cambio de modelo. La operación recibe pares de elementos consecutivos y devuelve, opcionalmente, un elemento nuevo entre ellos. Para que eso sea posible el tipo de la lista debe poder representar tanto un dato como un separador, y la forma correcta de expresarlo en Kotlin es una jerarquía sellada.

sealed interface FilaArticulos {
    data class Dato(val articulo: ArticuloUi) : FilaArticulos
    data class Separador(val etiqueta: String) : FilaArticulos
}

val filas: Flow<PagingData<FilaArticulos>> = pager.flow
    .map { data -> data.map { FilaArticulos.Dato(it.aModeloUi()) } }
    .map { data ->
        data.insertSeparators { antes: FilaArticulos.Dato?, despues: FilaArticulos.Dato? ->
            when {
                despues == null -> null
                antes == null -> FilaArticulos.Separador(despues.articulo.mes)
                antes.articulo.mes != despues.articulo.mes ->
                    FilaArticulos.Separador(despues.articulo.mes)
                else -> null
            }
        }
    }
    .cachedIn(viewModelScope)

Los dos parámetros nulos tienen un significado preciso que hay que respetar. Un primer parámetro nulo indica el principio de la lista, y devolver un separador ahí produce la cabecera inicial. Un segundo parámetro nulo indica el final, y devolver algo ahí produce un pie. Las reglas de negocio suelen olvidar uno de los dos casos, con el resultado típico de una lista a la que le falta la primera cabecera.

Hay un límite que conviene conocer: el separador solo ve dos elementos consecutivos dentro de lo cargado, nunca el conjunto. Si el criterio de agrupación necesitara conocer el total del grupo —una cabecera que diga marzo con doce artículos— esa información tiene que venir calculada desde la consulta o el servidor, porque en esta capa es literalmente inobservable.

En la interfaz, las filas resultantes se distinguen por tipo, y ese es exactamente el lugar donde el tipo de contenido de la lista perezosa deja de ser un detalle de rendimiento y pasa a ser necesario, porque un separador y una fila de datos tienen estructuras de composición incompatibles.

🧬

Mapear

Barato y frecuente. Se ejecuta en cada presentación, así que solo admite trabajo trivial y sin efectos secundarios.

🪧

Separar

Ve dos elementos consecutivos y puede insertar uno nuevo. Exige que el tipo de la lista sea una jerarquía sellada.

🧹

Filtrar

Técnicamente disponible y casi siempre desaconsejable: rompe la relación entre página pedida y elementos mostrados.

La trampa del filtrado

Filtrar sobre datos paginados existe en la API y es una de las operaciones que más problemas causa, por una razón estructural que conviene entender antes de usarla. La librería pide páginas de veinte elementos y decide cuándo pedir la siguiente en función de cuántos elementos hay entre la posición del usuario y el borde. Si un filtro descarta la mitad, cada página aporta diez filas visibles y la aritmética de anticipación deja de corresponderse con la realidad.

En el caso benigno, eso solo significa peticiones más frecuentes. En el maligno —un filtro que descarta casi todo— la interfaz pide una página, no obtiene ninguna fila visible, no llega al umbral que dispara la carga siguiente y la lista se queda visualmente vacía o congelada mientras el usuario mira una pantalla sin contenido y sin indicador. Es un fallo especialmente difícil de diagnosticar porque el código no falla en ninguna parte.

flowchart TD
P[Se pide una pagina de veinte elementos] --> F{Filtro en la capa de presentacion}
F -->|Descarta pocos| OK[Casi veinte filas visibles y el umbral se alcanza]
F -->|Descarta casi todo| V[Cero filas visibles]
V --> U[No se alcanza el umbral de anticipacion]
U --> S[No se pide la pagina siguiente y la lista se detiene]
style OK fill:#a6e3a1,color:#11111b
style S fill:#f38ba8,color:#11111b
💡
Donde va cada filtro

Si el criterio de filtrado es estable y expresable en la fuente, va en la cláusula de la consulta o en el parámetro de la API, y entonces cada página pedida son elementos útiles. Si el criterio cambia con la interacción, lo correcto es reconstruir el flujo con el nuevo criterio en lugar de filtrar la salida. Filtrar el PagingData solo es admisible para exclusiones marginales, del orden de descartar un elemento entre cien.

Probar lo que no se ve

Una fuente de paginación es una clase con dos métodos y sin dependencias de Android, lo que la convierte en uno de los componentes más fáciles de probar bien. La librería ofrece además un ayudante que ejecuta cargas sobre ella sin necesidad de montar la maquinaria completa.

@Test fun laPrimeraCargaDevuelveLaPaginaInicialConClaves() = runTest {
    val api = ApiFalsa(paginas = mapOf(1 to listaDe(20), 2 to listaDe(20)))
    val fuente = ArticulosPagingSource(api)

    val resultado = fuente.load(
        LoadParams.Refresh(key = null, loadSize = 20, placeholdersEnabled = false),
    )

    val esperado = LoadResult.Page(
        data = listaDe(20), prevKey = null, nextKey = 2,
    )
    assertEquals(esperado, resultado)
}

@Test fun unFalloDeRedSeDevuelveComoErrorYNoSePropaga() = runTest {
    val fuente = ArticulosPagingSource(ApiQueFalla(IOException()))

    val resultado = fuente.load(
        LoadParams.Append(key = 2, loadSize = 20, placeholdersEnabled = false),
    )

    assertTrue(resultado is LoadResult.Error)
}

Los casos que de verdad merecen una prueba son cinco y son siempre los mismos: la primera carga con clave nula, una carga intermedia con sus dos claves presentes, la última página con clave siguiente nula, el error de red devuelto como resultado y el cálculo de la clave de refresco a partir de un estado con ancla. Los cinco son deterministas, rápidos y protegen exactamente los puntos donde se rompen las listas paginadas en producción.

Las transformaciones se prueban de otra forma, y aquí hay un obstáculo real: un PagingData no se puede inspeccionar directamente. La salida es recoger los elementos con el ayudante de pruebas correspondiente, que ejecuta la presentación y devuelve una lista comparable.

@Test fun seInsertaUnSeparadorAlCambiarDeMes() = runTest {
    val entrada = PagingData.from(listOf(deMarzo("a"), deMarzo("b"), deAbril("c")))

    val salida = conSeparadores(entrada).asSnapshot()

    assertEquals(
        listOf("SEP marzo", "a", "b", "SEP abril", "c"),
        salida.map { it.etiquetaDePrueba() },
    )
}

Para el mediador remoto, la prueba valiosa es de integración con una base de datos en memoria: se le pide una carga de tipo refresco con una API falsa, se comprueba que devuelve éxito y que la tabla de datos y la de claves quedaron pobladas de forma coherente. Y la prueba que más defectos reales encuentra es la del fallo: una API que lanza una excepción a mitad, seguida de la verificación de que la base de datos quedó exactamente como estaba. Esa es la prueba que detecta la transaccionalidad rota antes de que la detecte un usuario sin conexión.

Una transformacion perezosa no es una funcion sobre datos sino una regla sobre el tiempo

Conviene cerrar el nivel entendiendo por qué estas transformaciones se comportan de forma tan distinta a sus homónimas de las colecciones, porque la diferencia no es un capricho de la API sino una consecuencia inevitable del modelo. Cuando mapeas una lista, estás aplicando una función a un valor: el conjunto entero existe, la operación termina y el resultado es otro valor completo del que puedes preguntar cualquier cosa. Cuando mapeas un flujo paginado no estás transformando un valor, porque no hay ninguno; estás registrando una regla que se ejecutará más adelante, un número indeterminado de veces, sobre elementos que aún no existen, en un orden que depende de por dónde decida moverse un dedo humano. La transformación deja de pertenecer al dominio de las funciones y pasa al dominio de las políticas. Y todas las reglas prácticas de esta lección son corolarios directos de ese desplazamiento. El coste importa porque una política se ejecuta muchas veces y una función una sola. Los efectos secundarios están prohibidos porque una política que se ejecuta un número impredecible de veces convierte cualquier efecto en un fallo no reproducible. La posición absoluta no existe porque una política no ve el conjunto, solo la ventana en la que le toca actuar. El filtrado es peligroso porque una política que descarta elementos rompe la relación aritmética entre lo que se pide y lo que se muestra, y esa relación era la que gobernaba cuándo pedir más. Y las pruebas cambian de forma porque no se puede verificar un valor que no existe: hay que ejecutar la política sobre una entrada controlada y observar lo que produce. Interiorizar esto vale mucho más que memorizar los nombres de los métodos, porque es lo que permite predecir el comportamiento de una operación que no has usado nunca, y porque el mismo desplazamiento del valor a la política reaparece, con otros nombres, en cada sistema que decide procesar datos que no caben.

⚔️ Transforma y protege
  1. Mapea entidades a modelos de presentación y mide cuántas veces se ejecuta el mapeo durante un recorrido de doscientos elementos.
  2. Mueve ese mapeo al otro lado del anclaje en memoria, rota el dispositivo y explica la diferencia en el número de ejecuciones.
  3. Inserta separadores por mes cubriendo explícitamente el primer y el último elemento, y comprueba que la cabecera inicial aparece.
  4. Filtra el PagingData descartando el noventa por ciento de los elementos y describe con precisión qué le ocurre a la lista y por qué.
  5. Escribe las cinco pruebas de la fuente de paginación y una prueba de fallo del mediador que verifique que la base de datos quedó intacta.