Saltar al contenido
K
Jetpack y arquitecturaPaso a paso40 min de lectura · 10 min de máquina Intermedio

Repository: una sola puerta a los datos

Al terminar: Escribir un Repository y un caso de uso.

Al terminar esta lección, los partidos de Marcador vivirán detrás de una puerta con nombre, PartidosRepository. La lista de partidos y las tablas saldrán de ahí, y PartidoViewModel leerá el partido que eliges del mismo sitio. La app se verá igual que antes, con una diferencia: ya no hay espera falsa al elegir un partido.

Importa porque hasta ahora cada pantalla iba a buscar los datos donde le venía bien. Con un repositorio, los datos tienen un único sitio de consulta, y cambiar de dónde salen (memoria hoy, una base de datos en la lección de Room) no toca ni una pantalla. Temas: Repository; Flow como fuente de la verdad; dominio. No hay nada nuevo que instalar: todo lo que usas (corrutinas, Flow, ViewModel, :torneo) ya está en el proyecto.

Dos avisos antes de empezar. Primero, esta lección quita el delay(400) con el que PartidoViewModel simulaba una lectura lenta: la lectura pasa a ser instantánea y el indicador de carga que modelaste en Modelar el UiState casi deja de verse. Segundo, el repositorio en memoria es provisional: en El repositorio con Room se sustituye por uno que guarda en una base de datos, sin tocar las pantallas, y más adelante otro trae los datos de la red (Red en Marcador: la lista de árbitros). En la lección siguiente, Casos de uso: RegistrarGol, el repositorio recibe además los goles que se registran a mano.

Repository: una sola puerta a los datos

Un repositorio es una clase (o interfaz) a la que se le piden los datos. Quien pregunta no sabe si salen de una lista en memoria, de una base de datos o de la red.

Tres reglas hacen que un repositorio sirva:

ReglaEn PartidosRepository
Es una interfaz en domain: el dominio dice qué necesita, data decide cómointerface PartidosRepository en domain; PartidosRepositoryEnMemoria en data
Recibe y devuelve tipos del dominio, nunca las clases propias de la base de datos (las verás en Room)Partido y Gol (del dominio compartido, el módulo :torneo, que ya viste en Capas) y PartidoDeLista (de domain/)
Lo que cambia con el tiempo es un Flow; lo que ocurre una vez es suspendobservarPartidos() frente a registrarGol(...)

El repositorio más pequeño posible, con datos ajenos al proyecto (notas de una libreta), y lo que imprime:

ejemplo mínimo: un repositorio de notas (con comentarios)
interface RepositorioDeNotas {
    fun observarNotas(): Flow<List<String>>       // cambia con el tiempo: Flow
    suspend fun guardar(nota: String)             // ocurre una vez: suspend
}
 
class NotasEnMemoria : RepositorioDeNotas {
    private val notas = MutableStateFlow(emptyList<String>())   // la fuente de la verdad
    override fun observarNotas(): Flow<List<String>> = notas.asStateFlow()  // se ofrece de solo lectura
    override suspend fun guardar(nota: String) = notas.update { it + nota } // cambia la lista: avisa a quien observa
}
 
fun main() = runBlocking {
    val repositorio: RepositorioDeNotas = NotasEnMemoria()  // quien lo usa solo conoce la interfaz
    val lector = launch { repositorio.observarNotas().collect { println("notas: $it") } }
    delay(50)                                  // da tiempo a que el lector reciba la lista vacía
    repositorio.guardar("comprar pan")         // el lector se entera solo
    delay(50)
    lector.cancel()                            // deja de observar
}
notas: []
notas: [comprar pan]

El lector no pregunta dos veces: se queda observando, y cada cambio le llega solo.

Un repositorio completo, en una biblioteca

