Saltar al contenido
K
Cimientos del código compartidoPaso a paso25 min de lectura · 8 min de máquina Avanzado

El ViewModel compartido

Al terminar: Montar las capas y el UiState de Google en commonMain y ver cómo se ve desde Swift.

La forma de organizar una app que Google recomienda para Android (capas de interfaz, dominio y datos, con el estado bajando y los eventos subiendo) no cambia en KMP: las mismas capas, el mismo flujo de datos, los mismos nombres. Lo único nuevo es dónde pones la línea entre lo compartido y lo nativo.

Al terminar, shared tendrá su primer ViewModel, escrito una sola vez en commonMain, con su estado de pantalla, y habrás visto qué le pasa en Swift cuando se te olvida una línea del fichero de Gradle. La pantalla de Android que lo pinta es la lección siguiente, El ViewModel compartido en Android.

Dónde corta la Pokédex

Es el corte que ya viste en Qué es KMP y qué no, ahora con los nombres reales de la app: todo lo que no dibuja píxeles es compartido.

ComposeAndroid
SwiftUIiOS
Aquí corta la Pokédex
ViewModelHomeViewModel · HomeUiState
Fachada y dominioPokedex · Pokemon
Fuentes de datosPokeAPI · base de datos
ComposeAndroid
SwiftUIiOS
Dónde corta la Pokédex: todo lo que no dibuja píxeles es compartido.

Dos palabras del esquema. La fachada es la clase que esconde lo de debajo detrás de una puerta sencilla: aquí, Pokedex. Los dispatchers son los que deciden en qué hilo corre cada trabajo (AppDispatchers, de Corrutinas en commonMain).

En la práctica: el estado vive en commonMain como StateFlow; cada interfaz solo lo lee y manda eventos hacia arriba. Por eso la vista es intercambiable: la misma lógica sirve a Compose y a SwiftUI.

Paso 1 · Instala el ViewModel de AndroidX en shared

Objetivo: que shared pueda declarar clases que hereden de ViewModel, aunque el código esté en commonMain y se compile también para iOS.

Es la misma receta de Los ficheros de Gradle de tu proyecto (versión, librería, dependencia, Sync), con una diferencia de KMP: la dependencia no va a un bloque dependencies { } suelto, sino al source set que la usa, commonMain.dependencies { }. Un source set es una carpeta de código que se compila para un grupo de plataformas (lo viste en Qué es KMP y qué no); commonMain es la de código común.

gradle
libs.versions.tomlse modifica: [libraries]
shared
build.gradle.ktsse modifica: commonMain y el framework
Los dos ficheros de la instalación, en la vista Project.

1 · Qué instalas y por qué ahora. lifecycle-viewmodel es la librería de AndroidX que trae la clase ViewModel. Desde la versión 2.8.0 es multiplataforma, así que el mismo ViewModel que usaste en Android sirve en commonMain.

2 · La versión, en [versions]. No añades nada. Abre gradle/libs.versions.toml y mira la sección [versions]: debe haber una clave lifecycle (en Crear el proyecto Pokédex la dejaste en 2.11.0), la misma que usa lifecycle-runtime-ktx. Todas las librerías androidx.lifecycle se publican juntas con el mismo número, así que comparten esa línea. Si en tu catálogo la clave se llama distinto, usa la tuya en los version.ref de abajo. La librería es la de Google (androidx.lifecycle), la que documenta KMP, y publica versiones para iPhone (ios_arm64) y para el simulador (ios_simulator_arm64).

3 · La librería, en [libraries]. En el mismo fichero, añade esta línea al final de la sección [libraries], antes de la línea en blanco y de la cabecera [plugins]:

libs.versions.toml: la línea nueva de [libraries]
# Alias: androidx-lifecycle-viewmodel. En el build.gradle.kts se escribe con
# puntos: libs.androidx.lifecycle.viewmodel.
# module = "grupo:nombre": las coordenadas de la librería en Maven.
# version.ref apunta a la clave lifecycle de [versions].
androidx-lifecycle-viewmodel = { module = "androidx.lifecycle:lifecycle-viewmodel", version.ref = "lifecycle" }

