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

Capas en Marcador: el directo

Al terminar: Mover el directo del partido a su propia capa con un único dueño del reloj.

Al terminar esta lección, la feature del marcador tendrá su capa de dominio, junto a la de ui; cada capa con un trabajo. El reloj del partido, los goles y el aviso de cada 15 minutos saldrán del ViewModel y vivirán en una clase nueva, Directo, que no importa nada de Android. La app se verá igual que antes: lo que cambia es dónde vive cada cosa.

Importa porque, con el reloj dentro del ViewModel, arreglar el aviso o probar una regla exige tocar código de Android. Con Directo en domain, esa regla vive sola y se puede probar sin móvil. Temas: crear un paquete con el IDE, una clase de dominio con alcance prestado y un ViewModel que solo traduce. No hay nada que instalar: usas las corrutinas, el ViewModel y el módulo :torneo que ya tienes.

Paso · sacar el directo del ViewModel

Objetivo. Crear Lado.kt y Directo.kt en un paquete de dominio nuevo, y dejar el PartidoViewModel como un traductor. Al terminar, «Empezar partido» sigue arrancando el reloj, «Gol Rayo FC» sigue subiendo el marcador, y el aviso sale cada 15 minutos con el marcador correcto.

app → kotlin+java → com.sebastiannarvaez.marcador → feature
marcador
domain
Directo.ktse crea
Lado.ktse crea
ui
PartidoViewModel.ktse modifica
MarcadorContent.ktse modifica (import)
MarcadorUiState.ktse modifica (import)
Dos ficheros nuevos y tres 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.

1 · Crea el paquete domain de la feature. En el panel de proyecto, con el selector en Android, despliega app → kotlin+java. Clic derecho sobre com.sebastiannarvaez.marcador (el que no lleva (test) ni (androidTest)) → New → Package. El campo ya trae com.sebastiannarvaez.marcador: escribe .feature.marcador.domain al final (con el punto delante) y pulsa Intro. Cada punto es una carpeta dentro de otra, y feature.marcador ya existe: solo se crea domain. Queda hermana de ui: una feature se parte en capas, y las reglas del partido (qué es un directo, cuándo avisa) no son de pantalla, así que van en su propia carpeta.

2 · Crea Lado.kt y Directo.kt. Clic derecho sobre domain, dentro de feature → marcador → New → Kotlin Class/File → elige File y escribe el nombre (Lado; después, otra vez, Directo). Cada uno se abre con solo la línea package com.sebastiannarvaez.marcador.feature.marcador.domain. Lado ya existe, pero dentro de MarcadorContent.kt: abre ese fichero (feature → marcador → ui → MarcadorContent.kt), borra enum class Lado { LOCAL, VISITANTE } con su comentario de encima (ahora vivirá en domain) y, cuando el IDE marque Lado en rojo, añade su import (más abajo).

Piezas nuevas de este paso:

  • package com.sebastiannarvaez.marcador.feature.marcador.domain: la primera línea del fichero; tiene que coincidir con la carpeta donde está.
  • El par privado/público de StateFlow y SharedFlow (instanteActual y instante); asStateFlow() es la vista de solo lectura del mismo flujo.
  • iniciar(alcance), Job y la lista de trabajos: los del temporizador y el horno de la lección anterior, ahora en Directo. Instante agrupa lo que cambia con el reloj, como Estado en el horno.
  • ?: Unit: alEvento es un when que devuelve valor, así que todas sus ramas deben dar el mismo tipo, y ese tipo es Unit, el «no devuelvo nada» de Kotlin. x?.let { … } da null si x es null, y ?: Unit cambia ese null por Unit. Dos líneas de ejemplo: val a: Unit = carga?.let { println(it) } ?: Unit, que imprime si hay carga y, si no, no hace nada, pero en ambos casos vale Unit.
  • partido.eventos.filter { … }.fold(antes) { m, evento -> … } (fold, de Colecciones): recorre los goles de ese minuto y va acumulando el marcador, empezando por antes. despuesDe es la función de tu módulo :torneo que devuelve el marcador tras un evento.

3 · Cambios en PartidoViewModel.kt (en feature → marcador → ui), en este orden. Los bloques están más abajo, en ese mismo orden.

Borra:

  1. los cinco MutableStateFlow de minutoActual, enMarcha, avisoActual, golesAMano y golesEnVivo;
  2. la función golAMano;
  3. la función empezar();
  4. en cargar, las cinco líneas que ponían esos flujos a cero.

Cambia:

  1. los import;
  2. Carga.Lista y el combine de uiState;
  3. las ramas Empezar y Gol de alEvento (la rama Elegir se queda como está) y la línea que crea el estado Lista.

Los import que quedan sin usar se ven en gris: puedes borrarlos o dejarlos, no rompen nada.

