El mismo ViewModel en iOS
Al terminar: Sostener un ViewModel de Kotlin desde SwiftUI con su ciclo de vida correcto y sin fugas.
El HomeViewModel ya vive en commonMain (lo escribiste en El ViewModel compartido) y Android lo consume sin ceremonia, como viste en El ViewModel compartido en Android: el propio sistema decide cuándo crearlo y cuándo destruirlo, junto a la pantalla a la que sirve. En iOS nadie hace eso, porque SwiftUI no tiene ningún equivalente.
Así que hay que escribirlo. Al terminar esta lección, la app de iOS tendrá el mismo ViewModel de Kotlin detrás de su pantalla, con un dueño de ciclo de vida propio que lo entierra cuando toca, y el estado llegando como una secuencia que SwiftUI sabe recorrer.
Las dos mitades salen de lecciones anteriores: el ViewModel de AndroidX, de ViewModel: qué sobrevive, y @StateObject con su ciclo de vida, de ObservableObject y @StateObject. Lo propio de KMP es cómo se unen: tres piezas pequeñas y dos decisiones que, tomadas mal, fallan en silencio: declarar el dueño con @StateObject y vaciar su almacén en deinit. No instalas nada nuevo: la anotación @NativeCoroutines y el paquete de Swift que la usa llegaron en Un Flow hasta Swift.
Por qué hay que escribir algo
En Android, cada pantalla (Activity) ya es un ViewModelStoreOwner: el sistema mantiene el almacén vivo al rotar y lo vacía cuando la pantalla se va de verdad. En iOS ese papel no lo hace nadie, así que se escribe.
Cómo se pide un ViewModel a un almacén
Antes del ejercicio, tres piezas de AndroidX que el paso 2 vuelve a usar. El ejemplo corre como un test, sin pantalla: es para leer, no para escribir.
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.ViewModelStore
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import kotlinx.coroutines.isActive
import kotlin.test.Test
// Un ViewModel cualquiera. private set: desde fuera se lee, solo cambia dentro.
class Contador : ViewModel() {
var cuenta = 0
private set
fun sumar() { cuenta++ }
// onCleared: lo llama el almacén cuando se vacía.
override fun onCleared() { println("onCleared: el almacén me vació") }
}
class AlmacenDemoTest {
@Test
fun demo() {
// El almacén y la fábrica: viewModelFactory { } crea la fábrica
// e initializer { } dice qué fabricar cuando aún no hay ninguno.
val almacen = ViewModelStore()
val fabrica = viewModelFactory { initializer { Contador() } }
// [Contador::class] pide uno de ese tipo: si ya hay, lo devuelve; si no, lo fabrica.
val a = ViewModelProvider.create(almacen, fabrica)[Contador::class]
val b = ViewModelProvider.create(almacen, fabrica)[Contador::class]
println(a === b) // === : ¿el mismo objeto?
a.sumar()
println(b.cuenta) // b es a: ve el 1
println(a.viewModelScope.isActive) // isActive: ¿sigue viva su corrutina madre?
almacen.clear() // vaciar: llama a onCleared y cancela el scope
println(a.viewModelScope.isActive)
}
}Salida:
true
1
true
onCleared: el almacén me vació
falsePedir dos veces el mismo tipo al mismo almacén devuelve el mismo objeto, y vaciar el almacén es lo que entierra al ViewModel.
Reto · Ejercicio: el guardarropa, a prueba
Antes de montar el guardarropa en Swift, compruébalo en un test que corre en las tres plataformas: Android, iOS y la JVM.
Va en la carpeta ejercicios de commonTest, la que creaste para la batería en expect/actual frente a interfaces. En la vista Project, clic derecho sobre shared → New → File, como en El módulo compartido y la prueba de humo, y escribe la ruta completa desde shared; Android Studio crea la subcarpeta guardarropa:
src/commonTest/kotlin/com/sebastiannarvaez/pokedex/ejercicios/guardarropa/GuardarropaTest.kt- Escribe un ViewModel llamado
Abrigoque recuerde si se lo devolvieron: una propiedad que empieza en falso, que desde fuera solo se lee (private set), y que suonClearedpone a verdadero. - Escribe dos tests, cada uno con un almacén de ViewModels nuevo y la fábrica del ejemplo:
- el primero pide dos veces un
Abrigoal mismo almacén y comprueba que es el mismo objeto (assertSamehace esa comprobación); - el segundo pide un
Abrigo, vacía el almacén y comprueba que el abrigo se devolvió y que suviewModelScopeya no está activo (isActive).
- el primero pide dos veces un
- Ejecuta, en la Terminal de Android Studio, desde la carpeta de tu proyecto:
./gradlew :shared:jvmTest :shared:testAndroidHostTest :shared:iosSimulatorArm64Test.
Resultado esperado: BUILD SUCCESSFUL, y GuardarropaTest con sus dos tests en verde en cada uno de los tres destinos. Con tus ejercicios son 9 tests por destino (los 7 de antes y estos 2); sin ellos, 5. Si olvidas vaciar el almacén, falla el segundo test.
Ver solución
package com.sebastiannarvaez.pokedex.ejercicios.guardarropa
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.ViewModelStore
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import kotlinx.coroutines.isActive
import kotlin.test.Test
import kotlin.test.assertFalse
import kotlin.test.assertSame
import kotlin.test.assertTrue
// Un abrigo que sabe si ya se lo devolvieron.
class Abrigo : ViewModel() {
var devuelto = false
private set
// onCleared: lo llama el almacén cuando se vacía.
override fun onCleared() {
devuelto = true
}
}
class GuardarropaTest {
// La fábrica: cómo se hace un Abrigo cuando todavía no hay ninguno.
private val fabrica = viewModelFactory { initializer { Abrigo() } }
@Test
fun elMismoGuardarropaDevuelveElMismoAbrigo() {
val guardarropa = ViewModelStore()
val primero = ViewModelProvider.create(guardarropa, fabrica)[Abrigo::class]
val segundo = ViewModelProvider.create(guardarropa, fabrica)[Abrigo::class]
// assertSame: el mismo objeto, no dos iguales.
assertSame(primero, segundo)
}
@Test
fun vaciarElGuardarropaDevuelveElAbrigo() {
val guardarropa = ViewModelStore()
val abrigo = ViewModelProvider.create(guardarropa, fabrica)[Abrigo::class]
val alcance = abrigo.viewModelScope
assertTrue(alcance.isActive)
// clear(): lo mismo que hará deinit en iOS.
guardarropa.clear()
assertTrue(abrigo.devuelto)
assertFalse(alcance.isActive)
}
}Con el almacén vacío, onCleared() se ejecutó y el viewModelScope se canceló: es exactamente lo que deinit hará en iOS con el almacén del dueño. Y el primer test es la razón de que, en la vista, se pueda llamar a la función que pide el ViewModel dentro de body sin crear uno nuevo cada vez. Salida: 2 tests más en cada destino, 0 fallos.
Paso 1 · El dueño, en Swift
El archivo. IosViewModelStoreOwner.swift es el guardarropa del teatro: una clase de Swift que guarda el almacén de ViewModels y lo vacía cuando ella desaparece. Es del lado de iOS, así que se crea en Xcode.
Créalo. Abre iosApp/iosApp.xcodeproj con Xcode (como en Ejecutar en iOS). En el Project navigator (Cmd+1), clic derecho sobre la carpeta iosApp que contiene ContentView.swift (la de dentro del proyecto, no el icono azul de arriba) → New File from Template… → Swift File → Next, escribe IosViewModelStoreOwner y pulsa Create. La carpeta está sincronizada con el disco: un .swift que crees dentro entra solo en la app. Si el menú de tu versión de Xcode se llama distinto, crea el fichero en la Terminal, desde la carpeta de tu proyecto: touch iosApp/iosApp/IosViewModelStoreOwner.swift, y Xcode lo muestra solo.
Dos ideas de Swift que ya conoces hacen todo el trabajo: una class puede tener deinit (el código que corre justo antes de liberarla, como viste en ARC), y ViewModelStoreOwner es una interface de Kotlin que en Swift llega como un protocolo: la clase lo cumple dándole un viewModelStore (los protocolos, en Protocolos y extensiones). Escríbelo:
import Shared
/// El dueño del ciclo de vida de los ViewModel en iOS.
///
/// En Android, quien decide cuándo muere un ViewModel es el sistema. En iOS no
/// hay equivalente, así que hay que escribirlo: esta clase guarda el almacén y
/// lo vacía en `deinit`, que es lo que acaba llamando a `onCleared()` y cancela
/// el `viewModelScope`.
///
/// Es `ObservableObject` para poder usarla como `@StateObject` en la vista, que
/// es lo único que garantiza que SwiftUI la cree una sola vez y no cada vez
/// que vuelve a construir la vista.
final class IosViewModelStoreOwner: ObservableObject, ViewModelStoreOwner {
// El almacén donde viven los ViewModel; es lo que exige ViewModelStoreOwner.
let viewModelStore = ViewModelStore()
// deinit: corre justo antes de que Swift libere esta clase.
deinit {
// Vacía el almacén: cada ViewModel recibe onCleared().
viewModelStore.clear()
}
}Ese deinit no es opcional: es lo que acaba llamando a onCleared() en Kotlin y cancela el viewModelScope. Sin él, los ViewModel se quedan colgados con sus corrutinas dentro.
Y ObservableObject tampoco es decoración: es el requisito para poder usar la clase como @StateObject.
Paso 2 · La fábrica y el estado, en Kotlin
El archivo es HomeIos.kt, y va en iosMain, el source set de iOS: ese código solo se compila para el iPhone y el simulador. Créalo en Android Studio con el método de El módulo compartido y la prueba de humo: clic derecho sobre la carpeta shared → New → File, con la ruta desde shared:
src/iosMain/kotlin/com/sebastiannarvaez/pokedex/feature/home/HomeIos.ktEscríbelo, entero:
package com.sebastiannarvaez.pokedex.feature.home
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.ViewModelStoreOwner
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import com.rickclephas.kmp.nativecoroutines.NativeCoroutinesState
import com.sebastiannarvaez.pokedex.Pokedex
import kotlinx.coroutines.flow.StateFlow
/**
* Crea el ViewModel dentro del almacén que le pasa Swift.
*
* El tipo de retorno es concreto, no genérico, a propósito: la función genérica
* de la receta oficial obliga a pelearse con `KClass` desde Swift. Una función
* por pantalla es más código, pero es código que se lee.
*/
fun homeViewModel(owner: ViewModelStoreOwner, pokedex: Pokedex): HomeViewModel =
// ViewModelProvider entrega ViewModels guardados en un almacén: si ya hay
// uno del tipo pedido, devuelve ese; si no, lo fabrica.
ViewModelProvider.create(
// El almacén donde vive: el IosViewModelStoreOwner que pasa Swift.
owner = owner,
// La fábrica: cómo construir el ViewModel cuando todavía no existe.
// viewModelFactory { } crea la fábrica, e initializer { } dice cómo
// se fabrica un HomeViewModel en concreto.
factory = viewModelFactory { initializer { HomeViewModel(pokedex) } },
// [HomeViewModel::class] pide uno de ese tipo; ::class es la
// referencia a la clase (el KClass de antes).
)[HomeViewModel::class]
/**
* El estado, como algo que Swift puede recorrer.
*
* `@NativeCoroutinesState` y no `@NativeCoroutines`: además de la secuencia
* asíncrona, genera la propiedad con el valor actual, que es lo que evita que
* la pantalla parpadee vacía en el primer fotograma.
*
* Y es una **propiedad de extensión**, no una función: la anotación solo se
* puede aplicar a propiedades, y el compilador lo dice sin rodeos.
*/
@NativeCoroutinesState
val HomeViewModel.uiStateForIos: StateFlow<HomeUiState>
get() = uiStateEn Swift queda HomeIosKt.homeViewModel(owner:pokedex:), sin genéricos por medio. El HomeIosKt es el nombre de la clase que Kotlin inventa para meter las funciones sueltas del fichero HomeIos.kt (nombre del fichero más Kt). En Las funciones que cruzan a Swift viste que conviene no exponer funciones de nivel superior: esta es la excepción controlada, una sola función de entrada por pantalla y sin lógica; todo lo demás va en clases.
Paso 3 · La vista
El archivo. ContentView.swift ya existe: es la pantalla de inicio de iOS que dejó Un Flow hasta Swift. Ábrelo en Xcode (Project navigator → iosApp → ContentView.swift), selecciona todo su contenido (Cmd+A) y sustitúyelo por esto. Xcode puede subrayar HomeIosKt en rojo hasta que compile: esa clase la genera Kotlin a partir de HomeIos.kt. Pulsa Cmd+B (Product → Build) y el subrayado desaparece cuando termina.
import SwiftUI
import Shared
import KMPNativeCoroutinesAsync
struct ContentView: View {
/// @StateObject y no @ObservedObject: con @ObservedObject SwiftUI crearía
/// un dueño nuevo cada vez que se vuelve a construir la vista, el ViewModel
/// se recrearía y el estado se perdería sin que nada avisara.
@StateObject private var owner = IosViewModelStoreOwner()
private let pokedex = Pokedex()
@State private var estado = HomeUiState(greeting: "", heartbeat: 0)
var body: some View {
// let crea una constante local; body puede llevar líneas antes de la vista.
// body se ejecuta muchas veces, pero no se crea un ViewModel cada vez:
// el almacén del dueño ya tiene uno de este tipo y lo devuelve.
let viewModel = HomeIosKt.homeViewModel(owner: owner, pokedex: pokedex)
VStack(spacing: 8) {
Text(estado.greeting)
.font(.title2)
Text("Latido \(estado.heartbeat)")
.font(.footnote)
.foregroundStyle(.secondary)
}
.padding()
.task {
// El valor actual primero, para no pintar un fotograma vacío.
estado = viewModel.uiStateForIos
do {
for try await nuevo in asyncSequence(for: viewModel.uiStateForIosFlow) {
estado = nuevo
}
} catch {
// Aquí solo llegan los errores del flujo. Si la vista desaparece,
// la tarea se cancela y el bucle termina sin pasar por este catch.
}
}
}
}
#Preview {
ContentView()
}Fíjate en el orden dentro del .task: primero el valor actual, después el bucle. Esa línea de más es la diferencia entre una pantalla que aparece con el texto puesto y una que parpadea vacía durante un fotograma.
Paso 4 · Compruébalo
Ejecuta la app en el simulador (Cmd+R, como en Ejecutar en iOS). Se lee «Hola desde iOS …» con la versión de iOS de tu simulador y, debajo, «Latido 0», «Latido 1»… avanzando cada segundo (el número depende de cuándo mires), y el mismo HomeViewModel de commonMain detrás de las dos plataformas.
Esta app tiene una sola pantalla y nadie la cierra, así que el deinit no se llega a ver en la app; ya lo comprobaste en el ejercicio, con el almacén vaciado a mano. Lo que sí hace la app es pedir el ViewModel en cada pasada de body, y por eso el almacén tiene que devolver siempre el mismo.
Punto de control
- Existe
IosViewModelStoreOwner.swift, esObservableObjectyViewModelStoreOwner, y sudeinitllama aviewModelStore.clear(). - En
ContentView.swiftel dueño se declara con@StateObject, no con@ObservedObject. - Existe
HomeIos.kteniosMainconhomeViewModel(owner:pokedex:)y la propiedad de extensiónuiStateForIos. - La anotación es
@NativeCoroutinesState, no@NativeCoroutines. - El
.taskleeviewModel.uiStateForIosantes de entrar en el bucle sobreuiStateForIosFlow. GuardarropaTest(enejercicios/guardarropa) pasa en los tres destinos. Con tus ejercicios,./gradlew :shared:jvmTestpasa 9 tests (y 9 los otros dos destinos); sin ellos, 5.- En el simulador se ve el saludo y el latido avanzando, sin fotograma vacío al abrir.
Comprueba lo que entendiste
6 preguntas · necesitas 5 para completar la lección
1.¿Qué hace exactamente el
deinitdeIosViewModelStoreOwner?2.Se declara el dueño con
@ObservedObjecten vez de@StateObject. ¿Qué ocurre?3.¿Por qué
IosViewModelStoreOwnerimplementaObservableObjectsi la vista no observa ninguna de sus propiedades?4.Pones
@NativeCoroutinesStatesobre una función. ¿Qué responde el compilador y cuál es el arreglo?5.¿Qué diferencia práctica hay entre usar
@NativeCoroutinesStatey@NativeCoroutinessobre el estado de la pantalla?6.Pides dos veces un ViewModel del mismo tipo al mismo almacén. ¿Qué recibes, y por qué importa en iOS?