wandres.dev
MULTIPLATAFORMA · expect y actual

Qué comparte de verdad Kotlin Multiplatform

Kotlin Multiplatform no promete escribir una vez y ejecutar en todas partes: promete compilar un mismo árbol de fuentes tantas veces como objetivos declares, cada vez contra la biblioteca estándar, las bibliotecas de plataforma y el backend de esa máquina concreta. Esta lección disecciona ese modelo de compilación, explica por qué la vieja frontera entre lógica compartida e interfaz nativa dejó de ser una ley del lenguaje para convertirse en una decisión de biblioteca, y contrasta la estrategia de Kotlin con las máquinas virtuales embebidas y los motores de dibujo propios que ocupan el resto del panorama.

⏱ 22 min

La frase que más ha estorbado a la comprensión de Kotlin Multiplatform es escribe una vez, ejecuta en todas partes, porque describe casi exactamente lo contrario de lo que ocurre. Aquel lema pertenecía a un mundo donde se producía un único binario y se instalaba una máquina virtual en cada máquina física para interpretarlo; la portabilidad vivía en el destino, y el precio era un intermediario permanente entre tu programa y el sistema operativo. Kotlin invierte los dos términos: lo portátil son las fuentes, lo específico es el binario, y no hay ningún intermediario en tiempo de ejecución porque cada objetivo produce el artefacto que esa plataforma habría producido de todos modos. Compartir, aquí, no significa distribuir el mismo código compilado a varios sitios; significa que el mismo texto pasa por el compilador tantas veces como plataformas tengas, y que cada pasada lo resuelve contra un conjunto distinto de declaraciones disponibles. Entender esa diferencia no es una sutileza pedagógica: de ella se deducen el modelo de conjuntos de fuentes, la existencia de expect y actual, la forma en que se publican las bibliotecas y hasta el motivo por el que puedes adoptar la tecnología en un solo módulo sin reescribir nada.

🎯 Al terminar esta lección sabrás
  • Describir el modelo de compilación multiplataforma como una compilación por objetivo sobre un árbol de fuentes compartido, y no como un artefacto único portátil.
  • Identificar qué garantiza realmente el código común: la biblioteca estándar común más lo que tú declares disponible en todos los objetivos.
  • Situar con precisión dónde termina la lógica compartida y dónde empieza la interfaz, y por qué esa frontera se movió con Compose Multiplatform.
  • Contrastar la estrategia de Kotlin con las máquinas virtuales embebidas y los motores de dibujo propios en términos de coste de ejecución y de granularidad de adopción.

Un compilador con varios backends, no una máquina virtual portátil

El compilador de Kotlin analiza tu código una vez por objetivo y produce una representación intermedia común; a partir de ahí, backends distintos la bajan a formatos distintos. En la plataforma de la máquina virtual de Java emite bytecode; en Kotlin/Native atraviesa LLVM hasta código máquina para la arquitectura pedida; en JavaScript emite módulos que un navegador o un servidor de Node ejecutan directamente; en WebAssembly emite un módulo con su propio modelo de memoria y su recolector. En ningún caso se añade un tiempo de ejecución que traduzca llamadas: el binario resultante habla el idioma nativo de su plataforma y llama a las bibliotecas del sistema sin puentes ni serialización intermedia.

// commonMain: este texto se compilará una vez por objetivo
fun normalizar(nombre: String): String =
    nombre.trim().lowercase().replace(Regex("\\s+"), " ")

Esa función no se distribuye compilada a tres plataformas. Se compila tres veces, y cada vez String, trim y Regex se resuelven contra la implementación de la biblioteca estándar de ese objetivo. La ilusión de unidad la sostiene un contrato: existe un subconjunto de la biblioteca estándar, el común, cuya interfaz es idéntica en todos los objetivos aunque su implementación no lo sea. Ese subconjunto es la superficie mínima garantizada, y todo lo que el código común pueda usar más allá de él tienes que declararlo tú y proveerlo en cada objetivo.

De ahí se sigue una consecuencia que conviene enunciar sin adornos: el código común no es código sin plataforma, es código cuya resolución está restringida a la intersección de las plataformas que declaraste. Añadir un objetivo nuevo no amplía lo que el código común puede hacer; lo estrecha, porque la intersección solo puede encoger. Es la primera vez en muchos lenguajes que la lista de plataformas de destino es una entrada del sistema de tipos y no un detalle del guion de compilación.

