Saltar al contenido
K
Cimientos del código compartidoPaso a paso40 min de lectura · 15 min de máquina Avanzado

Las funciones que cruzan a Swift

Al terminar: Exponer funciones suspend y errores a Swift sin que una excepción mate el proceso.

La lección expect/actual frente a interfaces dejó algo feo a propósito: Platform_iosKt.currentPlatform() en mitad del código de SwiftUI, con el nombre de un fichero colado en la API que ve Swift. Hoy se arregla, y el arreglo son dos cosas que no se parecen en nada.

Al terminar, Swift conocerá un solo símbolo del módulo compartido, una clase llamada Pokedex, y la función que resuelve la plataforma habrá desaparecido del header (el fichero Shared.h que ya abriste en Los tipos que cruzan a Swift). Además sabrás por qué el intento obvio, la anotación @ObjCName, no sirve para esto.

Y hay otra parte que casi no toca código, pero decide cómo escribirás la capa de red: qué le pasa a una función suspend al cruzar, y por qué sin @Throws una excepción de Kotlin mata la app en vez de llegar a Swift. Esa la verás ocurrir tú, en el simulador.

Los comandos de Gradle se escriben en la Terminal de Android Studio (View → Tool Windows → Terminal), abierta en ~/Pokedex-KMP.

El nombre de un fichero no debería ser tu API

Cuando una función de Kotlin vive suelta en un fichero, sin clase (una función «de nivel superior»), Kotlin/Native la mete en una clase que genera él, el facade (la «fachada» del fichero), y le pone el nombre del fichero: Platform.ios.kt se convierte en Platform_iosKt. Renombrar o reorganizar ese fichero rompería el código de Swift sin que nada en Kotlin lo insinuara.

La primera idea es renombrar esa clase con @ObjCName, una anotación (una etiqueta que se pone delante de una declaración para darle información al compilador) que cambia el nombre con el que algo llega a Objective-C y a Swift. Se escribiría arriba del todo de Platform.ios.kt, antes de package. No lo escribas: no compila.

ejemplo: el intento sobre el fichero (no lo escribas)
// Permite usar una anotación todavía experimental.
@file:OptIn(kotlin.experimental.ExperimentalObjCName::class)
// `@file:` la pone sobre el fichero entero: así se pretende renombrar su facade.
@file:kotlin.native.ObjCName("PlatformKt")

La salida es dar al módulo una puerta de entrada con nombre propio: una clase normal, Pokedex, que por dentro resuelve la plataforma y de la que Swift solo conoce el nombre de la clase. Y esconder la función suelta con la visibilidad de Kotlin. Hay una excepción prevista: más adelante habrá una función de entrada suelta, la que arranca la inyección de dependencias en iOS. Es una sola función, sin lógica; todo lo demás irá en clases.

Esconder: internal primero, @HiddenFromObjC después

En Kotlin, una declaración sin ninguna palabra delante (una clase, una función, una propiedad) es pública: la ve cualquiera. internal la marca como «visible solo dentro de este módulo» (shared): para quien venga de fuera, como Swift, no existe. Una declaración de commonMain marcada internal no sale en Shared.h.

Lo que cruza y lo que no

Antes de tocar la app, tres cosas que decide el puente y que conviene haber visto una vez: qué le pasa a suspend, qué le pasa a una excepción y qué nombres cambian solos.

suspend sí cruza

Todavía no hay nada asíncrono en el proyecto, pero la decisión de cómo exponerlo se toma ahora, porque condiciona el código que pedirá datos por internet en HTTP y Ktor: el cliente de PokeAPI.

Traducido a lo práctico: si Pokedex tuviera una función suspend cargar(id: Int) (todavía no la tiene; es un ejemplo), podrías escribir try await pokedex.cargar(id: 25) desde Swift y funcionaría. (try await es la forma de Swift de llamar a una función que puede esperar y puede fallar; verás su declaración en el header más abajo.) Con un aviso: es experimental, y llamada así, a pelo, la cancelación no se propaga como esperarías: cancelar la Task de Swift no cancela la corrutina de Kotlin. Lo arregla la librería que instalas en Un Flow hasta Swift, que sí propaga la cancelación a Kotlin; lo medirás en Cancelar desde Swift.

Lo que no cruza es Flow: esa es la lección Un Flow hasta Swift, y necesita una librería entera.

@Throws: sin él, una excepción mata la app

