wandres.dev
KOTLIN/NATIVE · sin máquina virtual

Objective-C y Swift: el mapeo, los nombres y el export que madura

La interoperabilidad con el mundo Apple es la aplicación más extendida de Kotlin/Native y también la más asimétrica: entrar en Objective-C desde Kotlin es casi transparente, salir de Kotlin hacia Swift ha sido durante años un ejercicio de renuncias. Esta lección detalla cómo se proyectan clases, protocolos, categorías y bloques, reconstruye las reglas de traducción de nombres que explican las firmas extrañas, cataloga qué pierde el framework generado al pasar por Objective-C, y sitúa el export directo a Swift y el consumo de paquetes Swift que llegó con Kotlin 2.4.

⏱ 24 min

Hay una asimetría en el corazón de la interoperabilidad con Apple que conviene nombrar antes de estudiar nada más. Ir de Kotlin hacia Objective-C funciona extraordinariamente bien: las clases del sistema aparecen como clases de Kotlin, los protocolos como interfaces, los bloques como lambdas, y escribir contra Foundation desde Kotlin es más cómodo que hacerlo desde Objective-C. Ir en la otra dirección, exponer código Kotlin para que lo consuma una aplicación en Swift, ha significado durante casi una década atravesar Objective-C como intermediario, y ese intermediario no conoce los tipos por valor, ni las enumeraciones con carga, ni los genéricos serios. El resultado eran API que en Kotlin eran elegantes y en Swift llegaban planas. Esa es exactamente la brecha que el export directo lleva años cerrando.

🎯 Al terminar esta lección sabrás
  • Leer la proyección de clases, protocolos, categorías y bloques de Objective-C en el sistema de tipos de Kotlin.
  • Predecir cómo se traduce un selector en un nombre de función de Kotlin y por qué a veces aparece un sufijo.
  • Enumerar lo que pierde una API de Kotlin al exportarse como framework de Objective-C y controlarlo con anotaciones.
  • Situar el export directo a Swift y el consumo de paquetes Swift como dependencias.

Cómo se proyectan las clases y los protocolos

Los frameworks del sistema llegan preprocesados: platform.Foundation, platform.UIKit y el resto se generan con el mismo mecanismo que cualquier otra interoperabilidad y se distribuyen con el compilador. Las reglas de proyección son sistemáticas. Una clase de Objective-C se convierte en una clase de Kotlin que hereda de NSObject; un protocolo se convierte en una interfaz; una categoría se convierte en funciones de extensión sobre el tipo extendido; y id se convierte en Any?.

Los tipos con equivalente conocido se traducen automáticamente en ambas direcciones: NSString y String, NSArray y List, NSDictionary y Map, los numéricos envueltos y sus primitivos. La conversión es real, con copia, no un simple cambio de vista, y en bucles calientes esa copia se nota.

La diferencia más agradecida respecto a C está en los bloques. Un puntero a función de C no puede capturar contexto, pero un bloque de Objective-C sí lo lleva consigo, así que se proyecta directamente como un tipo función de Kotlin y una lambda cualquiera —con capturas incluidas— se puede pasar donde se espera un bloque.

La nulabilidad viaja con la fidelidad que permita el origen. Objective-C anota sus cabeceras con marcas de nulabilidad y cinterop las respeta, de modo que una API bien anotada llega a Kotlin con tipos nulables y no nulables correctos. Cuando la anotación falta, el tipo llega como nulable por prudencia, y esa prudencia es la razón de la constelación de interrogaciones que puebla el código de interoperabilidad con frameworks antiguos.

@OptIn(ExperimentalForeignApi::class)
class FuenteDeDatos(private val filas: List<String>) : NSObject(), UITableViewDataSourceProtocol {

    override fun tableView(tableView: UITableView, numberOfRowsInSection: NSInteger): NSInteger =
        filas.size.toLong()

    override fun tableView(tableView: UITableView, cellForRowAtIndexPath: NSIndexPath): UITableViewCell {
        val celda = UITableViewCell(style = UITableViewCellStyleDefault, reuseIdentifier = "c")
        celda.textLabel?.text = filas[cellForRowAtIndexPath.row.toInt()]
        return celda
    }
}

