wandres.dev
MULTIPLATAFORMA · KMP y Compose Multiplatform

Kotlin Multiplatform desde Android: qué se comparte de verdad

Kotlin Multiplatform no es una promesa de escribir una vez y ejecutar en todas partes, sino una técnica de reparto: decidir con criterio qué porciones de una aplicación son idénticas en cualquier plataforma y cuáles solo parecen idénticas hasta que se miran de cerca. Esta lección establece el gradiente de compartición que va del dominio puro a la interfaz, explica por qué las reglas de negocio y el acceso a datos son la zona natural de convergencia mientras el arranque, los permisos y la integración con el sistema deben quedarse nativos, y desmonta el error más caro del principiante en KMP: intentar compartir aquello cuyo valor reside precisamente en no ser compartido.

⏱ 21 min

Todo el que llega a Kotlin Multiplatform desde Android llega con una expectativa equivocada, y conviene deshacerla antes de escribir la primera línea. La expectativa dice que KMP sirve para escribir una aplicación y obtener dos, y la realidad dice que KMP sirve para dejar de escribir dos veces aquello que nunca tuvo motivo para escribirse dos veces. La diferencia parece retórica y es económica: la primera lectura conduce a intentar compartirlo todo, chocar contra la interfaz, contra el sistema de permisos y contra el arranque, y concluir a los seis meses que la tecnología no estaba madura; la segunda conduce a trazar una frontera deliberada, colocar en el lado común lo que es genuinamente común y descubrir que ese lado, medido en líneas que de verdad importan, suele ser mucho mayor de lo que la intuición sugiere. Esta lección trata exactamente de dónde cae esa frontera y de por qué cae ahí y no en otro sitio.

🎯 Al terminar esta lección sabrás
  • Situar KMP en el mapa: compilación de Kotlin a varios destinos, no un entorno de ejecución común.
  • Reconocer el gradiente de compartición que va del dominio puro a la interfaz de usuario.
  • Justificar por qué el dominio y la capa de datos son la zona natural de convergencia.
  • Identificar lo que debe quedarse nativo y aprender a defender esa frontera.

Qué es realmente KMP y qué no promete

Kotlin Multiplatform es una propiedad del compilador antes que una arquitectura. El mismo código fuente en Kotlin se compila a bytecode de la máquina virtual de Java para Android, a un binario nativo mediante Kotlin/Native para iOS y escritorio, y a JavaScript o WebAssembly para la web. No hay una máquina virtual común corriendo debajo, no hay un puente que traduzca llamadas en tiempo de ejecución y no hay coste de serialización entre el código compartido y el nativo, porque el código compartido termina siendo código nativo de cada plataforma. Esa es la diferencia estructural con las soluciones basadas en un motor interpretado o en un puente de mensajes, y es la razón por la que el consumo de memoria y el rendimiento de un módulo KMP son indistinguibles de los de un módulo escrito a mano en cada lado.

Conviene además desactivar una confusión terminológica que enturbia muchas discusiones. Kotlin Multiplatform y Compose Multiplatform son dos decisiones independientes y se pueden tomar por separado: la primera comparte lógica y no dice nada sobre la interfaz; la segunda comparte interfaz y presupone la primera. Un proyecto puede adoptar KMP para su capa de datos y mantener dos interfaces completamente nativas durante años, y esa es de hecho la configuración más habitual en producción. Confundir ambas cosas lleva a rechazar KMP por objeciones que solo aplican a la interfaz compartida, que es como rechazar una biblioteca de red porque no te gusta un sistema de temas.

// commonMain: compila para todos los destinos, no existe Context ni UIApplication
class PoliticaDeReintento(private val maximo: Int, private val base: Duration) {
    fun esperaAntesDelIntento(n: Int): Duration =
        if (n >= maximo) Duration.ZERO else base * (1 shl n)
    fun debeReintentar(n: Int, error: ErrorDeRed): Boolean =
        n < maximo && error.esTransitorio
}

