Los tipos que cruzan a Swift
Al terminar: Predecir cómo llega cada tipo de Kotlin al Shared.h que después lee Swift.
Tu Kotlin no llega a Swift directamente. Pasa por Objective-C, un lenguaje de 1984 que casi no tiene genéricos (solo unos ligeros, para colecciones), ni espacios de nombres, ni tipos de valor. Casi todo lo raro que te va a pasar en iOS sale de esa escala intermedia.
Al terminar esta lección el proyecto tendrá su primer modelo de dominio de verdad —Pokemon y PokemonType— y habrás leído, línea a línea, el header de Objective-C que el compilador genera a partir de él. No es un ejercicio teórico: ese header es lo que Swift va a ver durante las más de cincuenta lecciones siguientes.
Y vas a aprender una costumbre que vale más que cualquier tabla: cuando dudes de cómo llega algo a Swift, abre el header en vez de buscar en la documentación.
Paso 1 · Un modelo de dominio que mirar
Se crean dos ficheros en commonMain y un test. Son el dominio de la app, es decir, las clases con las que la app piensa sus datos: no saben de JSON (el formato de texto en que responde una API), ni de columnas (las de una base de datos), ni de que existan dos pantallas (la de Android y la de iOS).
Crea los ficheros como en El módulo compartido y la prueba de humo: clic derecho sobre shared → New → File y la ruta completa (Android Studio crea la carpeta domain la primera vez; plan B: mkdir -p y touch desde la Terminal de Android Studio). El primero es src/commonMain/kotlin/com/sebastiannarvaez/pokedex/domain/Pokemon.kt; ábrelo y pega esto:
package com.sebastiannarvaez.pokedex.domain
/**
* Un Pokemon tal y como lo necesita la interfaz, no tal y como lo devuelve la
* API.
*
* Es el modelo de dominio: no sabe de JSON, ni de columnas, ni de que existan
* dos apps. Las clases que hablan con PokeAPI llegan en la leccion de red, y
* son otras: traducir de una a otra es trabajo del repositorio.
*/
data class Pokemon(
val id: Int,
val name: String,
val types: List<PokemonType>,
) {
/**
* La ilustracion oficial. PokeAPI no la devuelve en el listado, asi que se
* construye a partir del identificador.
*/
val artworkUrl: String
get() = "$ARTWORK_BASE/$id.png"
companion object {
private const val ARTWORK_BASE =
"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/other/official-artwork"
}
}El segundo, src/commonMain/kotlin/com/sebastiannarvaez/pokedex/domain/PokemonType.kt, es un enum class (el conjunto cerrado de valores del Recuerda de arriba):
package com.sebastiannarvaez.pokedex.domain
/**
* Los dieciocho tipos del juego.
*
* Es un `enum` y no un `String` porque la interfaz pinta un color por tipo, y
* un color por cadena de texto es un `when` con rama `else` que nadie revisa.
* El precio se paga en Swift, donde un `enum` de Kotlin pierde la
* exhaustividad: eso se cuenta en la leccion de tipos que cruzan.
*/
enum class PokemonType {
NORMAL, FIGHTING, FLYING, POISON, GROUND, ROCK, BUG, GHOST, STEEL,
FIRE, WATER, GRASS, ELECTRIC, PSYCHIC, ICE, DRAGON, DARK, FAIRY;
companion object {
/**
* PokeAPI devuelve el tipo como texto en minusculas. Si aparece uno
* que no conocemos, no se revienta: se ignora. Una API que anade un
* tipo nuevo no deberia tumbar una app publicada.
*/
fun deApi(nombre: String): PokemonType? =
entries.firstOrNull { it.name.equals(nombre, ignoreCase = true) }
}
}El ; después de FAIRY es el punto y coma que Kotlin exige cuando, tras los valores de un enum, hay más código dentro (aquí, un companion object).
Por último, el test: src/commonTest/kotlin/com/sebastiannarvaez/pokedex/PokemonTest.kt.
package com.sebastiannarvaez.pokedex
import com.sebastiannarvaez.pokedex.domain.Pokemon
import com.sebastiannarvaez.pokedex.domain.PokemonType
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
class PokemonTest {
@Test
fun laIlustracionSeConstruyeDesdeElIdentificador() {
val pikachu = Pokemon(id = 25, name = "pikachu", types = listOf(PokemonType.ELECTRIC))
assertEquals(
"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/other/official-artwork/25.png",
pikachu.artworkUrl,
)
}
@Test
fun elTipoSeLeeSinImportarMayusculas() {
assertEquals(PokemonType.GRASS, PokemonType.deApi("grass"))
assertEquals(PokemonType.GRASS, PokemonType.deApi("GRASS"))
}
@Test
fun unTipoDesconocidoNoRevientaLaApp() {
// Si PokeAPI anade un tipo manana, la app publicada tiene que seguir
// funcionando. Por eso devuelve null en vez de lanzar.
assertNull(PokemonType.deApi("cosmico"))
}
}Son tres tests: la ilustración sale del identificador, el tipo se lee sin importar las mayúsculas, y un tipo desconocido da null en vez de reventar. Ejecútalos en las tres plataformas desde la Terminal de Android Studio:
./gradlew :shared:jvmTest :shared:testAndroidHostTest :shared:iosSimulatorArm64TestDebe terminar en BUILD SUCCESSFUL, con 7 tests por destino: los 2 de GreetingTest, estos 3 de PokemonTest y los 2 de tu ejercicio de la batería. (Sin esos dos del ejercicio serían 5.)
Paso 2 · Genera el header y ábrelo
./gradlew :shared:linkDebugFrameworkIosSimulatorArm64
open shared/build/bin/iosSimulatorArm64/debugFramework/Shared.framework/Headers/Shared.h(open abre el fichero con el programa que tengas asociado a .h; si no hay ninguno, elige Xcode o cualquier editor de texto. En la vista Project está en shared/build/bin/iosSimulatorArm64/debugFramework/Shared.framework/Headers/; desde la Terminal se llega antes.)
Shared.h es el header: el fichero de cabecera donde Kotlin/Native describe, en Objective-C, todo lo que Swift puede ver de tu módulo. Lo escribe el compilador que estás usando, con tu versión de Kotlin y tu código. No se equivoca ni se queda desactualizado. Todo lo que sigue sale de ahí; el real trae además tus comentarios y cada firma en una sola línea, y aquí va recortado y repartido en líneas para leerlo mejor.
Para leerlo no hace falta saber Objective-C. Con esta chuleta basta:
| Lo que ves en el header | Qué es |
|---|---|
@interface Nombre : Base | una clase (la de Kotlin) |
- (…)metodo | método de instancia; con + es estático |
__attribute__((swift_name("x"))) | el nombre con el que lo ves en Swift |
Tipo * | una referencia a un objeto |
id<Nombre> | un protocolo (lo que en Kotlin es una interface) |
@property (readonly) … | una propiedad de solo lectura (un val) |
- (instancetype)initWith… | un constructor; instancetype significa «un objeto de esta misma clase» |
int32_t | un entero de 32 bits: el Int de Kotlin |
NSString * | un texto: el String de Kotlin |
NSArray<X *> * | una lista de objetos X: la List de Kotlin |
El prefijo Shared de los nombres de Objective-C es el nombre del framework; en Swift no lo escribes gracias a swift_name.
Leer el header: una data class conserva casi todo, con otros nombres
__attribute__((swift_name("Pokemon")))
@interface SharedPokemon : SharedBase
- (instancetype)initWithId:(int32_t)id
name:(NSString *)name
types:(NSArray<SharedPokemonType *> *)types
__attribute__((swift_name("init(id:name:types:)")))
__attribute__((objc_designated_initializer));
@property (class, readonly, getter=companion) SharedPokemonCompanion *companion;
- (SharedPokemon *)doCopyId:(int32_t)id
name:(NSString *)name
types:(NSArray<SharedPokemonType *> *)types
__attribute__((swift_name("doCopy(id:name:types:)")));
@endTres cosas que leer ahí:
copyse llamadoCopy.NSObject, la clase base de todos los objetos de Objective-C, ya tiene un métodocopycon otro significado, así que Kotlin/Native le pone el prefijodopara no pisarlo. Es la misma regla que convertiráinitKoinendoInitKoinen la lección Las funciones que cruzan a Swift.- No hay
componentN.val (id, name) = pokemones la desestructuración de data class e igualdad: abre un objeto en sus partes con funcionescomponent1,component2… que Kotlin genera. Funciona en Kotlin y no existe al otro lado; la desestructuración no cruza. Intesint32_t: un número a pelo, sin envolver. Qué es una «caja» y cuándo aparece se explica en la sección siguiente.
Las cajas, que es lo que más confunde
Un primitivo es un tipo básico de Kotlin que guarda un valor simple: Int, Long, Double, Boolean. Objective-C solo admite objetos en ciertos sitios (dentro de una colección, o cuando el valor puede faltar), así que en esos sitios el primitivo viaja envuelto en una caja, un objeto que contiene el número. El modelo de hoy no tiene ningún primitivo nulable, así que esto se enseña con un ejemplo aparte. Si Pokemon tuviera un val height: Int?, el header no diría int32_t: diría SharedInt * (así se escribe en Objective-C; en Swift la misma clase se llama KotlinInt, y es la que tú vas a ver). Con un val heightInDecimetres: Int?, el header real dice:
@property (readonly) SharedInt * _Nullable heightInDecimetres;La regla, en una frase: un primitivo viaja a pelo cuando es un valor suelto que no puede faltar; va en caja en cuanto puede faltar o va dentro de algo genérico. «Dentro de algo genérico» incluye ser elemento de una colección, parámetro de una lambda o argumento de tipo.
| Kotlin | Swift |
|---|---|
Int | Int32 |
Long | Int64 |
Double | Double |
Boolean | Bool |
String | String |
Int? | KotlinInt? |
String? | String? |
List<String> | [String] |
List<Int> | [KotlinInt] |
Unit (retorno) | Void |
Unit es el tipo de una función de Kotlin que no devuelve nada útil; en Swift equivale a Void. Y Int32 no es el Int de Swift (que tiene 64 bits): para mezclarlos hay que convertir, por ejemplo Int(pokemon.id).
Fíjate en el salto entre List<String> y List<Int>: el primero llega limpio porque String ya es un objeto; el segundo se llena de cajas. En el proyecto se paga en la lección Un Flow hasta Swift, donde un Flow<Int> obliga a abrir cada caja con .int32Value (o con .intValue, que devuelve un Int de Swift).
Un enum class llega como clase, no como enum
__attribute__((swift_name("PokemonType")))
@interface SharedPokemonType : SharedKotlinEnum<SharedPokemonType *>
+ (instancetype)alloc __attribute__((unavailable)); // no se pueden crear valores nuevos desde Swift
...
+ (SharedKotlinArray<SharedPokemonType *> *)values __attribute__((swift_name("values()")));
@property (class, readonly) NSArray<SharedPokemonType *> *entries __attribute__((swift_name("entries")));
@endHereda de SharedKotlinEnum, una clase que define Kotlin/Native: es una @interface, no un enum de Swift. La consecuencia es directa y cara: un switch sobre PokemonType no es exhaustivo. Swift exige default, y el día que añadas un tipo nuevo el compilador de Swift no te avisará de los sitios que faltan por cubrir. En Kotlin sí.
Con una sealed interface pasa lo mismo por la misma razón: llega como una clase base con subclases, y no hay ningún atributo que le diga a Swift «estas son todas». Por eso el estado de pantalla de la lección El ViewModel compartido será una data class y no una jerarquía sellada.
Las colecciones se copian en la frontera
List<PokemonType> llega como NSArray<SharedPokemonType *> *. Las colecciones de Kotlin se convierten a sus equivalentes de Foundation (la biblioteca básica de Apple, de la que salen NSArray, NSString…), son inmutables al otro lado, y —esto es lo que se olvida— se copian al cruzar. Pasar una lista de diez mil elementos a Swift no es gratis.
En esta app importa de verdad: la lista de Pokémon crece con cada página. Por eso, a partir de la lección El puente de paginación para iOS, lo que cruza a Swift no es la lista entera cada vez que el Flow envía un valor nuevo.
Los nombres pueden chocar
El prefijo Shared que ves en todo el header es el nombre del framework, el baseName = "Shared" de shared/build.gradle.kts. Swift no lo sufre gracias al atributo swift_name; Objective-C sí.
Lo que sí puede morderte es lo otro: dos clases con el mismo nombre en paquetes distintos. En la práctica uno de los dos recibe un guion bajo al final (Pokemon_ en Swift, comprobado creando una segunda Pokemon en otro paquete), pero la cita lo deja claro: el algoritmo no es estable. Es un riesgo real en cuanto entren librerías que escriben código por ti al compilar —Room, kotlinx.serialization— y meten clases en tu framework. Si la clase que refleja el JSON de PokeAPI (lo que se llama DTO, «objeto de transferencia de datos») se llamara Pokemon, como la de dominio, tendrías dos.
Las funciones sueltas también cambian de nombre
Con el header ya a mano, se resuelve el misterio que dejó expect/actual frente a interfaces: por qué PlatformKt no existía. Ábrelo y busca currentPlatform; con la chuleta se lee así: es una clase (@interface) con un método estático (+) que devuelve un protocolo (id<SharedPlatform>, la interfaz Platform de Kotlin).
__attribute__((swift_name("Platform_iosKt")))
@interface SharedPlatform_iosKt : SharedBase
+ (id<SharedPlatform>)currentPlatform __attribute__((swift_name("currentPlatform()")));
@endLas funciones de nivel superior (las que se escriben sueltas en un fichero, fuera de una clase) llegan como métodos estáticos de una clase que toma el nombre del fichero. El actual vive en Platform.ios.kt, así que la clase es Platform_iosKt, con el sufijo de plataforma incluido. El nombre sale del fichero, no de la declaración, y por eso conviene que Swift no llame a funciones de nivel superior: Las funciones que cruzan a Swift enseña a no depender de ellas.
Reto · Ejercicio: predice el header de un libro
Este ejercicio va en la carpeta ejercicios que creaste en expect/actual frente a interfaces, con una subcarpeta nueva: shared/src/commonMain/kotlin/com/sebastiannarvaez/pokedex/ejercicios/cruce/Libro.kt (clic derecho sobre shared → New → File y la ruta completa src/commonMain/kotlin/com/sebastiannarvaez/pokedex/ejercicios/cruce/Libro.kt).
En el paquete com.sebastiannarvaez.pokedex.ejercicios.cruce, crea un fichero con:
- un conjunto cerrado
Formatocon los valoresPAPELyDIGITAL; - una
data classLibrocon cinco campos:titulo(texto),paginas(entero),nota(un entero que puede faltar),capitulos(una lista de enteros) yformato.
Antes de compilar, escribe en un papel qué tipo verá Swift en cada campo y cómo se llamará la función que copia un Libro. Después ejecuta ./gradlew :shared:linkDebugFrameworkIosSimulatorArm64 y busca en el header con el comando siguiente (grep muestra las líneas que contienen un texto, y -A12 añade las 12 siguientes a cada una):
grep -A12 'swift_name("Libro")' shared/build/bin/iosSimulatorArm64/debugFramework/Shared.framework/Headers/Shared.hResultado esperado. Entre lo que sale están estas cinco líneas, en orden alfabético; antes de ellas salen también el constructor init…, doCopy…, isEqual, hash y description:
@property (readonly) NSArray<SharedInt *> *capitulos __attribute__((swift_name("capitulos")));
@property (readonly) SharedFormato *formato __attribute__((swift_name("formato")));
@property (readonly) SharedInt * _Nullable nota __attribute__((swift_name("nota")));
@property (readonly) int32_t paginas __attribute__((swift_name("paginas")));
@property (readonly) NSString *titulo __attribute__((swift_name("titulo")));También sale doCopyTitulo:paginas:nota:capitulos:formato: con swift_name("doCopy(titulo:paginas:nota:capitulos:formato:)"), y Formato como @interface SharedFormato : SharedKotlinEnum<SharedFormato *>.
Ver solución
package com.sebastiannarvaez.pokedex.ejercicios.cruce
// Un enum de Kotlin: en Swift llegará como clase, no como enum.
enum class Formato { PAPEL, DIGITAL }
data class Libro(
val titulo: String, // String → NSString * → String en Swift
val paginas: Int, // Int → int32_t → Int32 (sin caja)
val nota: Int?, // Int? → SharedInt * → KotlinInt? (con caja: es nulable)
val capitulos: List<Int>, // List<Int> → NSArray<SharedInt *> → [KotlinInt] (caja: dentro de una colección)
val formato: Formato, // Formato → SharedFormato * → Formato (una clase)
)nota y capitulos son cajas que alguien tendrá que abrir desde Swift con .int32Value o .intValue:
if let nota = libro.nota?.int32Value {
print("nota: \(nota)")
}KotlinInt hereda de NSNumber, que ofrece .int32Value (un Int32, el tipo exacto de un Int de Kotlin) y .intValue (un Int de Swift); es lo que dice la documentación cuando avisa de que «los valores primitivos de Kotlin deben convertirse a NSNumber y desde NSNumber manualmente». Además, doCopy pide los cinco argumentos.
La consecuencia de diseño: cada primitivo nulable de un modelo que cruza es una caja que alguien tiene que abrir. Cuando llegue el detalle del Pokémon, con sus campos opcionales de verdad, el proyecto decidirá entre exponerlos nulables o darles un valor por defecto en el dominio.
Punto de control
domain/Pokemon.ktydomain/PokemonType.ktexisten encommonMain../gradlew :shared:jvmTest :shared:testAndroidHostTest :shared:iosSimulatorArm64Testtermina enBUILD SUCCESSFUL, conPokemonTest3,GreetingTest2 yAvisoDeBateriaTest2 en cada destino../gradlew :shared:linkDebugFrameworkIosSimulatorArm64generaShared.framework/Headers/Shared.h.- En ese header,
PokemondeclaradoCopyId:name:types:y ningúncomponent1. PokemonTypeaparece como@interface, no comoenum, y exponeentries.currentPlatformaparece dentro de@interface SharedPlatform_iosKt, conswift_name("Platform_iosKt").- Los tres campos de
Pokemonllegan comoint32_t,NSString *yNSArray<SharedPokemonType *> *. - Tienes
ejercicios/cruce/Libro.kty has leído su header.
Comprueba lo que entendiste
5 preguntas · necesitas 4 para completar la lección
1.En el header generado, el
copy()de ladata classPokemonaparece comodoCopy(id:name:types:). ¿Por qué ese prefijo?2.¿Qué pasa con
val (id, name) = pokemonal cruzar a Swift?3.El
id: IntdePokemonllega comoint32_t, sin caja (sin envolver). Si fueraInt?, ¿qué aparecería en el header?4.Un
switchde Swift sobrePokemonTypeexigedefault. ¿Cuál es la consecuencia real de eso?5.Ante una duda sobre cómo llega un tipo de Kotlin a Swift, ¿cuál es la fuente que no se equivoca?