fun descargar(url: NSURL, alTerminar: (NSData?, NSError?) -> Unit) {
    NSURLSession.sharedSession.dataTaskWithURL(url) { datos, _, error ->
        alTerminar(datos, error)          // un bloque es una lambda, con capturas
    }.resume()
}
ℹ️
La herencia no es libre en ambos sentidos

Puedes heredar de una clase de Objective-C y puedes implementar sus protocolos, que es lo que permite escribir vistas y delegados enteros en Kotlin. Lo que no puedes es hacer que una clase de Objective-C herede de una de Kotlin: la jerarquía solo crece en una dirección, y por eso una arquitectura que quiera compartir lógica suele exponer objetos Kotlin como colaboradores y no como bases.

La aritmética de los nombres

Objective-C no tiene nombres de método sino selectores, es decir, secuencias de partes con dos puntos donde cada parte etiqueta un argumento. Kotlin tiene nombres de función y parámetros con nombre, que es otra cosa. La traducción toma la primera parte del selector como nombre de la función y las restantes como nombres de parámetros, lo cual produce firmas que resultan extrañas al leerlas por primera vez pero son perfectamente predecibles.

De ahí salen las dos peculiaridades que más desconciertan. La primera es que dos selectores distintos pueden colapsar en el mismo nombre de Kotlin —caso típico de sobrecargas que en Objective-C se distinguían solo por las etiquetas posteriores—, y el compilador desambigua añadiendo un sufijo derivado del selector completo. La segunda es que los inicializadores se proyectan como constructores cuando la forma lo permite, de modo que initWithFrame: se escribe en Kotlin como una construcción con un parámetro llamado frame.

// initWithContentsOfURL:options:error: se proyecta como constructor con nombres.
val datos = NSData(contentsOfURL = url, options = 0uL, error = null)

// stringWithFormat: es un metodo de clase, y aparece en el companion.
val texto = NSString.stringWithFormat("%d elementos", 7)

// dateByAddingTimeInterval: conserva el nombre y etiqueta el argumento.
val manana = NSDate().dateByAddingTimeInterval(86_400.0)

Un detalle que ahorra desconcierto al depurar: los tipos numéricos de Objective-C no se colapsan al equivalente más cómodo de Kotlin sino al que corresponde exactamente por ancho y signo, de ahí que aparezcan enteros sin signo y de sesenta y cuatro bits donde el código de Swift escribiría simplemente un entero. Insertar conversiones en la frontera es inevitable y es preferible hacerlo en un solo sitio de la envoltura antes que salpicarlas por toda la lógica.

En la dirección contraria hay control explícito. La anotación @ObjCName fija el nombre con el que una declaración de Kotlin aparecerá en Objective-C y en Swift, y resuelve el problema habitual de que los nombres idiomáticos en un lenguaje resulten torpes en el otro. @HiddenFromObjC excluye del framework lo que no debe formar parte de la superficie pública. Y @ShouldRefineInSwift exporta el símbolo con una marca que hace que Swift lo trate como detalle de implementación, para que una fina capa escrita en Swift lo envuelva con la firma que de verdad quieres publicar.

@ObjCName(swiftName = "Session", name = "MOTSession")
class Sesion(private val token: String) {

    @Throws(RedException::class)
    suspend fun cargar(id: String): Perfil = cliente.perfil(id)

    @ShouldRefineInSwift
    fun crudo(datos: ByteArray): ByteArray = transformar(datos)

    @HiddenFromObjC
    internal fun diagnostico(): String = token.take(4)
}
flowchart TD
A[Codigo Kotlin comun] --> B[Compilador nativo]
B --> C[Framework con cabeceras Objective C]
C --> D[Swift lo ve a traves del puente]
D --> E[Se pierden valores enums con carga y genericos]
B --> F[Export directo a Swift]
F --> G[Swift ve tipos nativos sin NSObject]
H[Paquetes Swift como dependencia] --> B

Lo que se pierde al pasar por Objective-C

