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#

 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 peliculas 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        peliculas = 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 PeliculasScreen(
3    // viewModel() crea el ViewModel si no existe, o devuelve el que ya hay
4    viewModel: PeliculasViewModel = viewModel(factory = PeliculasViewModel.Factory)
5) {
6    // ...
7}

Si instanciaras el ViewModel directamente con PeliculasViewModel(), 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// ─── ui/listado/PeliculasViewModel.kt ───────────────────────────────────────────
 2class PeliculasViewModel(
 3    private val repository: PeliculasRepository
 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                PeliculasViewModel(PeliculasRepository())
12            }
13        }
14    }
15}
16
17// Uso en el Composable:
18val viewModel: PeliculasViewModel = viewModel(factory = PeliculasViewModel.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// ─── ui/listado/PeliculasViewModel.kt ───────────────────────────────────────────
2class PeliculasViewModel : ViewModel() {
3
4    // Mutable y PRIVADO: solo el ViewModel puede modificarlo
5    private val _uiState = MutableStateFlow<PeliculasUiState>(PeliculasUiState.Cargando)
6
7    // Inmutable y PÚBLICO: los Composables solo pueden leerlo
8    val uiState: StateFlow<PeliculasUiState> = _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/Pelicula.kt ───────────────────────────────────────────
 2// Modelo de datos — se reutilizará en B3 con ROOM y Retrofit2
 3data class Pelicula(
 4    val id: Int,
 5    val titulo: String,
 6    val genero: String,
 7    val puntuacion: Double,
 8    val sinopsis: String = "",
 9    val esFavorita: Boolean = false
10)
11
12// ─── ui/listado/PeliculasUiState.kt ───────────────────────────────────────────
13// Estados posibles de la pantalla de listado
14sealed class PeliculasUiState {
15    // data object (Kotlin 1.9+): genera toString() legible ("Cargando"
16    // en lugar del hash de referencia). Usar siempre para estados sin datos.
17    data object Cargando : PeliculasUiState()
18    data class Exito(val peliculas: List<Pelicula>) : PeliculasUiState()
19    data class Error(val mensaje: String) : PeliculasUiState()
20}

data object vs object: Con object, println(estado) mostraría algo como PeliculasUiState$Cargando@3a2b1c. Con data object (Kotlin 1.9+) muestra simplemente Cargando, lo que facilita enormemente el debugging.

4.4 Ejemplo base de AppFlix: repositorio y ViewModel completos#

 1// ─── data/repository/PeliculasRepository.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 PeliculasRepository {
 5
 6    private val peliculas = listOf(
 7        Pelicula(1, "Dune: Parte 2", "Sci-Fi", 8.5, "La epopeya de Paul Atreides continúa."),
 8        Pelicula(2, "Oppenheimer", "Drama", 8.9, "La historia del padre de la bomba atómica."),
 9        Pelicula(3, "Barbie", "Comedia", 7.1, "Barbie y Ken viajan al mundo real."),
10        Pelicula(4, "Pobres Criaturas", "Drama", 8.0, "Una mujer resucitada descubre la vida."),
11        Pelicula(5, "Alien: Romulus", "Acción", 7.4, "Un grupo de jóvenes en una estación espacial.")
12    )
13
14    // suspend: puede suspenderse sin bloquear el hilo principal
15    suspend fun getPeliculas(): List<Pelicula> {
16        kotlinx.coroutines.delay(800)   // simula latencia de red
17        return peliculas
18    }
19
20    suspend fun getPeliculaPorId(id: Int): Pelicula? {
21        kotlinx.coroutines.delay(300)
22        return peliculas.find { it.id == id }
23    }
24}
25
26// ─── ui/listado/PeliculasViewModel.kt ────────────────────────────────────────
27class PeliculasViewModel(
28    private val repository: PeliculasRepository
29) : ViewModel() {
30
31    // Estado principal de la UI
32    private val _uiState = MutableStateFlow<PeliculasUiState>(PeliculasUiState.Cargando)
33    val uiState: StateFlow<PeliculasUiState> = _uiState.asStateFlow()
34
35    // Estado del campo de búsqueda — independiente del UiState principal
36    private val _busqueda = MutableStateFlow("")
37    val busqueda: StateFlow<String> = _busqueda.asStateFlow()
38
39    init {
40        // Se ejecuta al crear el ViewModel — carga los datos automáticamente
41        cargarPeliculas()
42    }
43
44    fun cargarPeliculas() {
45        // viewModelScope: corrutina ligada al ViewModel, se cancela al destruirlo
46        viewModelScope.launch {
47            _uiState.value = PeliculasUiState.Cargando
48            try {
49                val peliculas = repository.getPeliculas()
50                _uiState.value = PeliculasUiState.Exito(peliculas)
51            } catch (e: Exception) {
52                _uiState.value = PeliculasUiState.Error(
53                    e.message ?: "Error desconocido al cargar las películas"
54                )
55            }
56        }
57    }
58
59    fun actualizarBusqueda(texto: String) {
60        _busqueda.value = texto
61    }
62
63    fun toggleFavorita(id: Int) {
64        // update{} es atómico y thread-safe: lee el estado actual y produce el nuevo
65        // en una sola operación, sin condiciones, evitando problemas de concurrencia.
66        _uiState.update { estado ->
67            if (estado is PeliculasUiState.Exito) {
68                estado.copy(
69                    peliculas = estado.peliculas.map { pelicula ->
70                        if (pelicula.id == id) pelicula.copy(esFavorita = !pelicula.esFavorita)
71                        else pelicula
72                    }
73                )
74            } else estado
75        }
76    }
77
78    companion object {
79        val Factory: ViewModelProvider.Factory = viewModelFactory {
80            initializer {
81                PeliculasViewModel(PeliculasRepository())
82            }
83        }
84    }
85}

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// ─── ui/listado/PantallaListado.kt ───────────────────────────────────────────
  2@Composable
  3fun PantallaListado(
  4    viewModel: PeliculasViewModel = viewModel(factory = PeliculasViewModel.Factory),
  5    onNavegaADetalle: (Int) -> Unit   // callback de navegación (se conecta al NavHost en T3)
  6) {
  7    // collectAsStateWithLifecycle: activo en foreground, pausado en background
  8    val uiState by viewModel.uiState.collectAsStateWithLifecycle()
  9    val busqueda by viewModel.busqueda.collectAsStateWithLifecycle()
 10
 11    Scaffold(
 12        topBar = { TopAppBar(title = { Text("AppFlix") }) }
 13    ) { paddingValues ->
 14        Column(modifier = Modifier.padding(paddingValues)) {
 15
 16            OutlinedTextField(
 17                value = busqueda,
 18                onValueChange = viewModel::actualizarBusqueda,
 19                modifier = Modifier
 20                    .fillMaxWidth()
 21                    .padding(horizontal = 16.dp, vertical = 8.dp),
 22                placeholder = { Text("Buscar película...") },
 23                leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) },
 24                trailingIcon = {
 25                    AnimatedVisibility(visible = busqueda.isNotEmpty()) {
 26                        IconButton(onClick = { viewModel.actualizarBusqueda("") }) {
 27                            Icon(Icons.Default.Clear, contentDescription = "Borrar")
 28                        }
 29                    }
 30                },
 31                singleLine = true
 32            )
 33
 34            // when exhaustivo sobre la sealed class
 35            when (val estado = uiState) {
 36                is PeliculasUiState.Cargando -> {
 37                    Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
 38                        CircularProgressIndicator()
 39                    }
 40                }
 41
 42                is PeliculasUiState.Exito -> {
 43                    val peliculasFiltradas = estado.peliculas.filter {
 44                        busqueda.isBlank() || it.titulo.contains(busqueda, ignoreCase = true)
 45                    }
 46                    if (peliculasFiltradas.isEmpty()) {
 47                        Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
 48                            Text("Sin resultados para \"$busqueda\"")
 49                        }
 50                    } else {
 51                        LazyColumn(
 52                            contentPadding = PaddingValues(16.dp),
 53                            verticalArrangement = Arrangement.spacedBy(8.dp)
 54                        ) {
 55                            items(peliculasFiltradas, key = { it.id }) { pelicula ->
 56                                ItemPelicula(
 57                                    pelicula = pelicula,
 58                                    onClickItem = { onNavegaADetalle(pelicula.id) },
 59                                    onToggleFavorita = { viewModel.toggleFavorita(pelicula.id) }
 60                                )
 61                            }
 62                        }
 63                    }
 64                }
 65
 66                is PeliculasUiState.Error -> {
 67                    Column(
 68                        modifier = Modifier.fillMaxSize().padding(32.dp),
 69                        horizontalAlignment = Alignment.CenterHorizontally,
 70                        verticalArrangement = Arrangement.Center
 71                    ) {
 72                        Icon(
 73                            Icons.Default.ErrorOutline,
 74                            contentDescription = null,
 75                            modifier = Modifier.size(64.dp),
 76                            tint = MaterialTheme.colorScheme.error
 77                        )
 78                        Spacer(modifier = Modifier.height(16.dp))
 79                        Text(
 80                            text = estado.mensaje,
 81                            color = MaterialTheme.colorScheme.error
 82                        )
 83                        Spacer(modifier = Modifier.height(16.dp))
 84                        Button(onClick = viewModel::cargarPeliculas) { Text("Reintentar") }
 85                    }
 86                }
 87            }
 88        }
 89    }
 90}
 91
 92@Composable
 93fun ItemPelicula(
 94    pelicula: Pelicula,
 95    onClickItem: () -> Unit,
 96    onToggleFavorita: () -> Unit
 97) {
 98    Card(onClick = onClickItem, modifier = Modifier.fillMaxWidth()) {
 99        Row(
100            modifier = Modifier.fillMaxWidth().padding(16.dp),
101            verticalAlignment = Alignment.CenterVertically
102        ) {
103            Column(modifier = Modifier.weight(1f)) {
104                Text(pelicula.titulo, style = MaterialTheme.typography.titleSmall)
105                Text(
106                    "${pelicula.genero} · ★ ${pelicula.puntuacion}",
107                    style = MaterialTheme.typography.bodySmall,
108                    color = MaterialTheme.colorScheme.onSurfaceVariant
109                )
110            }
111            IconButton(onClick = onToggleFavorita) {
112                Icon(
113                    imageVector = if (pelicula.esFavorita) Icons.Default.Favorite
114                                  else Icons.Default.FavoriteBorder,
115                    contentDescription = if (pelicula.esFavorita) "Quitar de favoritos"
116                                         else "Añadir a favoritos",
117                    tint = if (pelicula.esFavorita) MaterialTheme.colorScheme.error
118                           else MaterialTheme.colorScheme.onSurfaceVariant
119                )
120            }
121        }
122    }
123}

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 AppFlix#