Lo que KMP no promete es una biblioteca estándar universal de capacidades del dispositivo. Kotlin trae consigo su biblioteca estándar, las corrutinas y las colecciones, y el ecosistema aporta serialización, red y persistencia. Todo lo demás —cámara, biometría, notificaciones, ciclo de vida de la aplicación, gestión de permisos— sigue viviendo en cada plataforma y solo se alcanza declarando una interfaz común e implementándola dos veces. Esto no es un defecto pendiente de corregir: es la consecuencia de que esas capacidades no son las mismas en los dos sistemas, ni en su modelo de permisos, ni en su ciclo de vida, ni en sus garantías.

ℹ️
El reparto típico no es simétrico

En bases de código maduras la proporción compartida se mueve entre el treinta y el setenta por ciento, y esa horquilla dice más de la aplicación que de la tecnología. Una aplicación cuyo valor está en la lógica —cálculo financiero, sincronización compleja, reglas de negocio densas— comparte mucho. Una aplicación cuyo valor está en la interacción —un editor gráfico, una cámara, un reproductor con controles del sistema— comparte poco y no por ello ha fracasado. Medir el éxito de KMP en porcentaje compartido es el equivalente a medir la calidad de un texto en número de palabras.

El gradiente: dominio, datos, presentación, interfaz

La forma útil de razonar es imaginar la aplicación como capas ordenadas por su distancia respecto del sistema operativo, y observar que la facilidad de compartición decrece monótonamente conforme uno se acerca al dispositivo.

flowchart TD
A[Dominio puro - reglas, entidades, casos de uso] --> B[Datos - red, cache, mapeo, sincronizacion]
B --> C[Presentacion - estado de pantalla y eventos]
C --> D[Interfaz - composicion, navegacion, gestos]
D --> E[Sistema - permisos, arranque, notificaciones]
A -.->|comparticion trivial| F[commonMain]
B -.->|comparticion alta con bibliotecas KMP| F
C -.->|comparticion posible con matices| F
D -.->|decision abierta| G[Nativo o Compose Multiplatform]
E -.->|siempre nativo| H[androidMain e iosMain]

El dominio es trivialmente compartible porque no depende de nada: entidades, reglas de validación, cálculos, máquinas de estado, políticas de reintento. Si el precio con impuestos se calcula igual en los dos sistemas, escribirlo dos veces solo garantiza que algún día diverjan. La capa de datos es la que más beneficio produce, porque concentra la complejidad real —el mapeo de respuestas, la caché, la invalidación, la resolución de conflictos en sincronización— y es exactamente donde los errores se pagan caros y donde la duplicación produce bugs que solo aparecen en una plataforma.

// commonMain: nada aqui sabe en que sistema se ejecuta
data class Suscripcion(val id: String, val renuevaEn: Instant, val activa: Boolean)

class CalcularEstadoSuscripcion(private val reloj: Reloj) {
    operator fun invoke(s: Suscripcion): EstadoVisible = when {
        !s.activa -> EstadoVisible.Cancelada
        s.renuevaEn < reloj.ahora() -> EstadoVisible.Caducada
        s.renuevaEn - reloj.ahora() < 3.days -> EstadoVisible.PorRenovar
        else -> EstadoVisible.Vigente
    }
}

La presentación es la primera capa discutible. Un modelo de vista escrito sobre corrutinas y flujos se comparte técnicamente sin problema, y muchos equipos lo hacen; el matiz es que iOS tiene sus propias convenciones de observación y su propio ciclo de vida, y que consumir un flujo de Kotlin desde SwiftUI exige un puente que hay que mantener. Compartir presentación es una decisión legítima con un coste concreto, no un paso obvio.

