Kotlin Multiplatform en apps Android e iOS: qué lógica compartimos realmente y dónde la plataforma sigue ganando
Kotlin Multiplatform se vende con una promesa sencilla: escribir la lógica de negocio una sola vez y compilarla tanto para Android como para iOS, dejando que cada plataforma mantenga su propia interfaz nativa. La promesa es real, pero la línea que separa "esto se comparte sin fricción" de "esto cuesta más compartido que duplicado" no está donde la documentación la dibuja. Después de llevar KMP a un par de apps con lógica de negocio no trivial, la frontera que de verdad funciona es más angosta que la que prometimos al principio del proyecto.
Lo que comparte bien: modelos de datos y lógica de dominio pura
Los candidatos obvios para commonMain son los que menos sorpresas dan: clases de datos, validaciones, cálculos y cualquier transformación que solo dependa de tipos de Kotlin estándar. Un modelo de dominio como este no tiene ninguna razón para existir dos veces:
// commonMain
data class Pedido(
val id: String,
val items: List<ItemPedido>,
val estado: EstadoPedido
) {
fun total(): BigDecimal =
items.fold(BigDecimal.ZERO) { acc, item -> acc + item.subtotal() }
}
Esto compila igual en Android y en iOS porque no toca nada específico de la plataforma: ni IO, ni UI, ni una API del sistema. Lo mismo pasa con la lógica de validación de formularios, las reglas de negocio que deciden si un pedido puede cancelarse, o el parseo y formateo de datos que no dependen de locale del sistema operativo. En los proyectos donde migramos esta capa, fue la que menos retrabajo exigió y la que menos bugs de plataforma introdujo, porque el comportamiento es literalmente el mismo bytecode compilado dos veces, no una reimplementación con la esperanza de que ambas se comporten igual.
Los repositorios también comparten bien, pero con una condición: que la interfaz quede en commonMain y la implementación que toca una API específica de plataforma quede en los source sets correspondientes.
// commonMain
interface PedidoRepository {
suspend fun obtenerPedido(id: String): Pedido
suspend fun guardarPedido(pedido: Pedido)
}
La lógica de cache, de reintentos, de mapeo de errores de red a errores de dominio: todo eso vive en código común porque son decisiones de negocio, no de plataforma. Lo que cambia entre Android e iOS es el cliente HTTP subyacente y el motor de persistencia, y ahí es donde entra expect/actual.
Donde expect/actual empieza a costar más de lo que ahorra
expect/actual es el mecanismo que permite declarar una API en código común y darle una implementación distinta por plataforma. Funciona bien cuando la superficie de la API es chica y estable: acceso al reloj del sistema, generación de UUIDs, lectura de una preferencia simple. El problema aparece cuando la API de la plataforma subyacente no es simétrica, y terminamos escribiendo una abstracción común que no representa bien a ninguna de las dos plataformas.
El caso donde esto se nos volvió costoso fue la persistencia local. Quisimos abstraer SQLDelight (que sí es multiplatform de fábrica) detrás de una interfaz propia para poder, en teoría, cambiar de motor sin tocar el código común. En la práctica, las diferencias en cómo Android maneja transacciones en background con WorkManager y cómo iOS maneja el ciclo de vida de una tarea en background con BGTaskScheduler significaban que la interfaz común terminaba teniendo parámetros opcionales que solo tenían sentido en una plataforma, o callbacks que en la otra plataforma simplemente no se invocaban nunca. Mantuvimos esa abstracción durante varios sprints antes de aceptar que estábamos pagando el costo de diseñar y mantener una interfaz genérica sin ganar nada a cambio, porque nunca tuvimos intención real de cambiar de motor de persistencia. La solución fue dejar la lógica de qué guardar y cuándo en código común, y dejar el cómo -el detalle de la transacción, el manejo del ciclo de vida en background- como código específico de cada plataforma, sin forzarlo a una interfaz compartida.
Algo parecido pasa con cualquier lógica que dependa de permisos del sistema, notificaciones push, o acceso a sensores. El expect/actual para estas APIs tiende a degenerar en una interfaz que expone el mínimo común denominador entre Android e iOS, lo cual en la práctica significa que ninguna de las dos plataformas puede usar sus capacidades completas a través de esa capa, y tarde o temprano alguien agrega un método específico de plataforma que rompe la abstracción que se había prometido.
Lo que todavía no compartimos: UI, navegación y estado de presentación
Compose Multiplatform y SwiftUI siguen siendo mundos separados en nuestros proyectos, y no por falta de herramientas -Compose Multiplatform ya soporta iOS- sino porque el costo de forzar un mismo árbol de UI a verse "nativo" en ambas plataformas no compensa todavía frente a mantener dos capas de presentación delgadas que consumen el mismo ViewModel compartido.
// commonMain
class PedidoViewModel(
private val repository: PedidoRepository
) {
private val _estado = MutableStateFlow<PedidoUiState>(PedidoUiState.Cargando)
val estado: StateFlow<PedidoUiState> = _estado
suspend fun cargarPedido(id: String) {
_estado.value = PedidoUiState.Cargando
_estado.value = try {
PedidoUiState.Listo(repository.obtenerPedido(id))
} catch (e: Exception) {
PedidoUiState.Error(e.message ?: "Error desconocido")
}
}
}
Este ViewModel en commonMain expone un StateFlow que tanto una Activity de Android como un ViewController de iOS pueden observar, cada uno con su propio mecanismo nativo de binding. Compartimos la lógica de qué estado mostrar y cuándo; no compartimos cómo se dibuja ese estado. Esta frontera -lógica de presentación sí, renderizado no- nos dio la mejor relación entre código compartido y fricción de mantenimiento, muy por encima de cualquier intento de compartir también el árbol de vistas.
La navegación tampoco la compartimos. Cada plataforma tiene su propio paradigma -NavController en Android, NavigationStack en iOS- y las pocas veces que probamos abstraerla detrás de una capa común terminamos con una API que no respetaba bien ni los patrones de deep linking de Android ni el comportamiento de swipe-back nativo de iOS.
Qué revisamos antes de decidir si algo va a código común
Antes de mover una capa a commonMain, confirmamos que su comportamiento no dependa de ninguna API de plataforma incluso de forma indirecta, porque una dependencia escondida ahí termina asomando como un expect/actual mal disimulado; preferimos mantener dos implementaciones nativas antes que forzar una interfaz común cuando la superficie de la API de la plataforma subyacente no es simétrica entre Android e iOS; compartimos la lógica de qué y cuándo (dominio, estado de presentación, reglas de negocio) y dejamos el cómo (persistencia de bajo nivel, ciclo de vida en background, renderizado) como código nativo; y revisamos cada abstracción expect/actual existente cada vez que agregamos una plataforma o una funcionalidad nueva, porque la que tenía sentido con dos casos de uso simples puede volverse la pieza más cara de mantener del proyecto en cuanto el primer caso de uso real no entra en el molde que diseñamos para ella.