Conviene por eso saber qué contiene exactamente esa superficie mínima, porque su forma explica la mayoría de las sorpresas iniciales. Están las colecciones, las cadenas y su manipulación, los números con sus conversiones explícitas, las secuencias, las corrutinas del lenguaje, las expresiones regulares, la medición de tiempo y las duraciones, la aleatoriedad no criptográfica y el manejo de excepciones. No están, y no por olvido, el sistema de ficheros, los hilos como entidad manipulable, la reflexión completa, la red, ni ninguna noción de proceso o de entorno.

// commonMain: esto no compila y el motivo no es un fallo del compilador
fun leerConfiguracion(): String = java.io.File("app.conf").readText()

La ausencia de esas piezas no es una carencia que alguien vaya a rellenar en una versión futura: es la afirmación de que no existe una semántica única que valga a la vez para un sistema de ficheros con permisos, un navegador sin acceso al disco y un entorno donde el almacenamiento es una interfaz asíncrona. Cuando el código común necesita algo de esa lista, el modelo no lo esconde bajo una implementación mediocre que funcione a medias en todas partes; te obliga a declarar la frontera, que es el asunto de las tres lecciones siguientes.

Hay una segunda pregunta, más fina, que casi nadie se hace y que separa a quien usa el modelo de quien lo entiende: de lo que sí está en la superficie común, ¿cuánto se comporta exactamente igual en todos los objetivos? La respuesta es: casi todo, y las excepciones están documentadas y son deliberadas. La aritmética de enteros con desbordamiento, el orden de iteración de las colecciones estándar, la semántica de las cadenas como secuencias de unidades de dieciséis bits y las reglas de comparación están fijadas por especificación. Lo que no está fijado con la misma dureza es aquello que depende del entorno de ejecución: la precisión de las funciones matemáticas transcendentes, la representación textual por defecto de algunos números en coma flotante, o el momento exacto en que un recolector de basura libera algo.

// Igual en todos los objetivos: el desbordamiento está especificado
val x: Int = Int.MAX_VALUE
val y = x + 1                 // -2147483648 en cualquier plataforma

Esa distinción importa más de lo que parece porque delimita qué puedes afirmar en una prueba común. Una prueba escrita en el conjunto común se ejecuta en todos los objetivos, y por tanto una aserción sobre un valor que depende del entorno fallará en alguno tarde o temprano. La regla práctica es afirmar sobre el resultado del dominio y no sobre su representación, que es buena higiene incluso en un proyecto de una sola plataforma y aquí simplemente se vuelve obligatoria.

Sí conviene añadir un matiz temporal, porque la superficie común no es estática: ha ido creciendo versión a versión a medida que se encontraban abstracciones que sí admitían una semántica única, y varias cosas que hace unos años exigían una frontera declarada hoy vienen de serie. La consecuencia práctica es que merece la pena revisar periódicamente los agujeros heredados de un proyecto antiguo, porque una parte de ellos ya no hace falta.

ℹ️
La intersección se estrecha al añadir, no al quitar

Quitar un objetivo de tu lista nunca rompe el código común; añadirlo puede romperlo. Es la asimetría fundamental del modelo y conviene tenerla en cuenta al planificar, porque el coste de soportar una plataforma más no es proporcional a su tamaño sino a lo distinta que sea de las que ya tienes.

flowchart TD
A[Fuentes comunes] --> B[Analisis y representacion intermedia]
B --> C[Backend JVM]
B --> D[Backend Native con LLVM]
B --> E[Backend JS]
B --> F[Backend Wasm]
C --> G[Bytecode y jar o APK]
D --> H[Codigo maquina y framework]
E --> I[Modulo JavaScript]
F --> J[Modulo WebAssembly]

Lógica compartida, interfaz nativa y una frontera que se movió