Antes del proyecto, el mismo molde con libros y préstamos (no son clases de Marcador). Cinco piezas que verás después en Marcador:

  • val en una interfaz: una interfaz puede exigir una propiedad, y la clase que la cumple la declara con override val en su constructor (se declara y se inicializa a la vez), como se vio en Herencia, interfaces y composición.
  • lista.update { … }: cambia el valor de un MutableStateFlow y avisa a quien lo observa.
  • libros.map { if (…) it.copy(…) else it }: recorre la lista y sustituye solo el elemento que cumple la condición, sin tocar los demás.
  • object Almacen (en el paso de Marcador, el mismo atajo se llama Repositorios): un objeto con una sola instancia, accesible desde cualquier sitio. Es un atajo temporal: lo sustituye Hilt en Hilt y los ViewModels, y ahí se ve qué le pasa a quien depende de él.
  • stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), inicial): convierte un Flow en StateFlow que se queda activo 5 segundos sin nadie mirando (lo justo para sobrevivir a rotar).
ejemplo mínimo: un repositorio de libros y un ViewModel que lo observa (con comentarios)
data class Libro(val id: Int, val titulo: String, val prestado: Boolean = false)
 
interface LibrosRepository {                                     // en domain/: QUÉ se necesita
    val estanterias: List<String>                                // una interfaz puede exigir una propiedad
    fun observarLibros(): Flow<List<Libro>>                      // cambia con el tiempo: Flow
    suspend fun libro(id: Int): Libro?                           // ocurre una vez: suspend
    suspend fun prestar(id: Int)
}
 
class LibrosEnMemoria(
    override val estanterias: List<String> = listOf("A", "B"),   // override val en el constructor: se declara y se inicializa
    inicial: List<Libro> = emptyList(),
) : LibrosRepository {
    private val lista = MutableStateFlow(inicial)                // la fuente de la verdad
    override fun observarLibros(): Flow<List<Libro>> = lista.asStateFlow()
    override suspend fun libro(id: Int): Libro? = lista.value.firstOrNull { it.id == id }
    override suspend fun prestar(id: Int) {
        lista.update { libros -> libros.map { if (it.id == id) it.copy(prestado = true) else it } }  // cambia solo ese
    }
}
 
object Almacen {                                                 // una sola instancia, accesible desde cualquier sitio
    val libros: LibrosEnMemoria = LibrosEnMemoria()
}
 
class EstanteriaViewModel : ViewModel() {
    val prestados: StateFlow<Int> = Almacen.libros.observarLibros()
        .map { libros -> libros.count { it.prestado } }          // transforma cada lista en un número
        .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), 0)  // a StateFlow, con 0 al principio
}

El ViewModel no guarda los libros: los observa y los resume. Si el repositorio cambia, prestados cambia solo.

Reto · Ejercicio: una agenda con repositorio

Fuera de la app, en el Kotlin Playground (play.kotlinlang.org).

Modela una agenda como una interfaz con tres operaciones: observar la lista de citas mientras cambia, pedir una cita por su número (o nada si no existe) y guardar una cita nueva. Escribe una implementación en memoria que guarde la lista en un flujo de estado y empiece con «Dentista» y «Fútbol». La cita 1 es la primera de la lista, la que está en la posición 0. Pista: lista.getOrNull(posición) devuelve null si esa posición no existe, en vez de lanzar un error.

En main, con corrutinas:

  1. imprime la cita 1 y la 7;
  2. recoge el número de citas mientras guardas «Cine», con 50 ms antes y después;
  3. cancela la recogida e imprime la cita 3.

La consola debe mostrar:

cita 1: Dentista
cita 7: null
citas: 2
citas: 3
cita 3: Cine
Ver solución
solución: la agenda como repositorio
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
 
interface Agenda {
    fun observarCitas(): Flow<List<String>>          // cambia con el tiempo: Flow
    suspend fun cita(id: Int): String?               // ocurre una vez: suspend
    suspend fun guardar(nombre: String)
}
 
class AgendaEnMemoria : Agenda {
    private val citas = MutableStateFlow(listOf("Dentista", "Fútbol"))   // la fuente de la verdad
    override fun observarCitas(): Flow<List<String>> = citas.asStateFlow()
    override suspend fun cita(id: Int): String? = citas.value.getOrNull(id - 1)  // getOrNull: null si el índice no existe, en vez de lanzar
    override suspend fun guardar(nombre: String) = citas.update { it + nombre }
}
 