Este es el párrafo más caro de toda la interoperabilidad, así que va literal:

Léelo otra vez: sin @Throws, una excepción que cruce a Swift no se captura; termina la app. Y Swift no te avisa, porque el método ni siquiera aparece como throws, así que no hay try que escribir ni compilador que proteste. Vamos a verlo, con un experimento que luego se borra.

1 · Tres funciones de prueba. Crea Demo.kt junto a Greeting.kt: clic derecho sobre el módulo shared → New → File y la ruta completa src/commonMain/kotlin/com/sebastiannarvaez/pokedex/Demo.kt, como en El módulo compartido y la prueba de humo. Plan B, desde la Terminal: touch shared/src/commonMain/kotlin/com/sebastiannarvaez/pokedex/Demo.kt (touch crea un fichero vacío en la ruta que le des).

Demo.kt: el experimento (se borra al final)
package com.sebastiannarvaez.pokedex
 
class Demo {
    // `error("boom")` lanza una excepcion y nunca llega a devolver nada, asi que
    // encaja con cualquier tipo de retorno, aqui `Int` (su tipo es `Nothing`:
    // «esto no vuelve»). Esta no avisa de ello a Swift.
    fun sinAviso(): Int = error("boom")
 
    // `@Throws(X::class)`: declara que puede lanzar X; Swift lo vera como `throws`.
    @Throws(IllegalStateException::class)
    fun conAviso(): Int = error("boom")
 
    // Una `suspend` sin `@Throws`, para ver como llega.
    suspend fun cargar(id: Int): String = error("sin red")
}

2 · Mira el header. Vuelve a generarlo y abre la parte de Demo (error() lanza una IllegalStateException):

./gradlew :shared:linkDebugFrameworkIosSimulatorArm64
open shared/build/bin/iosSimulatorArm64/debugFramework/Shared.framework/Headers/Shared.h

Busca SharedDemo. Verás, resumidos:

Shared.h: lo que llega de Demo (resumido)
- (int32_t)conAvisoAndReturnError:(NSError * _Nullable * _Nullable)error;  // @Throws: lleva un parametro de error
- (int32_t)sinAviso;                                                       // sin @Throws: no lleva ninguno
- (void)cargarId:(int32_t)id completionHandler:(void (^)(NSString * _Nullable, NSError * _Nullable))completionHandler;

conAviso tiene el parámetro NSError (Swift lo convierte en throws); sinAviso no tiene nada, y cargar lleva un completion handler que Swift importa como async throws. Justo encima de cada una, el header trae un comentario que dice cuáles son las únicas excepciones que Kotlin convierte en error de Swift: para conAviso, IllegalStateException; para cargar, que no lleva @Throws, solo CancellationException (lo mismo que decía la cita). El comentario acaba diciendo que cualquier otra excepción es fatal: termina la app.

3 · Provócalo. Hoy ContentView.swift llama todavía a Platform_iosKt. En Xcode (open iosApp/iosApp.xcodeproj en la Terminal, como en Ejecutar en iOS), abre iosApp/iosApp/ContentView.swift en el Project navigator y añade .onAppear { … } debajo de .padding():

ContentView.swift: el experimento (se borra al final)
Text(saludo)
    .font(.title2)
    .padding()
    // `onAppear` corre su bloque cuando la vista aparece en pantalla.
    .onAppear {
        // Con @Throws, Swift obliga a `try` y deja capturar el fallo.
        // `_ =` tira el resultado, que aqui no interesa.
        do { _ = try Demo().conAviso() }
        catch { print("CAPTURADO:", error.localizedDescription) }
        // Sin @Throws: nada obliga a nada... y la app muere.
        _ = Demo().sinAviso()
    }

Cmd+R. La consola enseña:

CAPTURADO: boom
Function doesn't have or inherit @Throws annotation and thus exception isn't propagated from Kotlin to Objective-C/Swift as NSError.
It is considered unexpected and unhandled instead. Program will be terminated.
Uncaught Kotlin exception: kotlin.IllegalStateException: boom

Y la app se cierra (debajo de esas líneas sale además la lista de llamadas que llevaron hasta ahí). La primera llamada, avisada, se capturó con un catch. La segunda, sin aviso, no tuvo escapatoria. Con try await Demo().cargar(id: 1) pasa lo mismo: la excepción de una suspend sin @Throws (aquí IllegalStateException, que no es CancellationException) también termina el programa, con el mensaje Exception doesn't match @Throws-specified class list and thus isn't propagated from Kotlin to Objective-C/Swift as NSError.