El framework generado es un ciudadano de Objective-C, y por tanto todo lo que Kotlin exprese fuera de ese vocabulario se aplana. Las clases selladas pierden su exhaustividad y llegan como una jerarquía abierta, así que el when que en Kotlin el compilador verificaba se convierte en Swift en una cadena de comprobaciones de tipo con una rama por defecto obligatoria. Las clases de datos no llegan como tipos comparables por valor. Los argumentos por defecto desaparecen y hay que declarar todas las sobrecargas o pasar todos los parámetros. Los genéricos solo sobreviven en los casos que Objective-C admite, que son pocos. Y las funciones de nivel superior se agrupan en una clase artificial con sufijo derivada del nombre del fichero.

Hay además una consecuencia menos comentada y bastante molesta en la práctica: como todo lo exportado se convierte en subclases de la clase raíz de Objective-C, no hay nada parecido a un tipo por valor al otro lado. Una clase de datos de Kotlin que representa un identificador o una cantidad llega a Swift como un objeto de referencia con semántica de identidad, lo que significa que compararla, guardarla en un conjunto o usarla como clave de diccionario funciona por la correspondencia entre equals y el método de igualdad de Objective-C, no por la semántica de valor que Swift esperaría de una estructura.

Dos comportamientos merecen atención por sus consecuencias en tiempo de ejecución. Las excepciones de Kotlin que crucen la frontera sin estar declaradas terminan el proceso: no hay traducción implícita, y solo las funciones anotadas con @Throws se proyectan al protocolo de error de Apple, que en Swift aparece como una función que lanza. Y las funciones suspendidas se exportan como métodos con manejador de terminación, que Swift presenta como funciones asíncronas, con la limitación de que la cancelación estructurada no cruza la frontera con la misma fidelidad.

Sobre esto último conviene ser preciso porque produce fugas de trabajo silenciosas. Cuando Swift cancela una tarea que estaba esperando a una función suspendida exportada, la corrutina del lado Kotlin no se entera automáticamente por el mismo mecanismo estructurado que usaría si el llamante fuese Kotlin. La consecuencia es que un trabajo caro puede seguir ejecutándose después de que la pantalla que lo pidió haya desaparecido. La solución habitual es no exponer funciones suspendidas desnudas sino un objeto con un ámbito propio y una operación de cancelación explícita, de modo que la frontera tenga un mando que Swift pueda accionar.

⚠️
Un fallo silencioso muy caro

Olvidar @Throws en una función que puede lanzar no produce ningún aviso de compilación y funciona perfectamente en las pruebas donde nada falla. El día que falle, la aplicación no lanzará una excepción capturable en Swift: se detendrá. Anotar la frontera es parte de definirla, no un adorno.

El export directo y los paquetes Swift

La respuesta a todo lo anterior es dejar de usar Objective-C como intermediario. El export directo a Swift genera una interfaz de Swift a partir del código de Kotlin sin pasar por el puente, lo que permite representar cosas que Objective-C no sabe expresar: tipos que no heredan de NSObject, nombres idiomáticos, y una correspondencia mucho más fiel de la jerarquía. Es el trabajo de mayor recorrido en el target Apple y avanza por versiones, ampliando gradualmente qué construcciones de Kotlin sabe representar; su estado en cada versión hay que consultarlo, porque cambia rápido.

Kotlin 2.4 añadió además la pieza simétrica que faltaba: la posibilidad de declarar paquetes de Swift como dependencias de un módulo de Kotlin. Hasta entonces, consumir una biblioteca del ecosistema Swift desde Kotlin obligaba a que esa biblioteca expusiera una interfaz compatible con Objective-C, lo que en la práctica excluía casi todo lo escrito en Swift moderno, donde las estructuras, los protocolos con tipos asociados y las enumeraciones con carga son el vocabulario normal. Con el consumo de paquetes, la frontera deja de ser un embudo unidireccional y el módulo compartido puede apoyarse en bibliotecas del lado Apple sin obligar a nadie a escribir una capa de compatibilidad.