com.ejemplo.appflix/
├── MainActivity.kt
├── ui/
│   ├── listado/
│   │   ├── PantallaListado.kt           ← @Composable
│   │   ├── PeliculasViewModel.kt        ← ViewModel + Factory
│   │   └── PeliculasUiState.kt          ← sealed class
│   ├── detalle/
│   │   ├── PantallaDetalle.kt
│   │   └── DetalleViewModel.kt
│   ├── navegacion/
│   │   └── AppNavigation.kt             ← NavHost (Tema 3)
│   └── theme/
│       ├── Color.kt
│       ├── Type.kt
│       └── Theme.kt
└── data/
    ├── model/
    │   └── Pelicula.kt                  ← data class compartida
    ├── repository/
    │   └── PeliculasRepository.kt
    └── datasource/
        ├── LocalDataSource.kt           ← ROOM (Bloque 3)
        └── RemoteDataSource.kt          ← Retrofit2 (Bloque 3)

6. Eventos de un solo disparo: SharedFlow#

Hasta ahora hemos 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 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 películas, indicador de carga Navegación, Snackbar, diálogo
 1// Eventos de UI — ocurren una sola vez
 2sealed class PeliculasEvento {
 3    data class MostrarSnackbar(val mensaje: String) : PeliculasEvento()
 4    data class NavegarADetalle(val id: Int) : PeliculasEvento()
 5}
 6
 7class PeliculasViewModel(private val repository: PeliculasRepository) : ViewModel() {
 8
 9    private val _uiState = MutableStateFlow<PeliculasUiState>(PeliculasUiState.Cargando)
10    val uiState: StateFlow<PeliculasUiState> = _uiState.asStateFlow()
11
12    // replay = 0: los eventos no se repiten para nuevos colectores
13    // extraBufferCapacity = 1: evita suspensión si no hay colector en ese instante
14    private val _eventos = MutableSharedFlow<PeliculasEvento>(
15        replay = 0,
16        extraBufferCapacity = 1
17    )
18    val eventos: SharedFlow<PeliculasEvento> = _eventos.asSharedFlow()
19
20    fun toggleFavorita(pelicula: Pelicula) {
21        _uiState.update { estado ->
22            if (estado is PeliculasUiState.Exito) {
23                estado.copy(peliculas = estado.peliculas.map {
24                    if (it.id == pelicula.id) it.copy(esFavorita = !it.esFavorita) else it
25                })
26            } else estado
27        }
28        viewModelScope.launch {
29            _eventos.emit(
30                PeliculasEvento.MostrarSnackbar(
31                    if (pelicula.esFavorita) "\"${pelicula.titulo}\" eliminada de favoritos"
32                    else "\"${pelicula.titulo}\" añadida a favoritos"
33                )
34            )
35        }
36    }
37}
38
39// En el Composable: LaunchedEffect escucha los eventos del ViewModel
40@Composable
41fun PantallaListadoConEventos(
42    viewModel: PeliculasViewModel,
43    navController: NavController
44) {
45    val snackbarHostState = remember { SnackbarHostState() }
46
47    // LaunchedEffect con Unit: se lanza una única vez al montar el Composable
48    LaunchedEffect(Unit) {
49        viewModel.eventos.collect { evento ->
50            when (evento) {
51                is PeliculasEvento.MostrarSnackbar ->
52                    snackbarHostState.showSnackbar(evento.mensaje)
53                is PeliculasEvento.NavegarADetalle ->
54                    navController.navigate(/* ruta tipada — ver T3 */)
55            }
56        }
57    }
58    // ...
59}

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.

 1// PeliculasViewModelTest.kt - com.ejemplo.appflix (test)
 2@OptIn(ExperimentalCoroutinesApi::class)
 3class PeliculasViewModelTest {
 4
 5    // Reemplaza Dispatchers.Main con un TestDispatcher para tests unitarios
 6    @get:Rule
 7    val mainDispatcherRule = MainDispatcherRule()
 8
 9    private lateinit var viewModel: PeliculasViewModel
10
11    @Before
12    fun setup() {
13        viewModel = PeliculasViewModel(PeliculasRepository())
14    }
15
16    @Test
17    fun `estado inicial es Cargando`() {
18        // El ViewModel lanza la carga en init, pero con UnconfinedTestDispatcher
19        // la corrutina no se ha ejecutado todavía antes de esta aserción.
20        // Comprobamos que el estado de salida es el correcto.
21        assertTrue(viewModel.uiState.value is PeliculasUiState.Cargando ||
22                   viewModel.uiState.value is PeliculasUiState.Exito)
23    }
24
25    @Test
26    fun `cargarPeliculas produce estado Exito con datos`() = runTest {
27        // advanceUntilIdle ejecuta todas las corrutinas pendientes hasta que no queda ninguna
28        advanceUntilIdle()
29
30        val estado = viewModel.uiState.value
31        assertTrue("El estado debe ser Exito", estado is PeliculasUiState.Exito)
32        assertTrue(
33            "Debe haber al menos una película",
34            (estado as PeliculasUiState.Exito).peliculas.isNotEmpty()
35        )
36    }
37
38    @Test
39    fun `actualizarBusqueda actualiza el StateFlow de busqueda`() = runTest {
40        viewModel.actualizarBusqueda("Dune")
41        assertEquals("Dune", viewModel.busqueda.value)
42    }
43
44    @Test
45    fun `toggleFavorita invierte el estado de favorita`() = runTest {
46        advanceUntilIdle()
47
48        val estadoInicial = viewModel.uiState.value as PeliculasUiState.Exito
49        val pelicula = estadoInicial.peliculas.first()
50
51        viewModel.toggleFavorita(pelicula.id)
52
53        val estadoFinal = viewModel.uiState.value as PeliculasUiState.Exito
54        val modificada = estadoFinal.peliculas.find { it.id == pelicula.id }!!
55        assertNotEquals(
56            "El estado de favorita debe haber cambiado",
57            pelicula.esFavorita,
58            modificada.esFavorita
59        )
60    }
61}
62
63// Regla auxiliar reutilizable en todos los tests con corrutinas
64@OptIn(ExperimentalCoroutinesApi::class)
65class MainDispatcherRule(
66    val testDispatcher: TestDispatcher = UnconfinedTestDispatcher()
67) : TestWatcher() {
68    override fun starting(description: Description) = Dispatchers.setMain(testDispatcher)
69    override fun finished(description: Description) = Dispatchers.resetMain()
70}

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.


