Qué comparte KMP: la lógica sí, la interfaz casi nunca
Kotlin Multiplatform no es la promesa de escribir la aplicación una vez y ejecutarla en todas partes, sino una herramienta para decidir con precisión qué capas merecen un único código fuente y cuáles no. Esta lección establece el eje que gobierna ese reparto: lo que es política de la aplicación —el contenedor de estado, las reglas de dominio, el acceso a datos, la serialización y la validación— se comparte con un rendimiento extraordinario; lo que es presentación —los componentes, la navegación, los gestos y las convenciones visuales de cada sistema— rara vez compensa compartirlo. Analiza por qué la frontera cae justo ahí y no en otro sitio, qué proporción del código real ocupa cada capa en una aplicación típica, qué cambia con Compose Multiplatform y qué coste impone la frontera cuando se traza mal.
La primera decepción de quien llega a Kotlin Multiplatform es descubrir que el eslogan no dice lo que parecía decir. No se comparte la aplicación: se comparte una parte de ella, y elegir bien esa parte es el noventa por ciento del trabajo intelectual del enfoque. La buena noticia es que la frontera no es arbitraria ni depende de la moda del año, sino de una propiedad estructural que ya conoces si has trabajado MVI con seriedad: la lógica que decide qué debe verse es idéntica en cualquier plataforma, mientras que el código que decide cómo se ve está atado a un conjunto de convenciones, componentes y expectativas que difieren de un sistema a otro y que los usuarios notan cuando se violan. MVI, casualmente o no, coloca esa frontera exactamente donde KMP la necesita: el container produce estado, la interfaz lo dibuja. Compartir lo primero y no lo segundo no es un compromiso: es el reparto natural del patrón llevado a su conclusión.
- Distinguir con criterio operativo entre política de la aplicación y presentación, y ubicar la frontera de KMP en esa junta.
- Estimar qué proporción del código real de una aplicación cae a cada lado y por qué el ahorro es mayor de lo que la intuición sugiere.
- Evaluar Compose Multiplatform sin idealizarlo: qué resuelve, qué no resuelve y qué cobra en las plataformas de Apple.
- Reconocer las tres formas típicas de trazar mal la frontera y el coste concreto que impone cada una.
El eje que decide: política contra presentación
La pregunta útil no es “¿esto se puede compartir?” sino “¿esto sería idéntico si la aplicación no tuviera interfaz gráfica alguna?”. Un cálculo de descuento, una máquina de estados de autenticación, un cliente de red con reintentos, la caché en disco, la deserialización de la respuesta, la regla que dice que un carrito vacío no puede pagarse: todo eso es verdadero con independencia de que se dibuje en Compose, en SwiftUI o en un terminal de texto. Es política de la aplicación, y no cambia al cruzar de sistema operativo porque no describe una pantalla, describe el negocio.
Esa pregunta tiene una virtud que otras formulaciones no tienen: se puede responder sin discutir. O el cálculo del descuento cambia al pasar de Android a iOS, o no cambia. Si alguien sostiene que sí cambia, casi siempre resulta que lo que cambia no es la regla sino su presentación —el formato de la moneda, el texto del mensaje, el momento en que se muestra—, y esa distinción es precisamente la que hay que hacer explícita.
En el otro lado está lo que sí depende del sistema y de forma no negociable. El gesto de volver atrás en iOS es un deslizamiento desde el borde izquierdo con una animación interactiva; en Android es un botón del sistema o un gesto distinto con otra física. La navegación en iOS suele apilar controladores; en Android suele haber un grafo con destinos. El selector de fecha, el menú contextual, el diálogo modal, la tipografía dinámica, la forma de anunciar contenido a un lector de pantalla: todo eso tiene expectativas divergentes que el usuario percibe con una precisión sorprendente aunque no sepa nombrarla. Ese código no es “el mismo con otro nombre”: es genuinamente distinto.
flowchart TB P[Politica compartida: dominio datos y container] --> A[Android dibuja con Compose] P --> I[iOS dibuja con SwiftUI] A --> U1[Convenciones de Android] I --> U2[Convenciones de Apple] style P fill:#a6e3a1,color:#11111b style A fill:#89b4fa,color:#11111b style I fill:#f9e2af,color:#11111b
Colocada así, la frontera coincide con la que MVI ya te obligaba a mantener. El container expone un StateFlow de un estado inmutable y recibe intenciones; la interfaz es una función pura de ese estado. Si esa separación está limpia, la interfaz no contiene decisiones y por tanto no hay nada que compartir en ella salvo píxeles; y si la separación no está limpia, KMP te lo dirá enseguida con errores de compilación, porque el código común no tiene acceso a nada de Android.
El contraste se ve mejor con código delante. Del lado compartido no hay ninguna pista de dónde se está ejecutando: solo tipos de la biblioteca estándar, corrutinas y modelos propios.
// Compartido: decide que debe verse, sin saber como se vera
data class CarritoEstado(
val lineas: List<Linea> = emptyList(),
val cargando: Boolean = false,
) {
val total: Dinero get() = lineas.fold(Dinero.CERO) { acc, l -> acc + l.subtotal }
val puedePagar: Boolean get() = lineas.isNotEmpty() && !cargando
}
Del lado de cada plataforma solo queda dibujar esa información con las convenciones locales. Nótese que no hay una sola decisión en el fragmento siguiente: no calcula, no valida, no compara; se limita a traducir campos en componentes, que es exactamente el trabajo que no tiene sentido compartir.
// Android: solo dibuja, y lo hace como se espera en Android
@Composable
fun CarritoScreen(estado: CarritoEstado, onPagar: () -> Unit) {
Column {
estado.lineas.forEach { LineaItem(it) }
Text(estado.total.formateado())
Button(onClick = onPagar, enabled = estado.puedePagar) { Text("Pagar") }
}
}
La prueba definitiva de que la frontera está bien trazada es esta: si una regla de negocio cambia, solo debería tocarse el fichero compartido; si cambia el diseño, solo deberían tocarse los ficheros de plataforma. Cuando un cambio de negocio obliga a editar tres ficheros en dos plataformas, la lógica se había filtrado hacia arriba sin que nadie lo notara.
Intentar mover una capa a código común es la prueba de arquitectura más honesta que existe. Si al subir el container al módulo compartido arrastras un Context, un Uri, un Resources o una anotación de Room, no tienes un problema de KMP: tienes un problema de acoplamiento que llevaba tiempo escondido detrás de la comodidad de que todo compilaba junto. Muchos equipos descubren, al hacer este ejercicio, que su “capa de dominio” era en realidad una capa de Android con otro nombre.
Conviene también desactivar el matiz que esconde el adverbio del título. Que la interfaz “casi nunca” se comparta no significa que nada de ella pueda serlo: los formateadores, los textos y sus pluralizaciones, la lógica de qué botón está habilitado, el orden de una lista o la decisión de cuándo mostrar un estado vacío son presentación en sentido amplio y sin embargo no son componentes, de modo que suben al módulo compartido sin problema. Lo que no sube es el árbol de componentes y todo lo que lo rodea. Afinar esa distinción —presentación como decisión frente a presentación como dibujo— es lo que separa un reparto útil de uno hecho por intuición.
El reparto real y la aritmética del ahorro
Antes de dar cifras conviene fijar cómo se miden, porque el reparto depende de qué se cuente. Contar ficheros favorece a la interfaz, que tiende a fragmentarse en muchos componentes pequeños. Contar líneas la favorece también, porque la composición declarativa es verbosa por diseño. Contar decisiones —bifurcaciones, validaciones, casos límite, invariantes— invierte el resultado por completo y se acerca mucho mejor a lo que de verdad cuesta escribir y mantener un programa.
La intuición dice que la interfaz es la mayor parte de una aplicación, y por eso mucha gente concluye que compartir solo la lógica es un premio de consolación. La medición dice otra cosa. En aplicaciones de producto —comercio, banca, salud, productividad—, la superficie que ocupan el dominio, los repositorios, el cliente de red, los modelos serializables, la caché, la lógica de sincronización, la validación y los container de cada pantalla suele situarse entre la mitad y dos tercios del código no generado. La interfaz parece enorme porque es lo que se ve, pero suele ser código repetitivo y de baja densidad de decisión.
El ahorro tampoco se mide solo en líneas escritas. Se mide sobre todo en divergencia evitada. Dos implementaciones independientes de la misma regla de negocio no se mantienen sincronizadas por buena voluntad: derivan. Una corrige un caso límite que la otra no, una interpreta el redondeo de otra manera, una despliega el cambio dos semanas antes. Ese coste no aparece en ningún presupuesto y se paga en incidencias que solo ocurren en una plataforma y que nadie reproduce en la otra. Compartir la política elimina la clase entera.
Merece la pena desglosarlo con algo más de detalle, porque el reparto sorprende a casi todo el mundo. Los modelos serializables y sus adaptadores suelen ser el bloque más voluminoso y el más aburrido, y se comparten al cien por cien. El cliente de red, con su política de reintentos, sus cabeceras, su renovación de credenciales y su manejo de errores, es un módulo entero que en dos aplicaciones nativas existe por duplicado con dos conjuntos distintos de fallos. La capa de persistencia se comparte si se elige una biblioteca multiplataforma para ella. Y los contenedores de cada pantalla, que en una aplicación con cuarenta pantallas suman una cantidad de código nada despreciable, se comparten enteros porque no dibujan nada.
Hay además un tercer sumando que suele quedar fuera del cálculo y que en productos con lógica densa acaba siendo el mayor: el diseño se hace una vez. Cuando dos equipos implementan por separado una máquina de estados de pago, no duplican solo el código sino también las reuniones, las dudas, los casos límite descubiertos tarde y las decisiones que hay que revisar cuando alguien encuentra un escenario no contemplado. Ese trabajo intelectual es más caro que el mecanográfico, y es exactamente el que el módulo compartido reduce a la mitad.
Comparte sin dudar
Dominio, casos de uso, validación, máquinas de estado, container de cada pantalla, modelos serializables, cliente de red y su política de reintentos, mapeadores y lógica de caché.
No compartas casi nunca
Composables y vistas, navegación, animaciones, gestos, permisos, notificaciones, accesibilidad, widgets del sistema y todo lo que dependa de una convención visual.
Comparte la firma, no la implementación
Almacenamiento seguro, base de datos, ubicación, cámara, biometría, analítica: define la interfaz en común y deja una implementación por plataforma detrás.
El beneficio silencioso
Los tests de la política se escriben una vez y corren para las dos plataformas. La suite compartida suele ser la parte de KMP que más contento deja al equipo.
Una advertencia sobre estas cuatro casillas: la tercera es la que más equipos se saltan, y saltársela es lo que produce los proyectos que nunca acaban de arrancar. La tentación de meter la base de datos, el almacenamiento seguro o el acceso a la ubicación directamente en el módulo compartido mediante mecanismos del lenguaje es fuerte, porque parece más directo. La alternativa —definir la interfaz arriba y dejar la implementación abajo— cuesta unas líneas más y ahorra meses, porque mantiene el módulo compartido demostrable en aislamiento.
Compose Multiplatform: qué cambia y qué no
La existencia de Compose Multiplatform obliga a matizar la regla, no a abandonarla. Hoy es perfectamente viable compartir también la interfaz en iOS, y hay categorías donde es la elección correcta: herramientas internas, paneles de administración, aplicaciones de nicho donde la marca manda sobre la convención del sistema, prototipos y productos cuyo público no compara la aplicación con el resto del teléfono. En esos casos el ahorro es enorme y el precio, invisible.
Conviene además reconocer lo que ha cambiado de fondo: la pregunta ya no es si es técnicamente posible compartir la interfaz, porque lo es y funciona, sino si compartirla es deseable en tu producto concreto. Ese desplazamiento de la pregunta es una buena noticia, porque una decisión de viabilidad la toma la herramienta y una decisión de conveniencia la tomas tú, con información que solo tú tienes.
El precio existe, sin embargo, y conviene decirlo sin adornos. En iOS, Compose dibuja sobre un lienzo propio, de modo que no obtienes componentes nativos sino reproducciones; la accesibilidad, el manejo del teclado, la selección de texto, el desplazamiento con su física exacta y las novedades del sistema llegan cuando la biblioteca las implementa, no cuando Apple las publica. El tamaño del binario crece. Y el equipo asume una dependencia estratégica en el ritmo de un tercero para la capa que más contacto tiene con el usuario.
De ahí sale un criterio simple: comparte la interfaz cuando la fidelidad con las convenciones del sistema no sea un requisito de producto, y no la compartas cuando lo sea. Nótese que este criterio es de producto, no de ingeniería, y por tanto no lo decide quien escribe el código sino quien responde por la experiencia.
Hay una asimetría interesante en cómo se percibe ese precio: en Android nadie lo nota, porque Compose es el marco nativo, mientras que en iOS todo el coste recae sobre una sola plataforma. Eso convierte una decisión aparentemente simétrica —compartir la interfaz— en una que reparte el beneficio entre los dos equipos y el coste solo en uno. Conviene tenerlo presente cuando la propuesta se discute, porque explica reacciones que de otro modo parecerían caprichosas.
Existe además una posición intermedia que muchos equipos descubren tarde y que conviene tener presente desde el principio: compartir la interfaz por pantallas y no por aplicaciones. Los formularios internos, los flujos de configuración, las pantallas de ayuda o los paneles poco visitados pueden compartirse sin que nadie lo note, mientras que la pantalla principal, el flujo de compra y todo lo que tenga contacto diario con el usuario se implementa nativo en cada plataforma. Esa granularidad es posible porque el módulo compartido es solo una biblioteca más, y las dos aplicaciones siguen siendo aplicaciones normales de su sistema.
Compose Multiplatform mejora rápido y buena parte de las objeciones concretas de hoy tendrán respuesta en unas cuantas versiones. Lo que no cambiará es el eje: mientras exista una plataforma con convenciones propias y un público que las espera, seguirá habiendo una diferencia entre reproducir una interfaz y usar la del sistema. Evalúa la herramienta cada año; el criterio de decisión, no hace falta.
Discutir si compartir la interfaz “es mejor” en abstracto no lleva a ninguna parte. La pregunta correcta es qué pasa si un usuario de iOS percibe la aplicación como ligeramente ajena: si la respuesta es “nada, es una herramienta interna”, comparte; si la respuesta es “nos baja la conversión y aparece en las reseñas”, no compartas, por muy elegante que resulte la alternativa.
Lo más interesante de este reparto es que Kotlin Multiplatform no propone ninguna arquitectura. No te obliga a MVI, no te impone capas, no define dominio ni presentación. Se limita a poner una restricción física —este código no puede ver Android ni iOS— y a dejar que tú decidas qué cabe dentro. Y sin embargo, equipos que jamás habían hablado entre sí acaban trazando la misma línea, en el mismo sitio, por razones idénticas. Eso ocurre porque la frontera no es un invento de la herramienta sino una junta real del software, que existía antes de que KMP la hiciera visible: la separación entre las decisiones que un programa toma y la forma en que las presenta. Todas las arquitecturas serias de los últimos cincuenta años han estado buscando esa misma junta con nombres distintos —puertos y adaptadores, núcleo funcional y cáscara imperativa, dominio contra infraestructura, política contra mecanismo— y todas han descubierto lo mismo: que la política es estable y portátil precisamente porque no sabe dónde se ejecuta, y que el mecanismo es volátil y local precisamente porque su trabajo es adaptarse a un entorno concreto. KMP es, en ese sentido, un detector de arquitectura antes que un multiplicador de plataformas. Si tu código sube limpio al módulo común, es que la junta ya estaba bien cortada y solo faltaba nombrarla. Si sube arrastrando medio sistema operativo, la herramienta no te ha creado un problema: te ha entregado la factura de una deuda que llevaba años acumulando intereses en silencio, y ha tenido la cortesía de emitirla en forma de error de compilación en vez de en forma de incidencia en producción.
- Lista los paquetes de una funcionalidad tuya y clasifica cada uno como política o presentación aplicando la pregunta de la aplicación sin interfaz gráfica.
- Elige el fichero de dominio que creas más limpio y busca en él toda referencia a Android: importaciones, anotaciones, tipos, extensiones. Anota cuántas encuentras.
- Estima en líneas cuánto código compartirías hoy sin refactorizar nada, y cuánto compartirías tras eliminar los acoplamientos del punto anterior.
- Toma una pantalla y escribe qué parte de su interfaz es genuinamente distinta entre Android e iOS, y qué parte solo lo es por inercia histórica.
- Argumenta, para tu producto concreto, si compartir también la interfaz con Compose Multiplatform es una ventaja o un riesgo, y en qué evidencia apoyas la respuesta.