Orbit en KMP: un container, dos anfitriones
El núcleo de Orbit es una biblioteca multiplataforma: el contenedor, el reductor, las intenciones y el canal de efectos viven en el conjunto común sin depender de Android. Lo que no es multiplataforma es el hogar donde ese contenedor se aloja, porque el ViewModel de Jetpack y su ámbito de corrutinas son una pieza exclusiva de una plataforma. Esta lección muestra cómo escribir un contenedor compartido que Android envuelve en un ViewModel y que iOS instancia y destruye a mano, qué implica que el ámbito de corrutinas deje de cancelarse solo, cómo consumir estado y efectos desde SwiftUI sin duplicar la lógica, y por qué la suite de pruebas del contenedor es la parte del enfoque que antes devuelve la inversión.
Llegado este punto, la pregunta práctica es si toda la disciplina acumulada en los niveles anteriores sobrevive al cruce de plataforma, y la respuesta es que sobrevive casi entera. El contenedor de Orbit —el estado inmutable, el reductor puro, las intenciones que disparan trabajo, el canal de efectos— está escrito en Kotlin puro con corrutinas y no necesita nada de Android para funcionar. Lo que sí necesita Android es el ViewModel, y ahí está la única costura real: el contenedor puede ser común, pero su hogar no puede serlo, porque el ViewModel de Jetpack resuelve tres problemas —supervivencia al cambio de configuración, ámbito que se cancela solo y rehidratación tras la muerte del proceso— que en iOS o no existen o se resuelven de otra manera. Compartir el contenedor y dejar el hogar a cada plataforma es, exactamente, la aplicación del reparto que llevas dos lecciones estudiando.
- Separar el contenedor compartido del anfitrión de plataforma y justificar por qué la costura cae ahí.
- Construir un
ContainerHosten el conjunto común que reciba su ámbito de corrutinas por constructor. - Consumir estado y efectos desde SwiftUI gestionando a mano el ciclo de vida que Android regala.
- Escribir la suite de pruebas del contenedor una sola vez y ejecutarla sobre las dos plataformas.
El contenedor es común; su hogar no
El módulo central de Orbit declara soporte multiplataforma, de modo que puede añadirse como dependencia de commonMain sin arrastrar nada de Android. Con él llegan la interfaz que marca a un objeto como anfitrión de un contenedor, la fábrica que lo crea a partir de un ámbito de corrutinas y un estado inicial, y los constructores de intenciones con su reducción y su emisión de efectos. Nada de eso conoce el sistema operativo.
// commonMain
data class PerfilEstado(
val cargando: Boolean = false,
val nombre: String = "",
val error: String? = null,
)
sealed interface PerfilEfecto {
data class Avisar(val mensaje: String) : PerfilEfecto
}
class PerfilContainer(
scope: CoroutineScope,
private val repo: PerfilRepositorio,
) : ContainerHost<PerfilEstado, PerfilEfecto> {
override val container = scope.container<PerfilEstado, PerfilEfecto>(PerfilEstado())
fun cargar() = intent {
reduce { state.copy(cargando = true, error = null) }
val resultado = repo.obtener()
reduce {
resultado.fold(
onSuccess = { state.copy(cargando = false, nombre = it.nombre) },
onFailure = { state.copy(cargando = false, error = it.message) },
)
}
if (resultado.isFailure) postSideEffect(PerfilEfecto.Avisar("No se pudo cargar"))
}
}
El único parámetro sospechoso es el ámbito de corrutinas, y no es casual: es precisamente el punto por donde entra la plataforma. En Android lo proporcionará el ViewModel; en iOS habrá que crearlo y, sobre todo, cancelarlo. Ese parámetro es la costura entera del enfoque, reducida a un argumento de constructor.
flowchart TB C[PerfilContainer en commonMain] --> A[ViewModel de Android aporta viewModelScope] C --> I[Objeto de iOS aporta un scope propio] A --> CO[Compose recolecta el estado] I --> SW[SwiftUI observa el estado] style C fill:#a6e3a1,color:#11111b style A fill:#89b4fa,color:#11111b style I fill:#f9e2af,color:#11111b
Dos anfitriones para el mismo contenedor
Del lado de Android el envoltorio es casi trivial y conserva todas las garantías que ya conocías, porque el ámbito que se entrega es el del propio ViewModel y muere con él.
// androidMain
class PerfilViewModel(repo: PerfilRepositorio) : ViewModel() {
val delegado = PerfilContainer(viewModelScope, repo)
}
Del lado de iOS no hay nadie que cree ni destruya el ámbito por ti, de modo que la responsabilidad pasa al objeto que envuelve el contenedor. La regla es sencilla y no admite descuidos: quien crea el ámbito lo cancela, y lo cancela en el destructor.
@MainActor
final class PerfilModelo: ObservableObject {
@Published private(set) var estado: PerfilEstado
private let contenedor: PerfilContainer
private let ambito = IosScope()
init(repo: PerfilRepositorio) {
contenedor = PerfilContainer(scope: ambito.scope, repo: repo)
estado = contenedor.estadoInicial
observar()
}
deinit { ambito.cancelar() }
}
La asimetría es real y conviene nombrarla sin disimulo cuando se presenta el enfoque a un equipo de iOS: en Android el ciclo de vida del contenedor viene resuelto por el marco, mientras que en iOS es trabajo del programador. No es difícil, pero es un contrato que alguien debe recordar, y los contratos que dependen de la memoria acaban rompiéndose alguna vez.
Si el ámbito no se cancela, el contenedor sigue vivo con sus corrutinas ejecutándose, reteniendo el repositorio y los cierres de observación. En Android este error es casi imposible porque viewModelScope se cancela solo; en iOS es el fallo número uno del enfoque. Detéctalo pronto instrumentando la creación y la destrucción de cada contenedor y comparando los recuentos durante una sesión de navegación.
Lo que hay que adaptar
El estado cruza bien: es una clase de datos inmutable y llega a Swift como un objeto que se lee sin ceremonia. Los efectos son más delicados, porque el canal de efectos es un flujo y hereda todos los problemas de la lección anterior: hace falta un envoltorio, manual o generado, y hace falta cancelar su recolección. Las intenciones cruzan como métodos ordinarios, con la particularidad de que las que son funciones suspendidas llegan con cierre de finalización, razón adicional para que la superficie pública del contenedor sean métodos que devuelven de inmediato y hacen su trabajo dentro del ámbito.
Funciona sin tocar nada
Estado inmutable, reductor puro, intenciones, inyección del repositorio y toda la lógica de dominio que cuelga del contenedor.
Requiere adaptación
El ámbito de corrutinas, la recolección del estado y del canal de efectos, y la traducción de la jerarquía sellada de efectos a Swift.
No cruza
El ViewModel, SavedStateHandle, la recolección consciente del ciclo de vida y todo lo que dependa del ciclo de vida de Android.
Se comparte entero
La suite de pruebas del contenedor, incluidas las aserciones sobre la secuencia de estados y sobre los efectos emitidos.
Merece un párrafo aparte la supervivencia a la muerte del proceso, porque es donde la simetría se rompe del todo. En Android el mecanismo es SavedStateHandle, atado al ViewModel y por tanto no compartible. Si quieres que el contenedor común participe, la única salida limpia es que reciba por constructor una abstracción de persistencia definida en común, implementada en Android sobre el mecanismo del sistema y en iOS sobre lo que corresponda. Es un caso de manual de la regla de la lección anterior: interfaz en común, implementación inyectada.
La suite compartida
La parte del enfoque que antes devuelve la inversión no es el ahorro de líneas sino la suite de pruebas. Un test escrito en commonTest sobre el contenedor se ejecuta una vez por destino, con lo que se valida la misma política sobre la máquina virtual y sobre el binario nativo. Cualquier divergencia de comportamiento entre plataformas —redondeos, formatos, orden de emisión, comportamiento del planificador— aparece en la construcción y no en una reseña de la tienda.
// commonTest
@Test
fun cargarConErrorEmiteAviso() = runTest {
val contenedor = PerfilContainer(backgroundScope, RepoQueFalla())
contenedor.test(this) {
expectInitialState()
containerHost.cargar()
expectState { copy(cargando = true) }
expectState { copy(cargando = false, error = "fallo") }
expectSideEffect(PerfilEfecto.Avisar("No se pudo cargar"))
}
}
La promesa comercial de compartir código es que se hace la mitad del trabajo, y la experiencia de cualquier equipo que lo haya llevado a producción es que el trabajo no desaparece: se desplaza y cambia de naturaleza. Lo que antes eran dos implementaciones independientes, cada una completa y cada una responsable solo de sí misma, pasa a ser una implementación única más un conjunto de contratos que dos mundos deben respetar. Ese contenedor común es ahora un punto de fallo compartido: un error en el reductor rompe las dos aplicaciones a la vez, un cambio en la forma del estado obliga a coordinar dos equipos en el mismo ciclo, y una decisión de diseño que antes podía tomar cada plataforma por su cuenta ahora necesita acuerdo. A cambio, desaparece la clase entera de fallos por divergencia, que es silenciosa, cara y prácticamente indetectable hasta que un usuario reporta que en su teléfono el descuento se calcula distinto. Ese intercambio —fallos correlacionados a cambio de fallos divergentes, coordinación a cambio de duplicación— es el mismo que aparece cada vez que alguien extrae una biblioteca compartida, monta un servicio común o unifica dos bases de datos, y siempre se decide igual: importa menos cuánto código compartes que si el equipo puede sostener la coordinación que ese código exige. Un contenedor común entre dos equipos que se hablan todos los días es un activo; el mismo contenedor entre dos equipos que se enteran de los cambios por la lista de fallos es una fuente de resentimiento con forma de repositorio. La arquitectura, aquí como casi siempre, acaba siendo una afirmación sobre la organización que la mantiene.
- Extrae un contenedor de Orbit tuyo al conjunto común pasándole el ámbito por constructor, y comprueba que compila sin dependencias de Android.
- Envuélvelo en un
ViewModelde Android y en un objeto observable de Swift, y verifica que ambos consumen el mismo estado. - Instrumenta la creación y destrucción del ámbito en iOS y demuestra con una sesión de navegación que no quedan contenedores vivos.
- Modela la persistencia frente a la muerte del proceso como interfaz común con implementación por plataforma, y prueba la restauración.
- Traslada tus pruebas del contenedor a
commonTesty ejecútalas sobre los dos destinos: anota cualquier diferencia de comportamiento que aparezca.