El ViewModel compartido en Android
Al terminar: Consumir el ViewModel compartido desde Compose en la app de Android.
El HomeViewModel de El ViewModel compartido ya vive en commonMain, pero todavía nadie lo pinta. En Android el sistema se ocupa de casi todo (cuándo crearlo, cuándo conservarlo al girar el móvil, cuándo destruirlo), así que esta mitad es corta: dos librerías de Compose (y una tercera, solo para la vista previa), una pantalla y una MainActivity que la muestra.
Al terminar, la app de Android enseñará el saludo y, debajo, un latido que avanza cada segundo, y la pantalla tendrá una vista previa en el IDE que se dibuja sin ejecutar nada de shared. Lo de iOS es la lección siguiente, El mismo ViewModel en iOS.
Paso 1 · Instala las librerías de Compose para el ViewModel, en androidApp
Objetivo: que la pantalla de Android pueda pedir el ViewModel y leer su StateFlow con Compose.
Es la receta de siempre (versión, librería, dependencia, Sync), pero ahora la dependencia va al módulo de la app de Android. Son dos librerías, porque cada una trae una función distinta:
lifecycle-viewmodel-composetraeviewModel().lifecycle-runtime-composetraecollectAsStateWithLifecycle().
La versión. No hay que añadir nada: las dos usan la clave lifecycle de [versions], igual que lifecycle-viewmodel.
Las librerías. En gradle/libs.versions.toml, al final de [libraries], añade dos líneas:
# Trae viewModel(), la función que le pide el ViewModel a Android.
androidx-lifecycle-viewmodel-compose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "lifecycle" }
# Trae collectAsStateWithLifecycle(): pasa un StateFlow a Compose y lo corta
# cuando la pantalla no se ve.
androidx-lifecycle-runtime-compose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "lifecycle" }Las dependencias. En androidApp/build.gradle.kts, dentro de dependencies { … } (en cualquier sitio del bloque; junto a las demás implementation queda ordenado):
// implementation: solo las usa la pantalla de Android; ni shared ni iOS
// las necesitan.
implementation(libs.androidx.lifecycle.viewmodel.compose)
implementation(libs.androidx.lifecycle.runtime.compose)Hace falta declararlas aquí aunque shared ya dependa de lifecycle-viewmodel: implementation no se transmite entre módulos, y de shared solo llega a androidApp lo que shared declara con api, como el propio lifecycle-viewmodel. Estas dos librerías de Compose no las usa shared, así que son de androidApp.
Pulsa Sync Now. Debe terminar sin rojo. Qué pasa si te saltas una, lo verás en el paso siguiente, al escribir la pantalla:
- Sin
lifecycle-viewmodel-compose, la pantalla no compila: saleUnresolved reference 'compose'en elimportdeviewModelyUnresolved reference 'viewModel'donde lo usas. - Sin
lifecycle-runtime-compose, hoy compila igual, porque Compose ya la trae por dentro. Se declara de todos modos porqueHomeScreenla usa directamente, y no conviene depender de lo que otra librería arrastre.
Y una más, solo para ver la vista previa. El paso siguiente escribe un @Preview, y en Compose: composables y modifiers viste que dibujarlo en el IDE necesita ui-tooling, el motor que lo pinta, con debugImplementation (solo en la versión de depuración, como LeakCanary en Context y Application). Mira primero: busca ui-tooling en [libraries] y debugImplementation en androidApp/build.gradle.kts. Hay dos casos:
- Ya están las dos cosas: sáltate este apartado entero. Añadir otra vez la misma clave al catálogo rompe el Sync con
TOML syntax error … androidx-compose-ui-tooling previously defined. - Falta alguna: añade lo que falte, como sigue. En
gradle/libs.versions.toml, junto a la línea deandroidx-compose-ui-tooling-preview:
# El motor de las vistas previas. Sin versión: la pone el BOM de Compose.
androidx-compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling" }Y en androidApp/build.gradle.kts, dentro de dependencies { … }, en una línea nueva:
// debugImplementation: solo entra en la app de depuración; la de publicar no la lleva.
debugImplementation(libs.androidx.compose.ui.tooling)Pulsa Sync Now. Sin esta línea la app funciona igual y solo falla el panel de la vista previa, así que es fácil no notarlo hasta el paso siguiente.
Paso 2 · La pantalla, partida en dos
El archivo. HomeScreen.kt es la pantalla de inicio de Android. Se parte en dos funciones a propósito: la que habla con el ViewModel y la que solo dibuja un estado. Créalo con el método de El módulo compartido y la prueba de humo, pero con clic derecho sobre la carpeta androidApp → New → File, y la ruta desde src/:
src/main/kotlin/com/sebastiannarvaez/pokedex/android/home/HomeScreen.ktLa separación es el state hoisting de Compose. HomeScreen no se puede previsualizar porque pide el ViewModel; HomeContent solo recibe estado, así que el IDE la pinta sin ejecutar nada de shared. Escríbelo:
package com.sebastiannarvaez.pokedex.android.home
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.sebastiannarvaez.pokedex.Pokedex
import com.sebastiannarvaez.pokedex.feature.home.HomeUiState
import com.sebastiannarvaez.pokedex.feature.home.HomeViewModel
/**
* La pantalla se parte en dos a proposito.
*
* `HomeScreen` habla con el ViewModel y no se puede previsualizar. `HomeContent`
* solo recibe estado, asi que el IDE la pinta sin ejecutar nada del modulo
* compartido. Es la separacion que hace que las vistas previas sirvan.
*/
@Composable
fun HomeScreen(modifier: Modifier = Modifier, viewModel: HomeViewModel = viewModel { HomeViewModel(Pokedex()) }) {
// collectAsStateWithLifecycle y no collectAsState: deja de escuchar cuando
// la pantalla no se ve, y con el flujo frio de debajo eso apaga el trabajo.
val estado by viewModel.uiState.collectAsStateWithLifecycle()
HomeContent(estado = estado, modifier = modifier)
}
@Composable
private fun HomeContent(estado: HomeUiState, modifier: Modifier = Modifier) {
Column(
modifier = modifier.fillMaxSize().padding(24.dp),
verticalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterVertically),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(text = estado.greeting, style = MaterialTheme.typography.titleLarge)
Text(
text = "Latido ${estado.heartbeat}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
@Preview(showBackground = true)
@Composable
private fun HomeContentPreview() {
MaterialTheme {
HomeContent(estado = HomeUiState(greeting = "Hola desde Android 35", heartbeat = 7))
}
}Dos piezas de HomeScreen merecen una explicación:
viewModel: HomeViewModel = viewModel { HomeViewModel(Pokedex()) }. Es un parámetro (elViewModelque usa la pantalla) con un valor por defecto, que es la llamada a la funciónviewModel; casualmente se llaman igual, y Kotlin distingue uno de otra por los paréntesis. Hasta ahora llamabas aviewModel()sin nada detrás, porque elViewModelse construía solo. Este necesita unPokedexen su constructor, así que le pasas una lambda entre llaves con la receta: «si todavía no existe uno, créalo así». La lambda solo se ejecuta la primera vez; si ya existe (por ejemplo tras rotar el móvil),viewModeldevuelve el mismo y la receta no se usa.collectAsStateWithLifecycle, y nocollectAsState. Deja de escuchar cuando la pantalla no se ve y, junto con elWhileSubscribeddelViewModel, eso apaga el trabajo de verdad: el flujo del latido es frío, no hace nada si nadie lo recoge. Si quitas cualquiera de las dos piezas, el contador sigue corriendo en segundo plano.
Si getValue sale subrayado, es un import que falta: val estado by … necesita androidx.compose.runtime.getValue, y el compilador lo explica sin nombrar el import:
e: Type 'State<HomeUiState>' has no method 'getValue(Nothing?, KProperty0<*>)',
so it cannot serve as a delegate.Conecta la pantalla. MainActivity.kt debe dibujar HomeScreen. Ábrelo (androidApp/src/main/kotlin/com/sebastiannarvaez/pokedex/android/MainActivity.kt, el que dejó la prueba de humo), selecciona todo su contenido y sustitúyelo por esto. Es la versión final de MainActivity para toda esta etapa de la Pokédex: ya no construye un Pokedex ni dibuja el saludo, solo monta el tema y delega en HomeScreen:
package com.sebastiannarvaez.pokedex.android
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.ui.Modifier
import com.sebastiannarvaez.pokedex.android.home.HomeScreen
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge()
super.onCreate(savedInstanceState)
setContent {
MaterialTheme {
Scaffold(modifier = Modifier.fillMaxSize()) { padding ->
HomeScreen(modifier = Modifier.padding(padding))
}
}
}
}
}Ejecútalo. Con la configuración androidApp, como en Ejecutar en Android, la app enseña el saludo y, debajo, «Latido 0», «Latido 1»… avanzando cada segundo.
Punto de control
androidApp/build.gradle.ktstieneviewmodel.composeyruntime.compose, y el catálogo tiene las dos entradas nuevas deandroidx-lifecycle-…. Además,debugImplementation(libs.androidx.compose.ui.tooling)y su entrada en el catálogo.HomeScreen.ktexiste enandroidApp/src/main/kotlin/com/sebastiannarvaez/pokedex/android/home/, conHomeScreenyHomeContent, yMainActivitysolo delega enHomeScreen.- La app de Android enseña el saludo y el latido avanzando.
HomeContentse previsualiza en el IDE (panel de la vista previa del editor) sin ejecutar nada del módulo compartido.- Los tests no cambian:
./gradlew :shared:jvmTestsigue pasando los mismos de antes. Con tus ejercicios son 7 por destino (la JVM, Android y el simulador de iOS); sin ellos, 5.
Comprueba lo que entendiste
4 preguntas · necesitas 3 para completar la lección
1.¿Qué apaga
SharingStarted.WhileSubscribed(5_000)y por qué cinco segundos y no cero?2.
val estado by viewModel.uiState.collectAsStateWithLifecycle()no compila y el error habla de delegados. ¿Qué falta?3.
shareddeclaraapi(libs.androidx.lifecycle.viewmodel). ¿Por quéandroidApptiene que declarar ademáslifecycle-viewmodel-composeylifecycle-runtime-compose?4.¿Qué hace la lambda de
viewModel { HomeViewModel(Pokedex()) }?