4 · La dependencia, en commonMain. Abre shared/build.gradle.kts. Dentro de sourceSets { commonMain.dependencies { … } }, añade la primera línea:

build.gradle.kts de shared: commonMain, con la línea nueva
        commonMain.dependencies {
            // api y no implementation: HomeViewModel hereda de ViewModel, así que
            // ViewModel asoma en una firma pública de shared. Y export (abajo)
            // solo acepta dependencias declaradas con api.
            api(libs.androidx.lifecycle.viewmodel)
            // Las dos líneas siguientes ya estaban: no las toques.
            implementation(libs.kotlinx.coroutines.core)
            api(libs.kermit)
        }

5 · El export, en el bloque del framework. Esta es la segunda línea, y la fácil de olvidar. En el mismo fichero busca el bloque iosTarget.binaries.framework { … }, el que fabrica el framework que Xcode enlaza (el paquete de código nativo que Swift importa como Shared). Añade el export entre baseName y isStatic:

build.gradle.kts de shared: el framework, con la línea nueva
        iosTarget.binaries.framework {
            baseName = "Shared"
            // Mete los tipos de ViewModel dentro del framework. Solo se puede
            // exportar una dependencia declarada con api, y esta lo está.
            // Con implementation, Gradle se niega a enlazar el framework.
            export(libs.androidx.lifecycle.viewmodel)
            isStatic = true
        }

(Tu fichero lleva además un comentario sobre isStatic; déjalo donde está.)

6 · Sync Now, y comprueba. Pulsa Sync Now en el aviso que sale arriba del editor. Debe terminar sin rojo, y las dos líneas nuevas de build.gradle.kts no deben subrayarse. Si sale en rojo, repasa la caja «Si Sync sale en rojo» de Los ficheros de Gradle de tu proyecto. Los dos fallos de esta instalación:

  • Un alias que no existe. Si el catálogo no tiene la línea, o la escribiste con otro nombre, el fichero falla al compilar con Unresolved reference 'viewmodel'., con la línea del build.gradle.kts (por ejemplo build.gradle.kts:39:41). Compara el alias con el del catálogo, leyendo los guiones como puntos.
  • Una versión que no existe. Con lifecycle = "2.99.0", Gradle responde Could not find androidx.lifecycle:lifecycle-viewmodel:2.99.0. Revisa el número en [versions].

Paso 2 · El estado de pantalla: data class, y no sealed

El archivo. HomeUiState.kt es el estado de la pantalla de inicio: un solo objeto con todo lo que la pantalla necesita para pintarse. Se crea en commonMain, porque lo usarán las dos interfaces.

Créalo con el método de El módulo compartido y la prueba de humo: en la vista Project, clic derecho sobre la carpeta shared → New → File, y escribe la ruta completa desde src/, con el nombre del fichero:

src/commonMain/kotlin/com/sebastiannarvaez/pokedex/feature/home/HomeUiState.kt

Android Studio crea las carpetas que falten. Plan B: mkdir -p y touch con esa ruta, desde la Terminal de Android Studio.

shared
src/commonMain/kotlin/.../feature/home/HomeUiState.ktse crea
src/commonMain/kotlin/.../feature/home/HomeViewModel.ktse crea después
Los dos ficheros de `shared` de esta lección.

Escríbelo, con su comentario:

shared/src/commonMain/kotlin/com/sebastiannarvaez/pokedex/feature/home/HomeUiState.kt
package com.sebastiannarvaez.pokedex.feature.home
 
/**
 * Todo lo que la pantalla necesita para pintarse, en un solo objeto.
 *
 * Es una `data class` y no una `sealed interface` a proposito. Una jerarquia
 * sellada se lee muy bien en Kotlin, pero cruza mal a Swift: pierde la
 * exhaustividad, y el `switch` de SwiftUI acaba con un `default` que nadie
 * revisa. Los estados sellados se quedan puertas adentro.
 */
data class HomeUiState(
    val greeting: String = "",
    val heartbeat: Int = 0,
)

La regla. En Modelar el UiState usaste una jerarquía sealed para los estados de carga, y en Kotlin sigue siendo lo mejor. Aquí se aplica lo que acabas de recordar: data class para el estado de pantalla, y sealed puertas adentro, en la capa de datos, donde no cruza la frontera.