fun main() = runBlocking {
    val agenda: Agenda = AgendaEnMemoria()
    println("cita 1: ${agenda.cita(1)}")
    println("cita 7: ${agenda.cita(7)}")
    val contador = launch { agenda.observarCitas().collect { println("citas: ${it.size}") } }
    delay(50)
    agenda.guardar("Cine")
    delay(50)
    contador.cancel()
    println("cita 3: ${agenda.cita(3)}")
}

La agenda hace de repositorio: lo que cambia con el tiempo (observarCitas) es un Flow, y lo que se pide una vez (cita, guardar) son funciones de suspensión. La recogida imprime dos veces porque el flujo de estado entrega primero la lista que ya hay y después la nueva.

Paso · PartidosRepository y una lista que se actualiza sola

Objetivo. Crear el repositorio (interfaz y versión en memoria) y un ListaViewModel que observa los partidos, y hacer que PartidoViewModel lea el partido del repositorio. Al terminar, la app se ve igual que antes, con el indicador de carga casi invisible, y las pestañas Partidos y Tablas ya salen del repositorio.

app → kotlin+java → com.sebastiannarvaez.marcador → feature
partidos
domain
Partidos.ktse crea
data
PartidosRepositoryEnMemoria.ktse crea
DatosDeEjemplo.ktse modifica
ui
ListaViewModel.ktse crea
Listas.ktse modifica (import)
marcador → ui
PartidoViewModel.ktse modifica
app → kotlin+java → com.sebastiannarvaez.marcador → ui
AppMarcador.ktse modifica
Tres ficheros nuevos y cuatro que se modifican, en la vista Android.

El título de los bloques de código con barras (app/src/main/java/…) es la ruta del fichero en tu proyecto; en la vista Android lo encuentras en app → kotlin+java → com.sebastiannarvaez.marcador.

Crea los ficheros. En el panel de proyecto, con el selector en Android, despliega app → kotlin+java. La carpeta domain de partidos no existe todavía: clic derecho sobre com.sebastiannarvaez.marcador (el que no lleva (test) ni (androidTest)) → New → Package. El campo ya trae com.sebastiannarvaez.marcador: escribe .feature.partidos.domain al final (con el punto delante) y pulsa Intro. Es la tercera capa de la feature de partidos, y aquí va lo que es regla y no sabe de Android ni de dónde salen los datos: qué es un partido de la lista y qué se le puede pedir a quien los guarda. Después, clic derecho sobre domain, dentro de feature → partidos → New → Kotlin Class/File → File → Partidos. Lo mismo sobre data de esa misma carpeta (PartidosRepositoryEnMemoria) y sobre su ui (ListaViewModel). Cada fichero se abre con solo su línea package. Cuando Android Studio marque una referencia en rojo, pulsa Option+Enter y elige el import.

Mueve PartidoDeLista. Es la clase que envuelve un partido con su id (PartidoDeLista(id, partido)). Hoy está en feature → partidos → data → DatosDeEjemplo.kt: abre ese fichero, borra la clase (con su comentario) y créala en Partidos.kt. Al borrarla, DatosDeEjemplo.kt y Listas.kt mostrarán PartidoDeLista en rojo: pulsa Option+Enter sobre el nombre y elige el import de domain.PartidoDeLista, o hazlo a mano: en DatosDeEjemplo.kt añade ese import y, en Listas.kt, cambia el import de data.PartidoDeLista por el de domain.PartidoDeLista.

Antes de escribir, recuerda :torneo. partido.registrar(gol) devuelve un partido nuevo con ese gol añadido (el partido original no cambia); torneo.registrar(partido) mete un partido en un Torneo y devuelve un Result (con .getOrThrow() sacas el valor o, si hubo un fallo, lanzas ese error como una excepción, como en Excepciones: try, catch y require). Y armarTorneo es una función que tú escribes: rehace el Torneo a partir de la lista de partidos, para calcular las tablas.

