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.
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 (
instanteActualyinstante);asStateFlow()es la vista de solo lectura del mismo flujo. iniciar(alcance),Joby la lista de trabajos: los del temporizador y el horno de la lección anterior, ahora enDirecto.Instanteagrupa lo que cambia con el reloj, comoEstadoen el horno.?: Unit:alEventoes unwhenque devuelve valor, así que todas sus ramas deben dar el mismo tipo, y ese tipo esUnit, el «no devuelvo nada» de Kotlin.x?.let { … }danullsixesnull, y?: Unitcambia esenullporUnit. 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 valeUnit.partido.eventos.filter { … }.fold(antes) { m, evento -> … }(fold, de Colecciones): recorre los goles de ese minuto y va acumulando el marcador, empezando porantes.despuesDees la función de tu módulo:torneoque 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:
- los cinco
MutableStateFlowdeminutoActual,enMarcha,avisoActual,golesAManoygolesEnVivo; - la función
golAMano; - la función
empezar(); - en
cargar, las cinco líneas que ponían esos flujos a cero.
Cambia:
- los
import; Carga.Listay elcombinedeuiState;- las ramas
EmpezaryGoldealEvento(la ramaElegirse queda como está) y la línea que crea el estadoLista.
Los import que quedan sin usar se ven en gris: puedes borrarlos o dejarlos, no rompen nada.
Intenta escribirlo tú:
Lado, endomain, con los dos lados que pueden marcar.Directo, endomainy 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.- El
ViewModel, reducido a traducir: guarda elDirectodel 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
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 }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):
import com.sebastiannarvaez.marcador.feature.partidos.data.DatosDeEjemplo
import com.sebastiannarvaez.marcador.feature.marcador.domain.DirectoCarga.Lista ahora lleva el Directo:
data class Lista(val directo: Directo) : CargaEl combine de uiState traduce el Directo a Exito:
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):
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) ?: UnitY cargar crea el Directo al elegir un partido válido (sustituye a la línea carga.value = Carga.Lista(id, elegido.partido)):
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:
import com.sebastiannarvaez.marcador.feature.marcador.domain.Ladoimport com.sebastiannarvaez.marcador.feature.marcador.domain.LadoFíjate en qué hace cada decisión:
| Decisión | Por qué |
|---|---|
Directo no importa android.* | Se puede probar en la JVM, sin emulador |
golesEnVivo y golesAMano son privados | Solo Directo los escribe; fuera solo se ve marcador |
iniciar(alcance) recibe el CoroutineScope | El ViewModel pasa viewModelScope: el reloj muere con la pantalla |
El aviso lee golesEnVivo.value y golesAMano.value | Son los dueños; el combine de arriba puede ir un paso por detrás |
Un solo while | Con 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 → domaincontieneDirecto.ktyLado.kt, y es hermana deui. - «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íneasimportempieza porandroidni porandroidx. PartidoViewModel.ktya no contienegolAMano,empezarni ningúnMutableStateFlowdel reloj.
Comprueba lo que entendiste
5 preguntas · necesitas 4 para completar la lección
1.¿Qué hace
PartidoViewModelcon elDirectodespués de esta lección?2.El aviso del minuto 15 lee
golesEnVivo.valueygolesAMano.valueen lugar delFlowcombinadomarcador. ¿Por qué?3.Tras crear
Ladoenfeature.marcador.domain, la compilación falla enMarcadorUiState.ktconUnresolved reference 'Lado'. ¿Qué falta?4.¿Por qué
Directovive endomainde la feature y no dentro del módulo:torneo?5.¿Por qué
Directousa un solowhilepara el minuto, los goles del guion y el aviso?