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ón viewModel() 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 object vs object: Con object, println(estado) mostraría algo como LibrosUiState$Cargando@3a2b1c. Con data object (Kotlin 1.9+) muestra simplemente Cargando, 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 Snackbar es 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}

UnconfinedTestDispatcher ejecuta las corrutinas inmediatamente en el mismo hilo (eager), ideal para tests de ViewModel donde quieres que las corrutinas del init terminen antes de las aserciones. StandardTestDispatcher encola las corrutinas y requiere advanceUntilIdle() 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#

Calendar  Última modificación: jueves, 23 de julio de 2026