Ya vistas: la val que exige una interfaz y override val en el constructor (Herencia, interfaces y composición), y .also (Funciones de alcance), que en armarTorneo hace algo con el torneo recién creado y lo devuelve.

Intenta escribirlo tú, en este orden:

  1. En domain, PartidoDeLista (un id y su partido, que se mueve aquí desde data) y la interfaz PartidosRepository: da los equipos, deja observar la lista de partidos mientras cambia, devuelve un partido por su id (o nada si no existe) y guarda un gol en un partido. Al lado, una función, armarTorneo, que construye el torneo «Copa Barrio» con unos equipos y registra en él unos partidos. Guardar un gol todavía no lo llama nadie: lo usará el caso de uso de la lección siguiente.
  2. En data, PartidosRepositoryEnMemoria, que la cumple guardando la lista en un flujo de estado que es la única fuente de la verdad; empieza, si no se le dice otra cosa, con los equipos y los partidos de ejemplo, y registrar un gol sustituye solo ese partido. Y un objeto único, Repositorios, con una instancia compartida: es el mismo atajo temporal que Almacen en el ejemplo de los libros. Su propiedad partidos se declara con el tipo de la clase concreta (PartidosRepositoryEnMemoria) y no con el de la interfaz, para no complicar este paso; las dos cosas, el atajo y ese tipo, se corrigen con Hilt, en Hilt: instalarlo y el primer módulo y en Hilt y los ViewModels.
  3. ListaViewModel, que expone los partidos y el torneo armado con ellos (al principio, una lista vacía y un torneo sin partidos), y los cambios de PartidoViewModel y AppMarcador que se listan justo debajo.

Debes ver la app igual que antes, con el indicador de carga casi invisible al elegir un partido, y las tablas con las mismas cifras.

Dónde va cada trozo (obligatorio). Los ficheros nuevos van enteros. En los que ya existen:

  • DatosDeEjemplo.kt: borra PartidoDeLista y añade el import de domain.PartidoDeLista.
  • PartidoViewModel.kt: sustituye el import de DatosDeEjemplo por el de Repositorios, declara repositorio antes de partidoId, y en cargar borra delay(400) (su import queda en gris, sin usar: déjalo, no pasa nada) y lee el partido del repositorio.
  • AppMarcador.kt: borra val torneo = DatosDeEjemplo.torneo y su import, y en su sitio pide partidos y torneo al ListaViewModel; la lista de Partidos recibe partidos en lugar de DatosDeEjemplo.partidos.
  • Listas.kt: cambia el import de data.PartidoDeLista por el de domain.PartidoDeLista.
Intenta primero. Cuando termines, compara con la solución.Mostrar el código
app/src/main/java/com/sebastiannarvaez/marcador/feature/partidos/domain/Partidos.kt
package com.sebastiannarvaez.marcador.feature.partidos.domain
 
import com.sebastiannarvaez.marcador.torneo.Equipo
import com.sebastiannarvaez.marcador.torneo.Gol
import com.sebastiannarvaez.marcador.torneo.Partido
import com.sebastiannarvaez.marcador.torneo.Torneo
import kotlinx.coroutines.flow.Flow
 
data class PartidoDeLista(val id: Int, val partido: Partido)
 
// Es una INTERFAZ en la capa de dominio: el dominio dice QUE necesita («dame los
// partidos», «guarda este gol») y la capa de datos decide COMO (memoria, Room, red).
//
// Reglas del repositorio:
//   - devuelve y recibe tipos de DOMINIO (Partido, Gol), nunca las clases propias de la
//     base de datos (las verás en Room);
//   - lo que cambia con el tiempo se expone como Flow; lo que ocurre una vez, como `suspend`;
//   - es la fuente de la verdad: quien quiera saber los partidos, los OBSERVA aqui.
interface PartidosRepository {
    val equipos: List<Equipo>
 
    fun observarPartidos(): Flow<List<PartidoDeLista>>
 
    suspend fun partido(id: Int): Partido?
 
    suspend fun registrarGol(partidoId: Int, gol: Gol)
}
 
