El ViewModel como hogar del container MVI
Las tres lecciones anteriores describieron un objeto con tres propiedades muy concretas: sobrevive a los cambios de configuración, trae un scope de corrutinas que se cancela solo y dispone de un canal para atravesar la muerte del proceso. Esta lección demuestra que esas tres propiedades son exactamente las que un container MVI necesita para funcionar, y que por eso el ViewModel no es un lugar arbitrario donde alojarlo sino el único lugar razonable. Recorre el reparto completo de responsabilidades —dónde vive el estado, cómo se nombran los intents, por dónde salen los efectos—, fija la frontera con la vista mediante la asimetría entre lo que entra como función y lo que sale como flujo, y cierra con la construcción por inyección y las pruebas de la lógica sin plataforma.
Llevas tres lecciones acumulando propiedades de un objeto: sobrevive a la destrucción de la pantalla, trae un scope de corrutinas que muere con él y tiene un canal para cruzar la muerte del proceso. Ahora pon esa lista al lado de la que escribiste en el nivel del container: la lógica MVI necesita un sitio que retenga el estado más allá de la vista, un scope donde ejecutar los intent sin fugarse y una forma de rehidratarse cuando el sistema la haya borrado. Las dos listas son la misma lista. Que el container de Orbit viva dentro de un ViewModel no es una convención heredada ni un gesto de cortesía hacia Android: es la constatación de que alguien ya había resuelto, con nombres distintos, los tres problemas de infraestructura que el patrón daba por resueltos.
- Justificar por qué el
containerMVI encaja exactamente en las garantías que da elViewModel. - Repartir con precisión estado,
intenty side effects dentro de la clase. - Fijar la frontera con la vista mediante la asimetría entre funciones que entran y flujos que salen.
- Construir el
ViewModelpor inyección y probar su lógica sin depender de la plataforma.
Por qué el container vive aquí
Cada garantía del ViewModel responde a una necesidad del patrón, y la correspondencia es punto por punto. El estado MVI es una única fuente de verdad que debe seguir viva mientras el usuario permanezca en la pantalla, aunque el sistema haya reconstruido esa pantalla siete veces: eso lo da la retención por ViewModelStore. Los intent son corrutinas que hacen trabajo asíncrono y deben cancelarse en cuanto la intención del usuario termine, sin dejar nada colgando: eso lo da viewModelScope, sobre el que la fábrica container de orbit-viewmodel construye directamente. Y la semilla desde la que ese estado se reconstruye tras la muerte del proceso llega por SavedStateHandle, que se inyecta en el constructor. Tres necesidades, tres garantías, cero infraestructura propia.
@HiltViewModel
class PerfilViewModel @Inject constructor(
private val repo: PerfilRepo,
handle: SavedStateHandle,
) : ContainerHost<PerfilState, PerfilEfecto>, ViewModel() {
private val usuarioId: String = checkNotNull(handle["usuarioId"])
override val container = container<PerfilState, PerfilEfecto>(
initialState = PerfilState(usuarioId = usuarioId),
) {
cargar() // onCreate: se ejecuta al primer observador
}
}
Retención por ViewModelStore
El estado único del patrón sigue vivo mientras el usuario permanezca en la pantalla, aunque el sistema haya reconstruido esa pantalla muchas veces.
viewModelScope
Cada intent es una corrutina hija que se cancela sola cuando la intención del usuario termina. Ni un identificador que guardar, ni una suscripción que soltar.
SavedStateHandle
La semilla desde la que el estado se reconstruye tras la muerte del proceso llega por el constructor y alimenta el estado inicial del container.
Merece detenerse en el bloque final. Ese onCreate de la fábrica no se dispara al construir el objeto, sino la primera vez que alguien empieza a observar el container, y solo esa primera vez. La diferencia es la que separa una pantalla que recarga sus datos en cada rotación de una que los carga una vez y los conserva: como el ViewModel es el mismo objeto tras el cambio de configuración, su container ya está creado y el bloque no vuelve a ejecutarse. La carga inicial se escribe una vez y se comporta bien sin condicionales defensivos del tipo comprobar si ya había datos.
Durante años, alojar la lógica MVI en un ViewModel significaba atarla a Android y renunciar a compartirla. Eso cambió: las versiones modernas de la librería de ciclo de vida publican ViewModel y viewModelScope como artefactos multiplataforma, con implementaciones para cada objetivo de Kotlin. Hoy puedes escribir en commonMain una clase que sea a la vez ViewModel y ContainerHost, compartirla íntegra entre Android, iOS y escritorio, y dejar en cada plataforma únicamente el código que colecciona los dos flujos de salida.
El reparto dentro de la clase
Con el hogar elegido, la clase se ordena en tres zonas nítidas, y no hay una cuarta. La primera es el estado: una data class inmutable declarada fuera, con valores por defecto, que representa todo lo que la pantalla puede mostrar. No hay campos sueltos en el ViewModel que compitan con él: si un dato influye en lo que se pinta, está dentro del estado; si es una dependencia colaboradora —un repositorio, un caso de uso—, es una propiedad privada del constructor.
Conviene ser estricto con esa primera zona, porque la tentación de añadir un campo suelto es constante. Cualquier propiedad mutable que viva fuera del estado —una bandera para saber si ya se cargó, un contador de reintentos, la última consulta enviada— crea una segunda fuente de verdad que no se emite, no se serializa y no aparece en ninguna prueba. Si influye en lo que ocurre, va dentro del estado, aunque la pantalla no lo pinte.
La segunda zona son los intent, que en la práctica son las funciones públicas de la clase y constituyen su API entera. Aquí importa el vocabulario: se nombran por lo que el usuario hizo, no por lo que el sistema debe hacer a continuación. refrescar, pulsarSeguir, escribirBusqueda son buenos nombres porque describen hechos ocurridos en la interfaz; mostrarDialogo o navegarADetalle son malos porque describen decisiones de presentación y, al colarse en la firma pública, hacen que la vista mande sobre la lógica en lugar de informarla.
fun pulsarSeguir() = intent {
reduce { state.copy(enviando = true) }
runCatching { repo.seguir(state.usuarioId) }
.onSuccess { reduce { state.copy(enviando = false, siguiendo = true) } }
.onFailure {
reduce { state.copy(enviando = false) }
postSideEffect(PerfilEfecto.Aviso("No se pudo completar"))
}
}
Hay una consecuencia del vocabulario que suele pasar inadvertida y que decide la calidad del diseño a medio plazo: si las funciones públicas nombran hechos del usuario, el ViewModel puede cambiar por completo su reacción sin que la vista se entere. Que pulsar seguir dispare una confirmación, una petición optimista o una cola diferida es una decisión interna. Si en cambio la función se llamara mostrar diálogo de confirmación, esa decisión habría quedado congelada en la firma, y cambiarla obligaría a tocar todas las pantallas que la invocan.
Un intent que crece más allá de unas pocas líneas suele estar haciendo un trabajo que no le tocaba. Su papel es orquestar: pedir a un colaborador, traducir el resultado a estado y, si procede, emitir un efecto. Las reglas de negocio densas —qué cuenta como válido, cómo se combinan dos fuentes, qué precedencia tiene un descuento— pertenecen al dominio, donde se prueban aisladas y se reutilizan desde varias pantallas. Cuando el ViewModel engorda, casi nunca es porque la pantalla sea compleja: es porque absorbió lógica ajena.
La tercera zona son los side effects, declarados como una sealed interface externa y emitidos únicamente con postSideEffect. Fíjate en el ejemplo: el fallo produce dos cosas distintas y deliberadamente separadas. Por un lado un reduce que devuelve el estado a la normalidad, porque el hecho de que ya no se esté enviando es estado; por otro un efecto con el aviso, porque mostrar un mensaje una vez no es estado. Esa separación, que estudiaste a nivel de tipos en el container, se materializa aquí como disciplina de escritura línea a línea.
flowchart TD UI[Vista] -->|llama funciones publicas| VM[ViewModel que es ContainerHost] VM --> CT[container sobre viewModelScope] CT --> RD[reduce actualiza el estado] CT --> PS[postSideEffect emite un evento] RD --> SF[stateFlow] PS --> SE[sideEffectFlow] SF -->|colecciona| UI SE -->|colecciona| UI SH[SavedStateHandle] --> CT style CT fill:#a6e3a1,color:#11111b style UI fill:#89b4fa,color:#11111b style SH fill:#f9e2af,color:#11111b
La frontera con la vista
Mira el diagrama y verás que la frontera no es simétrica, y esa asimetría es el diseño entero. Hacia dentro entran llamadas a funciones: gestos discretos, con nombre, sin valor de retorno. Hacia fuera no sale nada llamable: salen dos flujos que la vista observa. La vista nunca pregunta ni tira del estado, y el ViewModel nunca conoce a la vista. Esa asimetría es lo que hace que el mismo ViewModel sirva sin cambios para una pantalla en Compose, una pantalla en vistas clásicas, una prueba instrumentada o un test unitario: ninguno de esos consumidores es especial, todos hacen lo mismo.
@Composable
fun PerfilScreen(viewModel: PerfilViewModel = hiltViewModel()) {
val state by viewModel.collectAsState()
viewModel.collectSideEffect { efecto ->
when (efecto) {
is PerfilEfecto.Aviso -> mostrarAviso(efecto.texto)
is PerfilEfecto.Volver -> navegador.atras()
}
}
PerfilContenido(state = state, onSeguir = viewModel::pulsarSeguir)
}
Esa asimetría tiene además una lectura temporal. Las funciones que entran son momentos: ocurren una vez, no se repiten y no dejan rastro por sí mismas. Los flujos que salen son continuidades: siempre tienen algo que ofrecer a quien se asome, incluso a quien se asoma tarde. Confundir ambas direcciones —pedir un dato al ViewModel con una función que devuelva el estado, o entregar un evento a la vista mediante una propiedad que se lee— es lo que rompe el modelo, porque introduce lecturas que no reaccionan y valores que se consumen sin que nadie sepa cuándo.
El detalle que conviene notar es que el Composable que de verdad pinta —PerfilContenido— no recibe el ViewModel, sino un estado y unas funciones. Elevar así el estado deja el árbol de interfaz completamente ignorante de que existe Orbit, de que existe Android y de dónde vinieron los datos, lo que lo hace previsualizable y probable de forma aislada. El ViewModel es entonces la única pieza que conoce a ambos mundos, y su tamaño mide bien lo sano que está el diseño: si crece, casi siempre es porque está haciendo trabajo que pertenecía al dominio o a la vista.
Esa frontera también resuelve una duda habitual sobre la granularidad. Una pantalla con varias secciones no necesita un ViewModel por sección: necesita un estado con varias partes y funciones que nombren los gestos de cada una. Multiplicar los ViewModel dentro de una misma pantalla reparte la fuente de verdad, obliga a sincronizarlos entre sí y devuelve por la puerta de atrás justo el problema que el patrón venía a eliminar. La regla razonable es un container por intención del usuario, y una pantalla suele ser exactamente eso.
Que el ViewModel solo dependa de corrutinas y de sus colaboradores lo hace comprobable sin plataforma. Con las utilidades de prueba de Orbit lo pones en modo de test, invocas una función pública como lo haría un usuario y afirmas sobre la secuencia de estados y de side effects que produjo. Las pruebas quedan escritas en el mismo vocabulario que la pantalla —pulsó seguir, apareció el estado enviando, llegó el aviso— y no requieren emulador, ciclo de vida ni vistas. Solo la parte de colección, que es la que sí depende de la plataforma, necesita pruebas de interfaz.
@Test
fun `pulsar seguir marca enviando y termina siguiendo`() = runTest {
val vm = PerfilViewModel(repoFalso, SavedStateHandle(mapOf("usuarioId" to "42")))
vm.test(this) {
expectInitialState()
containerHost.pulsarSeguir()
expectState { copy(enviando = true) }
expectState { copy(enviando = false, siguiendo = true) }
}
}
Detente en lo que acaba de ocurrir, porque es más raro de lo que parece. Un patrón nacido fuera de Android —MVI viene del mundo funcional y de las interfaces web— exigía tres cosas para ser viable: un estado que sobreviviera a la vista, un ámbito de ejecución que se cancelara con la pantalla y una forma de rehidratarse tras desaparecer. Y resulta que la plataforma ya ofrecía las tres, sin haber oído hablar del patrón, porque las había necesitado por sus propias razones: la retención existía para no repetir peticiones al girar, el scope existía para no fugar corrutinas, el estado guardado existía porque el sistema mata procesos. Orbit no reimplementó ninguna. Se limitó a colocar su container encima y a heredarlas enteras. La lección general es un criterio para juzgar cualquier arquitectura que te propongan, incluida la tuya. Una arquitectura sana busca el punto donde sus necesidades coinciden con las garantías que ya da el sustrato, y ahí se instala; entonces cada garantía llega probada por millones de instalaciones, mantenida por otros y coherente con el resto de la app. Una arquitectura enferma decide primero cómo debería ser el mundo y luego construye una capa que simule ese mundo sobre el real: un contenedor de estado propio que no sabe de rotaciones, un gestor de tareas paralelo al del sistema, un mecanismo de persistencia que ignora la muerte del proceso. Esa segunda vía siempre parece más pura en el diagrama y siempre es peor en el dispositivo, porque cada garantía que fabricas es una garantía que tendrás que mantener, depurar y volver a fabricar cuando la plataforma cambie. La pregunta que separa ambos caminos es tan corta que cabe en una revisión de código: esto que voy a construir, ¿me lo estaba dando ya alguien más abajo con otro nombre?
- Para una pantalla real, escribe el
ViewModelcompleto comoContainerHost, con estado inicial leído delSavedStateHandley carga en el bloque de creación. - Explica por qué ese bloque no vuelve a ejecutarse tras una rotación y qué condicional defensivo te ahorra escribir.
- Revisa los nombres de tus funciones públicas y reescribe las que describan decisiones de presentación en lugar de hechos del usuario.
- Toma un fallo de red y reparte su consecuencia entre un
reducey unpostSideEffect, justificando qué parte es estado y qué parte es hecho. - Escribe una prueba que invoque una función pública y afirme sobre la secuencia de estados y efectos, y razona por qué no necesita emulador.