Durante años la respuesta canónica a qué se comparte fue: la lógica sí, la interfaz no. Era una recomendación defendible y también una descripción incompleta, porque el lenguaje nunca prohibió compartir interfaz; simplemente no existía todavía una biblioteca de interfaz que se compilara en todos los objetivos con calidad aceptable. La regla no era del modelo de compilación sino del ecosistema, y cuando el ecosistema cambió, la regla dejó de aplicarse tal cual. Es un buen ejercicio de higiene intelectual distinguir siempre, al leer una recomendación sobre este territorio, si describe una restricción del lenguaje o una limitación temporal de las bibliotecas disponibles, porque las primeras envejecen bien y las segundas caducan sin avisar.

Ese cambio ya está consumado. Kotlin Multiplatform es estable desde noviembre de 2023, y Compose Multiplatform alcanzó la estabilidad para iOS en mayo de 2025 con la versión 1.8.0, momento a partir del cual compartir la capa de presentación dejó de ser una apuesta. En 2026 la recarga en caliente es estable, los tiempos de compilación han bajado alrededor de un veinticinco por ciento con menos de la mitad de memoria, y el desplazamiento y la entrada de texto en iOS se comportan como los nativos, que era precisamente el detalle donde antes se notaba la costura. Netflix, McDonald’s y Cash App llevan años con la tecnología en producción, de modo que la pregunta ya no es si aguanta sino dónde te conviene poner la costura.

Conviene, aun así, mantener separadas dos afirmaciones que suelen confundirse. Que el lenguaje permita compartir cualquier cosa es una propiedad del modelo de compilación. Que compartir la interfaz sea buena idea en tu caso es una decisión de producto que depende de cuánta identidad visual específica de cada sistema quieras conservar y de cuánto código de plataforma estés dispuesto a mantener. El modelo no te empuja hacia ninguno de los dos lados: te da un dial continuo, y ese dial es su rasgo distintivo.

// commonMain: nada de esto sabe qué plataforma lo ejecutará
class Carrito(private val lineas: List<Linea>) {
    val total: Long get() = lineas.sumOf { it.precio * it.unidades }
    fun conDescuento(porcentaje: Int): Carrito = TODO()
}

Ese fragmento no es interesante por lo que hace sino por lo que no menciona: no nombra ningún componente visual, ningún almacenamiento y ningún sistema operativo, y por eso puede subir hasta la raíz del grafo de fuentes sin negociar nada con nadie. La regla que ordena la posición de cada archivo no es su tema sino su dependencia: sube lo que no necesita al mundo, baja lo que sí. Todo lo demás de este nivel son mecanismos para tratar con lo que baja.

Donde ese dial sí tiene un coste medible es en la frontera de salida, es decir, en cómo ve tu código Kotlin el lenguaje que consume el resultado. Hacia la máquina virtual la frontera es casi transparente porque Kotlin y Java comparten representación. Hacia el mundo de Apple la historia ha sido más larga: durante años la exportación pasaba por el modelo de objetos de Objective-C, que no sabe nada de genéricos con varianza, de clases selladas ni de funciones suspendidas, de modo que la interfaz que veía quien programaba en Swift era una traducción empobrecida de la original.

// commonMain: una firma perfectamente natural en Kotlin
sealed interface Resultado
data class Exito(val valor: String) : Resultado
data class Fallo(val causa: Throwable) : Resultado

Esa jerarquía, exportada por la vía antigua, llegaba al otro lado como un conjunto de clases sin la garantía de exhaustividad que la hacía valiosa. Por eso importa el movimiento de Kotlin 2.4, que incorpora el soporte de paquetes de Swift como dependencias y mejoras en la exportación directa hacia Swift: la frontera deja de ser una traducción hacia un tercer lenguaje intermedio y pasa a expresarse en los términos del lenguaje que realmente está al otro lado. En JavaScript y en WebAssembly el problema es análogo aunque las asperezas sean distintas, y la regla de diseño que se deduce es la misma en los tres casos: cuanto más idiomático sea el tipo que expones, más caro será el cruce, y conviene que la superficie exportada sea deliberadamente estrecha.

🧮

Dominio y reglas

Modelos, validaciones, cálculos, máquinas de estado. Sin dependencias de plataforma y con pruebas que corren en la máquina de desarrollo. Es lo primero que se comparte y casi nunca se devuelve.

🔌

Acceso a datos

