Capas y flujo unidireccional
Al terminar: Organizar la app en capas de UI, dominio y datos con una única fuente de la verdad.
Al terminar esta lección sabrás repartir una app en tres capas (ui, domain y data), seguir el flujo de datos unidireccional y dar a cada dato un único dueño. Lo practicas en un ejercicio de Kotlin puro, un horno que se calienta solo, escrito en el Kotlin Playground. El proyecto no cambia todavía: en la lección siguiente aplicas todo esto a Marcador.
Importa porque una app crece por sitios que no ves al principio. Cuando cada cosa tiene su carpeta y su dueño, arreglar un fallo es buscar en un solo sitio, y probar una regla no exige un móvil. Temas: capas de UI, dominio y datos; flujo de datos unidireccional; única fuente de la verdad; una clase de dominio con alcance prestado. No hay nada que instalar.
Tres capas, cada una con un trabajo
Una capa es un grupo de código con una responsabilidad. Aquí, cada capa es un paquete (una carpeta cuyo nombre aparece en la línea package de sus ficheros). Una feature (funcionalidad) es un trozo de la app que el usuario reconoce como una cosa: el marcador, la lista de partidos, los goleadores. Marcador no tiene una sola ui, una sola data y una sola domain para todo: cada feature lleva las suyas dentro, feature/marcador/ui, feature/partidos/data, etc.
¿Y lo que no es de una sola feature? La regla completa, en cuatro frases:
- Lo que usa una sola feature se queda en ella.
- Lo que no es de ninguna feature (la base de datos, la red, los errores, los avisos, lo de pantalla que comparten varias) va en
core, en la carpeta de su capa:core/database,core/network,core/domain,core/ui,core/notifications. - Una feature puede usar el dominio de otra cuando ese dominio es suyo: los partidos son de
partidos, aunque los lean varias features. - Lo que sirve para leer la respuesta de una API de red (lo verás en la parte de red) va con la API que lo usa.
El mapa de carpetas de Marcador, en una frase cada una. Hoy existen algunas y las demás irán apareciendo, cada una cuando haga falta:
| Carpeta | Qué guarda |
|---|---|
feature/* | Una carpeta por funcionalidad, cada una con sus capas ui, domain y data |
core/* | Lo que no es de ninguna feature, una carpeta por capa |
di | Cómo se montan las piezas de la app (lo verás en la lección de Hilt) |
ui (la de com.sebastiannarvaez.marcador) | La cáscara de la app: las pestañas, la pila de pantallas y la barra |
laboratorio | Pruebas para ver cómo funciona Android |
ejercicios | Los ejercicios de cada lección |
Ya hay tres carpetas ui y no son lo mismo: la de cada feature (feature/marcador/ui) pinta las pantallas de esa feature; core/ui guarda lo de pantalla que usan varias features; y la ui de la raíz junta las pantallas en una sola app.
| Capa | Su trabajo | ¿Conoce Android? | En Marcador |
|---|---|---|---|
ui | Pintar el estado y recoger lo que hace el usuario | Sí (Compose, ViewModel) | feature/marcador/ui: PantallaMarcador, PartidoViewModel, MarcadorUiState |
domain | Las reglas de esta app: qué es un directo, cuándo avisa, qué es un gol | No | feature/marcador/domain: Directo y Lado, que crearás en la lección siguiente |
data | De dónde salen los datos y dónde se guardan | Depende (Room, DataStore o la red sí, y los verás más adelante; una lista en memoria no) | feature/partidos/data: DatosDeEjemplo |
¿Y :torneo? El módulo :torneo (con Partido, Marcador y despuesDe) también es Kotlin puro, y también es dominio: es el dominio compartido del torneo, las reglas que valen para cualquier app que use partidos. El paquete feature/marcador/domain añade las reglas propias de esta pantalla: un directo con reloj y aviso. Directo, que escribirás en la lección siguiente, usa los tipos de :torneo, pero no vive dentro de él por una razón de diseño, no técnica: el reloj a 250 ms por minuto y el aviso de cada 15 minutos son reglas de esta app, no del torneo. :torneo debe seguir sirviendo a cualquier app que use partidos, y por eso solo guarda lo que valdría para todas.
Kotlin puro significa código que solo necesita la JVM (la máquina virtual que ejecuta Kotlin sin Android, la de Los ficheros de Gradle de tu proyecto): ni un import android.* ni un import androidx.*. Por eso domain se puede probar en el ordenador, sin emulador.
Quién conoce a quién (las flechas van de quien depende hacia quien es conocido):
ui ──► domain ◄── dataPor dónde se mueve la información (aquí las flechas son el recorrido de los datos, no dependencias):
el estado BAJA data ──► domain ──► ui ──► pantalla
los eventos SUBEN pantalla ──► ui ──► domain ──► dataui y data conocen al dominio; el dominio no conoce a ninguna de las dos. Hoy ui todavía llama a data directamente (PartidoViewModel lee DatosDeEjemplo); en Repository: una sola puerta a los datos esa llamada pasa por una interfaz.
Estado abajo, eventos arriba, un solo dueño
Dos reglas se apoyan una en otra.
- Flujo de datos unidireccional (unidirectional data flow, UDF): el estado baja hacia la pantalla; los eventos (un toque, un botón) suben hacia quien manda. Nadie escribe el estado de otro.
- Única fuente de la verdad (single source of truth, SSOT): cada dato tiene un dueño, y solo el dueño lo modifica. El resto lo observa.
En Marcador, cuando termines la lección siguiente: el marcador lo posee Directo, el ViewModel lo traduce a MarcadorUiState y la pantalla lo pinta. El tiempo también tiene un solo dueño: un único bucle cuenta el minuto, aplica los goles del guion y calcula el aviso con ese mismo marcador.
// Dominio: Kotlin puro. Es el dueño del contador y lo expone de solo lectura.
class Contador {
private val golesActuales = MutableStateFlow(0) // privada: solo el Contador la escribe
val goles: StateFlow<Int> = golesActuales.asStateFlow() // hacia fuera, de solo lectura
fun marcarGol() = golesActuales.update { it + 1 } // el evento entra por una función
}Error típico: dos dueños para el mismo dato
Es el fallo que aparece cuando el ViewModel guarda una copia «por comodidad». No da error de compilación: da una pantalla que a veces enseña un dato viejo.
class PantallaMala(private val contador: Contador) : ViewModel() {
// Copia 1: la que pinta la pantalla.
val goles = MutableStateFlow(0)
// Copia 2: la que lee el aviso de «¡tres goles!».
private var golesParaElAviso = 0
}class PantallaBuena(private val contador: Contador) : ViewModel() {
// El dato sigue en Contador; aquí solo se reexpone para la pantalla.
val goles: StateFlow<Int> = contador.goles
}Falla porque dos copias se actualizan en momentos distintos: entre una y otra, el aviso puede leer un gol de menos. Con un solo dueño esa pregunta no existe.
Una clase de dominio con alcance prestado
Antes del ejercicio, las piezas que necesita y que aún no has visto juntas. Una clase de dominio puede lanzar su propio reloj, pero no crea la corrutina: usa el alcance que le presta quien la llama. La de Marcador, Directo, lo hará así en la lección siguiente.
fun arrancar(alcance: CoroutineScope): List<Job>: la clase no crea su corrutina; usa la que le pasan. Así, quien la llama decide cuándo se cancela.Jobylaunch:alcance.launch { … }arranca una corrutina y devuelve unJob, un manejador de esa corrutina con el que se puede cancelar (job.cancel()), como viste en Alcance, Job y cancelación. La función lo devuelve para que elViewModelpueda cancelarlo, por ejemplo al cambiar de partido.- Por qué
List<Job>y noJob?: cuando el directo ya estaba en marcha no se lanza nada, y «ningún trabajo» se expresa con una lista vacía (emptyList()). ConJob?habría que devolvernully comprobarlo en cada uso; con una lista, elViewModelrecorre lo que haya (trabajos.forEach { it.cancel() }) sin casos especiales, y mañana el directo podría lanzar más de una corrutina sin cambiar la firma. fun f(…) = expresión: la función de cuerpo de expresión de Funciones y parámetros; con= when (…) { … }devuelve lo que dé la rama.x?.let { … }: ejecuta el bloque solo sixno esnull; si esnull, no hace nada. Dentro del bloque,itesx.data class Estado(…)dentro de la clase: un tipo pequeño que agrupa lo que cambia con el tiempo.
Las piezas de arriba juntas, en una clase de dominio inventada, un temporizador de cocina (no es Directo), y en el ViewModel que la traduce:
class Temporizador(private val segundosTotales: Int) { // Kotlin puro: ni android ni androidx
data class Estado(val segundo: Int = 0, val sonando: Boolean = false) // agrupa lo que cambia con el tiempo
private val estadoActual = MutableStateFlow(Estado()) // privada y escribible
val estado: StateFlow<Estado> = estadoActual.asStateFlow() // pública y de solo lectura
fun reiniciar() = estadoActual.update { Estado() } // cuerpo de expresión: = en vez de llaves y return
fun arrancar(alcance: CoroutineScope): List<Job> { // no crea su alcance: usa el que le pasan
if (estadoActual.value.segundo > 0) return emptyList() // ya arrancado: ningún trabajo nuevo
return listOf(alcance.launch { // launch devuelve un Job; va en una lista
while (estadoActual.value.segundo < segundosTotales) {
delay(1_000)
estadoActual.update { it.copy(segundo = it.segundo + 1) } // copy: el mismo Estado con un campo cambiado
}
estadoActual.update { it.copy(sonando = true) }
})
}
}
// En el ViewModel, que solo traduce y reenvía (fragmento: CocinaEvento, temporizadorActual y trabajos son del ViewModel de la cocina, que no se muestra entero):
fun alEvento(evento: CocinaEvento) {
when (evento) {
CocinaEvento.Arrancar -> temporizadorActual?.let { trabajos = it.arrancar(viewModelScope) } // le presta su alcance
CocinaEvento.Reiniciar -> temporizadorActual?.reiniciar()
}
}Reto · Ejercicio: el horno
Fuera de tu proyecto, en el Kotlin Playground.
Escribe una clase de dominio, sin nada de Android, que caliente un horno:
- se crea con la temperatura objetivo y los milisegundos que tarda cada paso;
- es la única dueña de su estado: los grados (empiezan en 20), si está encendido y el último aviso (empieza en «ninguno»). Desde fuera solo se pueden leer;
- tiene una función que lo enciende en el alcance de corrutinas que le pasen y devuelve la lista de trabajos que lanzó: ninguno si ya estaba encendido o si ya había llegado a la temperatura;
- en un solo bucle, cada paso sube 20 grados; cuando los grados son múltiplo de 60 (60, 120, 180…), guarda como aviso un texto con los grados de ese momento, por ejemplo «120 grados»; al llegar a la temperatura objetivo, se apaga.
En main, con objetivo 180 y 10 ms por paso:
- recoge los avisos distintos e imprímelos (pistas, de Flow: scan y combine:
mapsaca el aviso de cada estado ydistinctUntilChangeddescarta los repetidos); - enciende el horno e imprime cuántos trabajos devolvió;
- intenta encenderlo otra vez e imprime cuántos devolvió;
- espera a que acaben los trabajos (
job.join()suspende hasta que ese trabajo termina, como viste en Alcance, Job y cancelación) e imprime el final.
La consola debe mostrar:
trabajos: 1
otra vez: 0
ninguno
60 grados
120 grados
180 grados
final: 180 grados, encendido falseVer solución
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
// Dominio: Kotlin puro. El horno es el único dueño de su estado.
class Horno(private val objetivo: Int, private val msPorPaso: Long) {
data class Estado(val grados: Int = 20, val encendido: Boolean = false, val ultimoAviso: String = "ninguno")
private val estadoActual = MutableStateFlow(Estado()) // privado: solo el horno lo cambia
val estado: StateFlow<Estado> = estadoActual.asStateFlow() // público y de solo lectura
fun encender(alcance: CoroutineScope): List<Job> { // el alcance es prestado
if (estadoActual.value.encendido || estadoActual.value.grados >= objetivo) return emptyList()
estadoActual.update { it.copy(encendido = true) }
return listOf(alcance.launch { // un solo bucle
while (estadoActual.value.grados < objetivo) {
delay(msPorPaso)
val nuevos = estadoActual.value.grados + 20
estadoActual.update { it.copy(grados = nuevos) }
if (nuevos % 60 == 0) estadoActual.update { it.copy(ultimoAviso = "$nuevos grados") }
}
estadoActual.update { it.copy(encendido = false) }
})
}
}
fun main() = runBlocking {
val horno = Horno(objetivo = 180, msPorPaso = 10)
val avisos = launch { horno.estado.map { it.ultimoAviso }.distinctUntilChanged().collect { println(it) } }
val trabajos = horno.encender(this)
println("trabajos: ${trabajos.size}")
println("otra vez: ${horno.encender(this).size}")
trabajos.forEach { it.join() }
avisos.cancel()
println("final: ${horno.estado.value.grados} grados, encendido ${horno.estado.value.encendido}")
}Es la misma idea que tendrá Directo en la lección siguiente: el alcance es prestado, quien llama decide cuándo cancelar y «ninguno» se expresa con una lista vacía. Nada de esto importa android.
Por qué «ninguno» sale después de «trabajos: 1» y «otra vez: 0»: launch no ejecuta el cuerpo del horno ni el recolector de avisos hasta que runBlocking suspende (en trabajos.forEach { it.join() }), así que los println de main salen primero y los avisos van detrás.
Punto de control
- En el Playground, la consola muestra exactamente las siete líneas de arriba.
- Tu clase del horno no tiene ningún
importque empiece porandroidni porandroidx. - Sabes decir de cada capa qué conoce y qué no, y quién es el dueño de cada dato del horno.
Siguiente: Capas en Marcador: el directo, donde sacas el reloj del partido del ViewModel a una clase Directo y creas el paquete de dominio de la feature.
Comprueba lo que entendiste
5 preguntas · necesitas 4 para completar la lección
1.¿Qué característica tiene el código de la capa
domainde Marcador?2.En el flujo de datos unidireccional, ¿en qué dirección viaja cada cosa?
3.Una pantalla enseña un marcador y el aviso de cada 15 minutos dice otro. Según lo aprendido, ¿cuál es la causa más probable?
4.¿Por qué una clase de dominio guarda el
MutableStateFlowcomo privado y expone solo unStateFlow?5.Una clase de dominio arranca su reloj con
fun arrancar(alcance: CoroutineScope): List<Job>. ¿Para qué recibe el alcance en lugar de crearlo?