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ó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// ─── 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 objectvsobject: Conobject,println(estado)mostraría algo comoPeliculasUiState$Cargando@3a2b1c. Condata object(Kotlin 1.9+) muestra simplementeCargando, 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}
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.
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#
- 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