Diagrama de arquitectura: AppFlix al final de T2#

MainActivity
    └── setContent → AppFlixTheme
            └── AppNavigation  (NavHost — Tema 3)
                    ├── PantallaListado
                    │       │ collectAsStateWithLifecycle
                    │       └── PeliculasViewModel
                    │               │ viewModelScope.launch
                    │               └── PeliculasRepository
                    │                      └── [datos estáticos — B2]
                    │                      └── [LocalDataSource (ROOM) — B3]
                    │                      └── [RemoteDataSource (Retrofit2) — B3]
                    └── PantallaDetalle
                            │ collectAsStateWithLifecycle
                            └── DetalleViewModel
                                    └── PeliculasRepository (misma instancia)

Actividades prácticas#

Actividad 2.1 — Refactorización a MVVM (5 h) Se entrega la app del Bloque 1 con toda la lógica en los Composables. Refactoriza introduciendo ViewModel, Repository, data class y StateFlow con sealed class UiState. Al terminar debes poder responder: "¿Qué responsabilidad tiene cada clase? ¿Qué ocurre cuando el usuario rota la pantalla?"

Actividad 2.2 — Tests del ViewModel (3 h) Escribir los cuatro tests de la sección 7 para el propio ViewModel. Intenta comprender qué hace advanceUntilIdle() y por qué es necesaria la MainDispatcherRule.

