Chuleta de interop Kotlin → Swift
Al terminar: Consultar de un vistazo cómo llega cada tipo y cada construcción al otro lado.
Esta lección no se lee: se consulta. Es la tabla de correspondencias entre Kotlin y Swift, y todo lo que hay aquí sale del header de Objective-C que genera tu propia Pokédex (probado compilando contra él), no de la documentación.
El header se escribe solo al enlazar el framework. Para tener el tuyo delante, en la Terminal de Android Studio (View → Tool Windows → Terminal, en ~/Pokedex-KMP):
./gradlew :shared:linkDebugFrameworkIosSimulatorArm64
open shared/build/bin/iosSimulatorArm64/debugFramework/Shared.framework/Headers/Shared.hTipos básicos
Sacado de las firmas de Pokemon, PokemonDetail y Session:
| Kotlin | En el header | En Swift | Dónde se ve |
|---|---|---|---|
Int | int32_t | Int32 | Pokemon.id |
Long | int64_t | Int64 | Session.expiresAtEpochSeconds |
Boolean | BOOL | Bool | PokemonDetail.isLegendary |
String | NSString * | String | Pokemon.name |
String? | NSString * _Nullable | String? | Session.email |
Char | unichar | UInt16 | |
UInt | uint32_t | UInt32 | |
List<T> | NSArray<T *> * | [T] | Pokemon.types |
Unit (retorno) | void | Void | PokemonListPresenter.refresh() |
Cajas: primitivos nulables o dentro de un contenedor
Un primitivo se desempaqueta solo si no es nulable y está en posición directa. Si puede ser nulo, o entra en un contenedor genérico, se mete en una clase que hereda de NSNumber. Este framework declara once: KotlinByte, KotlinUByte, KotlinShort, KotlinUShort, KotlinInt, KotlinUInt, KotlinLong, KotlinULong, KotlinFloat, KotlinDouble y KotlinBoolean. En Objective-C la de Int se llama SharedInt; el atributo swift_name la presenta a Swift como KotlinInt. Para sacar el valor, .int32Value (un Int32) o .intValue (un Int).
Lo que ve Swift, comprobado:
| Kotlin | Swift |
|---|---|
Int? | KotlinInt? |
List<Int> | [KotlinInt] |
Set<Int> | Set<KotlinInt> |
Map<Int, PokemonType?> | [KotlinInt: Any] |
Char? | Any? |
Los identificadores de favoritos son el caso de la Pokédex: favoriteIds es un StateFlow<Set<Int>> y llega como NSSet<SharedInt *>. Por eso Swift convierte el conjunto una sola vez, al recibirlo (se explica en Favoritos en iOS):
for try await ids in asyncSequence(for: vm.favoriteIdsForIosFlow) {
idsFavoritos = Set(ids.map { $0.int32Value })
}Clases y estructuras
data class
- (SharedPokemon *)doCopyId:(int32_t)id name:(NSString *)name types:(NSArray<SharedPokemonType *> *)types __attribute__((swift_name("doCopy(id:name:types:)")));
- (BOOL)isEqual:(id _Nullable)other __attribute__((swift_name("isEqual(_:)")));
- (NSUInteger)hash __attribute__((swift_name("hash()")));
- (NSString *)description __attribute__((swift_name("description()")));| Lo que te da Kotlin | Qué llega a Swift |
|---|---|
equals | isEqual(_:) |
hashCode | hash() |
toString | description() |
copy() | doCopy(...), con todos los argumentos obligatorios |
component1(), component2()… | Nada: no hay desestructuración |
copy se renombra porque en Objective-C los métodos que empiezan por copy tienen un significado especial para la gestión de memoria (la regla completa, en Las funciones que cruzan a Swift).
enum class
PokemonType son dieciocho constantes en Kotlin y, en el header, una clase con propiedades de clase:
@interface SharedPokemonType : SharedKotlinEnum<SharedPokemonType *>
@property (class, readonly) SharedPokemonType *fire __attribute__((swift_name("fire")));
+ (SharedKotlinArray<SharedPokemonType *> *)values __attribute__((swift_name("values()")));
@property (class, readonly) NSArray<SharedPokemonType *> *entries __attribute__((swift_name("entries")));
@endNo es un enum de Swift. Es una clase con propiedades estáticas: un switch sobre ella necesita default (si no, switch must be exhaustive), y la lista de todos es PokemonType.entries, que es [PokemonType].
sealed interface
AppError tiene cuatro implementaciones en Kotlin. En el header, un protocolo vacío y cuatro clases que lo cumplen:
__attribute__((swift_name("AppError")))
@protocol SharedAppError
@required
@end
@interface SharedAppErrorLento : SharedBase <SharedAppError>
@interface SharedAppErrorServidor : SharedBase <SharedAppError>Objective-C no puede decir «estas son todas», así que un switch sobre un AppError en Swift también exige default. Por eso las pantallas no reciben AppError: reciben UiError, una data class con titulo, detalle y sePuedeReintentar, ya listos para pintar.
object y companion object
Un object llega como clase con una propiedad estática shared; un companion, con companion:
@property (class, readonly, getter=shared) SharedAppErrorLento *shared __attribute__((swift_name("shared")));
@property (class, readonly, getter=companion) SharedPokemonTypeCompanion *companion __attribute__((swift_name("companion")));En Swift: AppErrorLento.shared y PokemonType.companion.deApi(nombre:). Las fábricas de un companion quedan verbosas; si una se va a usar mucho desde iOS, una función de nivel superior en iosMain queda mejor.
Funciones
Nivel superior
Las funciones sueltas de Xxx.kt acaban como métodos estáticos de una clase XxxKt. La regla de la Pokédex: el código compartido no se escribe con funciones sueltas hacia Swift, y las únicas que cruzan son las de entrada de iosMain, una por pantalla, sin lógica (FavoritesIosKt.favoritesViewModel(owner:), HomeIosKt…) y la que arranca Koin (KoinIosKt.startKoinIos(config:secureStorage:)). Es la excepción controlada que anunció Las funciones que cruzan a Swift.
Para ver cuáles hay en tu header:
grep -o 'swift_name("[A-Za-z_]*Kt")' shared/build/bin/iosSimulatorArm64/debugFramework/Shared.framework/Headers/Shared.hEntre ellas salen tres con guion bajo (Modules_iosKt, Idioma_iosKt, DatabaseBuilder_iosKt): vienen de ficheros actual llamados Modules.ios.kt…, y el punto del nombre pasa a _. Es inofensivo, pero conviene saberlo.
Nombres que el compilador renombra
| En Kotlin | En Swift | Por qué |
|---|---|---|
Pokemon.copy() | doCopy(...) | copy* tiene semántica de memoria |
initKoin() | ModulesKt.doInitKoin(extra:) | init* está reservado para constructores |
AppDispatchers.default | default_ | default es palabra reservada |
PokemonDetail.description | description_ | choca con el description de NSObject |
La regla de init, copy y new está en Las funciones que cruzan a Swift. Un campo llamado como algo de NSObject no rompe la compilación, pero deja un nombre feo para siempre: en un módulo que se exporta, los nombres son globales (no hay paquetes al otro lado). La función de arranque se llama startKoinIos justo para no heredar el do.
suspend
Una suspend fun llega como método con bloque de terminación, que Swift presenta como async throws:
- (void)isFavoritePokemonId:(int32_t)pokemonId completionHandler:(void (^)(SharedBoolean * _Nullable, NSError * _Nullable))completionHandler __attribute__((swift_name("isFavorite(pokemonId:completionHandler:)")));En Kotlin devuelve Boolean (no nulable); en Swift, con async, el resultado es un KotlinBoolean. Cuando el valor sí es nulable de verdad, el header lo marca _Nullable_result y en Swift es un opcional (try await tokenStore.leer() da un Session?).
Valores por defecto
Se pierden. Todos: la firma de Swift lleva todos los parámetros, obligatorios.
- (BOOL)caducaAntesDeAhoraEpochSeconds:(int64_t)ahoraEpochSeconds margenSegundos:(int64_t)margenSegundos __attribute__((swift_name("caducaAntesDe(ahoraEpochSeconds:margenSegundos:)")));En Kotlin margenSegundos vale 60 por defecto; en Swift caducaAntesDe(ahoraEpochSeconds: 1) no compila (missing argument for parameter 'margenSegundos'). Lo mismo le pasa a un constructor: por eso Pokedex tiene un constructor secundario sin parámetros en lugar de un argumento por defecto (el caso completo, en Corrutinas en commonMain).
Flow
Un Flow llega como un protocolo con el nombre de su librería, sin tipo de elemento:
@property (readonly) id<SharedKotlinx_coroutines_coreFlow> pokemon __attribute__((swift_name("pokemon")));No es una AsyncSequence y no funciona con for await. La Pokédex lo resuelve con KMP-NativeCoroutines, y de ahí salen los nombres que verás en el header y en Swift:
@NativeCoroutinessobre unFlowo unasuspend: conserva el nombre; su tipo es una envoltura que se pasa aasyncSequence(for:)oasyncFunction(for:).@NativeCoroutinesStatesobre unStateFlow: dos propiedades,uiStateForIos(el valor actual, con su tipo) yuiStateForIosFlow(la secuencia de cambios, con el sufijoFlow).
let ahora = vm.uiStateForIos
for try await nuevo in asyncSequence(for: vm.uiStateForIosFlow) {
estado = nuevo
}El sufijo ForIos lo ponemos nosotros, y el Flow lo pone el plugin: las tres reglas, en Cancelar desde Swift.
Genéricos
Sobreviven como lightweight generics de Objective-C, que solo admiten tipos-clase. Una clase Caja<T> de Kotlin:
let a = Caja<NSString>(valor: "a") // vale: NSString es una clase
let b = Caja<String>(valor: "a") // error: 'Caja' requires that 'String' be a class type
let c = Caja<Int>(valor: 1) // el mismo errorY en una función genérica el parámetro de tipo se borra: fun <T : Comparable<T>> mayor(a: T, b: T): T llega con id en lugar de T.
value class
Una value class no se exporta como tal: el header usa su tipo interno, o id si es nulable. Duration (de la biblioteca estándar) llega como int64_t/Int64, con el valor y la unidad mezclados dentro del entero, así que un 5 escrito a mano no son 5 segundos; y Result tampoco sirve como retorno (una suspend que devuelve Result<…> llega como id). No las pongas en la API que cruza.
Excepciones
| Kotlin | Swift |
|---|---|
fun f() que lanza | La excepción termina el proceso |
@Throws(Exception::class) fun f() | try f(), capturable con do/catch |
suspend fun sin @Throws | Solo propaga CancellationException |
La excepción original viaja dentro del NSError:
@interface NSError (NSErrorSharedKotlinException)
@property (readonly) id _Nullable kotlinException;
@endAnotaciones para arreglar la frontera
| Anotación | Para qué |
|---|---|
@ObjCName("nombre") | Renombrar una clase o función que en Swift queda fea o choca |
@HiddenFromObjC | Quitarla del header: no cruza (exige un opt-in experimental) |
@ShouldRefineInSwift | Marcarla como privada de Swift (__nombre) para envolverla allí |
@Throws(...) | Hacerla capturable |
internal | Lo más simple: si no es público, no se exporta |
internal es la que más usa la Pokédex y la única que no hay que aprender: currentPlatform(), PokemonRepository y los DTO no aparecen en el header aunque sean media aplicación. @ObjCName no vale para ficheros enteros (lo viste en Las funciones que cruzan a Swift).
El prefijo, y las dependencias que asoman
Todas las clases llevan Shared delante porque el framework se llama así (baseName = "Shared"). En Swift el prefijo desaparece al importar el módulo, pero reaparece en los tipos de las dependencias que no exportas: Kotlinx_coroutines_coreFlow, Koin_coreModule, Kotlinx_datetimeLocalDate. Cuando veas uno de esos nombres largos, es una librería asomando por la frontera.
Al revés, una dependencia que sí exportas pierde el nombre largo: ViewModel aparece a secas. Lo consigue una línea del build, que se instaló en El ViewModel compartido (la diferencia entre api, implementation y export está en La primera dependencia):
export(libs.androidx.lifecycle.viewmodel)Lo que vendrá con Swift export
Swift export, la exportación directa a Swift sin pasar por Objective-C, está en Alpha: no montes nada encima todavía. Según su documentación, casi toda esta chuleta cambiaría:
| Hoy (Objective-C) | Con Swift export |
|---|---|
sealed → protocolo + clases | sealed → enum de Swift (con sealedType()) |
Int? → KotlinInt? | la nulabilidad se conserva, sin caja |
Flow → protocolo sin tipo de elemento | Flow → AsyncSequence (asAsyncSequence()) |
| Sin paquetes: nombres globales | Paquetes como enum anidados |
suspend → bloque de terminación | suspend → async |
Conviene separar «cómo funciona la frontera» de «cómo funciona la frontera hoy».
Reto · Diseña una API que cruce bien
Tienes que exponer a Swift esta función. Reescríbela para que el código de iOS quede decente.
suspend fun buscar(
texto: String,
limite: Int = 20,
espera: Duration = 5.seconds,
): Result<Map<Int, PokemonType?>>Ver solución
Tiene cuatro problemas de frontera (comprobados con el compilador):
Durationes unavalue class: llega comoInt64con la unidad codificada dentro, y un5desde Swift no son 5 segundos. Compila y da un comportamiento absurdo.- Los valores por defecto se pierden: en Swift hay que pasar los tres argumentos siempre.
Resultno se exporta bien: lasuspenddevuelveid. Modela el resultado con un tipo propio.Map<Int, PokemonType?>llega como[KotlinInt: Any]: claves en caja y valores sin tipo.
Una versión que cruza bien:
suspend fun buscarParaIos(
texto: String,
limite: Int,
esperaMillis: Long,
): List<Pokemon>Llega como buscarParaIos(texto:limite:esperaMillis:) con un [Pokemon] y sin cajas: esperaMillis dice su unidad en el nombre, los parámetros son explícitos porque en Swift lo serían igualmente, y List<Pokemon> sustituye al mapa con opcionales.
La decisión de fondo: conserva la API bonita para Kotlin y añade una capa fina para iOS (los ficheros *Ios.kt de iosMain son traducción de frontera, no lógica) en vez de degradar la API común para que sirva a las dos.
Comprueba lo que entendiste
5 preguntas · necesitas 4 para completar la lección
1.¿Dónde consultas cómo llega exactamente tu código Kotlin a Swift?
2.Tienes
val letra: Char?en una clase que se exporta. ¿Cómo llega a Swift?3.En Kotlin tienes un
Flow<Set<Int>>con los identificadores de los favoritos. En Swift acabas escribiendoSet(ids.map { $0.int32Value }). ¿Por qué?4.¿Por qué
PokemonDetail.descriptionllega a Swift comodescription_?5.En Kotlin,
initKoin(extra: KoinApplication.() -> Unit = {}). ¿Qué ve Swift?