// Las tablas y los goleadores no se guardan: se CALCULAN a partir de los partidos. Guardar
// ademas la tabla seria tener dos fuentes de verdad que se pueden contradecir.
fun armarTorneo(equipos: List<Equipo>, partidos: List<PartidoDeLista>): Torneo =
    Torneo("Copa Barrio", equipos).also { torneo ->
        partidos.forEach { torneo.registrar(it.partido).getOrThrow() }
    }
app/src/main/java/com/sebastiannarvaez/marcador/feature/partidos/data/PartidosRepositoryEnMemoria.kt
package com.sebastiannarvaez.marcador.feature.partidos.data
 
import com.sebastiannarvaez.marcador.feature.partidos.domain.PartidoDeLista
import com.sebastiannarvaez.marcador.feature.partidos.domain.PartidosRepository
import com.sebastiannarvaez.marcador.torneo.Equipo
import com.sebastiannarvaez.marcador.torneo.Gol
import com.sebastiannarvaez.marcador.torneo.Partido
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
 
// El StateFlow ES la fuente de la verdad; `observarPartidos` la ofrece de solo lectura.
class PartidosRepositoryEnMemoria(
    override val equipos: List<Equipo> = DatosDeEjemplo.torneo.equipos,
    inicial: List<PartidoDeLista> = DatosDeEjemplo.partidos,
) : PartidosRepository {
 
    private val lista = MutableStateFlow(inicial)
 
    override fun observarPartidos(): Flow<List<PartidoDeLista>> = lista.asStateFlow()
 
    override suspend fun partido(id: Int): Partido? = lista.value.firstOrNull { it.id == id }?.partido
 
    override suspend fun registrarGol(partidoId: Int, gol: Gol) {
        lista.update { partidos ->
            partidos.map { if (it.id == partidoId) it.copy(partido = it.partido.registrar(gol)) else it }
        }
    }
}
 
object Repositorios {
    val partidos: PartidosRepositoryEnMemoria = PartidosRepositoryEnMemoria()
}

En DatosDeEjemplo.kt solo cambia el import (y desaparece PartidoDeLista; el resto del fichero no cambia):

app/src/main/java/com/sebastiannarvaez/marcador/feature/partidos/data/DatosDeEjemplo.kt
import com.sebastiannarvaez.marcador.feature.partidos.domain.PartidoDeLista
app/src/main/java/com/sebastiannarvaez/marcador/feature/partidos/ui/ListaViewModel.kt
package com.sebastiannarvaez.marcador.feature.partidos.ui
 
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.sebastiannarvaez.marcador.feature.partidos.data.Repositorios
import com.sebastiannarvaez.marcador.feature.partidos.domain.PartidoDeLista
import com.sebastiannarvaez.marcador.feature.partidos.domain.armarTorneo
import com.sebastiannarvaez.marcador.torneo.Torneo
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
 
class ListaViewModel : ViewModel() {
    private val repositorio = Repositorios.partidos
 
    val partidos: StateFlow<List<PartidoDeLista>> = repositorio.observarPartidos()
        .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
 
    val torneo: StateFlow<Torneo> = repositorio.observarPartidos()
        .map { armarTorneo(repositorio.equipos, it) }
        .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), Torneo("Copa Barrio", repositorio.equipos))
}

En PartidoViewModel.kt, el import de DatosDeEjemplo se sustituye por el de Repositorios (el de Directo ya lo tienes de la lección anterior):

app/src/main/java/com/sebastiannarvaez/marcador/feature/marcador/ui/PartidoViewModel.kt
import com.sebastiannarvaez.marcador.feature.partidos.data.Repositorios

Al principio de la clase, antes de partidoId:

app/src/main/java/com/sebastiannarvaez/marcador/feature/marcador/ui/PartidoViewModel.kt
    private val repositorio = Repositorios.partidos

En cargar, se borra la línea delay(400) y se sustituye la línea val elegido = DatosDeEjemplo.partidos.firstOrNull { it.id == id } por esta, que lee del repositorio:

app/src/main/java/com/sebastiannarvaez/marcador/feature/marcador/ui/PartidoViewModel.kt
            val elegido = repositorio.partido(id)

