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 y ViewModel.
  • 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 dependencia androidx.compose.runtime:runtime-livedata:1.9.0 que permite observar LiveData.

  • 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:

  1. Presentación, esta es la capa que interactúa directamente con el usuario.
  2. Casos de uso, capa que suele contener las acciones que el usuario puede activar.
  3. Dominio, contiene la lógica de negocio, suele contener los modelos, por ejemplo las clases SuperHero o Editorial.
  4. 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.
  5. 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.

Imagen punto 5.4 Imagen punto 5.4

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.

Imagen punto 5.4 Imagen punto 5.4

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 a runBlocking. Permite controlar el tiempo con advanceTimeBy().
  • testDispatcher: Simula el lanzamiento de corutinas sin depender del tiempo real.
  • viewModel.uiState.test { ... }: Con Turbine, se puede recolectar los valores emitidos por el Flow.
  • awaitItem(): Espera a que se emita un valor (ideal para pruebas asíncronas).
  • advanceTimeBy(1000) : Simula que han pasado 1000 ms, haciendo que delay(1000) termine.

Fuentes