Actividad 2.3 — Hito vertebrador H2 (6 h) Integrar en AppFlix el patrón MVVM completo con datos simulados: estructura de paquetes correcta, diagrama de arquitectura documentado y al menos tres tests unitarios del ViewModel.


Pruebas de evaluación#

Prueba T2.1 — Diseño de arquitectura en papel (30 min) Dado el enunciado “App de seguimiento de hábitos diarios: crear hábitos, marcarlos como completados y ver la racha”, diseña en papel la arquitectura MVVM completa: clases, UiState, métodos del ViewModel y responsabilidad del Repository. No se pide código, se evalúa el razonamiento.

Prueba T2.2 — Depuración de ViewModel (20 min) Se entregará un ViewModel con cuatro errores deliberados: MutableStateFlow expuesto públicamente sin backing property, operación de red fuera de viewModelScope, LiveData en lugar de StateFlow, y object en lugar de data object para el estado Cargando. Deberás identificar, explicar y corregir cada error.

Prueba T2.3 — Defensa oral (5 min por alumno) Se seleccionarán dos fragmentos del código entregado en A2.3 y se preguntará por qué se implementó así, qué pasaría si se cambiase, o cuál es la alternativa y por qué no se eligió.


Referencias#

Calendar  Última modificación: viernes, 26 de junio de 2026