Conviene además notar que el gradiente no es una escalera que haya que subir entera. Es perfectamente coherente detenerse en la capa de datos y no volver a moverse nunca, y de hecho esa es la configuración más común entre los equipos que llevan varios años con KMP en producción. La frontera estable en datos concentra casi todo el beneficio —modelo de dominio único, contrato con el servidor único, política de caché única— y deja intacto el terreno donde cada plataforma tiene más que decir. Subir hasta la presentación añade retorno, pero también añade acoplamiento entre calendarios, y ese intercambio hay que valorarlo por separado y no heredarlo como consecuencia automática de haber empezado.

La frontera nativa y por qué se defiende

Hay tres familias de código que deben quedarse nativas por razones distintas y ninguna de ellas es técnica en el sentido de imposible.

La primera es todo lo que toca el sistema: permisos, notificaciones, servicios en segundo plano, arranque de la aplicación, integración con el gestor de tareas. No es que no se pueda envolver, es que envolverlo produce una abstracción que miente. El modelo de permisos de Android y el de iOS difieren en el momento de la petición, en la reversibilidad, en la granularidad y en lo que ocurre tras una denegación; una interfaz común que los unifique acabará teniendo tantos parámetros condicionales que será más difícil de leer que las dos implementaciones separadas.

La segunda es la interfaz cuando la interfaz es el producto. Una aplicación de banca puede permitirse pantallas equivalentes; una aplicación de dibujo, de cámara o de audio profesional no, porque su ventaja competitiva está en aprovechar hasta el último detalle de la plataforma.

La tercera, y la más subestimada, es todo aquello sujeto a revisión externa o a cambios anuales impuestos por el fabricante. El código que cambia porque una nueva versión del sistema lo obliga tiene un ritmo propio, y meterlo en el módulo común significa que cada ajuste dictado por un fabricante obliga a recompilar y republicar lo que consume la otra plataforma.

Defender esa frontera tiene una técnica concreta y es siempre la misma: el módulo común declara una interfaz redactada en el vocabulario del dominio, no en el del sistema, y cada plataforma la implementa como quiera. La diferencia entre una interfaz bien trazada y una mal trazada se aprecia en si sus nombres sobrevivirían a un cambio de sistema operativo.

// commonMain: vocabulario de dominio, no de plataforma
interface Notificador {
    suspend fun puedeAvisar(): Boolean
    suspend fun pedirPermisoSiHaceFalta(): Boolean
    suspend fun avisar(aviso: Aviso)
}

// mal: la interfaz filtra el modelo de Android y obliga a iOS a mentir
interface NotificadorFiltrado {
    fun crearCanal(id: String, importancia: Int)
    fun tienePermisoPost13(): Boolean
}

La segunda versión compila igual de bien y es igual de portable en apariencia, pero ha trasladado al conjunto común un concepto —el canal de notificación— que solo existe en un sistema, y a partir de ahí toda implementación de iOS será una simulación con partes vacías. Ese es el patrón de error más frecuente y el más difícil de revertir, porque para cuando se detecta el concepto ajeno ya se ha propagado a los llamantes.

Cómo se ve el reparto en un repositorio real

La materialización del gradiente es un conjunto de módulos con dependencias dirigidas en un solo sentido, del sistema hacia el dominio y nunca al revés. La estructura que mejor envejece separa el dominio de los datos aunque al principio parezca innecesario, porque es esa separación la que permite después mover uno sin el otro.

:shared:dominio      commonMain puro, sin dependencias externas
:shared:datos        commonMain + Ktor + persistencia, depende de :dominio
:shared:presentacion commonMain + corrutinas, depende de :dominio
:androidApp          Compose, navegacion, permisos, arranque
:iosApp              SwiftUI, navegacion, permisos, arranque
📝
El dominio no debe depender de nada, ni siquiera de tu propia infraestructura