Intenta escribirlo tú:

  1. Lado, en domain, con los dos lados que pueden marcar.
  2. Directo, en domain y sin nada de Android: se crea con el id del partido, el partido, los milisegundos por minuto y la duración (90 si no se dice otra). Lleva por separado los goles del guion y los goles a mano, y expone, solo para leer, el marcador (la suma de los dos) y el instante: el minuto, si corre y el último aviso, que empieza en «ninguno». Tiene una función, golAMano, que suma un gol al lado que le digan, y otra, iniciar, que arranca el partido una sola vez en el alcance que le pasen y devuelve sus trabajos (ninguno si ya había arrancado). Un solo bucle: cada minuto aplica los goles del guion de ese minuto y, cada 15, guarda el aviso «minuto 15 con 1-0» con el marcador que acaba de quedar; al final deja de correr.
  3. El ViewModel, reducido a traducir: guarda el Directo del partido elegido, convierte su marcador y su instante en el estado de éxito y reenvía los eventos.

Debes ver la app igual que antes, con el minuto avanzando y el aviso cada 15 minutos.

Intenta primero. Cuando termines, compara con la solución.Mostrar el código
app/src/main/java/com/sebastiannarvaez/marcador/feature/marcador/domain/Lado.kt
package com.sebastiannarvaez.marcador.feature.marcador.domain
 
// Un evento del dominio, sin nada de pantalla: «han marcado los de aqui».
enum class Lado { LOCAL, VISITANTE }
app/src/main/java/com/sebastiannarvaez/marcador/feature/marcador/domain/Directo.kt
package com.sebastiannarvaez.marcador.feature.marcador.domain
 
import com.sebastiannarvaez.marcador.torneo.Marcador
import com.sebastiannarvaez.marcador.torneo.Partido
import com.sebastiannarvaez.marcador.torneo.despuesDe
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
 
// Tres capas. La regla: `ui/` y `data/` conocen a `domain/`; `domain/` no conoce a
// ninguna de las dos.
//
//   ui/      pantallas (Compose) y ViewModels. Saben de Android. Convierten el dominio
//            en un UiState y los eventos del usuario en llamadas al dominio.
//   domain/  las reglas del negocio: que es un directo, cuando avisa, que es un gol.
//            KOTLIN PURO: ni un `import android.*`, ni `androidx.*`. Se prueba en la JVM.
//   data/    de donde salen y donde se guardan los datos (memoria, Room, DataStore...).
//
// FLUJO UNIDIRECCIONAL: el estado baja (data -> domain -> ui -> pantalla) y los eventos
// suben (pantalla -> ui -> domain -> data). Nadie escribe en el estado de otra capa.
// UNICA FUENTE DE LA VERDAD: cada dato tiene un solo dueño que lo modifica; los demas
// lo observan. El marcador lo posee `Directo`; la pantalla lo pinta, no lo cuenta.
class Directo(
    val partidoId: Int,
    val partido: Partido,
    private val msPorMinuto: Long,
    private val minutosDelPartido: Int = 90,
) {
    // Los goles que va marcando el guion (los aplica el bucle de `iniciar`).
    private val golesEnVivo = MutableStateFlow(Marcador(0, 0))
    private val golesAMano = MutableStateFlow(Marcador(0, 0))
 
    data class Instante(val minuto: Int = 0, val corriendo: Boolean = false, val ultimoAviso: String = "ninguno")
 
    private val instanteActual = MutableStateFlow(Instante())
    val instante: StateFlow<Instante> = instanteActual.asStateFlow()
 
    // El marcador que se ve = el del partido en vivo + los goles «a mano».
    val marcador: Flow<Marcador> = combine(golesEnVivo, golesAMano) { vivo, mano ->
        Marcador(vivo.local + mano.local, vivo.visitante + mano.visitante)
    }
 
    fun golAMano(lado: Lado) = golesAMano.update {
        when (lado) {
            Lado.LOCAL -> it.copy(local = it.local + 1)
            Lado.VISITANTE -> it.copy(visitante = it.visitante + 1)
        }
    }
 
    // Arranca el partido en el alcance que le den (quien lo llame decide cual). Un directo solo se
    // inicia una vez. UN SOLO BUCLE: en cada minuto aplica los goles del guion y, si toca,
    // calcula el aviso con el marcador que acaba de quedar (no con el `Flow` combinado, que
    // podria no haberse actualizado todavia).
    fun iniciar(alcance: CoroutineScope): List<Job> {
        if (instanteActual.value.corriendo || instanteActual.value.minuto > 0) return emptyList()
        instanteActual.update { it.copy(corriendo = true) }
        return listOf(alcance.launch {
            while (instanteActual.value.minuto < minutosDelPartido) {
                delay(msPorMinuto)
                val nuevo = instanteActual.value.minuto + 1
                golesEnVivo.update { antes ->
                    partido.eventos.filter { it.minuto == nuevo }.fold(antes) { m, evento -> m.despuesDe(evento, partido) }
                }
                instanteActual.update { it.copy(minuto = nuevo) }
                if (nuevo % 15 == 0) {
                    val vivo = golesEnVivo.value
                    val mano = golesAMano.value
                    val ahora = Marcador(vivo.local + mano.local, vivo.visitante + mano.visitante)
                    instanteActual.update { it.copy(ultimoAviso = "minuto $nuevo con $ahora") }
                }
            }
            instanteActual.update { it.copy(corriendo = false) }
        })
    }
}