Paso 3 · El ViewModel

El archivo. HomeViewModel.kt guarda el estado de la pantalla de inicio y lo expone como StateFlow. Vive junto a HomeUiState, en commonMain. Créalo igual que el anterior, con la ruta:

src/commonMain/kotlin/com/sebastiannarvaez/pokedex/feature/home/HomeViewModel.kt

Es el mismo ViewModel de Android; solo cambia el lugar. Usa Pokedex, la clase de Corrutinas en commonMain, que ya tiene greeting(), y su flujo heartbeat(), el latido que añadiste en Un Flow hasta Swift:

shared/src/commonMain/kotlin/com/sebastiannarvaez/pokedex/feature/home/HomeViewModel.kt
package com.sebastiannarvaez.pokedex.feature.home
 
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.sebastiannarvaez.pokedex.Pokedex
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
 
/**
 * El mismo ViewModel para las dos apps.
 *
 * `androidx.lifecycle.ViewModel` dejó de ser solo de Android: desde la 2.8.0
 * es multiplataforma, y `viewModelScope` se cancela igual en las dos. Lo que
 * cambia es quién decide cuándo muere: en Android lo hace el sistema, y en iOS
 * hay que escribir el dueño a mano.
 */
class HomeViewModel(pokedex: Pokedex) : ViewModel() {
 
    private val saludo = pokedex.greeting()
 
    /**
     * `WhileSubscribed(5_000)` es lo que apaga el trabajo caro cuando nadie
     * mira, con cinco segundos de gracia para no reiniciarlo al rotar la
     * pantalla. Sin eso, el latido seguiría contando en segundo plano.
     */
    val uiState: StateFlow<HomeUiState> = pokedex.heartbeat()
        .map { HomeUiState(greeting = saludo, heartbeat = it) }
        .stateIn(
            scope = viewModelScope,
            started = SharingStarted.WhileSubscribed(5_000),
            initialValue = HomeUiState(greeting = saludo),
        )
}

Compruébalo. Pulsa Sync Now si el IDE lo pide y ejecuta, en la Terminal de Android Studio (pestaña Terminal, abajo), desde la carpeta de tu proyecto:

./gradlew :shared:compileKotlinIosSimulatorArm64

Debe acabar en BUILD SUCCESSFUL.

Punto de control

  • HomeUiState es una data class con greeting y heartbeat, no una jerarquía sellada, y HomeViewModel vive en commonMain y expone un StateFlow<HomeUiState> con WhileSubscribed(5_000). Los dos están en shared/src/commonMain/kotlin/com/sebastiannarvaez/pokedex/feature/home/.
  • shared/build.gradle.kts tiene api(libs.androidx.lifecycle.viewmodel) y export(libs.androidx.lifecycle.viewmodel) dentro del bloque del framework, y el catálogo tiene la entrada androidx-lifecycle-viewmodel.
  • ./gradlew :shared:linkDebugFrameworkIosSimulatorArm64 termina en BUILD SUCCESSFUL, y en el Shared.h que genera SharedHomeViewModel hereda de SharedViewModel. Compruébalo en la Terminal, desde la carpeta de tu proyecto: grep -rn "SharedHomeViewModel :" shared/build/bin --include=Shared.h (grep busca un texto dentro de ficheros; -r entra en las carpetas, -n pone el número de línea y --include=Shared.h limita la búsqueda a ese fichero). Si hereda de SharedLifecycle_viewmodelViewModel, te falta el export.
  • Las dos apps siguen funcionando igual que antes: todavía ninguna usa el ViewModel.

Comprueba lo que entendiste

4 preguntas · necesitas 3 para completar la lección

  1. 1.Se olvida export(libs.androidx.lifecycle.viewmodel) en el bloque del framework, pero la dependencia sigue declarada como api. ¿Qué pasa?

  2. 2.¿Por qué HomeUiState es una data class y no una sealed interface, que sería más idiomática en Kotlin?

  3. 3.Al compilar aparece w: HomeViewModel.kt:28:18 StateFlow property is exposed to ObjC. ¿Qué conviene hacer?

  4. 4.¿Por qué HomeUiState se escribe en commonMain y no en androidApp?

Faltan 4 de 4