Tema 2. Arquitectura MVVM y ViewModel
- Bloque: B2 — Arquitectura MVVM y desarrollo de aplicaciones Android
- Duración aproximada: 14 horas
- RA2 — Desarrolla aplicaciones para dispositivos móviles analizando y empleando las tecnologías y librerías específicas.
| Código | Criterio |
|---|---|
| RA2-a | Se ha generado la estructura de clases necesaria para la aplicación. |
| RA2-b | Se han analizado y utilizado las clases que modelan ventanas, menús, alertas y controles para el desarrollo de aplicaciones gráficas sencillas. |
| RA2-g | Se han realizado pruebas de interacción usuario-aplicación para optimizar las aplicaciones desarrolladas a partir de emuladores. |
| RA2-i | Se han documentado los procesos necesarios para el desarrollo de las aplicaciones. |
Dependencias necesarias#
Partiendo de la última versión de AppDummy, si no lo tienes, puedes descargarla desde v3. Ejemplo práctico Tema 1D
. Se añaden las siguientes dependencias al build.gradle.kts del módulo app:
1// build.gradle.kts (app) — añadir a las del Bloque 1
2dependencies {
3 // ViewModel y Lifecycle
4 implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.10.0")
5 implementation("androidx.lifecycle:lifecycle-runtime-compose:2.10.0")
6
7 // Corrutinas
8 implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.2")
9
10 // Testing
11 testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.10.2")
12}Todos los artefactos
lifecycle-*deben compartir la misma versión. Consulta la página de releases de Lifecycle para verificar la última versión estable antes de iniciar un nuevo proyecto.
1. El problema que resuelve la arquitectura#
En el Bloque 1 construimos pantallas que gestionaban su propio estado con remember. Este enfoque funciona para pantallas simples, pero presenta problemas graves en cuanto la aplicación crece:
1// ❌ Antipatrón — lógica de negocio mezclada con la UI
2@Composable
3fun PantallaListadoMal() {
4 var libros by remember { mutableStateOf<List<String>>(emptyList()) }
5 var cargando by remember { mutableStateOf(false) }
6
7 // Problema 1: al rotar la pantalla se pierde todo el estado
8 // Problema 2: imposible testear la lógica sin instanciar la pantalla
9 // Problema 3: si otra pantalla necesita los mismos datos, hay que duplicar la lógica
10 LaunchedEffect(Unit) {
11 cargando = true
12 libros = cargarDatosDeAlgunSitio()
13 cargando = false
14 }
15}Los tres problemas fundamentales del enfoque sin arquitectura son la pérdida de estado cuando Android destruye y recrea la Activity (rotación de pantalla, proceso en background), la mezcla de responsabilidades (la UI decide qué datos cargar, cómo cargarlos y cómo mostrarlos a la vez), y la imposibilidad de testear la lógica sin instanciar una pantalla real de Android.
La solución es el patrón MVVM combinado con ViewModel.
2. El patrón MVVM#
MVVM (Model-View-ViewModel) es el patrón arquitectónico recomendado por Google para Android. Divide la aplicación en tres capas con responsabilidades bien definidas y un flujo de datos unidireccional: los datos bajan del modelo a la vista, los eventos suben de la vista al ViewModel.
Eventos (click, input...)
│
▼
┌─────────────────────┐
│ VIEW │
│ @Composable │
│ Solo muestra datos │
│ y captura eventos │
└─────────────────────┘
│
observa StateFlow · emite eventos
│
▼
┌──────────────────────┐
│ VIEWMODEL │
│ Gestiona el estado │
│ Sobrevive rotación │
│ Llama al Repository │
└──────────────────────┘
│
llama a métodos del Repository
│
▼
┌──────────────────────┐
│ REPOSITORY │
│ Única fuente de │
│ verdad de los datos │
│ BD + Red + Archivo │
└──────────────────────┘Responsabilidades de cada capa#
| Capa | Clase típica | Hace | No hace |
|---|---|---|---|
| View | @Composable |
Mostrar UI, capturar eventos | Lógica de negocio, acceso a datos |
| ViewModel | :ViewModel() |
Gestionar estado, coordinar lógica | Conocer detalles de la UI, acceder a BD/red directamente |
| Repository | clase normal | Coordinar fuentes de datos | Conocer la UI, exponer detalles de implementación |
| DataSource | DAO, API Service | Acceder a una fuente concreta | Mezclar fuentes, lógica de presentación |
| Model | data class |
Representar los datos | Cualquier lógica |
3. ViewModel 🔑#
ViewModel es la clase de Jetpack Architecture Components que implementa la capa ViewModel del patrón MVVM. Su característica más importante es que sobrevive a los cambios de configuración: cuando el usuario rota la pantalla, Android destruye y recrea la Activity, pero el ViewModel permanece intacto con todos sus datos.
3.1 Ciclo de vida#
Usuario abre la app
│
[ViewModel CREADO]
│
Activity creada ──► [app en uso]
│ │
Usuario rota la pantalla │
│ │
Activity DESTRUIDA ────────┘
ViewModel SOBREVIVE ◄── clave
│
Activity RECREADA ──► mismo ViewModel reutilizado
│
Usuario sale definitivamente
│
[ViewModel DESTRUIDO → onCleared()]3.2 Crear y obtener un ViewModel en Compose#
La función viewModel() de la librería lifecycle-viewmodel-compose crea o reutiliza el ViewModel del scope actual, gestionando su ciclo de vida correctamente:
1@Composable
2fun PantallaListado(
3 // viewModel() crea el ViewModel si no existe, o devuelve el que ya hay
4 viewModel: LibrosViewModel = viewModel(factory = LibrosViewModel.Factory)
5) {
6 // ...
7}Si instanciaras el ViewModel directamente con
LibrosViewModel(), se crearía una nueva instancia en cada recomposición y no sobreviviría a la rotación. La funciónviewModel()garantiza que Jetpack gestiona correctamente su ciclo de vida.
3.3 ViewModelProvider.Factory sin inyección de dependencias#
Cuando el ViewModel necesita recibir el repositorio en su constructor, se usa un ViewModelProvider.Factory. Sin Hilt ni Koin, el patrón recomendado desde Lifecycle 2.5+ es el DSL viewModelFactory { initializer { } }:
1// ─── screens/listado/LibrosViewModel.kt ──────────────────────────────────────────────────────────
2class LibrosViewModel(
3 private val repository: LibrosRepository
4) : ViewModel() {
5
6 companion object {
7 val Factory: ViewModelProvider.Factory = viewModelFactory {
8 initializer {
9 // Instanciamos el repositorio directamente aquí.
10 // En B3, cuando necesite ROOM o Retrofit2, vendrá de un AppContainer.
11 LibrosViewModel(LibrosRepository())
12 }
13 }
14 }
15}
16
17// Uso en el Composable:
18val viewModel: LibrosViewModel = viewModel(factory = LibrosViewModel.Factory)4. Estado de la UI: UiState con StateFlow#
4.1 StateFlow — flujo de estado reactivo#
StateFlow es un flujo de datos reactivo que siempre mantiene un valor actual. Cuando ese valor cambia, Compose lo detecta automáticamente y recompone los elementos afectados. Es la alternativa moderna a LiveData en proyectos Kotlin:
| Aspecto | LiveData |
StateFlow |
|---|---|---|
| Lenguaje | Java/Kotlin | Kotlin puro |
| Integración con corrutinas | Adaptadores externos | Nativa |
| Valor inicial | Opcional | Obligatorio |
| Observar en Compose | observeAsState() |
collectAsStateWithLifecycle() ✅ |
| Recomendación Google | Proyectos legacy | Proyectos nuevos ✅ |
4.2 El patrón backing property#
El ViewModel expone el estado a través de un par de propiedades: mutable y privada (que solo él puede modificar) e inmutable y pública (que los Composables solo pueden leer). Esta técnica se denomina backing property:
1// ─── screens/listado/LibrosViewModel.kt ──────────────────────────────────────────────────────────
2class LibrosViewModel : ViewModel() {
3
4 // Mutable y PRIVADO: solo el ViewModel puede modificarlo
5 private val _uiState = MutableStateFlow<LibrosUiState>(LibrosUiState.Cargando)
6
7 // Inmutable y PÚBLICO: los Composables solo pueden leerlo
8 val uiState: StateFlow<LibrosUiState> = _uiState.asStateFlow()
9}.asStateFlow() convierte el MutableStateFlow en un StateFlow de solo lectura. Sin este patrón, cualquier Composable podría modificar el estado directamente desde la UI, violando el principio de flujo unidireccional.
4.3 UiState como sealed class#
La clase UiState modela todos los estados posibles de una pantalla. Usar una sealed class obliga al compilador a verificar que el when en la UI cubre todos los casos posibles, eliminando bugs por estados no contemplados:
1// ─── datal/model/Libro.kt ────────────────────────────────────────────────────────────────────────
2// Modelo de datos — se reutilizará en B3 con ROOM y Retrofit2
3data class Libro(
4 val id: Int = 0,
5 val titulo: String = "",
6 val autor: String = "",
7 val year: Int? = 1900,
8 val isbn: String = "",
9 val cover: String = "",
10 val esFavorito: Boolean = false,
11 val leido: Boolean = false
12)
13
14// ─── screens/listado/LibrosUiState.kt ────────────────────────────────────────────────────────────
15// Estados posibles de la pantalla de listado
16sealed class LibrosUiState {
17 // data object (Kotlin 1.9+): genera toString() legible ("Cargando"
18 // en lugar del hash de referencia). Usar siempre para estados sin datos.
19 data object Cargando : LibrosUiState()
20 data class Exito(val libros: List<Libro>) : LibrosUiState()
21 data class Error(val mensaje: String) : LibrosUiState()
22}
data objectvsobject: Conobject,println(estado)mostraría algo comoLibrosUiState$Cargando@3a2b1c. Condata object(Kotlin 1.9+) muestra simplementeCargando, lo que facilita enormemente el debugging.
4.4 Ejemplo base de AppDummy: repositorio y ViewModel completos#
1// ─── data/repository/LibrosRepository.kt ─────────────────────────────────────────────────────────
2// En B3 este repositorio coordinará ROOM y Retrofit2.
3// Por ahora usa datos estáticos para centrar el aprendizaje en la arquitectura.
4class LibrosRepository {
5
6 private val libros = listOf(
7 Libro(1, "Proyecto Hail Mary", "Andy Weir", 2021, "9788418037016", "https://covers.openlibrary.org/b/isbn/9788418037016-L.jpg", true, false),
8 Libro(2, "Juego de tronos", "George R.R. Martin", 1996, "9780307951182", "https://covers.openlibrary.org/b/isbn/9780307951182-L.jpg", true, true),
9 Libro(3, "Festín de cuervos", "George R.R. Martin", 2005, "9780307951212", "https://covers.openlibrary.org/b/isbn/9780307951212-L.jpg", false, false),
10 Libro(4, "Cementerio de Animales", "Stephen King", 1983, "9788401499845", "https://covers.openlibrary.org/b/isbn/9788401499845-L.jpg", false, true),
11 Libro(5, "El juego de Ender", "Orson Scott Card", 1985, "9788498720068", "https://covers.openlibrary.org/b/isbn/9788498720068-L.jpg", false, true)
12 )
13
14 // suspend: puede suspenderse sin bloquear el hilo principal
15 suspend fun getLibros(): List<Libro> {
16 kotlinx.coroutines.delay(800.milliseconds) // simula latencia de red
17 return libros
18 }
19
20 suspend fun getAutores(): List<String> {
21 kotlinx.coroutines.delay(300.milliseconds)
22 return libros.map { it.autor }.distinct().sorted()
23 }
24
25 suspend fun getLibroPorId(id: Int): Libro? {
26 kotlinx.coroutines.delay(300.milliseconds)
27 return libros.find { it.id == id }
28 }
29} 1// ─── screens/listado/LibrosViewModel.kt ──────────────────────────────────────────────────────────
2class LibrosViewModel(
3 private val repository: LibrosRepository
4) : ViewModel() {
5
6 // Estado principal de la UI
7 private val _uiState = MutableStateFlow<LibrosUiState>(LibrosUiState.Cargando)
8 val uiState: StateFlow<LibrosUiState> = _uiState.asStateFlow()
9
10 // Estado del campo de búsqueda — independiente del UiState principal
11 private val _busqueda = MutableStateFlow("")
12 val busqueda: StateFlow<String> = _busqueda.asStateFlow()
13
14 // Estado del campo de búsqueda — independiente del UiState principal
15 private val _autores = MutableStateFlow(listOf("Todos"))
16 val autores: StateFlow<List<String>> = _autores.asStateFlow()
17
18 private val _autorSeleccionado = MutableStateFlow("Todos")
19 val autorSeleccionado: StateFlow<String> = _autorSeleccionado.asStateFlow()
20
21 init {
22 // Se ejecuta al crear el ViewModel — carga los datos automáticamente
23 cargarLibros()
24 cargarAutores()
25 }
26
27 fun cargarLibros() {
28 // viewModelScope: corrutina ligada al ViewModel, se cancela al destruirlo
29 viewModelScope.launch {
30 _uiState.value = LibrosUiState.Cargando
31 try {
32 val libros = repository.getLibros()
33 _uiState.value = LibrosUiState.Exito(libros)
34 } catch (e: Exception) {
35 _uiState.value = LibrosUiState.Error(
36 e.message ?: "Error desconocido al cargar los libros"
37 )
38 }
39 }
40 }
41
42 fun cargarAutores() {
43 viewModelScope.launch {
44 try {
45 val autores = repository.getAutores()
46 _autores.value = listOf("Todos") + autores
47 } catch (e: Exception) {
48 _uiState.value = LibrosUiState.Error(
49 e.message ?: "Error desconocido al cargar los autores"
50 )
51 }
52 }
53 }
54
55 fun actualizarBusqueda(texto: String) {
56 _busqueda.value = texto
57 }
58
59 fun toggleFavorito(id: Int) {
60 // update{} es atómico y thread-safe: lee el estado actual y produce el nuevo
61 // en una sola operación, sin condiciones, evitando problemas de concurrencia.
62 _uiState.update { estado ->
63 if (estado is LibrosUiState.Exito) {
64 estado.copy(
65 libros = estado.libros.map { Libro ->
66 if (Libro.id == id) Libro.copy(esFavorito = !Libro.esFavorito)
67 else Libro
68 }
69 )
70 } else estado
71 }
72 }
73
74 fun toggleLeido(id: Int) {
75 _uiState.update { estado ->
76 if (estado is LibrosUiState.Exito) {
77 estado.copy(
78 libros = estado.libros.map { Libro ->
79 if (Libro.id == id) Libro.copy(leido = !Libro.leido)
80 else Libro
81 }
82 )
83 } else estado
84 }
85 }
86
87 fun actualizarAutorSeleccionado(autor: String) {
88 _autorSeleccionado.value = autor
89 }
90
91 companion object {
92 val Factory: ViewModelProvider.Factory = viewModelFactory {
93 initializer {
94 LibrosViewModel(LibrosRepository())
95 }
96 }
97 }
98}4.5 View: consumir el estado con collectAsStateWithLifecycle#
collectAsStateWithLifecycle() es la función recomendada en Android para observar un StateFlow desde Compose. A diferencia de collectAsState(), pausa la recolección cuando la app pasa a segundo plano, ahorrando recursos y batería:
1// ─── screens/listado/PantallaListado.kt ──────────────────────────────────────────────────────────
2@OptIn(ExperimentalMaterial3Api::class)
3@Composable
4fun PantallaListado(
5 viewModel: LibrosViewModel = viewModel(factory = LibrosViewModel.Factory),
6 onNavegaADetalle: (Int) -> Unit = {} // callback de navegación (se conecta al NavHost en T3)
7) {
8 // collectAsStateWithLifecycle: activo en foreground, pausado en background
9 val uiState by viewModel.uiState.collectAsStateWithLifecycle()
10 val busqueda by viewModel.busqueda.collectAsStateWithLifecycle()
11 val autorSeleccionado by viewModel.autorSeleccionado.collectAsStateWithLifecycle()
12 val autores by viewModel.autores.collectAsStateWithLifecycle()
13
14 Scaffold(
15 topBar = {
16 TopAppBar(
17 title = { Text("AppDummy") },
18 actions = {
19 IconButton(onClick = { }) {
20 Icon(Icons.Default.AccountCircle, contentDescription = "Perfil")
21 }
22 }
23 )
24 }
25 ) { paddingValues ->
26 Column(modifier = Modifier.padding(paddingValues)) {
27 // Barra de búsqueda
28 OutlinedTextField(
29 value = busqueda,
30 onValueChange = viewModel::actualizarBusqueda,
31 modifier = Modifier
32 .fillMaxWidth()
33 .padding(horizontal = 16.dp, vertical = 8.dp),
34 placeholder = { Text("Buscar libros...") },
35 leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) },
36 trailingIcon = {
37 AnimatedVisibility(visible = busqueda.isNotEmpty()) {
38 IconButton(onClick = { viewModel.actualizarBusqueda("") }) {
39 Icon(Icons.Default.Clear, contentDescription = "Borrar búsqueda")
40 }
41 }
42 },
43 singleLine = true
44 )
45
46 // Chips de autores
47 LazyRow(
48 contentPadding = PaddingValues(horizontal = 16.dp),
49 horizontalArrangement = Arrangement.spacedBy(8.dp),
50 modifier = Modifier.padding(bottom = 8.dp)
51 ) {
52 items(autores) { autor ->
53 FilterChip(
54 selected = autor == autorSeleccionado,
55 onClick = { viewModel.actualizarAutorSeleccionado(autor) },
56 label = { Text(autor) }
57 )
58 }
59 }
60
61 // when exhaustivo sobre la sealed class
62 when (val estado = uiState) {
63 is LibrosUiState.Cargando -> {
64 Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
65 CircularProgressIndicator()
66 }
67 }
68
69 is LibrosUiState.Exito -> {
70 val librosFiltrados = estado.libros.filter {
71 val coincideBusqueda = busqueda.isBlank() || it.titulo.contains(busqueda, ignoreCase = true)
72 val coincideGenero = autorSeleccionado == "Todos" || it.autor == autorSeleccionado
73 coincideBusqueda && coincideGenero
74 }
75 if (librosFiltrados.isEmpty()) {
76 Box(
77 modifier = Modifier.fillMaxSize(),
78 contentAlignment = Alignment.Center
79 ) {
80 Column(horizontalAlignment = Alignment.CenterHorizontally) {
81 Icon(
82 Icons.Default.SearchOff,
83 contentDescription = null,
84 modifier = Modifier.size(64.dp),
85 tint = MaterialTheme.colorScheme.onSurfaceVariant
86 )
87 Spacer(modifier = Modifier.height(16.dp))
88 Text(
89 "Sin resultados para \"$busqueda\"",
90 style = MaterialTheme.typography.bodyLarge
91 )
92 }
93 }
94 } else {
95 LazyVerticalGrid(
96 contentPadding = PaddingValues(horizontal = 8.dp),
97 columns = GridCells.Fixed(2),
98 horizontalArrangement = Arrangement.spacedBy(8.dp),
99 verticalArrangement = Arrangement.spacedBy(8.dp)
100 ) {
101 items(librosFiltrados, key = { it.id }) { libro ->
102 ItemLibro(
103 libro = libro,
104 onClickItem = { onNavegaADetalle(libro.id) },
105 onToggleLeido = { viewModel.toggleLeido(it) },
106 onToggleFavorito = { viewModel.toggleFavorito(it) }
107 )
108 }
109 }
110 }
111 }
112
113 is LibrosUiState.Error -> {
114 Column(
115 modifier = Modifier.fillMaxSize().padding(32.dp),
116 horizontalAlignment = Alignment.CenterHorizontally,
117 verticalArrangement = Arrangement.Center
118 ) {
119 Icon(
120 Icons.Default.ErrorOutline,
121 contentDescription = null,
122 modifier = Modifier.size(64.dp),
123 tint = MaterialTheme.colorScheme.error
124 )
125 Spacer(modifier = Modifier.height(16.dp))
126 Text(
127 text = estado.mensaje,
128 color = MaterialTheme.colorScheme.error
129 )
130 Spacer(modifier = Modifier.height(16.dp))
131 Button(onClick = viewModel::cargarLibros) { Text("Reintentar") }
132 }
133 }
134 }
135 }
136 }
137}
138
139@Composable
140fun ItemLibro(
141 libro: Libro,
142 onClickItem: () -> Unit = {},
143 onToggleLeido: (Int) -> Unit = {},
144 onToggleFavorito: (Int) -> Unit = {}
145) {
146 Card(onClick = onClickItem, modifier = Modifier.fillMaxWidth()) {
147 Column(
148 modifier = Modifier.fillMaxWidth().padding(8.dp)
149 ) {
150 Text(libro.titulo, style = MaterialTheme.typography.titleSmall)
151 if (Patterns.WEB_URL.matcher(libro.cover).matches())
152 AsyncImage(
153 model = ImageRequest.Builder(LocalContext.current)
154 .data(libro.cover)
155 .crossfade(true)
156 .build(),
157 contentDescription = "Portada de ${libro.titulo}",
158 contentScale = ContentScale.Crop,
159 modifier = Modifier.width(200.dp)
160 .align(Alignment.CenterHorizontally)
161 )
162 else // Si no es una URL válida, se muestra una imagen por defecto
163 AsyncImage(
164 model = R.drawable.nocover,
165 contentDescription = "Portada de ${libro.titulo}",
166 contentScale = ContentScale.Crop,
167 modifier = Modifier.width(200.dp)
168 )
169
170 Text(
171 text = "${libro.autor} • ${libro.year}",
172 style = MaterialTheme.typography.bodySmall,
173 color = MaterialTheme.colorScheme.onSurfaceVariant
174 )
175 Row(
176 modifier = Modifier.fillMaxWidth(),
177 horizontalArrangement = Arrangement.Center
178 ) {
179 IconButton(
180 modifier = Modifier.fillMaxSize().weight(0.5f),
181 onClick = { onToggleLeido(libro.id) }) {
182 Icon(
183 imageVector = if (libro.leido) Icons.Default.BookmarkAdded
184 else Icons.Default.BookmarkBorder,
185 contentDescription = if (libro.leido) "Quitar leído" else "Marcar como leído",
186 tint = if (libro.leido) MaterialTheme.colorScheme.error
187 else MaterialTheme.colorScheme.onSurfaceVariant
188 )
189 }
190 IconButton(
191 modifier = Modifier.fillMaxSize().weight(0.5f),
192 onClick = { onToggleFavorito(libro.id) }) {
193 Icon(
194 imageVector = if (libro.esFavorito) Icons.Default.Favorite
195 else Icons.Default.FavoriteBorder,
196 contentDescription = if (libro.esFavorito) "Quitar favorito" else "Añadir favorito",
197 tint = if (libro.esFavorito) MaterialTheme.colorScheme.error
198 else MaterialTheme.colorScheme.onSurfaceVariant
199 )
200 }
201 }
202 }
203 }
204}
205
206@Preview(showBackground = true)
207@Composable
208fun PantallaListadoPreview() {
209 PantallaListado()
210}5. Clean Architecture simplificada#
Clean Architecture organiza el código en capas con dependencias bien definidas. Se aplicará una versión simplificada y pragmática: sin capa de dominio explícita, sin DTOs, sin inyección de dependencias externa, y reutilizando el mismo data class tanto para ROOM como para Retrofit2.
5.1 Regla de dependencias#
Las dependencias siempre apuntan hacia los datos, nunca hacia la UI:
UI → ViewModel → Repository → DataSource → Frameworks (ROOM, Retrofit2)El Composable conoce al ViewModel, pero no al revés. El ViewModel conoce al Repository, pero no al revés. El Repository coordina los DataSource, pero estos no se conocen entre sí.
5.2 Estructura de paquetes del proyecto AppDummy#
com.ejemplo.appdummy/
├── MainActivity.kt
│
├── screens/
│ ├── listado/
│ │ ├── PantallaListado.kt ← @Composable
│ │ ├── LibrosViewModel.kt ← ViewModel + Factory
│ │ └── LibrosUiState.kt ← sealed class
│ ├── detalle/
│ │ ├── PantallaDetalle.kt
│ │ └── DetalleViewModel.kt
│ └── navegacion/
│ └── AppNavigation.kt ← NavHost (Tema 3)
│
├── ui/
│ └── theme/
│ ├── Color.kt
│ ├── Type.kt
│ └── Theme.kt
│
└── data/
├── model/
│ └── Libro.kt ← data class compartida
├── repository/
│ └── LibrosRepository.kt
└── datasource/
├── LocalDataSource.kt ← ROOM (Bloque 3)
└── RemoteDataSource.kt ← Retrofit2 (Bloque 3)6. Eventos de un solo disparo: SharedFlow#
Hasta se ha usado StateFlow para el estado persistente de la UI. Pero hay acciones que deben ocurrir una sola vez y no repetirse si se suscribe un nuevo observador: mostrar un Snackbar, navegar a otra pantalla tras guardar, mostrar un cuadro de diálogo de confirmación. Para estos casos existe SharedFlow:
StateFlow |
SharedFlow |
|
|---|---|---|
| Tiene valor actual | ✅ Sí | ❌ No |
| Se repite al suscribirse | ✅ Sí (último valor) | ❌ No (con replay = 0) |
| Uso típico | Estado de la UI | Eventos de un solo disparo |
| Ejemplo | Lista de libros, indicador de carga | Navegación, Snackbar, diálogo |
1// Eventos de UI — ocurren una sola vez
2sealed class LibrosEvento {
3 data class MostrarSnackbar(val mensaje: String) : LibrosEvento()
4 data class NavegarADetalle(val id: Int) : LibrosEvento()
5}
6
7class LibrosViewModel(private val repository: LibrosRepository) : ViewModel() {
8
9 private val _uiState = MutableStateFlow<LibrosUiState>(LibrosUiState.Cargando)
10 val uiState: StateFlow<LibrosUiState> = _uiState.asStateFlow()
11
12 // ...
13
14 // replay = 0: los eventos no se repiten para nuevos colectores
15 // extraBufferCapacity = 1: evita suspensión si no hay colector en ese instante
16 private val _eventos = MutableSharedFlow<LibrosEvento>(
17 replay = 0,
18 extraBufferCapacity = 1
19 )
20 val eventos: SharedFlow<LibrosEvento> = _eventos.asSharedFlow()
21
22 // ...
23
24 fun toggleFavorito(libro: Libro) {
25 _uiState.update { estado ->
26 if (estado is LibrosUiState.Exito) {
27 estado.copy(libros = estado.libros.map {
28 if (it.id == libro.id) it.copy(esFavorito = !it.esFavorito) else it
29 })
30 } else estado
31 }
32 viewModelScope.launch {
33 _eventos.emit(
34 LibrosEvento.MostrarSnackbar(
35 if (libro.esFavorito) "\"${libro.titulo}\" eliminado de favoritos"
36 else "\"${libro.titulo}\" añadido a favoritos"
37 )
38 )
39 }
40 }
41
42 fun toggleLeido(libro: Libro) {
43 _uiState.update { estado ->
44 if (estado is LibrosUiState.Exito) {
45 estado.copy(libros = estado.libros.map {
46 if (it.id == libro.id) it.copy(leido = !it.leido) else it
47 })
48 } else estado
49 }
50 viewModelScope.launch {
51 _eventos.emit(
52 LibrosEvento.MostrarSnackbar(
53 if (libro.leido) "\"${libro.titulo}\" marcado como no leído"
54 else "\"${libro.titulo}\" marcado como leído"
55 )
56 )
57 }
58 }
59} 1// En el Composable: LaunchedEffect escucha los eventos del ViewModel
2@Composable
3fun PantallaListado(
4 viewModel: LibrosViewModel = viewModel(factory = LibrosViewModel.Factory),
5 onNavegaADetalle: (Int) -> Unit = {} // callback de navegación (se conecta al NavHost en T3)
6) {
7 val snackbarHostState = remember { SnackbarHostState() }
8 val scope = rememberCoroutineScope()
9 var snackbarJob by remember { mutableStateOf<Job?>(null) }
10
11 // LaunchedEffect con Unit: se lanza una única vez al montar el Composable
12 LaunchedEffect(Unit) {
13 viewModel.eventos.collect { evento ->
14 when (evento) {
15 is LibrosEvento.MostrarSnackbar -> {
16 // Cancelar snackbar previo si existe
17 snackbarJob?.cancel()
18 // Descarta el Snackbar visible en pantalla
19 snackbarHostState.currentSnackbarData?.dismiss()
20 // Lanzar un nuevo Snackbar
21 snackbarJob = scope.launch {
22 snackbarHostState.showSnackbar(evento.mensaje)
23 }
24 }
25 is LibrosEvento.NavegarADetalle -> { /* navController.navigate(ruta tipada — ver T3 )*/ }
26 }
27 }
28 }
29
30 Scaffold(
31 // ...
32 snackbarHost = { SnackbarHost(snackbarHostState) }
33 ) { paddingValues ->
34 // ...
35}La complejidad añadida para mostrar el
Snackbares mínima y permite que el ViewModel controle la lógica de negocio, mientras que la UI solo se encarga de mostrarlo. Esto mantiene el flujo unidireccional y facilita los tests. Además, de esta forma se puede controlar la cancelación de Snackbars previos y evitar que se acumulen en pantalla.
7. Testing básico del ViewModel#
Una de las ventajas clave del patrón MVVM es que el ViewModel puede testarse sin ningún componente de Android: sin Activity, sin Compose, sin contexto.
1import es.javiercarrasco.appdummy.data.repository.LibrosRepository
2import es.javiercarrasco.appdummy.screens.listado.*
3import junit.framework.TestCase.assertEquals
4import junit.framework.TestCase.assertTrue
5import kotlinx.coroutines.Dispatchers
6import kotlinx.coroutines.ExperimentalCoroutinesApi
7import kotlinx.coroutines.test.*
8import org.junit.Assert.assertNotEquals
9import org.junit.*
10import org.junit.rules.TestWatcher
11import org.junit.runner.Description
12
13// ─── LibrosViewModelTest.kt - com.ejemplo.appdummy (test) ────────────────────────────────────────
14@OptIn(ExperimentalCoroutinesApi::class)
15class LibrosViewModelTest {
16 // Reemplaza Dispatchers.Main con un TestDispatcher para tests unitarios
17 @get:Rule
18 val mainDispatcherRule = MainDispatcherRule()
19
20 private lateinit var viewModel: LibrosViewModel
21
22 @Before
23 fun setup() {
24 viewModel = LibrosViewModel(LibrosRepository())
25 }
26
27 @Test
28 fun `estado inicial es Cargando`() {
29 // El ViewModel lanza la carga en init, pero con UnconfinedTestDispatcher
30 // la corrutina no se ha ejecutado todavía antes de esta aserción.
31 // Comprobamos que el estado de salida es el correcto.
32 assertTrue(
33 viewModel.uiState.value is LibrosUiState.Cargando ||
34 viewModel.uiState.value is LibrosUiState.Exito
35 )
36 }
37
38 @Test
39 fun `cargarLibros produce estado Exito con datos`() = runTest {
40 // advanceUntilIdle ejecuta todas las corrutinas pendientes hasta que no queda ninguna
41 advanceUntilIdle()
42
43 val estado = viewModel.uiState.value
44 assertTrue("El estado debe ser Exito", estado is LibrosUiState.Exito)
45 assertTrue(
46 "Debe haber al menos un libro",
47 (estado as LibrosUiState.Exito).libros.isNotEmpty()
48 )
49 }
50
51 @Test
52 fun `actualizarBusqueda actualiza el StateFlow de busqueda`() = runTest {
53 viewModel.actualizarBusqueda("Dune")
54 assertEquals("Dune", viewModel.busqueda.value)
55 }
56
57 @Test
58 fun `toggleFavorito invierte el estado de favorito`() = runTest {
59 advanceUntilIdle()
60
61 val estadoInicial = viewModel.uiState.value as LibrosUiState.Exito
62 val libro = estadoInicial.libros.first()
63
64 viewModel.toggleFavorito(libro) // Si no has implementado toggleFavorito, con SnacckBar, utilizar libro.id.
65
66 val estadoFinal = viewModel.uiState.value as LibrosUiState.Exito
67 val modificada = estadoFinal.libros.find { it.id == libro.id }!!
68 assertNotEquals(
69 "El estado de favorito debe haber cambiado",
70 libro.esFavorito,
71 modificada.esFavorito
72 )
73 }
74}
75
76// Regla auxiliar reutilizable en todos los tests con corrutinas
77@OptIn(ExperimentalCoroutinesApi::class)
78class MainDispatcherRule(
79 val testDispatcher: TestDispatcher = UnconfinedTestDispatcher()
80) : TestWatcher() {
81 override fun starting(description: Description) = Dispatchers.setMain(testDispatcher)
82 override fun finished(description: Description) = Dispatchers.resetMain()
83}
UnconfinedTestDispatcherejecuta las corrutinas inmediatamente en el mismo hilo (eager), ideal para tests de ViewModel donde quieres que las corrutinas delinitterminen antes de las aserciones.StandardTestDispatcherencola las corrutinas y requiereadvanceUntilIdle()explícito para avanzarlas, útil cuando necesitas controlar el orden de ejecución.
Desarrollo práctico guiado: Diagrama de arquitectura de AppDummy 💻#
MainActivity
└── setContent → AppDummyTheme
└── AppNavigation (NavHost — Tema 3)
│
├── PantallaListado
│ │ collectAsStateWithLifecycle
│ └── LibrosViewModel
│ │ viewModelScope.launch
│ └── LibrosRepository
│ └── [datos estáticos — B2]
│ └── [LocalDataSource (ROOM) — B3]
│ └── [RemoteDataSource (Retrofit2) — B3]
│
└── PantallaDetalle (Detalle - Tema 3)
│ collectAsStateWithLifecycle
└── DetalleViewModel
└── LibrosRepository (misma instancia)Referencias#
- Guía de arquitectura de apps — Android Developers
- ViewModel overview — Android Developers
- ViewModels con dependencias — Android Developers
- StateFlow y SharedFlow — Android Developers
- UI Layer — Android Developers
- Testing de corrutinas — Android Developers
- Anexo B2-A3 — StateFlow, corrutinas y arquitectura: referencia completa