4 · Límpialo todo. Quita el .onAppear de ContentView.swift y borra Demo.kt (clic derecho sobre el fichero → Delete).

Lo peor es que nadie te dice que falta @Throws: el método existe y se puede llamar. Solo lo notas cuando la excepción llega, que en este caso es la app cerrándose de golpe en el móvil de alguien.

Los nombres reservados: doInitKoin

Ya has visto uno en la lección anterior: copy() es doCopy(). Objective-C reserva ciertos nombres para sus propios métodos de creación y copia (init, copy, mutableCopy, new), y Kotlin/Native esquiva el choque anteponiendo do. La regla, comprobada con el compilador: un nombre se renombra cuando empieza por una de esas cuatro palabras y lo que sigue no es una letra minúscula (es el final del nombre, una mayúscula o un número).

KotlinSwift
copy(), copy2()doCopy(), doCopy2()
initKoin()doInitKoin()
initial(), newest(), copied()no cambian

initial no cambia porque tras init viene una minúscula; initKoin sí, porque viene la K. Vale para métodos y para funciones de nivel superior. Por eso la función de arranque de iOS acabará llamándose startKoinIos en Koin: un solo grafo en commonMain: nombrarla bien en Kotlin ahorra explicar un nombre raro en Swift.

Reto · Ejercicio: una división que avisa

Crea, en una subcarpeta nueva calculadora dentro de shared/src/commonMain/kotlin/com/sebastiannarvaez/pokedex/ejercicios/ (la carpeta ejercicios es la que creaste en expect/actual frente a interfaces; si no existe, créala), una clase Calculadora con una función que divide dos enteros y que, si el divisor es cero, lanza una excepción con el mensaje «No se puede dividir entre cero». Márcala para que Swift la vea como una función que puede fallar.

Después, en Xcode, crea una vista que divida 10 entre 2 y 10 entre 0 y enseñe una línea por cada resultado. La carpeta de Swift se crea una vez: clic derecho sobre la carpeta iosApp (la que contiene ContentView.swift) → New Folder, Ejercicios; dentro, otra New Folder, Calculadora; y dentro, clic derecho → New File from Template… → SwiftUI View, CalculadoraVista. Si el menú se llama distinto, desde la Terminal: mkdir -p iosApp/iosApp/Ejercicios/Calculadora y touch iosApp/iosApp/Ejercicios/Calculadora/CalculadoraVista.swift; Xcode los muestra solos y los compila junto con la app, así que cada ejercicio usa nombres propios: dos ejercicios con una clase del mismo nombre chocarían al compilar.

Compila el framework (./gradlew :shared:linkDebugFrameworkIosSimulatorArm64) y mira la vista previa del lienzo (Editor → Canvas; si no se actualiza, Cmd+B). Debe enseñar:

10 / 2 = 5
Error: No se puede dividir entre cero

Pista sin código: la excepción de require (de Clases que se validan) es una IllegalArgumentException.

Ver solución
shared/src/commonMain/kotlin/com/sebastiannarvaez/pokedex/ejercicios/calculadora/Calculadora.kt
package com.sebastiannarvaez.pokedex.ejercicios.calculadora
 
class Calculadora {
    // @Throws: Swift vera la funcion como `throws` y podra capturar el fallo.
    @Throws(IllegalArgumentException::class)
    fun dividir(a: Int, b: Int): Int {
        // `require` lanza IllegalArgumentException si la condicion no se cumple.
        require(b != 0) { "No se puede dividir entre cero" }
        return a / b
    }
}
iosApp/iosApp/Ejercicios/Calculadora/CalculadoraVista.swift
import SwiftUI
import Shared
 
struct CalculadoraVista: View {
    // Intenta la division y devuelve el texto que se pinta.
    func resultado(_ a: Int32, _ b: Int32) -> String {
        do {
            // El `try` va dentro del texto porque es ahi donde se llama a la funcion.
            return "\(a) / \(b) = \(try Calculadora().dividir(a: a, b: b))"
        } catch {
            return "Error: \(error.localizedDescription)"
        }
    }
 
    var body: some View {
        VStack(spacing: 8) {
            Text(resultado(10, 2))
            Text(resultado(10, 0))
        }
        .padding()
    }
}
 
#Preview {
    CalculadoraVista()
}

