Casos de uso: RegistrarGol
Al terminar: Añadir un caso de uso que registra un gol sobre el repositorio y comprobar el 3-2 → 4-2.
Al terminar esta lección, registrar un gol en Marcador será una acción con reglas propias, RegistrarGol. Pulsarás «Gol Rayo FC» en el marcador, irás a la pestaña Partidos y verás que el 3-2 ha pasado a 4-2; las tablas se recalculan solas.
Importa porque «quién marca el gol» es una regla del juego, y las reglas no deben vivir dentro de una pantalla ni de un ViewModel: viven en el dominio, en una pieza con nombre que se puede probar y reutilizar. Temas: casos de uso; operator fun invoke; Result y cancelación. No hay nada nuevo que instalar.
Caso de uso: una acción con reglas
Un caso de uso es una clase con una sola operación que tiene sentido para el negocio: «registrar un gol», «prestar un libro», «confirmar una cita». Vive en domain, recibe el repositorio por el constructor y le pide lo que necesita.
operator fun invoke es la forma de hacer que un objeto se pueda llamar como una función. Con class RegistrarGol y su invoke, escribes registrarGol(1, Lado.LOCAL, 30) en lugar de registrarGol.invoke(1, Lado.LOCAL, 30).
Un caso de uso vale la pena cuando hay regla. En Marcador, el gol a mano no dice quién marcó: marca el jugador que toca por turno, según los goles que ya lleva el equipo. Si el caso de uso solo reenviara la llamada al repositorio, sobraría: el ViewModel llamaría al repositorio directamente. El criterio completo, con un caso de uso que junta dos repositorios, lo verás en Casos de uso: cuándo sí y cuándo sobran.
Error típico: runCatching dentro de una función suspend
suspend operator fun invoke(partidoId: Int, lado: Lado, minuto: Int): Result<Gol> = runCatching {
val partido = repositorio.partido(partidoId) ?: error("No existe el partido $partidoId")
// …
}suspend operator fun invoke(partidoId: Int, lado: Lado, minuto: Int): Result<Gol> = intentar {
val partido = repositorio.partido(partidoId) ?: error("No existe el partido $partidoId")
// …
}Falla porque CancellationException es una Exception más. Si la pantalla se cierra mientras la función espera en repositorio.partido, runCatching atrapa la excepción, devuelve un Result.failure y la corrutina cancelada continúa con lo que haya después de la llamada, como si nada. intentar atrapa los errores normales y relanza la cancelación.
Esa cancelación tiene una consecuencia para Marcador: si cierras la pantalla justo después de un gol, el guardado se cancela con ella. Más adelante, en El partido sigue aunque salgas, RegistrarGol pasará a guardar el gol en el alcance de la app, que no se cancela al cerrar una pantalla. intentar se queda igual: sigue siendo lo que evita tragarse una cancelación.
Un caso de uso completo, en una biblioteca
El mismo molde con libros (no son clases de Marcador). Parte del repositorio LibrosRepository y de LibrosEnMemoria de la lección anterior. Tres piezas:
?: error("…"): si lo de la izquierda esnull, lanza un error con ese mensaje; si no, sigue con el valor.check(condición) { mensaje }: lanza un error con ese mensaje si la condición es falsa..also { … }: hace algo con el valor y lo devuelve igual; aquí, guarda el préstamo y devuelve el libro ya prestado.
intentar convierte esos errores en un Result.failure, y lo que sale bien, en un Result.success. Ojo: intentar es la función de tu módulo :torneo, no de Kotlin, así que este ejemplo no se ejecuta tal cual en el Kotlin Playground; la salida de abajo es la que da en un proyecto que la tenga. Justo después ves qué hace por dentro, escrito a mano.
class PrestarLibro(private val repositorio: LibrosRepository) { // caso de uso: una operación con regla
suspend operator fun invoke(id: Int): Result<Libro> = intentar { // operator invoke: se llama prestarLibro(3)
val libro = repositorio.libro(id) ?: error("No existe el libro $id") // error: lanza si es null
check(!libro.prestado) { "${libro.titulo} ya está prestado" } // check: lanza si la condición falla
libro.copy(prestado = true).also { repositorio.prestar(id) } // also: hace algo y devuelve el mismo valor
}
}
fun main() = runBlocking {
val repositorio = LibrosEnMemoria(inicial = listOf(Libro(1, "Dune"), Libro(2, "Emma")))
val prestarLibro = PrestarLibro(repositorio)
println(prestarLibro(1)) // se presta: éxito
println(prestarLibro(1)) // ya está prestado: fallo
println(prestarLibro(9)) // no existe: fallo
}Success(Libro(id=1, titulo=Dune, prestado=true))
Failure(java.lang.IllegalStateException: Dune ya está prestado)
Failure(java.lang.IllegalStateException: No existe el libro 9)El ViewModel que lo use no necesita saber cómo se comprueba el préstamo: llama a prestarLibro(1) y decide qué hacer con el Result.
Qué hace intentar por dentro. Es un try con dos catch. El primero atrapa la CancellationException y la lanza otra vez con throw, para que cancelar siga funcionando; el segundo atrapa cualquier otro error y lo devuelve como Result.failure. Si el resultado va bien, Result.success(valor) lo envuelve. La clase CancellationException hay que importarla (kotlin.coroutines.cancellation.CancellationException). El mismo caso de uso, sin intentar, y por tanto ejecutable en el Playground:
import kotlin.coroutines.cancellation.CancellationException
class PrestarLibroAMano(private val repositorio: LibrosRepository) {
suspend operator fun invoke(id: Int): Result<Libro> = try {
val libro = repositorio.libro(id) ?: error("No existe el libro $id")
check(!libro.prestado) { "${libro.titulo} ya está prestado" }
Result.success(libro.copy(prestado = true).also { repositorio.prestar(id) }) // éxito: envuelve el libro
} catch (cancelacion: CancellationException) {
throw cancelacion // la cancelación NO se atrapa: se lanza otra vez
} catch (e: Exception) {
Result.failure(e) // cualquier otro error pasa a ser un fallo
}
}Reto · Ejercicio: una agenda con caso de uso
Fuera de la app, en el Kotlin Playground (play.kotlinlang.org).
Parte de la agenda del ejercicio de la lección anterior (una interfaz con las tres operaciones y su implementación en memoria, con «Dentista» y «Fútbol»), que puedes copiar de su solución. Esa parte ya la hiciste: aquí solo hay que añadir el caso de uso.
Escribe un caso de uso que se llame como una función con un número y devuelva un resultado, éxito o fallo: éxito con «Confirmada:» y el nombre de la cita, o fallo con el error «No existe la cita» y el número. Como en el ejemplo PrestarLibroAMano, que convierta los errores en fallo pero deje pasar la cancelación.
En main, con corrutinas, confirma la cita 1, la 7 y la 2. La consola debe mostrar:
Success(Confirmada: Dentista)
Failure(java.lang.IllegalStateException: No existe la cita 7)
Success(Confirmada: Fútbol)Ver solución
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
import kotlin.coroutines.cancellation.CancellationException
interface Agenda {
fun observarCitas(): Flow<List<String>>
suspend fun cita(id: Int): String?
suspend fun guardar(nombre: String)
}
class AgendaEnMemoria : Agenda {
private val citas = MutableStateFlow(listOf("Dentista", "Fútbol"))
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 }
}
class ConfirmarCita(private val agenda: Agenda) {
suspend operator fun invoke(id: Int): Result<String> = try {
val nombre = agenda.cita(id) ?: error("No existe la cita $id")
Result.success("Confirmada: $nombre")
} catch (cancelacion: CancellationException) {
throw cancelacion
} catch (e: Exception) {
Result.failure(e)
}
}
fun main() = runBlocking {
val agenda = AgendaEnMemoria()
val confirmar = ConfirmarCita(agenda)
println(confirmar(1))
println(confirmar(7))
println(confirmar(2))
}La agenda es la de la lección anterior; lo nuevo es ConfirmarCita. error(…) lanza IllegalStateException, y el catch la convierte en un valor; la CancellationException se relanza para que cancelar siga funcionando. En el proyecto, intentar ya hace ese try por ti.
Paso · RegistrarGol: un gol que se guarda y se ve en la lista
Objetivo. Crear el caso de uso RegistrarGol y llamarlo desde PartidoViewModel. Al terminar, un gol registrado en la pestaña Marcador aparece en la pestaña Partidos, y la pestaña Tablas se recalcula.
Crea el fichero. En el panel de proyecto, con el selector en Android, despliega app → kotlin+java. Clic derecho sobre domain, dentro de feature → marcador → New → Kotlin Class/File → File → RegistrarGol. Va en el dominio de la feature del marcador porque es una regla de esa feature: anotar un gol. El fichero se abre con solo su línea package. Cuando Android Studio marque una referencia en rojo, pulsa Option+Enter y elige el import.
Antes de escribir, recuerda :torneo. partido.golesDe(equipo) devuelve cuántos goles lleva ese equipo en el partido; equipo.plantilla es la lista de sus jugadores.
Ya vistas: check y error (Excepciones: try, catch y require) y .also (Funciones de alcance).
Piezas nuevas de este paso:
suspend operator fun invoke(…): una función que se puede llamar comoobjeto(…)y que puede suspender.- Los errores de
?: error("…")ycheck(…) { … }los convierteintentarenResult.failure. Unity?: Unit, ya vistos en Capas en Marcador: el directo.alEventoes unwhenque devuelve valor, y todas sus ramas deben devolver lo mismo:Unit, el «no devuelvo nada» de Kotlin. Unletdevuelve lo último que escribes dentro, así que el bloque deGol, que acaba en una llamada conlaunch(que devuelve otra cosa), termina con una líneaUnitsuelta para que su resultado seaUnit. Yx?.let { … }danullsi no hay partido cargado: el?: Unitdel final cambia esenullporUnit.
Intenta escribirlo tú, en este orden:
- En
domain, el caso de usoRegistrarGol, que recibe el repositorio, se llama como una función con el id del partido, el lado y el minuto, y devuelve el gol o el fallo: «No existe el partido 7» si no está, o «Toros no tiene jugadores» si la plantilla está vacía. La regla: marca el jugador al que le toca por turno (los goles que ya lleva el equipo, dando la vuelta a la plantilla), y el gol se guarda en el repositorio. - En
PartidoViewModel, el gol del botón se sigue viendo al instante en el directo y además se registra con el caso de uso.
Debes ver la app como antes, y con «Gol Rayo FC» en el marcador, la fila del primer partido en Partidos cambia de 3-2 a 4-2.
Dónde va cada trozo (obligatorio). El fichero nuevo va entero. En PartidoViewModel.kt: añade el import de RegistrarGol, declara registrarGol justo debajo de repositorio, y sustituye la rama Gol de alEvento.
Intenta primero. Cuando termines, compara con la solución.Mostrar el código
package com.sebastiannarvaez.marcador.feature.marcador.domain
import com.sebastiannarvaez.marcador.feature.partidos.domain.PartidosRepository
import com.sebastiannarvaez.marcador.torneo.Gol
import com.sebastiannarvaez.marcador.torneo.intentar
// Un caso de uso es una clase con UNA operacion (`invoke`, asi se llama como una funcion:
// `registrarGol(1, Lado.LOCAL, 30)`). Vale la pena cuando hay REGLA: aqui, «quien marca».
// Si solo reenviara la llamada al repositorio, sobraria: el ViewModel lo llamaria directo.
//
// Regla: el gol a mano no dice quien lo marco, asi que lo marca el jugador de la
// plantilla que toca por turno (los goles que ya lleva el equipo, modulo el tamano).
class RegistrarGol(private val repositorio: PartidosRepository) {
suspend operator fun invoke(partidoId: Int, lado: Lado, minuto: Int): Result<Gol> = intentar {
val partido = repositorio.partido(partidoId) ?: error("No existe el partido $partidoId")
val equipo = if (lado == Lado.LOCAL) partido.local else partido.visitante
check(equipo.plantilla.isNotEmpty()) { "${equipo.nombre} no tiene jugadores" }
val jugador = equipo.plantilla[partido.golesDe(equipo) % equipo.plantilla.size]
Gol(minuto, jugador, equipo).also { repositorio.registrarGol(partidoId, it) }
}
}En PartidoViewModel.kt, el import de RegistrarGol (con los demás de domain):
import com.sebastiannarvaez.marcador.feature.marcador.domain.RegistrarGolDebajo de private val repositorio = Repositorios.partidos:
private val registrarGol = RegistrarGol(repositorio)El evento Gol de alEvento se sustituye por este (la línea de Empezar y la de Elegir no cambian):
is MarcadorEvento.Gol -> (carga.value as? Carga.Lista)?.directo?.let { directo ->
// Se ve al instante en el directo Y se guarda con el caso de uso.
directo.golAMano(evento.lado)
// El `Result` con el fallo (no existe el partido, plantilla vacia) se descarta:
// por ahora no hay donde mostrarlo, y el gol ya se ve en el directo.
viewModelScope.launch {
registrarGol(directo.partidoId, evento.lado, directo.instante.value.minuto)
}
Unit
} ?: UnitLo que ocurre al pulsar «Gol Rayo FC»:
| Paso | Quién | Qué hace |
|---|---|---|
| 1 | Pantalla | Envía MarcadorEvento.Gol(LOCAL) |
| 2 | PartidoViewModel | Llama a directo.golAMano (se ve al instante) y lanza registrarGol(...) |
| 3 | RegistrarGol | Elige quién marcó por turno y llama al repositorio |
| 4 | PartidosRepositoryEnMemoria | Cambia el MutableStateFlow |
| 5 | ListaViewModel | Recibe la lista nueva por observarPartidos() y recalcula lista y tablas |
El directo del marcador es la reproducción del guion del partido elegido; los goles a mano son goles nuevos, que se guardan. Por eso el marcador visible suma las dos cosas y la lista muestra lo guardado. Si el Result de RegistrarGol falla, el ViewModel lo descarta: aún no hay dónde enseñar ese error.
Probarlo. Ejecuta la app con Run (variante gratisDebug en View → Tool Windows → Build Variants y emulador arrancado, como en La Activity). En la pestaña Marcador pulsa «Gol Rayo FC» una vez; ve a Partidos: el primer partido pasa de 3-2 a 4-2. En Tablas, la fila de Rayo FC pasa de 9 PJ 4 DG 15 pts a 9 PJ 5 DG 15 pts (la diferencia de goles sube uno; los partidos jugados y los puntos no cambian porque ya ganaba ese partido), y en Goleadores sube un gol el jugador del Rayo al que le tocó por turno. (Más adelante podrás añadir jugadores a un equipo, en Formularios: la pantalla, el teclado y las pruebas, y el jugador nuevo también entrará en ese turno.)
Punto de control
- La app compila sin nada en rojo y se abre en el emulador.
- Tras un «Gol Rayo FC», la fila del primer partido en Partidos dice 4-2 en lugar de 3-2, y la pestaña Tablas cambia.
RegistrarGolestá endomainyPartidoViewModello llama dentro de unlaunch.ListaViewModelno contiene ninguna lista de partidos escrita a mano: los recibe derepositorio.observarPartidos().
Comprueba lo que entendiste
4 preguntas · necesitas 3 para completar la lección
1.¿Cuándo tiene sentido un caso de uso como
RegistrarGol?2.
RegistrarGoles una funciónsuspend. ¿Por qué usaintentar { }y norunCatching { }?3.Pulsas «Gol Rayo FC» y el marcador sube al instante, aunque
registrarGolseasuspend. ¿Por qué?4.¿Qué hace
PartidoViewModelcon elResultque devuelveRegistrarGol?