Anexo 3. Referencia: StateFlow, corrutinas y arquitectura MVVM
- Tipo: Anexo de apoyo — material de consulta y profundización
- Bloque: B2 — Arquitectura MVVM y desarrollo de aplicaciones Android
- RA2 — Desarrolla aplicaciones para dispositivos móviles analizando y empleando las tecnologías y librerías específicas.
1. Corrutinas de Kotlin: referencia completa para ViewModel#
Qué es una corrutina#
Una corrutina es una unidad de trabajo que puede suspenderse y reanudarse sin bloquear el hilo en el que se ejecuta. Cuando una función suspend espera un resultado (una respuesta de red, una consulta a la base de datos), libera el hilo para que otras tareas lo usen, y lo recupera cuando el resultado está disponible.
1// Función normal — bloquea el hilo hasta que termina
2fun cargarDatos(): List<String> {
3 Thread.sleep(2000) // el hilo está bloqueado durante 2 segundos
4 return listOf("dato1", "dato2")
5}
6
7// Función suspend — libera el hilo mientras espera
8suspend fun cargarDatos(): List<String> {
9 delay(2000) // el hilo queda libre durante 2 segundos
10 return listOf("dato1", "dato2")
11}Dispatchers: en qué hilo se ejecuta cada corrutina#
Un Dispatcher determina en qué hilo o grupo de hilos se ejecuta una corrutina:
| Dispatcher | Hilo | Cuándo usarlo |
|---|---|---|
Dispatchers.Main |
Hilo principal (UI) | Actualizar la UI, observar LiveData |
Dispatchers.Main.immediate |
Hilo principal (inmediato) | ViewModel scope por defecto |
Dispatchers.IO |
Pool de hilos de I/O | Llamadas de red, acceso a BD, archivos |
Dispatchers.Default |
Pool de hilos CPU | Cálculos intensivos, ordenación de listas grandes |
Dispatchers.Unconfined |
Sin confinamiento | Tests (no usar en producción) |
1class EjemploViewModel : ViewModel() {
2
3 fun cargarYProcesar() {
4 viewModelScope.launch {
5 // Por defecto: Dispatchers.Main.immediate
6
7 // Cambiar a IO para operaciones de entrada/salida
8 val datos = withContext(Dispatchers.IO) {
9 repositorio.cargarDesdeRed() // llamada de red o BD
10 }
11
12 // De vuelta en Main — actualizar la UI con los datos
13 _uiState.value = UiState.Exito(datos)
14 }
15 }
16}En el Bloque 3, cuando ROOM y Retrofit2 estén integrados, sus operaciones
suspendse ejecutan automáticamente en el dispatcher correcto. No será necesariowithContext(Dispatchers.IO)manualmente para esas operaciones.
Scope de corrutinas: viewModelScope#
viewModelScope es el scope de corrutinas integrado en ViewModel. Sus características son que usa Dispatchers.Main.immediate como dispatcher por defecto, lleva un SupervisorJob (el fallo de una corrutina hija no cancela las demás), y se cancela automáticamente cuando el ViewModel es destruido (cuando el usuario sale definitivamente de la pantalla).
1class DemoViewModel : ViewModel() {
2
3 fun ejemploLanzamiento() {
4 // launch: lanza y no espera (fire-and-forget)
5 viewModelScope.launch {
6 val datos = repositorio.cargar() // suspende aquí sin bloquear
7 _uiState.value = UiState.Exito(datos)
8 }
9 }
10
11 suspend fun ejemploAsync() {
12 // async/await: lanza y espera el resultado
13 val deferred = viewModelScope.async {
14 repositorio.cargar()
15 }
16 val resultado = deferred.await()
17 _uiState.value = UiState.Exito(resultado)
18 }
19
20 fun dosPeticionesEnParalelo() {
21 viewModelScope.launch {
22 // async lanza ambas corrutinas en paralelo
23 val peliculas = async { repositorio.getPeliculas() }
24 val generos = async { repositorio.getGeneros() }
25
26 // await espera a que ambas terminen
27 _uiState.value = UiState.Exito(
28 peliculas = peliculas.await(),
29 generos = generos.await()
30 )
31 }
32 }
33}Manejo de errores en corrutinas#
1// Opción 1 — try/catch (recomendada para la mayoría de casos)
2viewModelScope.launch {
3 try {
4 val resultado = repositorio.cargar()
5 _uiState.value = UiState.Exito(resultado)
6 } catch (e: IOException) {
7 _uiState.value = UiState.Error("Sin conexión a internet")
8 } catch (e: HttpException) {
9 _uiState.value = UiState.Error("Error del servidor: ${e.code()}")
10 } catch (e: Exception) {
11 _uiState.value = UiState.Error(e.message ?: "Error desconocido")
12 }
13}
14
15// Opción 2 — CoroutineExceptionHandler (para errores no controlados)
16// Útil para logging centralizado o crashlytics
17private val manejadorErrores = CoroutineExceptionHandler { _, excepcion ->
18 _uiState.value = UiState.Error(excepcion.message ?: "Error no controlado")
19}
20
21viewModelScope.launch(manejadorErrores) {
22 val resultado = repositorio.cargar()
23 _uiState.value = UiState.Exito(resultado)
24}Cancelación de corrutinas#
Las corrutinas de viewModelScope se cancelan automáticamente al destruirse el ViewModel. Cuando se necesita cancelar manualmente una corrutina específica (por ejemplo, la búsqueda anterior al escribir una nueva letra), se usa un Job:
1class BuscadorViewModel : ViewModel() {
2
3 private val _resultados = MutableStateFlow<List<Pelicula>>(emptyList())
4 val resultados: StateFlow<List<Pelicula>> = _resultados.asStateFlow()
5
6 // Referencia al Job de la búsqueda activa
7 private var jobBusqueda: Job? = null
8
9 fun buscar(query: String) {
10 // Cancelar la búsqueda anterior si sigue activa
11 jobBusqueda?.cancel()
12
13 if (query.isBlank()) {
14 _resultados.value = emptyList()
15 return
16 }
17
18 jobBusqueda = viewModelScope.launch {
19 // debounce manual: espera 300ms antes de buscar
20 // evita buscar con cada letra tecleada
21 delay(300)
22 _resultados.value = repositorio.buscar(query)
23 }
24 }
25}2. Flow: el flujo de datos reactivo#
Cold Flow vs Hot Flow#
Un Cold Flow es un flujo que solo produce datos cuando hay un colector. Cada colector recibe su propia secuencia desde el principio:
1// Cold Flow — creado con el builder flow { }
2val flujoFrio: Flow<Int> = flow {
3 println("Inicio de la emisión") // se ejecuta por cada colector
4 emit(1)
5 delay(500)
6 emit(2)
7 delay(500)
8 emit(3)
9}
10
11// Primer colector: recibe 1, 2, 3 desde el principio
12flujoFrio.collect { println(it) }
13
14// Segundo colector: también recibe 1, 2, 3 desde el principio
15flujoFrio.collect { println(it) }Un Hot Flow produce datos independientemente de si hay colectores. Los nuevos colectores reciben los valores a partir del momento en que se suscriben, o el último valor almacenado (en el caso de StateFlow):
1// StateFlow — siempre tiene un valor actual
2// Nuevo colector: recibe inmediatamente el último valor emitido
3val estadoActual = MutableStateFlow(42)
4
5// SharedFlow — no tiene valor actual por defecto
6// Nuevo colector: no recibe nada hasta la siguiente emisión
7val eventos = MutableSharedFlow<String>(replay = 0)Operadores de Flow más utilizados#
1val flujoBase: Flow<Pelicula> = repositorio.getPeliculasFlow()
2
3// map — transforma cada elemento
4val titulos: Flow<String> = flujoBase.map { it.titulo }
5
6// filter — filtra elementos que cumplen la condición
7val soloFavoritas: Flow<Pelicula> = flujoBase.filter { it.esFavorita }
8
9// filterNotNull — elimina los valores nulos
10val sinNulos: Flow<Pelicula> = flujoBase.filterNotNull()
11
12// distinctUntilChanged — descarta emisiones iguales consecutivas
13// Útil para evitar recomposiciones innecesarias
14val sinDuplicados: Flow<Pelicula> = flujoBase.distinctUntilChanged()
15
16// debounce — espera un tiempo antes de emitir el último valor
17// Ideal para búsqueda mientras el usuario teclea
18val busquedaFlow: Flow<String> = textoBusqueda.debounce(300)
19
20// flatMapLatest — al llegar un nuevo valor, cancela el flujo anterior
21// Útil para búsquedas donde solo interesa el resultado de la última query
22val resultados: Flow<List<Pelicula>> = busquedaFlow.flatMapLatest { query ->
23 repositorio.buscar(query)
24}
25
26// combine — combina los últimos valores de dos flujos
27val uiState: Flow<UiState> = combine(
28 repositorio.getPeliculasFlow(),
29 filtrosFlow
30) { peliculas, filtros ->
31 UiState.Exito(peliculas.filter { it.genero in filtros })
32}stateIn: convertir un Flow frío en StateFlow#
En el ViewModel, cuando el repositorio expone un Flow<T> (por ejemplo, un Flow de ROOM que se actualiza automáticamente con cada cambio en la BD), se convierte en StateFlow con stateIn:
1class PeliculasViewModel(
2 private val repository: PeliculasRepository
3) : ViewModel() {
4
5 // SharingStarted.WhileSubscribed(5_000):
6 // Mantiene la suscripción activa 5 segundos después de que el último
7 // colector desaparezca. Sobrevive a rotaciones de pantalla (que tardan
8 // menos de 5s) sin mantener recursos en background indefinidamente.
9 val uiState: StateFlow<PeliculasUiState> = repository
10 .getPeliculasFlow()
11 .map { peliculas -> PeliculasUiState.Exito(peliculas) }
12 .catch { error -> emit(PeliculasUiState.Error(error.message ?: "Error")) }
13 .stateIn(
14 scope = viewModelScope,
15 started = SharingStarted.WhileSubscribed(5_000),
16 initialValue = PeliculasUiState.Cargando
17 )
18}| SharingStarted | Comportamiento | Cuándo usar |
|---|---|---|
WhileSubscribed(5000) |
Para cuando no hay colectores + 5s de gracia | Apps Android (recomendado) |
Lazily |
Inicia al primer colector, nunca para | Datos que no cambian |
Eagerly |
Inicia inmediatamente, nunca para | Datos críticos que deben estar listos |
3. StateFlow vs LiveData: comparativa detallada#
Diferencias técnicas#
| Característica | LiveData |
StateFlow |
|---|---|---|
| Valor inicial | Opcional (null por defecto) |
Obligatorio |
| Lenguaje | Java + Kotlin | Kotlin puro |
| Integración con corrutinas | Extensiones (liveData { }) |
Nativa |
| Operadores de transformación | map, switchMap limitados |
Todos los operadores de Flow |
| Observar en Compose | observeAsState() |
collectAsStateWithLifecycle() |
| Observar fuera de Compose | observe(owner, observer) |
collect { } en corrutina |
| Ciclo de vida | Consciente de Activity/Fragment | Manual (necesita scope o lifecycle) |
| Testing | Directo | Requiere MainDispatcherRule |
| Combinación de fuentes | MediatorLiveData (verboso) |
combine { } (conciso) |
Migración de LiveData a StateFlow#
1// ANTES — con LiveData
2class ViewModelConLiveData : ViewModel() {
3 private val _peliculas = MutableLiveData<List<Pelicula>>(emptyList())
4 val peliculas: LiveData<List<Pelicula>> = _peliculas
5
6 // En el Composable:
7 // val peliculas by viewModel.peliculas.observeAsState(emptyList())
8}
9
10// DESPUÉS — con StateFlow (equivalente moderno)
11class ViewModelConStateFlow : ViewModel() {
12 private val _peliculas = MutableStateFlow<List<Pelicula>>(emptyList())
13 val peliculas: StateFlow<List<Pelicula>> = _peliculas.asStateFlow()
14
15 // En el Composable:
16 // val peliculas by viewModel.peliculas.collectAsStateWithLifecycle()
17}collectAsState vs collectAsStateWithLifecycle#
1// collectAsState — sigue activo aunque la app esté en background
2// Solo adecuado para Kotlin Multiplatform (iOS no tiene "background")
3val estado by viewModel.uiState.collectAsState()
4
5// collectAsStateWithLifecycle — pausa cuando la app está en background
6// Recomendado en Android: ahorra CPU, memoria y batería
7val estado by viewModel.uiState.collectAsStateWithLifecycle()
8
9// collectAsStateWithLifecycle con lifecycle personalizado
10// (raramente necesario, por defecto usa Lifecycle.State.STARTED)
11val estado by viewModel.uiState.collectAsStateWithLifecycle(
12 minActiveState = Lifecycle.State.RESUMED // solo activo cuando la app está en primer plano
13)4. Patrones avanzados de UiState#
UiState genérico reutilizable#
Para apps con muchas pantallas que siguen el mismo patrón Cargando/Exito/Error, se puede definir un UiState genérico:
1// UiState genérico reutilizable para cualquier tipo de datos
2sealed interface UiState<out T> {
3 data object Cargando : UiState<Nothing>
4 data class Exito<T>(val datos: T) : UiState<T>
5 data class Error(val mensaje: String, val causa: Throwable? = null) : UiState<Nothing>
6}
7
8// Uso con diferentes tipos de datos
9val estadoPeliculas: StateFlow<UiState<List<Pelicula>>>
10val estadoDetalle: StateFlow<UiState<Pelicula>>
11val estadoUsuario: StateFlow<UiState<UsuarioPerfilDto>>
12
13// Funciones de extensión para simplificar el acceso
14fun <T> UiState<T>.datosONull(): T? = (this as? UiState.Exito)?.datos
15fun <T> UiState<T>.esCargando(): Boolean = this is UiState.Cargando
16fun <T> UiState<T>.esError(): Boolean = this is UiState.ErrorUiState con múltiples campos independientes#
Cuando una pantalla tiene varios estados independientes, puede ser más claro usar un único data class con todos los campos en lugar de una sealed class:
1// Para pantallas complejas con múltiples estados simultáneos
2data class ListadoUiState(
3 val peliculas: List<Pelicula> = emptyList(),
4 val estaCargando: Boolean = false,
5 val error: String? = null,
6 val busqueda: String = "",
7 val generoSeleccionado: String? = null,
8 val soloFavoritos: Boolean = false
9) {
10 // Propiedades derivadas — calculadas automáticamente al cambiar el estado
11 val peliculasFiltradas: List<Pelicula>
12 get() = peliculas.filter { pelicula ->
13 (busqueda.isBlank() || pelicula.titulo.contains(busqueda, ignoreCase = true)) &&
14 (generoSeleccionado == null || pelicula.genero == generoSeleccionado) &&
15 (!soloFavoritos || pelicula.esFavorita)
16 }
17
18 val hayResultados: Boolean get() = peliculasFiltradas.isNotEmpty()
19}
20
21// ViewModel con data class UiState
22class ListadoViewModel(private val repository: PeliculasRepository) : ViewModel() {
23
24 private val _uiState = MutableStateFlow(ListadoUiState())
25 val uiState: StateFlow<ListadoUiState> = _uiState.asStateFlow()
26
27 init { cargarPeliculas() }
28
29 fun cargarPeliculas() {
30 viewModelScope.launch {
31 _uiState.update { it.copy(estaCargando = true, error = null) }
32 try {
33 val peliculas = repository.getPeliculas()
34 _uiState.update { it.copy(peliculas = peliculas, estaCargando = false) }
35 } catch (e: Exception) {
36 _uiState.update { it.copy(estaCargando = false, error = e.message) }
37 }
38 }
39 }
40
41 fun actualizarBusqueda(texto: String) =
42 _uiState.update { it.copy(busqueda = texto) }
43
44 fun seleccionarGenero(genero: String?) =
45 _uiState.update { it.copy(generoSeleccionado = genero) }
46
47 fun toggleSoloFavoritos() =
48 _uiState.update { it.copy(soloFavoritos = !it.soloFavoritos) }
49
50 fun toggleFavorita(id: Int) {
51 _uiState.update { estado ->
52 estado.copy(
53 peliculas = estado.peliculas.map {
54 if (it.id == id) it.copy(esFavorita = !it.esFavorita) else it
55 }
56 )
57 }
58 }
59}Cuándo usar sealed class y cuándo data class para UiState#
| Criterio | sealed class |
data class con campos |
|---|---|---|
| Los estados son mutuamente excluyentes | ✅ Ideal | Posible con flags |
| La pantalla puede estar en varios “sub-estados” simultáneos | Complejo | ✅ Ideal |
| El compilador debe verificar que se cubren todos los casos | ✅ Garantizado | No |
| Se necesitan propiedades derivadas del estado combinado | Complejo | ✅ Fácil con get() |
| La pantalla es simple (solo carga/éxito/error) | ✅ Recomendada | Sobreingeniería |
| La pantalla tiene múltiples filtros y estados independientes | Difícil de modelar | ✅ Recomendada |
5. Arquitectura: patrones y buenas prácticas#
El principio de responsabilidad única aplicado a ViewModel#
Cada ViewModel debe gestionar el estado de una sola pantalla o de un flujo de pantallas muy relacionadas. Si un ViewModel se hace muy grande, es señal de que la pantalla hace demasiado o de que hay lógica que debería estar en el Repository.
1// ❌ ViewModel demasiado grande — señal de problemas
2class AppViewModel : ViewModel() {
3 // Estado del listado
4 val peliculas: StateFlow<...>
5 // Estado del detalle
6 val peliculaSeleccionada: StateFlow<...>
7 // Estado de favoritos
8 val favoritos: StateFlow<...>
9 // Estado de ajustes
10 val ajustes: StateFlow<...>
11 // ...30 funciones más
12}
13
14// ✅ Un ViewModel por pantalla o flujo de pantallas relacionadas
15class ListadoViewModel : ViewModel() { ... }
16class DetalleViewModel : ViewModel() { ... }
17class FavoritosViewModel : ViewModel() { ... }
18class AjustesViewModel : ViewModel() { ... }Repository como única fuente de verdad#
El Repository debe ser la única fuente de verdad de los datos. Cuando dos pantallas necesitan los mismos datos (por ejemplo, el estado de favoritos en el listado y en los favoritos), ambas deben leerlos del mismo Repository, no gestionar copias independientes:
1// ❌ Antipatrón — cada ViewModel gestiona su propia copia de los favoritos
2class ListadoViewModel : ViewModel() {
3 private val favoritos = mutableListOf<Int>() // copia propia
4}
5class FavoritosViewModel : ViewModel() {
6 private val favoritos = mutableListOf<Int>() // otra copia, puede desincronizarse
7}
8
9// ✅ Patrón correcto — ambos leen y escriben en el mismo Repository
10// (En B3 el repositorio usará ROOM, que expone un Flow que se actualiza automáticamente)
11class ListadoViewModel(private val repository: PeliculasRepository) : ViewModel()
12class FavoritosViewModel(private val repository: PeliculasRepository) : ViewModel()La evolución de la arquitectura en este curso#
En el Bloque 3, la única clase que cambia significativamente es
PeliculasRepository: dejará de devolver datos estáticos y empezará a coordinarLocalDataSource(ROOM) yRemoteDataSource(Retrofit2). ElViewModely losComposablesno cambiarán: esta es la principal ventaja de la arquitectura por capas.
6. Guía de diagnóstico de problemas frecuentes#
El estado se pierde al rotar la pantalla#
Síntoma: La lista se vuelve a cargar, el texto del campo de búsqueda desaparece, el indicador de carga vuelve a aparecer al rotar.
Causa: El estado está en remember en el Composable, no en el ViewModel.
Solución: Mover el estado al ViewModel con StateFlow.
1// ❌ Causa del problema
2@Composable
3fun Pantalla() {
4 var busqueda by remember { mutableStateOf("") } // se pierde al rotar
5}
6
7// ✅ Solución
8class MiViewModel : ViewModel() {
9 private val _busqueda = MutableStateFlow("") // sobrevive a la rotación
10 val busqueda: StateFlow<String> = _busqueda.asStateFlow()
11}El ViewModel se crea de nuevo en cada recomposición#
Síntoma: Los datos se recargan con cada cambio de estado, el ViewModel parece no recordar nada.
Causa: El ViewModel se instancia directamente en el Composable con MiViewModel() en lugar de con viewModel().
Solución:
1// ❌ Error — nueva instancia en cada recomposición
2@Composable
3fun Pantalla() {
4 val viewModel = MiViewModel() // nueva instancia cada vez
5}
6
7// ✅ Correcto — misma instancia gestionada por Jetpack
8@Composable
9fun Pantalla() {
10 val viewModel: MiViewModel = viewModel(factory = MiViewModel.Factory)
11}NetworkOnMainThreadException al hacer una llamada de red#
Síntoma: La app se cierra con NetworkOnMainThreadException.
Causa: Una llamada de red se está ejecutando en el hilo principal (Main thread).
Solución: Marcar la función del repositorio como suspend y asegurarse de que se llama desde una corrutina. Si se usa Retrofit2 (Bloque 3), sus funciones suspend automáticamente cambian a Dispatchers.IO.
1// ❌ Error — llamada de red en el hilo principal
2fun cargar() {
3 val datos = repositorio.cargarDatos() // NetworkOnMainThreadException
4 _uiState.value = UiState.Exito(datos)
5}
6
7// ✅ Correcto — dentro de viewModelScope
8fun cargar() {
9 viewModelScope.launch {
10 val datos = repositorio.cargarDatos() // suspend — no bloquea el hilo
11 _uiState.value = UiState.Exito(datos)
12 }
13}La UI no se actualiza al cambiar el estado#
Síntoma: El estado del ViewModel cambia (verificado con logs), pero la UI no se recompone.
Causas posibles:
- El
StateFlowestá siendo observado concollectAsState()pero hay un problema de lifecycle. - Se está modificando la lista directamente en lugar de crear una nueva instancia.
- Se está comparando por referencia y el objeto nuevo es igual al anterior.
1// ❌ Causa 2 — modificar la lista directamente no notifica a StateFlow
2fun añadirFavorita(id: Int) {
3 val listaActual = (_uiState.value as UiState.Exito).peliculas
4 listaActual.add(Pelicula(id, "Nueva")) // la lista es la misma referencia
5 // StateFlow no detecta el cambio porque la referencia no cambió
6}
7
8// ✅ Correcto — crear una nueva lista
9fun añadirFavorita(pelicula: Pelicula) {
10 _uiState.update { estado ->
11 if (estado is UiState.Exito) {
12 estado.copy(peliculas = estado.peliculas + pelicula) // nueva lista
13 } else estado
14 }
15}Los tests del ViewModel fallan con “Module with the Main dispatcher had not been initialized”#
Causa: viewModelScope usa Dispatchers.Main, que no existe en el entorno de tests unitarios.
Solución: Añadir la MainDispatcherRule (definida en T2, punto 7):
1class MiViewModelTest {
2 @get:Rule
3 val mainDispatcherRule = MainDispatcherRule() // inicializa Dispatchers.Main para tests
4 // ...
5}7. Tabla de referencia rápida: versiones del Bloque 2#
1// build.gradle.kts (app) — dependencias completas del Bloque 2
2dependencies {
3 // ── Compose BOM ────────────────────────────────────────────────────────
4 val composeBom = platform("androidx.compose:compose-bom:2026.01.01")
5 implementation(composeBom)
6 implementation("androidx.compose.ui:ui")
7 implementation("androidx.compose.ui:ui-tooling-preview")
8 implementation("androidx.compose.material3:material3")
9 debugImplementation("androidx.compose.ui:ui-tooling")
10
11 // ── Activity Compose ───────────────────────────────────────────────────
12 implementation("androidx.activity:activity-compose:1.12.3")
13
14 // ── ViewModel y Lifecycle ──────────────────────────────────────────────
15 implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.9.2")
16 implementation("androidx.lifecycle:lifecycle-runtime-compose:2.9.2")
17
18 // ── Navigation Compose ─────────────────────────────────────────────────
19 implementation("androidx.navigation:navigation-compose:2.9.7")
20
21 // ── Kotlin Serialization (para rutas tipadas de Navigation) ────────────
22 implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.8.0")
23
24 // ── Corrutinas ─────────────────────────────────────────────────────────
25 implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.2")
26
27 // ── Testing ────────────────────────────────────────────────────────────
28 testImplementation("junit:junit:4.13.2")
29 testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.10.2")
30 androidTestImplementation("androidx.test.ext:junit:1.2.1")
31 androidTestImplementation("androidx.compose.ui:ui-test-junit4")
32 debugImplementation("androidx.compose.ui:ui-test-manifest")
33}1// build.gradle.kts — plugins (nivel de módulo app)
2plugins {
3 id("com.android.application")
4 id("org.jetbrains.kotlin.android")
5 id("org.jetbrains.kotlin.plugin.serialization") // misma versión que Kotlin
6}| Librería | Versión | Notas |
|---|---|---|
| Compose BOM | 2026.01.01 | Gestiona versiones de todos los artefactos Compose |
| Kotlin | 2.1.20 | Compilador Compose integrado desde Kotlin 2.0 |
| lifecycle-* | 2.9.2 | Todos los artefactos lifecycle deben coincidir |
| navigation-compose | 2.9.7 | API tipada disponible desde 2.8.0 |
| kotlinx-serialization-json | 1.8.0 | Compatible con Kotlin 2.1.x |
| kotlinx-coroutines-android | 1.10.2 | Incluye viewModelScope extensions |
Referencias del Anexo#
- Kotlin Coroutines — kotlinlang.org
- Flow — kotlinlang.org
- StateFlow y SharedFlow — Android Developers
- Testing de corrutinas — Android Developers
- UI Layer — Android Developers
- Data Layer — Android Developers
- Type safety en Navigation Compose — Android Developers
- Releases de Lifecycle — Android Developers