La consecuencia estratégica es que la pregunta de arquitectura cambia. Durante años, la decisión sobre qué poner en el módulo compartido estaba fuertemente condicionada por lo que sobrevivía al puente, y eso empujaba a compartir solo lógica pura y a dejar fuera cualquier cosa que dependiera del ecosistema Apple. A medida que ambas direcciones maduran, esa restricción técnica se afloja y la frontera vuelve a poder trazarse donde tiene sentido de diseño, que es donde debió estar siempre.

🍎

Entrar en Objective-C

Maduro y prácticamente transparente. Clases, protocolos, categorías y bloques se proyectan con reglas sistemáticas y predecibles.

📦

Salir como framework

Funciona en todas partes y es la opción por defecto, a cambio de aplanar lo sellado, lo genérico y lo que tiene valores por defecto.

🔀

Export directo

Evita el puente y conserva mucho más del diseño original. En evolución activa: conviene fijar versión y revisar la superficie generada.

🧩

Paquetes Swift

Desde 2.4 se pueden consumir como dependencias, lo que abre el ecosistema Swift moderno a los módulos de Kotlin.

La API que exportas hacia Swift no es tu API de Kotlin con otra sintaxis, es un producto distinto con otro público, y tratarla como una consecuencia automática de la primera es lo que hace que los equipos de iOS odien el código compartido

El modo de fallo más común en los proyectos multiplataforma no es técnico y no se arregla con ninguna anotación: consiste en diseñar la API pensando exclusivamente en cómo se lee desde Kotlin, generar el framework, y entregarlo al equipo de iOS con la expectativa implícita de que lo que era idiomático en un lado lo será en el otro. No lo será, y el motivo es estructural. Lo que en Kotlin es una jerarquía sellada que el compilador obliga a cubrir por completo llega a Swift como una jerarquía abierta con una rama por defecto que silenciará para siempre el caso que añadas el mes que viene; lo que en Kotlin es un constructor con seis parámetros de los cuales cinco tienen valor razonable llega como un inicializador que exige los seis, cada vez, en todos los puntos de uso; lo que en Kotlin es una función suspendida cancelable dentro de un ámbito estructurado llega como una función asíncrona cuya cancelación no viaja con la misma fidelidad. Ninguna de esas pérdidas es un fallo de la herramienta: son la consecuencia inevitable de proyectar un lenguaje sobre otro que no tiene esas categorías, y seguirán existiendo en alguna medida incluso cuando el export directo esté completo, porque dos lenguajes distintos nunca tienen exactamente las mismas categorías. La conclusión práctica es que la frontera merece diseño propio y no derivación automática. Un módulo compartido bien construido tiene dos superficies: la interna, donde el código se escribe con todo el vocabulario de Kotlin porque solo lo consume Kotlin, y la exportada, que es deliberadamente más pequeña, más plana, con nombres elegidos para leerse bien en Swift, con las excepciones anotadas, con lo que no debe salir marcado para que no salga, y revisada por alguien que escribe Swift a diario. Esa segunda superficie no es una traducción de la primera sino un producto con su propio criterio de calidad, y la señal inequívoca de que un proyecto lo ha entendido es que la capa envolvente escrita en Swift existe, es fina y está mantenida; la señal de que no lo ha entendido es que el equipo de iOS importa el framework directamente y se queja.

⚔️ Auditar la frontera hacia Swift
  1. Genera el framework de un módulo tuyo y lee las cabeceras producidas. Localiza tres declaraciones cuya firma no se parezca a la de Kotlin y explica la regla que produjo cada una.
  2. Exporta una clase sellada con cuatro subclases y escribe en Swift el equivalente del when. Añade una quinta subclase y comprueba que Swift no te obliga a nada.
  3. Publica una función que pueda lanzar sin anotarla, provoca el fallo desde Swift y observa el resultado. Anótala con @Throws y repite.
  4. Aplica @ObjCName, @HiddenFromObjC y @ShouldRefineInSwift a tres declaraciones y compara la cabecera resultante con la anterior.
  5. Escribe una capa envolvente en Swift sobre esa superficie exportada y mide cuántas líneas hacen falta para que la API resulte idiomática. Ese número es el coste real de tu frontera.