En PartidoViewModel.kt, los import (los tres de torneo y flow.update desaparecen; el resto queda igual):

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

Carga.Lista ahora lleva el Directo:

app/src/main/java/com/sebastiannarvaez/marcador/feature/marcador/ui/PartidoViewModel.kt
        data class Lista(val directo: Directo) : Carga

El combine de uiState traduce el Directo a Exito:

app/src/main/java/com/sebastiannarvaez/marcador/feature/marcador/ui/PartidoViewModel.kt
            is Carga.Lista -> combine(paso.directo.marcador, paso.directo.instante) { marcador, instante ->
                MarcadorUiState.Exito(
                    partidoId = paso.directo.partidoId,
                    partido = paso.directo.partido,
                    marcador = marcador,
                    minuto = instante.minuto,
                    corriendo = instante.corriendo,
                    ultimoAviso = instante.ultimoAviso,
                )
            }

Las ramas Empezar y Gol de alEvento reenvían al Directo: sustituyen a las dos líneas que ya tienes con esos nombres (la rama Elegir no cambia):

app/src/main/java/com/sebastiannarvaez/marcador/feature/marcador/ui/PartidoViewModel.kt
        MarcadorEvento.Empezar -> (carga.value as? Carga.Lista)?.let { trabajos = it.directo.iniciar(viewModelScope) } ?: Unit
        is MarcadorEvento.Gol -> (carga.value as? Carga.Lista)?.directo?.golAMano(evento.lado) ?: Unit

Y cargar crea el Directo al elegir un partido válido (sustituye a la línea carga.value = Carga.Lista(id, elegido.partido)):

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

Dos ficheros de feature.marcador.ui solo necesitan un import, el de Lado, que ahora vive en domain (el resto de cada fichero no cambia). Pulsa Option+Enter sobre el nombre en rojo y elige el import, o pega la línea con las demás:

app/src/main/java/com/sebastiannarvaez/marcador/feature/marcador/ui/MarcadorContent.kt
import com.sebastiannarvaez.marcador.feature.marcador.domain.Lado
app/src/main/java/com/sebastiannarvaez/marcador/feature/marcador/ui/MarcadorUiState.kt
import com.sebastiannarvaez.marcador.feature.marcador.domain.Lado

Fíjate en qué hace cada decisión:

DecisiónPor qué
Directo no importa android.*Se puede probar en la JVM, sin emulador
golesEnVivo y golesAMano son privadosSolo Directo los escribe; fuera solo se ve marcador
iniciar(alcance) recibe el CoroutineScopeEl ViewModel pasa viewModelScope: el reloj muere con la pantalla
El aviso lee golesEnVivo.value y golesAMano.valueSon los dueños; el combine de arriba puede ir un paso por detrás
Un solo whileCon un reloj para el minuto y otro para los goles, el aviso podía salir un gol por detrás

Probarlo. Ejecuta la app con Run (el triángulo verde de la barra superior), con app en el selector de configuración, la variante gratisDebug elegida en View → Tool Windows → Build Variants como en Gradle: flavors y variantes, y el emulador arrancado como en La Activity. Pulsa «Empezar partido»: el minuto avanza a razón de 4 por segundo (MS_POR_MINUTO es 250) y el partido acaba en unos 22 segundos. Sin pulsar nada más, el aviso del minuto 15 dice minuto 15 con 1-1, y el del 30, minuto 30 con 1-1.

Punto de control

  • La app compila sin nada en rojo (Build → Make Project, o Run) y se abre en el emulador.
  • En el panel de proyecto (vista Android), feature → marcador → domain contiene Directo.kt y Lado.kt, y es hermana de ui.
  • «Empezar partido» arranca el reloj; cuando el minuto pasa de 15 ves «Último aviso (cada 15'): minuto 15 con 1-1» y, tras «Gol Rayo FC», el marcador sube.
  • Abre Directo.kt: ninguna de sus líneas import empieza por android ni por androidx.
  • PartidoViewModel.kt ya no contiene golAMano, empezar ni ningún MutableStateFlow del reloj.

Comprueba lo que entendiste

5 preguntas · necesitas 4 para completar la lección

  1. 1.¿Qué hace PartidoViewModel con el Directo después de esta lección?

  2. 2.El aviso del minuto 15 lee golesEnVivo.value y golesAMano.value en lugar del Flow combinado marcador. ¿Por qué?

  3. 3.Tras crear Lado en feature.marcador.domain, la compilación falla en MarcadorUiState.kt con Unresolved reference 'Lado'. ¿Qué falta?

  4. 4.¿Por qué Directo vive en domain de la feature y no dentro del módulo :torneo?

  5. 5.¿Por qué Directo usa un solo while para el minuto, los goles del guion y el aviso?

Faltan 5 de 5