Y en la línea carga.value = Carga.Lista(Directo(id, elegido.partido, MS_POR_MINUTO)), elegido ya es el partido (antes era la fila con su id), así que se quita .partido:

app/src/main/java/com/sebastiannarvaez/marcador/feature/marcador/ui/PartidoViewModel.kt
                carga.value = Carga.Lista(Directo(id, elegido, MS_POR_MINUTO))

En AppMarcador.kt, se borra val torneo = DatosDeEjemplo.torneo y su import, y estas tres líneas van justo debajo de val viewModel: PartidoViewModel = viewModel(); además, van con los demás import estos dos (ListaViewModel es de otra carpeta, feature → partidos → ui):

app/src/main/java/com/sebastiannarvaez/marcador/ui/AppMarcador.kt
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.sebastiannarvaez.marcador.feature.partidos.ui.ListaViewModel

Y las tres líneas:

app/src/main/java/com/sebastiannarvaez/marcador/ui/AppMarcador.kt
    val listaViewModel: ListaViewModel = viewModel()
    val partidos by listaViewModel.partidos.collectAsStateWithLifecycle()
    val torneo by listaViewModel.torneo.collectAsStateWithLifecycle()

En la pestaña Partidos, sustituye DatosDeEjemplo.partidos, por partidos,:

app/src/main/java/com/sebastiannarvaez/marcador/ui/AppMarcador.kt
                partidos,
app/src/main/java/com/sebastiannarvaez/marcador/feature/partidos/ui/Listas.kt
import com.sebastiannarvaez.marcador.feature.partidos.domain.PartidoDeLista

Lo que ocurre ahora al abrir la app:

PasoQuiénQué hace
1PartidosRepositoryEnMemoriaGuarda la lista de partidos en un MutableStateFlow: la fuente de la verdad
2ListaViewModelObserva observarPartidos() y la ofrece como StateFlow
3AppMarcadorRecoge partidos y torneo y se los pasa a las pestañas
4PartidoViewModelAl elegir un partido, lo pide al repositorio con partido(id)

Cuando el repositorio cambie la lista (la lección siguiente registra un gol), el paso 2 recalcula lista y tablas sin que ninguna pantalla haga nada.

Queda una incomodidad: los dos ViewModel siguen yendo a buscar el repositorio a Repositorios. Después de Casos de uso: RegistrarGol, la lección Hilt: instalarlo y el primer módulo instala la herramienta que lo monta todo, y Hilt y los ViewModels hace que los ViewModel lo reciban en vez de buscarlo.

Probarlo. Ejecuta la app con Run (variante gratisDebug en View → Tool Windows → Build Variants y emulador arrancado, como en La Activity). Al elegir un partido, el indicador de carga casi no se ve, porque la lectura ya es instantánea. La pestaña Partidos sigue mostrando el primer partido en 3-2, y en Tablas la fila de Rayo FC dice 9 PJ 4 DG 15 pts: las mismas cifras de antes, ahora calculadas desde el repositorio.

Punto de control

  • La app compila sin nada en rojo y se abre en el emulador.
  • Partidos y Tablas muestran lo mismo que antes (primer partido en 3-2; Rayo FC con 9 PJ 4 DG 15 pts).
  • ListaViewModel no contiene ninguna lista de partidos escrita a mano: los recibe de repositorio.observarPartidos().
  • PartidoViewModel ya no contiene delay(400).

Comprueba lo que entendiste

4 preguntas · necesitas 3 para completar la lección

  1. 1.¿Por qué PartidosRepository es una interfaz en domain y no una clase concreta?

  2. 2.En PartidosRepository, ¿por qué observarPartidos() devuelve un Flow y registrarGol(...) es suspend?

  3. 3.Si el repositorio publica una lista de partidos nueva, ¿por qué ListaViewModel y las tablas se actualizan sin que nadie los avise?

  4. 4.Las tablas no se guardan en el repositorio: armarTorneo las calcula a partir de los partidos. ¿Por qué?

Faltan 4 de 4