Dos fuentes: el almacén y el repositorio
Al terminar: Guardar lo que llega de la red en Room y construir el repositorio donde Room manda y la red refresca.
Al terminar esta lección, la base de datos de Marcador tendrá una tabla nueva, arbitro, con una copia local de los árbitros, y existirá el repositorio que la mantiene al día desde la red: Room manda y la red solo refresca. El repositorio todavía no está conectado a la pantalla (eso es la lección siguiente), pero compilará, y comprobarás que la base de antes se actualiza sin perder ningún gol.
Importa porque la pantalla de árbitros solo funcionaba con red: sin conexión, pantalla vacía y un mensaje de error. Casi todas las apps reales hacen lo contrario, y la pieza que lo permite es un repositorio con dos fuentes de datos. Temas: fuente de verdad local; observar frente a refrescar; @Upsert; una tabla nueva con migración automática; y cuándo conviene tener dos repositorios o dos APIs.
Dos fuentes, una verdad
Hasta ahora los árbitros viven en un solo sitio: internet. Pedirlos cada vez tiene tres problemas: sin red no hay nada que enseñar, cada visita espera a la red, y cada visita gasta datos del usuario.
La solución es guardar una copia local (en Room) y tratar las dos fuentes con papeles distintos:
| Fuente | Papel | Quién la lee |
|---|---|---|
| Room (el móvil) | Fuente de verdad: lo que se pinta | La pantalla, siempre |
| La red (el servidor) | Refresco: trae novedades | Solo el repositorio, para actualizar Room |
Por eso el repositorio nuevo ofrece dos operaciones con dos naturalezas distintas, en lugar del «dame la lista» de antes:
observarArbitros(): lo guardado, que cambia con el tiempo. Devuelve unFlow, como el de los partidos.refrescar(): una operación que ocurre una vez, tarda y puede fallar. Essuspendy el fallo esperable sale dentro de unResult(el «sobre» que ya conoces), no como excepción.
La pantalla nunca pinta la respuesta de la red directamente. La red escribe en Room, Room avisa por el Flow, y la pantalla pinta lo que Room dice. Si refrescar falla, Room no se toca y la pantalla sigue con lo que tenía.
Este repositorio conoce a las dos fuentes (la API de la liga y el DAO de Room), que viven en core/network y core/database porque no son de ninguna feature: son la red y la base de datos de toda la app. El repositorio es de los árbitros y nadie más lo usa, así que va en su feature, en feature/arbitros/data, por encima de las dos fuentes.
Un ejemplo con noticias
Un periódico inventado, sin Android: la «base» es un MutableStateFlow (una caja que guarda un valor y avisa cuando cambia) que ya trae una noticia, y el servidor falla la segunda vez que se le llama. Lee los comentarios: cada línea dice qué papel juega.
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
import java.io.IOException
import kotlin.coroutines.cancellation.CancellationException
interface Servidor { // la fuente remota
suspend fun titulares(): List<String>
}
class ServidorDePrueba : Servidor {
private var llamadas = 0
override suspend fun titulares(): List<String> {
llamadas++
if (llamadas == 2) throw IOException("sin red") // la segunda vez, el servidor no contesta
return listOf("Noticia v$llamadas")
}
}
class Periodico(private val servidor: Servidor) {
private val guardadas = MutableStateFlow(listOf("Noticia guardada")) // la fuente de verdad: lo guardado
fun observar(): StateFlow<List<String>> = guardadas // la pantalla mira SOLO esto
suspend fun refrescar(): Boolean = try {
guardadas.value = servidor.titulares() // red -> guardado
true
} catch (cancelacion: CancellationException) {
throw cancelacion // la cancelación no se atrapa: se vuelve a lanzar
} catch (e: Exception) {
false // si falla, lo guardado no se toca
}
}
fun main() = runBlocking {
val periodico = Periodico(ServidorDePrueba())
println("pantalla: ${periodico.observar().value}")
println("refrescar: ${periodico.refrescar()}")
println("pantalla: ${periodico.observar().value}")
println("refrescar: ${periodico.refrescar()}")
println("pantalla: ${periodico.observar().value}")
}pantalla: [Noticia guardada]
refrescar: true
pantalla: [Noticia v1]
refrescar: false
pantalla: [Noticia v1]Fíjate en las dos últimas líneas: el refresco falló y la pantalla sigue enseñando la noticia de antes. Ese es todo el truco. (Los dos catch son el patrón que ya conoces de Casos de uso: RegistrarGol: en el proyecto lo hace intentar por ti.)
Tres piezas nuevas
1 · @Upsert. Cada vez que se refresca llegan los mismos diez árbitros. Con @Insert, la segunda vez fallaría por la clave repetida (es lo que hace por defecto). @Upsert («update o insert») hace lo correcto: si el id ya existe, actualiza la fila; si no, la inserta. El id de la tabla es el que da el servidor, así que la misma persona es siempre la misma fila. Una ilustración con libros, para leer (no se ejecuta):
@Entity(tableName = "libro")
data class LibroEntity(@PrimaryKey val id: Int, val titulo: String)
@Dao
interface LibroDao {
@Upsert // «update o insert»
suspend fun guardar(libros: List<LibroEntity>)
}
// Primera vez: guardar(listOf(LibroEntity(1, "Dune")))
// -> la tabla tiene 1 fila: (1, Dune)
// Segunda vez: guardar(listOf(LibroEntity(1, "Dune (2.ª ed.)"), LibroEntity(2, "Emma")))
// -> el id 1 ya existía: se ACTUALIZA; el id 2 es nuevo: se INSERTA
// -> la tabla tiene 2 filas: (1, Dune (2.ª ed.)) y (2, Emma)2 · Una tabla nueva y la versión 3. La tabla arbitro no existe en la base de la versión 2 que ya tienes en el móvil. Subes version = 3, añades AutoMigration(from = 2, to = 3) y Room genera la receta a partir de la diferencia entre 2.json y 3.json. Es el caso fácil: no toca ninguna fila de partido ni de gol, así que los goles de antes se quedan donde estaban. Lo que ejecutará Room al abrir la base (Room lo escribe al compilar, en una carpeta de código generado):
CREATE TABLE IF NOT EXISTS `arbitro` (`id` INTEGER NOT NULL, `nombre` TEXT NOT NULL, `ciudad` TEXT NOT NULL, PRIMARY KEY(`id`))3 · Un Flow dentro de otro Flow: dos map seguidos. El DAO devuelve filas (ArbitroEntity) y el repositorio las traduce a Arbitro. Como lo guardado es un Flow de listas, hacen falta dos map: el de fuera transforma cada emisión del Flow, y el de dentro transforma cada elemento de la lista. Con datos ajenos a Marcador:
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
data class FilaLibro(val id: Int, val titulo: String) // lo guardado en la base
data class Libro(val titulo: String) // lo que usa la app
fun main() = runBlocking {
val guardado = flowOf(listOf(FilaLibro(1, "Dune"), FilaLibro(2, "Emma"))) // un Flow que emite UNA lista
guardado
.map { filas -> filas.map { Libro(it.titulo) } } // map de fuera: la emisión (la lista). map de dentro: cada fila
.collect { println(it) }
}[Libro(titulo=Dune), Libro(titulo=Emma)]¿Cuándo dos repositorios y cuándo dos APIs?
- Un repositorio por tipo de dato, no por fuente.
PartidosRepository(partidos y goles) yArbitrosRepository(árbitros) son datos distintos, con dueños distintos y que cambian a ritmos distintos: dos repositorios. Lo que no se hace es un repositorio para Room y otro para la red del mismo dato: ese es justo el que vas a escribir, uno solo con las dos fuentes dentro. - Una interfaz de Retrofit por servidor. Si mañana la app habla con un segundo servidor, tendrá una segunda interfaz y un segundo
Retrofitcon otra URL base, pero los dos pueden compartir el mismo cliente HTTP (el objetoOkHttpClient, que es quien de verdad hace las peticiones; en Marcador ya es único para toda la app gracias al@SingletondeRedModule).
Reto · Ejercicio: una cartelera que sigue sin red
Fuera de la app, en el Kotlin Playground (play.kotlinlang.org).
Una cartelera de cine con dos fuentes, como el periódico del ejemplo, pero con dos diferencias que te obligan a pensar: refrescar devuelve un Result<Unit> (como en el proyecto) en lugar de un Boolean (Unit es el valor «vacío» de Kotlin, el de una función que no tiene nada que devolver: Result<Unit> significa «salió bien, sin dato» o «falló, con este error», y por eso se imprime como Success(kotlin.Unit)), y lo guardado siempre queda ordenado alfabéticamente.
- Una interfaz de servidor con una función
suspendque devuelve la lista de películas. - Un servidor de prueba que la primera vez responda con «Dune» y «Alien» (en ese orden), y la segunda vez falle con
IOException("sin red"). - Una clase
Carteleraque reciba el servidor y guarde en memoria, como fuente de verdad, una lista que empieza solo con «Dune». Debe poder devolver lo guardado (para que la pantalla lo mire) y tener una operación de refrescar que copie la respuesta del servidor a lo guardado ordenada. Si sale bien, devuelveResult.success(Unit); si falla, devuelveResult.failure(…)con el fallo y no toca lo guardado; y la cancelación no se atrapa. - En
main: imprime lo guardado, refresca e imprime el resultado, imprime lo guardado, refresca otra vez e imprime el resultado, e imprime lo guardado por última vez.
La consola debe mostrar:
pantalla: [Dune]
refrescar: Success(kotlin.Unit)
pantalla: [Alien, Dune]
refrescar: Failure(java.io.IOException: sin red)
pantalla: [Alien, Dune]Ver solución
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
import java.io.IOException
import kotlin.coroutines.cancellation.CancellationException
interface ServidorCine {
suspend fun peliculas(): List<String>
}
class ServidorCineDePrueba : ServidorCine {
private var llamadas = 0
override suspend fun peliculas(): List<String> {
llamadas++
if (llamadas == 2) throw IOException("sin red")
return listOf("Dune", "Alien")
}
}
class Cartelera(private val servidor: ServidorCine) {
private val guardadas = MutableStateFlow(listOf("Dune"))
fun observar(): StateFlow<List<String>> = guardadas
suspend fun refrescar(): Result<Unit> = try {
guardadas.value = servidor.peliculas().sorted() // ordenada al guardar
Result.success(Unit)
} catch (cancelacion: CancellationException) {
throw cancelacion
} catch (e: Exception) {
Result.failure(e) // lo guardado no se toca
}
}
fun main() = runBlocking {
val cartelera = Cartelera(ServidorCineDePrueba())
println("pantalla: ${cartelera.observar().value}")
println("refrescar: ${cartelera.refrescar()}")
println("pantalla: ${cartelera.observar().value}")
println("refrescar: ${cartelera.refrescar()}")
println("pantalla: ${cartelera.observar().value}")
}La pantalla solo lee observar(); el servidor solo se toca dentro de refrescar(). Cuando el servidor falla, la última línea sigue mostrando las dos películas, y el fallo viaja como valor en el Result, no como excepción.
Paso · El almacén y el repositorio
Objetivo. Crear la tabla arbitro con su DAO, subir la base a la versión 3 sin perder ningún dato y escribir el repositorio de dos fuentes. Debes ver: la app funciona exactamente igual que antes, el partido 1 conserva su resultado después de actualizar encima, y aparece el fichero 3.json.
Antes de tocar nada, deja datos en la versión 2. La migración solo se ve con una base que ya existe. Ejecuta la app con la variante gratisDebug, abre Partidos y fíjate en el resultado del partido 1 (3-2 con la base recién creada; 5-2 si seguiste los puntos de control de Room y de sus migraciones). No desinstales la app hasta terminar la lección siguiente: desinstalar borra la base y la prueba no serviría.
Crea los ficheros. Con el selector en Android, ArbitroDao.kt va en core → database (la carpeta de la base, que ya tienes: clic derecho sobre database → New → Kotlin Class/File → File), junto a MarcadorDatabase.kt, porque la tabla nueva vive en la misma base que los partidos. ArbitrosRepositoryDosFuentes.kt va en feature → arbitros → data (clic derecho sobre data, igual): es el repositorio de los árbitros y se queda en su feature. No borras nada hoy.
Piezas nuevas de este paso:
@Upsert(deandroidx.room3.Upsert): insertar o actualizar según exista elid(la ilustración de los libros).Flow.map { filas -> filas.map { … } }: el primermaptransforma cada emisión y el segundo, cada elemento de la lista (el ejemplo de los libros).- Un repositorio en
feature/arbitros/dataque recibe dos cosas por el constructor, la API y el DAO (con@Inject constructor, para que Hilt sepa construirlo), y no implementa todavía la interfazArbitrosRepository: esa interfaz sigue pidiendoarbitros(), la de antes. Hoy la clase se compila sola y en la lección siguiente cambias el contrato y la enchufas.
Ya vistas: @Entity, @Dao, @Query y AutoMigration (Room: entidades y DAO y Room: migrar la base de datos); intentar (Casos de uso: RegistrarGol); recoverCatching y aErrorDeRed (POST y errores de red); el DTO con name y address.city (LigaApi.kt, de Retrofit y la primera llamada).
Intenta escribirlo tú, en este orden:
- En
core/database/ArbitroDao.kt: la entidadArbitroEntity(tablaarbitro, conidcomo clave,nombreyciudad) y el DAOArbitroDao, con una consulta que observa todos los árbitros ordenados por nombre (devuelve unFlowde lista de entidades) y una operaciónsuspendque guarda una lista de árbitros con upsert. - En
MarcadorDatabase.kt: la entidad nueva en la lista, versión 3, la migración automática de la 2 a la 3 junto a la que ya tienes, y la función que da el DAO. Compila con./gradlew :app:assembleGratisDebugen la Terminal de Android Studio: debe terminar bien y3.jsonaparece junto a1.jsony2.json(conls app/schemas/com.sebastiannarvaez.marcador.core.database.MarcadorDatabase). Si algo falla aquí, es esta tabla; el repositorio aún no existe. - En
di/DatosModule.kt: una receta más,arbitroDao, igual que la departidoDao: la tabla nueva vive en la misma base, y Hilt tiene que saber dar el DAO para construir el repositorio. - En
feature/arbitros/data/ArbitrosRepositoryDosFuentes.kt: una clase que recibe la API y el DAO (con@Inject constructor).observarArbitros()traduce las entidades del DAO aArbitro(dosmap).refrescar()pide la lista a la red, la guarda en el DAO y, si algo falla, devuelve el fallo ya traducido aErrorDeRed.
Intenta primero. Cuando termines, compara con la solución.Mostrar el código
package com.sebastiannarvaez.marcador.core.database
import androidx.room3.Dao
import androidx.room3.Entity
import androidx.room3.PrimaryKey
import androidx.room3.Query
import androidx.room3.Upsert
import kotlinx.coroutines.flow.Flow
// La tabla `arbitro`: la COPIA LOCAL de lo que dio la red. El id es el del servidor, asi
// que refrescar no duplica filas: la misma persona, la misma fila.
@Entity(tableName = "arbitro")
data class ArbitroEntity(
@PrimaryKey val id: Int,
val nombre: String,
val ciudad: String,
)
@Dao
interface ArbitroDao {
// Flow: emite lo guardado y vuelve a emitir cada vez que la tabla cambia.
@Query("SELECT * FROM arbitro ORDER BY nombre")
fun observarArbitros(): Flow<List<ArbitroEntity>>
// UPSERT = «update o insert»: si el id ya existe, actualiza la fila; si no, la inserta.
@Upsert
suspend fun guardar(arbitros: List<ArbitroEntity>)
}En MarcadorDatabase.kt, la anotación @Database y la clase cambian así (el resto del fichero no cambia; el import de AutoMigration ya lo tienes):
// Version 3: la tabla nueva `arbitro`. Una tabla nueva es el caso facil de una migracion
// automatica: Room compara 2.json con 3.json y escribe el CREATE TABLE. Los partidos y los
// goles de la version 2 se quedan como estaban.
@Database(
entities = [PartidoEntity::class, GolEntity::class, ArbitroEntity::class],
version = 3,
exportSchema = true,
autoMigrations = [AutoMigration(from = 1, to = 2), AutoMigration(from = 2, to = 3)],
)
abstract class MarcadorDatabase : RoomDatabase() {
abstract fun partidoDao(): PartidoDao
abstract fun arbitroDao(): ArbitroDao
}En DatosModule.kt (de di), el import de ArbitroDao junto a los demás y esta receta debajo de la de partidoDao (el resto del fichero no cambia). Sin @Singleton, como el otro DAO: lo guarda la propia base.
import com.sebastiannarvaez.marcador.core.database.ArbitroDao // La tabla de arbitros vive en la MISMA base: otro DAO de la misma receta.
@Provides
fun arbitroDao(base: MarcadorDatabase): ArbitroDao = base.arbitroDao()package com.sebastiannarvaez.marcador.feature.arbitros.data
import com.sebastiannarvaez.marcador.core.database.ArbitroDao
import com.sebastiannarvaez.marcador.core.database.ArbitroEntity
import com.sebastiannarvaez.marcador.core.network.LigaApi
import com.sebastiannarvaez.marcador.core.network.aErrorDeRed
import com.sebastiannarvaez.marcador.feature.arbitros.domain.Arbitro
import com.sebastiannarvaez.marcador.torneo.intentar
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import javax.inject.Inject
// El repositorio con DOS fuentes: Room (la local) y la red. Room MANDA: es la fuente de
// verdad. La red solo sirve para ponerla al dia. Vive en su feature (arbitros/data) y no
// en core/network ni en core/database porque conoce a las dos.
//
// HOY la clase aun no dice `: ArbitrosRepository` ni usa `override`: la interfaz sigue
// pidiendo `arbitros()`. En la leccion siguiente cambias la interfaz y se lo anades.
class ArbitrosRepositoryDosFuentes @Inject constructor(
private val api: LigaApi,
private val dao: ArbitroDao,
) {
// Lo que ve la pantalla sale SIEMPRE de la base, traducido a dominio.
fun observarArbitros(): Flow<List<Arbitro>> =
dao.observarArbitros().map { filas -> filas.map { Arbitro(it.id, it.nombre, it.ciudad) } }
// Red -> base. Al guardar, el Flow de arriba vuelve a emitir y la pantalla se entera
// sola. Si la red falla, la base no se toca y el fallo sale traducido.
suspend fun refrescar(): Result<Unit> = intentar {
val dtos = api.arbitros()
dao.guardar(dtos.map { ArbitroEntity(id = it.id, nombre = it.name, ciudad = it.address.city) })
}.recoverCatching { throw it.aErrorDeRed() }
}Lee refrescar() de dentro hacia fuera: intentar ejecuta el bloque y devuelve un Result; si ahí dentro falló la red, recoverCatching cambia la excepción de Retrofit por la de dominio (FalloDeRed). Y fíjate en el orden: primero la red (api.arbitros()) y solo si responde, la base (dao.guardar). Si la red falla, la segunda línea no llega a ejecutarse.
Probarlo. Ejecuta la app (Run, variante gratisDebug) sin desinstalar: Android actualiza la instalación y Room migra la base de la versión 2 a la 3 al abrirla.
- En Partidos, el partido 1 conserva el mismo resultado que anotaste al empezar.
- «Árbitros» sigue funcionando como antes, en el orden del servidor: todavía no usa la tabla nueva.
- En la Terminal,
./gradlew :app:testGratisDebugUnitTestsigue en verde: no has añadido tests.
Punto de control
./gradlew :app:assembleGratisDebugtermina enBUILD SUCCESSFULyapp/schemas/…/MarcadorDatabase/3.jsonaparece junto a1.jsony2.json../gradlew :app:testGratisDebugUnitTestcuenta 15 tests con tus ejercicios y 12 sin ellos, y 0 fallos (el ejercicio del temporizador de Tests de un ViewModel: fakes, setMain y Turbine suma 3 tests; la lección no añade ninguno).- Instalada encima de la versión 2, la app conserva el resultado del partido 1.
- Existen
ArbitroDao.ktyArbitrosRepositoryDosFuentes.kt(y Hilt ya sabe dar el DAO), pero la pantalla de árbitros sigue usandoArbitrosRepositoryRed: en la lección siguiente cambias el@BindsdeRepositoriosModule(la única línea de Hilt que se toca) y reescribes la pantalla.
En Dos fuentes: la pantalla y las pruebas le das al repositorio su contrato, la pantalla y las pruebas.
Comprueba lo que entendiste
6 preguntas · necesitas 5 para completar la lección
1.En un repositorio con dos fuentes (Room y la red), ¿cuál es la fuente de verdad y qué lee la pantalla?
2.¿Por qué la interfaz tiene
observarArbitros(): Flowyrefrescar(): Result<Unit>en lugar de una sola función que devuelva la lista?3.Cada vez que se refresca llegan los mismos diez árbitros. ¿Qué ventaja tiene
@Upsertfrente a@Insert?4.La base pasa de la versión 2 a la 3 al añadir la tabla
arbitro. ¿Qué ocurre con los goles que el usuario ya tenía guardados?5.Marcador tiene partidos y árbitros, cada uno con Room y con red. ¿Cuántos repositorios hacen falta según el criterio de la lección?
6.En
dao.observarArbitros().map { filas -> filas.map { Arbitro(it.id, it.nombre, it.ciudad) } }, ¿qué hace cadamap?