La prueba más rápida de que el reparto está bien hecho consiste en intentar compilar el módulo de dominio con las dependencias de red y de persistencia eliminadas del archivo de compilación. Si compila, la frontera es real y podrás mover el resto pieza a pieza con seguridad. Si no compila, lo que tienes no es un dominio sino una capa de servicios con nombre de dominio, y conviene arreglarlo antes de añadir una segunda plataforma, porque una arquitectura confusa duplicada sigue siendo confusa y además ahora tiene dos consumidores.

Los dos módulos de aplicación, el de Android y el de iOS, no son residuos ni envoltorios finos: son donde vive la identidad del producto en cada sistema y donde se concentra el trabajo que ningún usuario perdona si está mal hecho. Verlos como algo menor porque tienen menos líneas es el error de perspectiva que produce aplicaciones que funcionan y que nadie disfruta usando.

🧮

Dominio: comparte siempre

Reglas y cálculos sin dependencias son la victoria más barata y la que menos se discute.

🗄️

Datos: comparte con ganas

Aquí vive la complejidad real y aquí la duplicación produce los errores más caros.

🎛️

Presentación: comparte con criterio

Es posible y útil, pero exige un puente hacia las convenciones de cada plataforma.

🔐

Sistema: no comparta nunca

Permisos, arranque y notificaciones no son equivalentes, y fingir que lo son cuesta caro.

Compartir código es compartir decisiones, y esa es la parte que nadie presupuesta

La discusión pública sobre Kotlin Multiplatform se libra casi entera en el terreno técnico —cuánto se comparte, qué bibliotecas hay, cómo va el compilador— y por eso se pierde de vista lo único que determina si un proyecto KMP sobrevive: cuando dos plataformas comparten un módulo, dejan de poder tomar decisiones por separado sobre lo que ese módulo hace. Hasta ese momento, el equipo de Android podía cambiar el criterio de caducidad de la caché un martes sin avisar a nadie, y el de iOS podía decidir que su política de reintentos era distinta porque su base de usuarios se comportaba de otra manera. Ambas cosas eran deuda, sí, pero también eran autonomía, y la autonomía tiene un valor que no aparece en ninguna métrica de duplicación. El día en que ese criterio vive en commonMain, cambiarlo obliga a una conversación entre dos equipos que probablemente tienen prioridades distintas, calendarios de publicación distintos y jefes distintos. Lo que era una decisión local se convierte en una negociación, y las negociaciones se aplazan. El síntoma clásico de un KMP mal gobernado no es un fallo de compilación: es un parámetro que empieza a aceptar un valor para Android y otro para iOS, luego dos, luego una interfaz de configuración inyectada, y al cabo de un año un módulo común que ya no comparte lógica sino que aloja dos lógicas dentro del mismo archivo, con el coste de coordinación de compartir y ninguna de sus ventajas. Por eso la pregunta correcta antes de mover algo al lado común no es si el código es idéntico hoy, sino si estás dispuesto a que sea idéntico siempre; si la respuesta honesta es que las plataformas tendrán motivos legítimos para divergir, ese código no pertenece a commonMain aunque hoy se parezca letra por letra. Compartir lo que debe permanecer igual es lo que hace fuerte a KMP; compartir lo que solo casualmente coincide es lo que lo mata, y la diferencia entre ambas cosas nunca se lee en el código, se lee en la organización.

⚔️ Traza la frontera antes de tocar el proyecto
  1. Toma tu aplicación Android actual y clasifica cada paquete en una de las cinco capas del gradiente, sin cambiar nada de código todavía.
  2. Para cada clase del dominio, comprueba si importa algo de android. o de androidx.; anota cuántas lo hacen y por qué motivo real.
  3. Elige la regla de negocio más compleja que tengas y escribe, en una frase, qué tendría que ocurrir en el negocio para que Android e iOS la calculasen distinto.
  4. Localiza tres puntos donde tu código toca permisos o notificaciones y verifica que ninguno de ellos aparece en tu candidata a capa compartida.
  5. Estima el porcentaje compartible resultante y compáralo con tu expectativa inicial; explica la diferencia sin recurrir a la madurez de la tecnología.