Clientes de red, persistencia, serialización, caché. Compartible mediante bibliotecas multiplataforma que ya resolvieron por dentro el problema de la plataforma.

🎛️

Presentación

Modelos de vista, navegación, gestión de estado. Compartible sin controversia desde hace tiempo, porque no toca ningún componente visual.

🖼️

Interfaz

Compartible con Compose Multiplatform, o nativa por sistema. Aquí es donde la decisión deja de ser técnica y pasa a ser de producto.

Contra qué se compara: intérpretes, puentes y lienzos propios

Las otras estrategias multiplataforma resuelven el mismo problema desplazando el coste a otro sitio, y verlas en fila aclara qué es lo específico de Kotlin. La familia basada en un motor de JavaScript embebe un intérprete en la aplicación y dibuja con componentes nativos, lo que obliga a que todo cruce de frontera pase por una interfaz de conversión entre dos mundos de objetos con recolectores distintos. La familia basada en un lienzo propio compila anticipadamente y dibuja cada píxel con su propio motor, lo que da consistencia absoluta entre sistemas a cambio de reimplementar por su cuenta todo lo que el sistema operativo ya ofrecía, desde el comportamiento del texto seleccionable hasta la accesibilidad.

Kotlin no hace ninguna de las dos cosas. No añade tiempo de ejecución interpretado, no introduce una frontera de conversión y no sustituye el conjunto de componentes del sistema salvo que tú elijas la biblioteca que lo hace. Su moneda de cambio es otra: la interoperabilidad se resuelve en tiempo de compilación mediante declaraciones que el compilador debe poder emparejar en todos los objetivos, y eso traslada el esfuerzo desde el rendimiento en ejecución hacia el diseño de la frontera y hacia la configuración de compilación.

Merece la pena hacer explícita la consecuencia sobre el arranque y el tamaño, porque es donde más se nota la diferencia de estrategia. Una aplicación que embebe un intérprete paga ese intérprete en cada instalación y en cada arranque en frío, aunque el usuario solo abra una pantalla; una que trae su propio motor de dibujo paga el motor. Un módulo compartido en Kotlin no añade ninguna de las dos cosas: lo que se enlaza en el binario final es únicamente el código alcanzable desde tu programa, más la parte de la biblioteca estándar que uses, sometida además a la eliminación de código muerto que hace el enlazador nativo. Compartir lógica de dominio en un proyecto por lo demás nativo tiende, por eso, a mover la aguja del tamaño en el orden de los cientos de kilobytes y no de los megabytes.

Y hay un rasgo que las comparaciones de tabla suelen omitir porque no es una característica sino una topología: la adopción es granular. Puedes convertir un único módulo de validación en multiplataforma y dejar el noventa por ciento de cada aplicación intacto, porque el artefacto que produce ese módulo es un jar corriente para la máquina virtual y un framework corriente para el sistema de Apple. Las soluciones que traen su propio tiempo de ejecución no ofrecen ese grado de libertad: o entra la aplicación entera, o entra un contenedor con su frontera.

Esa granularidad tiene además un efecto sobre las herramientas que rara vez se menciona y que en la práctica pesa mucho. Como el artefacto que produces es el nativo de cada plataforma, todo el instrumental que ya usabas sigue siendo válido: los perfiladores, los depuradores, los analizadores de memoria, los informes de fallos y las trazas simbolizadas funcionan sin adaptadores, porque no hay una capa intermedia cuyo estado haya que reconstruir para entender qué estaba pasando. En los modelos con tiempo de ejecución embebido, en cambio, cada una de esas herramientas necesita su equivalente propio, y la calidad del diagnóstico depende de lo maduro que sea ese equivalente.

La contrapartida honesta es que el coste se desplaza a la compilación y a la configuración. Un proyecto multiplataforma compila más veces por definición, mantiene una jerarquía de conjuntos de fuentes que hay que entender, y expone al equipo a errores de resolución de dependencias que en un proyecto de una sola plataforma no existen. Es una elección deliberada de dónde poner la complejidad: en el momento de construir, donde la paga el equipo y se detecta en integración continua, en lugar del momento de ejecutar, donde la pagaría el usuario. En 2026 ese coste es notablemente menor que hace dos años, con compilaciones alrededor de un veinticinco por ciento más rápidas y menos de la mitad de memoria, pero sigue existiendo y conviene contarlo al decidir.