El Int de Kotlin llega a Swift como Int32, como viste en Los tipos que cruzan a Swift; por eso la función resultado recibe Int32. El header da dividir(a:b:). Y ahí está la lección de arriba: con @Throws, el fallo llega a Swift como un error normal y el catch lo recoge; sin él, la app se habría cerrado como en el experimento.

Paso 1 · Una fachada en commonMain

Ahora sí, el arreglo en la app. Primero el fichero, vacío; después, lo que lleva.

shared/src/commonMain/kotlin/.../Pokedex.ktse crea
shared/src/commonMain/kotlin/.../Platform.ktpasa a internal
shared/src/androidMain/kotlin/.../Platform.android.ktpasa a internal
shared/src/iosMain/kotlin/.../Platform.ios.ktpasa a internal
shared/src/jvmMain/kotlin/.../Platform.jvm.ktpasa a internal
androidApp/src/main/kotlin/com/sebastiannarvaez/pokedex/android/MainActivity.ktse modifica
iosApp/iosApp/ContentView.swiftse modifica
Un fichero nuevo en común, y cuatro que solo cambian una palabra (paso 2).

Crea Pokedex.kt en la carpeta donde ya está Greeting.kt: clic derecho sobre el módulo shared → New → File y la ruta completa, src/commonMain/kotlin/com/sebastiannarvaez/pokedex/Pokedex.kt. El fichero se abre vacío. Plan B, desde la Terminal:

touch shared/src/commonMain/kotlin/com/sebastiannarvaez/pokedex/Pokedex.kt

Con el fichero abierto, escribe:

shared/src/commonMain/kotlin/com/sebastiannarvaez/pokedex/Pokedex.kt
package com.sebastiannarvaez.pokedex
 
/**
 * La puerta de entrada del modulo compartido.
 *
 * Existe para que cada app tenga **un solo simbolo** que conocer. Sin ella, la
 * interfaz de iOS tendria que llamar a `Platform_iosKt.currentPlatform()`, un
 * nombre que sale del fichero donde vive el `actual` y que se cuela en la API
 * publica de Swift sin que nada en el codigo comun lo insinue.
 *
 * Aqui dentro iran, segun avance el curso, el cliente de red, la base de datos
 * y los repositorios. De momento solo resuelve la plataforma.
 */
class Pokedex {
 
    private val platform: Platform = currentPlatform()
 
    fun greeting(): String = Greeting(platform).greet()
}

La clase pide la plataforma a currentPlatform() una sola vez, por dentro, y la guarda en una propiedad private: Swift no la ve. Hacia fuera solo hay greeting().

Ahora las dos apps dejan de hablar de Greeting y de currentPlatform() y usan la puerta. En Android Studio, abre androidApp/src/main/kotlin/com/sebastiannarvaez/pokedex/android/MainActivity.kt (el que ya conoces de El módulo compartido y la prueba de humo) y cambia el import y la llamada que le pide el saludo a Greeting:

MainActivity.kt: el import y la llamada
// Antes:
//   import com.sebastiannarvaez.pokedex.Greeting
//   import com.sebastiannarvaez.pokedex.currentPlatform
import com.sebastiannarvaez.pokedex.Pokedex   // un solo import
 
// Antes:  Saludo(texto = Greeting(currentPlatform()).greet())
Saludo(texto = Pokedex().greeting())           // un objeto y un metodo

Y en Xcode, abre iosApp/iosApp/ContentView.swift. Sustituye todo su contenido por este (es el fichero entero):

iosApp/iosApp/ContentView.swift
import SwiftUI
import Shared
 
struct ContentView: View {
    // Un solo simbolo del modulo compartido. La plataforma se resuelve dentro,
    // donde no ensucia la API que ve Swift.
    private let saludo = Pokedex().greeting()
 
    var body: some View {
        Text(saludo)
            .font(.title2)
            .padding()
    }
}
 
#Preview {
    ContentView()
}

Paso 2 · Esconder la función: internal en los cuatro sitios

El segundo cambio es una sola palabra repetida cuatro veces: la declaración expect y sus tres actual pasan a internal.

Abre Platform.kt (el de commonMain) y añade internal delante de expect fun:

Platform.kt: la línea que cambia
/**
 * Una de las fronteras `expect` del modulo (la otra es `ioDispatcher()`).
 *
 * No se llama desde el codigo de dominio: la llama quien arranca la app, una
 * sola vez, y a partir de ahi la `Platform` viaja como parametro. Cuantas menos
 * llamadas tenga esta funcion, menos codigo queda atado a la plataforma.
 */
