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

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

con el bug: se traga la cancelación
suspend operator fun invoke(partidoId: Int, lado: Lado, minuto: Int): Result<Gol> = runCatching {
    val partido = repositorio.partido(partidoId) ?: error("No existe el partido $partidoId")
    // …
}
corregido: intentar relanza la cancelación
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 es null, 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.

ejemplo mínimo: un caso de uso, PrestarLibro (con comentarios)
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:

ejemplo mínimo: lo que hace intentar, escrito a mano (con comentarios)
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
solución: repositorio con Flow y caso de uso
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.

app → kotlin+java → com.sebastiannarvaez.marcador → feature → marcador
domain
RegistrarGol.ktse crea
ui
PartidoViewModel.ktse modifica
Un fichero nuevo y uno que se modifica, en la vista Android.

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 como objeto(…) y que puede suspender.
  • Los errores de ?: error("…") y check(…) { … } los convierte intentar en Result.failure.
  • Unit y ?: Unit, ya vistos en Capas en Marcador: el directo. alEvento es un when que devuelve valor, y todas sus ramas deben devolver lo mismo: Unit, el «no devuelvo nada» de Kotlin. Un let devuelve lo último que escribes dentro, así que el bloque de Gol, que acaba en una llamada con launch (que devuelve otra cosa), termina con una línea Unit suelta para que su resultado sea Unit. Y x?.let { … } da null si no hay partido cargado: el ?: Unit del final cambia ese null por Unit.

Intenta escribirlo tú, en este orden:

  1. En domain, el caso de uso RegistrarGol, 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.
  2. 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
app/src/main/java/com/sebastiannarvaez/marcador/feature/marcador/domain/RegistrarGol.kt
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):

app/src/main/java/com/sebastiannarvaez/marcador/feature/marcador/ui/PartidoViewModel.kt
import com.sebastiannarvaez.marcador.feature.marcador.domain.RegistrarGol

Debajo de private val repositorio = Repositorios.partidos:

app/src/main/java/com/sebastiannarvaez/marcador/feature/marcador/ui/PartidoViewModel.kt
    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):

app/src/main/java/com/sebastiannarvaez/marcador/feature/marcador/ui/PartidoViewModel.kt
        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
        } ?: Unit

Lo que ocurre al pulsar «Gol Rayo FC»:

PasoQuiénQué hace
1PantallaEnvía MarcadorEvento.Gol(LOCAL)
2PartidoViewModelLlama a directo.golAMano (se ve al instante) y lanza registrarGol(...)
3RegistrarGolElige quién marcó por turno y llama al repositorio
4PartidosRepositoryEnMemoriaCambia el MutableStateFlow
5ListaViewModelRecibe 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.
  • RegistrarGol está en domain y PartidoViewModel lo llama dentro de un launch.
  • ListaViewModel no contiene ninguna lista de partidos escrita a mano: los recibe de repositorio.observarPartidos().

Comprueba lo que entendiste

4 preguntas · necesitas 3 para completar la lección

  1. 1.¿Cuándo tiene sentido un caso de uso como RegistrarGol?

  2. 2.RegistrarGol es una función suspend. ¿Por qué usa intentar { } y no runCatching { }?

  3. 3.Pulsas «Gol Rayo FC» y el marcador sube al instante, aunque registrarGol sea suspend. ¿Por qué?

  4. 4.¿Qué hace PartidoViewModel con el Result que devuelve RegistrarGol?

Faltan 4 de 4