Saltar al contenido
K
ReferenciaReferencia15 min Básico

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.h

Tipos básicos

Sacado de las firmas de Pokemon, PokemonDetail y Session:

KotlinEn el headerEn SwiftDónde se ve
Intint32_tInt32Pokemon.id
Longint64_tInt64Session.expiresAtEpochSeconds
BooleanBOOLBoolPokemonDetail.isLegendary
StringNSString *StringPokemon.name
String?NSString * _NullableString?Session.email
CharunicharUInt16
UIntuint32_tUInt32
List<T>NSArray<T *> *[T]Pokemon.types
Unit (retorno)voidVoidPokemonListPresenter.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:

KotlinSwift
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):

Swift: abrir las cajas al recibir
for try await ids in asyncSequence(for: vm.favoriteIdsForIosFlow) {
    idsFavoritos = Set(ids.map { $0.int32Value })
}

Clases y estructuras

data class

Shared.h: lo que una data class aporta
- (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 KotlinQué llega a Swift
equalsisEqual(_:)
hashCodehash()
toStringdescription()
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:

Shared.h: el enum de tipos
@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")));
@end

No 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:

Shared.h: la jerarquía de errores
__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:

Shared.h: un object y un 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.h

Entre 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 KotlinEn SwiftPor qué
Pokemon.copy()doCopy(...)copy* tiene semántica de memoria
initKoin()ModulesKt.doInitKoin(extra:)init* está reservado para constructores
AppDispatchers.defaultdefault_default es palabra reservada
PokemonDetail.descriptiondescription_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:

Shared.h: el DAO de favoritos
- (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.

Shared.h: el valor por defecto perdido
- (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:

Shared.h: un flujo tal cual
@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:

  • @NativeCoroutines sobre un Flow o una suspend: conserva el nombre; su tipo es una envoltura que se pasa a asyncSequence(for:) o asyncFunction(for:).
  • @NativeCoroutinesState sobre un StateFlow: dos propiedades, uiStateForIos (el valor actual, con su tipo) y uiStateForIosFlow (la secuencia de cambios, con el sufijo Flow).
Swift: valor y secuencia
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:

Swift: genéricos de una clase 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 error

Y 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

KotlinSwift
fun f() que lanzaLa excepción termina el proceso
@Throws(Exception::class) fun f()try f(), capturable con do/catch
suspend fun sin @ThrowsSolo propaga CancellationException

La excepción original viaja dentro del NSError:

Shared.h: la excepción dentro del NSError
@interface NSError (NSErrorSharedKotlinException)
@property (readonly) id _Nullable kotlinException;
@end

Anotaciones para arreglar la frontera

AnotaciónPara qué
@ObjCName("nombre")Renombrar una clase o función que en Swift queda fea o choca
@HiddenFromObjCQuitarla del header: no cruza (exige un opt-in experimental)
@ShouldRefineInSwiftMarcarla como privada de Swift (__nombre) para envolverla allí
@Throws(...)Hacerla capturable
internalLo 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):

build.gradle.kts (Module :shared): el export del ViewModel
            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 + clasessealed → enum de Swift (con sealedType())
Int? → KotlinInt?la nulabilidad se conserva, sin caja
Flow → protocolo sin tipo de elementoFlow → AsyncSequence (asAsyncSequence())
Sin paquetes: nombres globalesPaquetes como enum anidados
suspend → bloque de terminaciónsuspend → 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.

así no vale
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):

  1. Duration es una value class: llega como Int64 con la unidad codificada dentro, y un 5 desde Swift no son 5 segundos. Compila y da un comportamiento absurdo.
  2. Los valores por defecto se pierden: en Swift hay que pasar los tres argumentos siempre.
  3. Result no se exporta bien: la suspend devuelve id. Modela el resultado con un tipo propio.
  4. Map<Int, PokemonType?> llega como [KotlinInt: Any]: claves en caja y valores sin tipo.

Una versión que cruza bien:

así queda cuando 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. 1.¿Dónde consultas cómo llega exactamente tu código Kotlin a Swift?

  2. 2.Tienes val letra: Char? en una clase que se exporta. ¿Cómo llega a Swift?

  3. 3.En Kotlin tienes un Flow<Set<Int>> con los identificadores de los favoritos. En Swift acabas escribiendo Set(ids.map { $0.int32Value }). ¿Por qué?

  4. 4.¿Por qué PokemonDetail.description llega a Swift como description_?

  5. 5.En Kotlin, initKoin(extra: KoinApplication.() -> Unit = {}). ¿Qué ve Swift?

Faltan 5 de 5