internal expect fun currentPlatform(): Platform

El resto del fichero no cambia. Cada actual pasa lo mismo, en su fichero: Platform.android.kt, Platform.ios.kt y Platform.jvm.kt (este último, el de la JVM, el destino donde corren los tests). Las tres líneas, una por fichero:

las tres líneas actual, cada una en su fichero
// Platform.android.kt
internal actual fun currentPlatform(): Platform = AndroidPlatform()
// Platform.ios.kt
internal actual fun currentPlatform(): Platform = IosPlatform()
// Platform.jvm.kt
internal actual fun currentPlatform(): Platform = JvmPlatform()

Y ahora la comprobación, la que importa. Genera el header y busca la función en él:

./gradlew :shared:linkDebugFrameworkIosSimulatorArm64
# `grep` filtra las lineas que cumplen un patron; `^\+` son los metodos de clase
# de Objective-C (los que empiezan por `+`), donde viven las funciones sueltas
grep -E "^\+ .*currentPlatform" shared/build/bin/iosSimulatorArm64/debugFramework/Shared.framework/Headers/Shared.h
(nada)

Antes del cambio, ese mismo comando enseñaba la línea + (id<SharedPlatform>)currentPlatform … dentro de Platform_iosKt. Ahora no devuelve nada. (Si buscas la palabra a secas, aparece en los comentarios de documentación que Kotlin copia al header, por eso el patrón.)

La forma de sacar algo de la API que ve Swift es la visibilidad de Kotlin, no una anotación. Lo que es internal no cruza, y punto.

Paso 3 · Funciona en las dos, comprobado

Arranca las dos apps (Run de androidApp en el emulador; Cmd+R en Xcode con un simulador). Cada una debe enseñar su saludo, el mismo greeting() de commonMain:

Hola desde Android 35
Hola desde iOS 18.2

(Los números son los de tu emulador y tu simulador.) Y los tests de las tres plataformas, que usan internal:

./gradlew :shared:jvmTest :shared:testAndroidHostTest :shared:iosSimulatorArm64Test

Debe terminar en BUILD SUCCESSFUL. Con tus ejercicios hechos salen 7 tests en cada una (los 2 de GreetingTest, los 3 de PokemonTest y los 2 de la batería); sin ellos, 5. La primera vez, el de iOS arranca un simulador y tarda más.

Punto de control

  • Existe Pokedex.kt en commonMain y la clase Pokedex expone greeting().
  • currentPlatform() es internal en los cuatro sitios: el expect y los tres actual.
  • ContentView.swift y MainActivity.kt llaman a Pokedex().greeting(), y ninguno menciona currentPlatform.
  • El grep de currentPlatform en Shared.h (con el patrón ^\+) no devuelve nada.
  • ./gradlew :shared:jvmTest :shared:testAndroidHostTest :shared:iosSimulatorArm64Test termina en BUILD SUCCESSFUL, con 7 tests en cada plataforma con tus ejercicios, o 5 sin ellos.
  • Las dos apps siguen enseñando su «Hola desde …».
  • Ya no existen Demo.kt ni el .onAppear del experimento. Tu ejercicio calculadora se queda en ejercicios.

Comprueba lo que entendiste

7 preguntas · necesitas 5 para completar la lección

  1. 1.Se intenta renombrar el facade de Platform.ios.kt con @file:kotlin.native.ObjCName("PlatformKt"). ¿Qué responde el compilador?

  2. 2.¿Qué consigue la clase Pokedex de commonMain que no conseguía llamar a currentPlatform() desde Swift?

  3. 3.Tras marcar currentPlatform() como internal, buscarla en Shared.h no devuelve nada. ¿Qué regla enseña eso?

  4. 4.Una función de Kotlin sin @Throws lanza una IllegalStateException mientras Swift la está llamando. ¿Qué ocurre?

  5. 5.Una función de Kotlin se llama initKoin(). ¿Cómo la ve Swift, y qué pasa con una llamada initial()?

  6. 6.Swift llama a una función de Kotlin marcada con @Throws(IllegalArgumentException::class), y la función lanza esa excepción. ¿Qué ocurre?

  7. 7.¿Cuándo conviene @HiddenFromObjC en lugar de internal?

Faltan 7 de 7