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

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:

CarpetaQué 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
diCó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
laboratorioPruebas para ver cómo funciona Android
ejerciciosLos 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.

CapaSu trabajo¿Conoce Android?En Marcador
uiPintar el estado y recoger lo que hace el usuarioSí (Compose, ViewModel)feature/marcador/ui: PantallaMarcador, PartidoViewModel, MarcadorUiState
domainLas reglas de esta app: qué es un directo, cuándo avisa, qué es un golNofeature/marcador/domain: Directo y Lado, que crearás en la lección siguiente
dataDe dónde salen los datos y dónde se guardanDepende (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  ◄──  data

Por 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 ──► data

ui 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.

ejemplo mínimo: un dueño que expone solo lectura (con comentarios)
// 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.

con el bug: dos dueños del marcador
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
}
corregido: un dueño, el ViewModel solo traduce
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.
  • Job y launch: alcance.launch { … } arranca una corrutina y devuelve un Job, 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 el ViewModel pueda cancelarlo, por ejemplo al cambiar de partido.
  • Por qué List<Job> y no Job?: cuando el directo ya estaba en marcha no se lanza nada, y «ningún trabajo» se expresa con una lista vacía (emptyList()). Con Job? habría que devolver null y comprobarlo en cada uso; con una lista, el ViewModel recorre 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 si x no es null; si es null, no hace nada. Dentro del bloque, it es x.
  • 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:

fragmento: una clase de dominio con alcance prestado y el alEvento que la usa (con comentarios)
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:

  1. recoge los avisos distintos e imprímelos (pistas, de Flow: scan y combine: map saca el aviso de cada estado y distinctUntilChanged descarta los repetidos);
  2. enciende el horno e imprime cuántos trabajos devolvió;
  3. intenta encenderlo otra vez e imprime cuántos devolvió;
  4. 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 false
Ver solución
solución: un horno de dominio
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 import que empiece por android ni por androidx.
  • 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. 1.¿Qué característica tiene el código de la capa domain de Marcador?

  2. 2.En el flujo de datos unidireccional, ¿en qué dirección viaja cada cosa?

  3. 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. 4.¿Por qué una clase de dominio guarda el MutableStateFlow como privado y expone solo un StateFlow?

  5. 5.Una clase de dominio arranca su reloj con fun arrancar(alcance: CoroutineScope): List<Job>. ¿Para qué recibe el alcance en lugar de crearlo?

Faltan 5 de 5