Y hay un último eje de comparación que no es técnico y que decide más adopciones que ninguno de los anteriores: quién escribe el código compartido. Las soluciones con tiempo de ejecución propio traen consigo un lenguaje y un conjunto de herramientas ajenos a los dos equipos que ya existen, de modo que compartir suele implicar crear un tercer equipo. Aquí el lenguaje compartido es el mismo que uno de los dos equipos ya usaba a diario, y el módulo común se puede mantener sin que nadie cambie de oficio. Esa continuidad explica buena parte de las adopciones grandes mucho mejor que cualquier comparativa de rendimiento.

💡
Empieza por un módulo sin interfaz

La adopción que menos duele empieza convirtiendo en multiplataforma un módulo de dominio puro, sin dependencias de plataforma y con buenas pruebas. Si algo sale mal, el impacto queda contenido en un artefacto que las aplicaciones existentes consumen igual que cualquier otro.

⚠️
El código común no es el mínimo común denominador

Suena parecido pero no lo es. El mínimo común denominador sería quedarse solo con lo que todas las plataformas ofrecen ya. El código común te deja declarar que necesitas algo, y obliga a cada objetivo a proveerlo. La intersección es el suelo, no el techo.

Lo que compartes no es código: es la definición de lo que tu producto considera verdadero, y por eso la pregunta correcta nunca fue cuánto por ciento comparto sino dónde quieres que viva la discrepancia

La métrica de porcentaje de código compartido es la más citada y la menos informativa de todo este territorio, porque cuenta líneas cuando el problema es de autoridad. Cuando una regla de negocio vive dos veces —una en Kotlin y otra en Swift, escritas por dos personas distintas en dos semanas distintas a partir de la misma conversación mal recordada— no tienes duplicación de código: tienes dos definiciones de la verdad, y el sistema no dispone de ningún mecanismo para descubrir cuál de las dos está mintiendo hasta que un cliente reclama. Los defectos más caros de las aplicaciones multiplataforma casi nunca son fallos de una implementación; son divergencias entre implementaciones que cada una considera correcta en su propio universo: un redondeo que difiere en el último céntimo, una zona horaria que se aplica antes de truncar en un sitio y después en otro, un estado que en una plataforma es alcanzable y en la otra no. Compartir lógica elimina esa clase entera de defectos por construcción, no porque el código compartido tenga menos errores, sino porque cuando lo tiene es el mismo error en todas partes y por tanto es reproducible, diagnosticable y arreglable una vez. Esta es también la razón por la que la frontera correcta no se decide contando líneas sino preguntando qué partes del sistema no toleran discrepancia. Un precio calculado no la tolera; la altura de una barra de navegación sí, y de hecho conviene que discrepe porque cada sistema tiene su propia idea de cómo debe verse. Colocar la costura donde el negocio exige unicidad y dejar libre todo lo demás produce arquitecturas donde el porcentaje compartido puede ser modesto y el riesgo eliminado enorme, mientras que perseguir el noventa y cinco por ciento suele significar haber empujado hacia el código común decisiones que eran legítimamente locales, y pagarlo con abstracciones que existen únicamente para reconciliar dos plataformas que nunca quisieron parecerse.

⚔️ Traza la frontera de tu propio sistema
  1. Toma un producto que exista en dos plataformas y enumera diez reglas de negocio implementadas dos veces. Marca cuáles han divergido alguna vez y busca el informe de error correspondiente.
  2. Clasifica cada una de esas reglas según si tolera discrepancia entre plataformas. Justifica cada respuesta con una consecuencia observable por el usuario, no con una preferencia estética.
  3. Escribe en un módulo común la más crítica de las que no toleran discrepancia y compílala para dos objetivos sin usar ninguna declaración específica de plataforma.
  4. Cuenta cuántas de sus dependencias actuales impedían moverla y clasifícalas en dos grupos: las que tienen equivalente multiplataforma y las que exigirán una frontera declarada.
  5. Redacta en tres frases la política de tu equipo sobre qué se comparte, formulada en términos de tolerancia a la divergencia y no en términos de porcentaje.