RemoteMediator: offline-first con Room como caché
Cuando la base de datos local pasa a ser la única fuente de verdad, la red deja de alimentar la pantalla y pasa a alimentar la base de datos. Esta lección construye ese patrón: una fuente de paginación que lee de Room, un mediador remoto que rellena páginas cuando la caché se agota, una tabla auxiliar de claves remotas que hace posible reanudar, y la transaccionalidad que impide que un fallo a mitad de escritura deje la caché en un estado del que ya no se puede paginar.
Hasta ahora la pantalla dependía de la red: sin conexión no había lista, y cada apertura pagaba de nuevo el viaje completo. El patrón que veremos aquí invierte esa dependencia y con ello cambia la naturaleza de la aplicación. La interfaz pasa a leer exclusivamente de la base de datos local, que responde en microsegundos y funciona en un túnel; la red se convierte en un proceso de fondo cuyo único cometido es mantener esa base de datos razonablemente fresca. El cambio parece un detalle de arquitectura y es en realidad un cambio de contrato con el usuario: la aplicación deja de decir necesito conexión para mostrarte algo y pasa a decir te muestro lo que sé y lo mejoro cuando pueda. Lo que hace falta para sostenerlo es un componente capaz de decidir cuándo la caché se ha quedado corta y de traducir esa carencia en peticiones de red y escrituras coherentes. Ese componente es RemoteMediator, y su dificultad no está en su interfaz, que es un solo método, sino en la contabilidad de claves que hay que mantener para que la reanudación sea posible.
- Distinguir el papel de la fuente local y el del mediador remoto en un flujo paginado.
- Implementar
RemoteMediatorcon sus tres tipos de carga y la marca de fin de paginación. - Persistir claves remotas en una tabla auxiliar y escribir la actualización dentro de una transacción.
- Combinar el estado de carga local con el mediador para representar la verdad en la interfaz.
Dos fuentes y una sola verdad
En este patrón hay dos actores y sus responsabilidades no se solapan. La fuente de paginación la genera Room a partir de una consulta y su único cometido es leer filas locales; no sabe que existe una red. El mediador remoto observa el consumo de esa fuente y, cuando la interfaz se acerca al borde de lo que hay guardado, sale a buscar más y lo escribe en la base de datos. La escritura invalida la fuente, la fuente vuelve a leer y la lista crece. El flujo de datos es unidireccional y ese es todo el secreto.
@Dao
interface ArticuloDao {
@Query("SELECT * FROM articulos ORDER BY publicado DESC")
fun paginados(): PagingSource<Int, ArticuloEntity>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertar(articulos: List<ArticuloEntity>)
@Query("DELETE FROM articulos")
suspend fun borrarTodo()
}
Room genera la implementación de esa fuente y le añade algo que una fuente propia no tiene gratis: se invalida sola cuando cambia la tabla. Esa invalidación automática es lo que cierra el ciclo sin que nadie coordine nada, y también lo que exige escribir en lotes y dentro de transacciones, porque cada escritura suelta desencadena una recarga completa.
La cláusula de ordenación de la consulta local es la que determina qué significa página siguiente para el usuario. Si el orden local no coincide con el que aplica el servidor al paginar, la lista mostrará elementos en un orden y los rellenará en otro, con huecos y saltos difíciles de diagnosticar. Cuando el criterio del servidor no sea expresable en SQL, la solución es guardar la posición asignada por el servidor como columna y ordenar por ella.
El contrato del mediador
El mediador tiene un método suspendido que recibe el tipo de carga solicitada y el estado actual de paginación, y devuelve un resultado que indica éxito, con su marca de fin de paginación, o error. Los tres tipos de carga tienen semánticas muy distintas y confundirlos es el error inicial más frecuente.
REFRESH
Primera carga o refresco explícito. Suele implicar vaciar la caché y volver a llenarla desde el principio, dentro de una transacción.
APPEND
La interfaz se acerca al final de lo guardado. Hay que averiguar la clave siguiente a partir del último elemento local y pedir esa página.
PREPEND
Solo tiene sentido si el conjunto crece por delante. Devolver éxito con fin de paginación es una respuesta legítima y frecuente.
@OptIn(ExperimentalPagingApi::class)
class ArticulosRemoteMediator(
private val api: ArticulosApi,
private val db: AppDatabase,
) : RemoteMediator<Int, ArticuloEntity>() {
override suspend fun initialize(): InitializeAction =
if (db.clavesDao().antiguedad() > CACHE_VALIDA)
InitializeAction.LAUNCH_INITIAL_REFRESH
else InitializeAction.SKIP_INITIAL_REFRESH
override suspend fun load(
loadType: LoadType,
state: PagingState<Int, ArticuloEntity>,
): MediatorResult = try {
val pagina = when (loadType) {
LoadType.REFRESH -> PRIMERA_PAGINA
LoadType.PREPEND -> return MediatorResult.Success(true)
LoadType.APPEND -> {
val ultimo = state.lastItemOrNull()
?: return MediatorResult.Success(true)
val claves = db.clavesDao().porArticulo(ultimo.id)
claves?.siguiente ?: return MediatorResult.Success(true)
}
}
val respuesta = api.articulos(pagina = pagina, tamano = state.config.pageSize)
db.withTransaction {
if (loadType == LoadType.REFRESH) {
db.articuloDao().borrarTodo()
db.clavesDao().borrarTodo()
}
db.clavesDao().insertar(respuesta.items.map {
ClaveRemota(it.id, anterior = pagina - 1, siguiente = pagina + 1)
})
db.articuloDao().insertar(respuesta.items.map { it.aEntidad() })
}
MediatorResult.Success(endOfPaginationReached = respuesta.items.isEmpty())
} catch (e: IOException) {
MediatorResult.Error(e)
} catch (e: HttpException) {
MediatorResult.Error(e)
}
}
El método de inicialización decide si al abrir la pantalla se muestra la caché tal cual o se refresca antes de nada. Saltarse el refresco inicial produce la sensación de instantaneidad y es correcto cuando el contenido tolera unos minutos de antigüedad; forzarlo es correcto cuando mostrar datos viejos sería engañoso, como en un saldo o en una disponibilidad.
Vaciar la caché y después pedir la red es la secuencia que deja al usuario sin nada cuando la petición falla. El orden correcto es pedir primero y borrar solo dentro de la misma transacción que inserta lo nuevo, de modo que un fallo de red no llegue nunca a tocar la base de datos. Es exactamente la diferencia entre una operación atómica y dos operaciones consecutivas.
Claves remotas: la contabilidad imprescindible
La tabla auxiliar de claves existe porque la base de datos local no guarda ninguna noción de páginas: guarda filas. Cuando la interfaz llega al último elemento local, el mediador necesita saber a qué página del servidor pertenecía ese elemento para pedir la siguiente, y esa información no está en ninguna parte salvo que la escribas.
@Entity(tableName = "claves_remotas")
data class ClaveRemota(
@PrimaryKey val articuloId: String,
val anterior: Int?,
val siguiente: Int?,
val creadoEn: Long = System.currentTimeMillis(),
)
Hay dos invariantes que esta tabla debe cumplir y que conviene enunciar con claridad. El primero: sus filas y las de la tabla de datos se escriben y se borran siempre juntas, dentro de la misma transacción, porque un artículo sin clave es un artículo a partir del cual no se puede continuar paginando y un usuario que llegue a él verá la lista terminar sin motivo. El segundo: la marca de tiempo permite decidir la antigüedad de la caché, y por tanto es la que alimenta la decisión de refrescar al inicializar.
flowchart TD
UI[La interfaz se acerca al final de lo guardado] --> M[RemoteMediator recibe APPEND]
M --> K[Lee la clave remota del ultimo elemento local]
K --> Q{Hay clave siguiente}
Q -->|No| FIN[Success con fin de paginacion]
Q -->|Si| NET[Peticion de red de esa pagina]
NET --> TX[Transaccion que escribe datos y claves juntos]
TX --> INV[Room invalida la PagingSource]
INV --> READ[La fuente local relee y la lista crece]
NET -.->|Fallo| ERR[MediatorResult Error sin tocar la base de datos]
style TX fill:#a6e3a1,color:#11111b
style ERR fill:#f38ba8,color:#11111bEl montaje final conecta ambas piezas en el mismo orquestador: la fuente local como fábrica y el mediador como componente remoto.
@OptIn(ExperimentalPagingApi::class)
val articulos: Flow<PagingData<ArticuloEntity>> = Pager(
config = PagingConfig(pageSize = 20),
remoteMediator = ArticulosRemoteMediator(api, db),
pagingSourceFactory = { db.articuloDao().paginados() },
).flow.cachedIn(viewModelScope)
Dos estados de carga que hay que leer juntos
Al introducir el mediador, la interfaz recibe dos juegos de estados: el de la fuente local, que describe la lectura de la base de datos, y el del mediador, que describe la actividad de red. Leer solo el primero produce una lista que nunca parece estar cargando, porque leer de Room es instantáneo; leer solo el segundo desatiende los estados de la lectura local.
La regla operativa es que el indicador de carga hacia el final debe mirar el estado del mediador, mientras que el estado vacío debe mirar el local y solo declararse cuando además el mediador haya terminado sin traer nada. Y hay una situación específica que conviene tratar aparte: red caída con caché llena. El mediador estará en error mientras la lista muestra contenido perfectamente utilizable. Sustituir esa lista por una pantalla de error sería absurdo; lo correcto es un aviso discreto de que lo mostrado puede estar desactualizado, con la opción de reintentar. Conviene notar además que el mediador se ejecuta en su propio ámbito y sobrevive a la recomposición, pero no a la muerte del proceso a mitad de una escritura: esa es otra razón por la que la transaccionalidad no es opcional, porque es lo único que garantiza que un proceso eliminado por el sistema no deje la tabla de datos y la de claves en desacuerdo.
El giro conceptual de esta lección es más profundo que la aparición de una clase nueva, y quien no lo interioriza acaba escribiendo un mediador correcto dentro de una arquitectura que sigue siendo la anterior. En el modelo tradicional la red es la verdad y la base de datos local es una copia degradada que se consulta cuando la verdad no está disponible; esa jerarquía obliga a que cada pantalla contenga la lógica de decidir cuál de las dos fuentes mira, y esa decisión, repetida en veinte pantallas por veinte personas distintas, es el origen de la mayoría de las incoherencias visibles en una aplicación conectada. El patrón que hemos construido invierte la jerarquía y con ello disuelve la decisión: la base de datos es la verdad, sin adjetivos, y la red es un proceso que la corrige. La interfaz no elige fuente porque solo hay una. La consecuencia inmediata es que el modo sin conexión deja de ser una funcionalidad que alguien tiene que implementar y pasa a ser el comportamiento por defecto del sistema, que es justo lo contrario de lo que ocurre cuando se añade una caché a posteriori. Pero la consecuencia más interesante es epistemológica y conviene enunciarla sin suavizarla: al aceptar que la verdad es local, aceptas también que tu aplicación siempre muestra el pasado. Todo lo que ve el usuario es un estado del servidor en algún instante anterior, y la única diferencia entre una aplicación bien hecha y una mal hecha es cuánto pasado y con qué honestidad se comunica. Esa idea, incómoda al principio, resulta liberadora en cuanto se aplica: deja de tener sentido perseguir la ilusión de datos en vivo y empieza a tener sentido la pregunta correcta, que es qué antigüedad tolera este dato concreto y qué le debo decir al usuario cuando la supera. La marca de tiempo de la tabla de claves, que parecía un campo auxiliar, es en realidad donde esa política vive.
- Sustituye la fuente de red por una consulta paginada de Room y comprueba que la pantalla abre con contenido en modo avión.
- Implementa el mediador con los tres tipos de carga y verifica que el prefijado devuelve fin de paginación de inmediato.
- Provoca un fallo de red durante un refresco y comprueba que la caché anterior sigue intacta; si no lo está, revisa el orden de tus operaciones.
- Elimina la escritura de claves remotas y observa exactamente en qué punto deja de crecer la lista y por qué.
- Corta la conexión con la caché llena y diseña la señal que le comunica al usuario que está viendo datos antiguos sin quitarle el contenido.