Tema 5: Arquitectura MVVM y Clean Architecture
Objetivos de este tema
- Entender la separación de responsabilidades en aplicaciones Android.
- Implementar el patrón MVVM en proyectos Android con Jetpack Compose.
- Integrar Clean Architecture con componentes como
Room
,Retrofit2
yViewModel
. - Aprender a usar corutinas de Kotlin para realizar operaciones asíncronas.
- Aplicar inyección de dependencias con Koin/Hilt.
- Realizar pruebas unitarias básicas en capas lógicas.
5.1. ¿Qué es MVVM?
MVVM (Model-View-ViewModel) es un patrón de arquitectura que separa la lógica de la interfaz de usuario (UI).
Componentes principales:
- Modelo (
Model
): representará la capa de datos o lógica de negocio. Únicamente contendrá la información, no habrán métodos o acciones que manipulen los datos y, no tendrá ninguna dependencia de la vista. - Vista (
View
): será la parte encargada de representar la información al usuario. En el patrón MVVM, las vistas son activas, reaccionando a eventos o cambios de los datos (Jetpack Compose en este caso). - Modelo de vista (
ViewModel
): es el intermediario entre el modelo y la vista, mantiene el estado de la UI y contiene la lógica de negocio y abstracción de la interfaz. El enlace con la vista se realizará mediante el enlace de datos.
Ejemplo básico con Jetpack Compose:
Como se hace uso de viewModel()
será necesaria añadir la siguiente dependencia:
1// ViewModel dependencies for Compose
2implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.9.2")
Contador básico:
1class CounterViewModel : ViewModel() {
2 // Técnica de backing con StateFlow para manejar el estado del contador.
3 private val _count = MutableStateFlow(0) // MutableStateFlow para el estado del contador.
4 val count: StateFlow<Int> = _count // Exponer el estado como StateFlow para que pueda ser observado por la UI.
5
6 fun increment() {
7 _count.value++
8 }
9}
10
11@Composable
12fun CounterScreen(viewModel: CounterViewModel = viewModel()) {
13 val count by viewModel.count.collectAsState()
14 Column {
15 Text("Contador: $count")
16 Button(onClick = viewModel::increment) {
17 Text("Incrementar")
18 }
19 }
20}
5.2. Separación de responsabilidades
View
(Compose): Dibuja la UI, no contiene lógica.ViewModel
: Gestiona el estado y eventos de la UI.Repository
: Interactúa con fuentes de datos (API, BD).Model
/Domain
: Reglas de negocio independientes del contexto.
5.3. ViewModel, Flow y LiveData
ViewModel
Se encarga de almacenar el estado de la UI y sobrevive a cambios de configuración (rotación de pantalla por ejemplo).
LiveData vs. Flow
-
LiveData: Observa los cambios en la UI (solo emite en contexto de Android).
1class LiveDataViewModel : ViewModel() { 2 private val _text = MutableLiveData("nombre") 3 val text: LiveData<String> = _text 4 5 fun updateText() { 6 _text.value = "Javier" // Actualizar el valor de LiveData. 7 } 8} 9 10@Composable 11fun Greeting(viewModel: LiveDataViewModel = viewModel()) { 12 // Usar observeAsState para observar cambios en LiveData. 13 val currentText by viewModel.text.observeAsState() 14 15 Column { 16 Text(text = "Hola $currentText!") 17 Button(onClick = { viewModel.updateText() }) { 18 Text("Actualizar nombre") 19 } 20 } 21}
Para utilizar
observeAsState()
deberás añadir la dependenciaandroidx.compose.runtime:runtime-livedata:1.9.0
que permite observarLiveData
. -
Flow: Colección asíncrona reactiva (ideal para Jetpack Compose).
1class FlowViewModel : ViewModel() { 2 private val _uiState = MutableStateFlow(0) 3 val uiState: StateFlow<Int> = _uiState 4 5 // Ejemplo con corutina 6 fun fetchData() { 7 viewModelScope.launch { 8 delay(1000) 9 _uiState.emit(_uiState.value + 10) 10 } 11 } 12} 13 14@Composable 15fun FlowScreen(viewModel: FlowViewModel = viewModel()) { 16 // Usar collectAsState para observar cambios en StateFlow. 17 val state by viewModel.uiState.collectAsState() 18 19 Column { 20 Text(text = "Estado actual: $state") 21 Button(onClick = { viewModel.fetchData() }) { 22 Text("Obtener datos") 23 } 24 } 25}
5.4. Introducción a Clean Architecture
Si además de la aplicación del patrón MVVM, se aplican conceptos de Clean Architecture se conseguirá mayor independencia entre módulos y proyectos más compactos. El uso de Clean Architecture se basa en la estructuración del código por capas, donde cada una de estas capas se comunicará con sus capas más cercanas. Además, cada una de estas capas tendrá un único objetivo, separando responsabilidades. Esta combinación permitirá soportar el crecimiento de la aplicación de manera más fiable.
Las capas comunes de Clean Architecture son:
- Presentación, esta es la capa que interactúa directamente con el usuario.
- Casos de uso, capa que suele contener las acciones que el usuario puede activar.
- Dominio, contiene la lógica de negocio, suele contener los modelos, por ejemplo las clases
SuperHero
oEditorial
. - Datos, esta capa contiene las definiciones de la fuente de datos y cómo se utilizará. Puede no limitarse a una única fuente de datos. Se suele utilizar el patrón repositorio para decidir que fuente de datos (DataSource) utilizar.
- Framework, esta capa define las distintas fuentes de datos, por ejemplo, Room en modo local o una API de forma remota.
El diagrama clásico que representa la Clean Architecture creado por Robert C. Martin es posible que ya lo hayas visto.
5.4.1. Capas: Presentation, Domain, Data
Evidentemente, puede hacerse una libre interpretación de la arquitectura, eliminando capas, unificando, etc. Esto es así porque no es realmente una arquitectura como tal, sino una guía con recomendaciones a seguir. En Android es muy común unificar las capas, lo que además permitirá simplificar el modelo.
- Capa presentación, donde se aplicará MVVM.
- Capa dominio, contendrá el modelo de negocio y los casos de uso.
- Capa datos, se utilizará el modelo repositorio y el acceso a datos.
La comunicación entre todos los componentes de las capas será la siguiente.
5.5. Uso de corutinas
Ya se ha hecho un uso básico de ellas en puntos anteriores, a grandes rasgos, manejan tareas asíncronas sin bloquear el hilo principal, engargado de gestionar la UI.
1viewModelScope.launch {
2 val data = withContext(Dispatchers.IO) {
3 apiService.fetchData()
4 }
5 _uiState.emit(data)
6}
Más adelante se hará un uso más detallado de ellas.
5.6. Pruebas unitarias básicas
En este punto se tratará de mostrar una prueba “básica” para evaluar el método fetchData()
de la clase FlowViewModel()
. En concreto se crearán pruebas utilizando JUnit
, kotlinx-coroutines-test
y Turbine
(para probar Flow
).
JUnit
ya se encuentra añadida por defecto en el Gradle de los proyectos de Android Studio, por lo que habrá que añadir las dos que faltan al build.gradle.kts (Module: app)
.
1// Coroutines y para pruebas
2androidTestImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.10.2")
3
4// Para utilizar InstantTaskExecutorRule
5androidTestImplementation("androidx.arch.core:core-testing:2.2.0")
6
7// Para Turbine (test de Flows, test, awaitItem, cancelAndConsumeRemainingEvents)
8androidTestImplementation("app.cash.turbine:turbine:1.2.1")
Información
Turbine es clave para probar Flow
de forma sencilla. Permite recolectar valores emitidos y hacer aserciones.
Se creará una clase en el directorio destinado a los androidTest.
1import androidx.arch.core.executor.testing.InstantTaskExecutorRule
2import app.cash.turbine.test
3import kotlinx.coroutines.Dispatchers
4import kotlinx.coroutines.ExperimentalCoroutinesApi
5import kotlinx.coroutines.test.*
6import org.junit.*
7import org.junit.Assert.assertEquals
8
9// FlowViewModelTest.kt
10
11@OptIn(ExperimentalCoroutinesApi::class)
12class FlowViewModelTest {
13 // Regla para ejecutar tareas en el hilo inmediato (sincroniza LiveData)
14 @get:Rule
15 val instantTaskExecutorRule = InstantTaskExecutorRule()
16
17 // Disponemos de un TestDispatcher para controlar el tiempo
18 private lateinit var testDispatcher: TestDispatcher
19 private lateinit var viewModel: FlowViewModel
20
21 @Before
22 fun setUp() {
23 testDispatcher = UnconfinedTestDispatcher() // Permite controlar corutinas
24 Dispatchers.setMain(testDispatcher)
25 viewModel = FlowViewModel()
26 }
27
28 @After
29 fun tearDown() {
30 Dispatchers.resetMain()
31 }
32
33 @Test // Cuando se llama a fetchData, emite nuevo valor tras 1 segundo.
34 fun testCallfetchData() = runTest {
35 // GIVEN: Estado inicial es 0
36 assertEquals(0, viewModel.uiState.value)
37
38 // WHEN: Se llama a fetchData
39 viewModel.fetchData()
40
41 // THEN: Aún no se ha emitido nada (por el delay de 1 segundo)
42 assertEquals(0, viewModel.uiState.value)
43
44 // Avanzamos el tiempo virtual
45 advanceTimeBy(1100) // Simula algo más de 1 segundo
46
47 // Verificamos que el valor cambió a 10
48 assertEquals(10, viewModel.uiState.value)
49 }
50
51 @Test // uiState emite valores correctamente con Turbine.
52 fun testUiStateTurbine() = runTest {
53 // GIVEN: Recolectamos el Flow con Turbine
54 viewModel.uiState.test {
55 // THEN: Primer valor emitido debe ser 0
56 assertEquals(0, awaitItem())
57
58 // WHEN: Llamamos a fetchData
59 viewModel.fetchData()
60
61 // Y avanzamos el tiempo
62 advanceTimeBy(1000)
63
64 // THEN: Debe emitir 10
65 assertEquals(10, awaitItem())
66
67 // Finalizamos la recolección
68 cancelAndConsumeRemainingEvents()
69 }
70 }
71
72 @Test // fetchData puede llamarse múltiples veces y suma correctamente.
73 fun testCallfetchDataMultipleTurbine() = runTest {
74 viewModel.uiState.test {
75 assertEquals(0, awaitItem())
76
77 // Primera llamada
78 viewModel.fetchData()
79 advanceTimeBy(1000)
80 assertEquals(10, awaitItem())
81
82 // Segunda llamada
83 viewModel.fetchData()
84 advanceTimeBy(1000)
85 assertEquals(20, awaitItem())
86 }
87 }
88}
Resumen del test
runTest
: Reemplaza arunBlocking
. Permite controlar el tiempo conadvanceTimeBy()
.testDispatcher
: Simula el lanzamiento de corutinas sin depender del tiempo real.viewModel.uiState.test { ... }
: ConTurbine
, se puede recolectar los valores emitidos por elFlow
.awaitItem()
: Espera a que se emita un valor (ideal para pruebas asíncronas).advanceTimeBy(1000)
: Simula que han pasado 1000 ms, haciendo quedelay(1000)
termine.