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

Un Flow hasta Swift

Al terminar: Consumir un Flow de Kotlin desde SwiftUI con KMP-NativeCoroutines.

Un Flow de Kotlin no cruza a Swift. Cruza el objeto, sí, pero llega como un tipo opaco (un tipo del que Swift solo ve el nombre, sin saber qué hay dentro ni qué se puede hacer con él): no hay for await que valga contra él (el bucle de Swift que espera valores uno a uno; lo repasas en el paso 5), ni cancelación que se propague, ni tipo genérico que sobreviva.

Esta lección lo arregla con KMP-NativeCoroutines, y lo importante no es instalarlo: es entender que la solución tiene dos mitades que se instalan por separado, una en Gradle y otra en Xcode, y que tienen que ir a la misma versión. Además es el primer paquete de Swift que viene de internet que instalas en este curso: hasta ahora solo enlazaste uno que vivía en tu disco.

Al terminar, la app de iOS tendrá un contador latiendo en pantalla, alimentado por un Flow que vive en commonMain, que se cancela solo cuando la vista desaparece. La lección siguiente, Cancelar desde Swift, te enseña los nombres que genera el plugin y demuestra que el plugin propaga la cancelación: cancelar en Swift apaga el trabajo de Kotlin.

Los comandos de Gradle se escriben en la Terminal de Android Studio (View → Tool Windows → Terminal), en ~/Pokedex-KMP; lo de Swift, en Xcode (open iosApp/iosApp.xcodeproj abre el proyecto, como en Ejecutar en iOS). Los ficheros nuevos de Kotlin se crean con el método de El módulo compartido y la prueba de humo: clic derecho sobre el módulo shared → New → File y la ruta completa desde la carpeta del módulo (plan B: touch con esa ruta en la Terminal).

Paso 1 · Algo que emitir

Abre Pokedex.kt (el de commonMain, en la carpeta donde ya está) y añade un latido: un contador que emite cada segundo mientras alguien escuche. Primero los import que necesita, arriba del fichero, debajo de los que ya hay:

Pokedex.kt: imports nuevos
import kotlin.time.Duration.Companion.seconds   // `1.seconds`: una duracion legible
import kotlinx.coroutines.delay                 // espera sin bloquear el hilo
import kotlinx.coroutines.flow.Flow             // el tipo de la secuencia
import kotlinx.coroutines.flow.flow             // la funcion `flow { … }`, que construye el Flow
import kotlinx.coroutines.flow.flowOn           // elige en que hilo corre el productor

Y la función nueva, dentro de la clase, debajo de greeting():

Pokedex.kt: la función nueva, dentro de la clase, debajo de greeting()
    /**
     * Latido: un contador que emite cada segundo mientras alguien escuche.
     *
     * No hace nada util, y por eso esta: comprueba que un `Flow` de Kotlin
     * llega entero a las dos interfaces antes de que haya datos de verdad que
     * transportar. Desaparece cuando llegue la lista de Pokemon.
     *
     * Es un flujo **frio**: no cuenta nada hasta que alguien se suscribe, y se
     * para solo cuando el ultimo suscriptor se va.
     */
    fun heartbeat(): Flow<Int> = flow {
        var latido = 0
        // `while (true)` repite sin parar; el Flow termina cuando lo cancelan.
        while (true) {
            // `emit` entrega el valor y `latido++` le suma 1 despues de usarlo.
            emit(latido++)
            delay(1.seconds)
        }
    }.flowOn(dispatchers.default)

Que no haga nada útil es la gracia: comprueba que la tubería entera funciona antes de que haya datos de verdad que transportar. Cuando llegue la lista de Pokémon, este método desaparece.

Genera el header y mira cómo le llega a Swift (con el comando de Los tipos que cruzan a Swift, ./gradlew :shared:linkDebugFrameworkIosSimulatorArm64; el fichero es Shared.h):

Shared.h: lo que llega de heartbeat()
- (id<SharedKotlinx_coroutines_coreFlow>)heartbeat;

Un tipo con un nombre largo y nada más: Swift no sabe qué Int sale de ahí, ni cómo recorrerlo, ni cómo pararlo. Ese es el problema que resuelve el resto de la lección.

Paso 2 · La primera mitad: instala el plugin de Gradle

Objetivo: que Gradle genere, para cada Flow que le señales, un envoltorio que Swift sepa recorrer.

Pokedex-KMP
gradle
libs.versions.tomlse modifica: [versions], [libraries] y [plugins]
build.gradle.ktsla raíz: se modifica el plugin con apply false
shared
build.gradle.ktsse modifica: el plugin y las dependencias
Los ficheros de Gradle de este paso: el catálogo, la raíz y el módulo compartido.

1 · Qué instalas y por qué ahora. Son tres piezas de la misma librería: un plugin de compilador (un plugin que se mete en el compilador de Kotlin y genera código nuevo mientras compila, como el de serialización que instalaste en JSON e instalar kotlinx.serialization) y dos librerías pequeñas: annotations (la anotación @NativeCoroutines) y core (lo que usa el código generado). Hacen falta ya porque el heartbeat() del paso 1 es el primer Flow que tiene que cruzar.

2 · La versión, en [versions]. Un solo número para las tres piezas (se publican a la vez, y ese número lo vas a repetir en Xcode). Abre gradle/libs.versions.toml y, al final de [versions]:

gradle/libs.versions.toml: [versions], línea nueva
nativeCoroutines = "1.0.6"

3 · Las librerías, en [libraries]. Al final de esa sección:

gradle/libs.versions.toml: [libraries], líneas nuevas
kmp-nativecoroutines-annotations = { module = "com.rickclephas.kmp:kmp-nativecoroutines-annotations", version.ref = "nativeCoroutines" }
kmp-nativecoroutines-core = { module = "com.rickclephas.kmp:kmp-nativecoroutines-core", version.ref = "nativeCoroutines" }

4 · El plugin, en [plugins]. Al final de esa sección (el nombre nativeCoroutines lleva mayúscula en medio, como los de los otros plugins del catálogo):

gradle/libs.versions.toml: [plugins], línea nueva
nativeCoroutines = { id = "com.rickclephas.kmp.nativecoroutines", version.ref = "nativeCoroutines" }

5 · Decláralo en la raíz con apply false, y aplícalo en el módulo. Abre el build.gradle.kts de la raíz (el que está suelto en ~/Pokedex-KMP, fuera de las carpetas de los módulos) y añade una línea al bloque plugins (apply false = «ten este plugin disponible, pero no lo apliques en la raíz»; la razón está en JSON e instalar kotlinx.serialization):

build.gradle.kts de la raíz: plugins, línea nueva
    alias(libs.plugins.nativeCoroutines) apply false

Después, en shared/build.gradle.kts, aplícalo (ahora sí, sin apply false):

build.gradle.kts de shared: plugins
plugins {
    alias(libs.plugins.kotlinMultiplatform)
    alias(libs.plugins.androidKmpLibrary)
    // Plugin de compilador: convierte los Flow anotados en algo que Swift
    // pueda recorrer con `for await`.
    alias(libs.plugins.nativeCoroutines)
}

6 · Las dependencias, solo para iOS. En el mismo fichero, dentro de sourceSets { … }, añade un bloque iosMain.dependencies (iosMain es el source set que solo compila iOS). Al usar una librería del catálogo, los guiones de su nombre pasan a puntos: kmp-nativecoroutines-annotations se escribe libs.kmp.nativecoroutines.annotations.

build.gradle.kts de shared, dentro de sourceSets: bloque nuevo
        // Solo iOS: la anotacion que hace recorrible un Flow desde Swift no
        // pinta nada en Android ni en la JVM.
        iosMain.dependencies {
            implementation(libs.kmp.nativecoroutines.annotations)
            implementation(libs.kmp.nativecoroutines.core)
        }

implementation porque ni la anotación ni core aparecen en la firma pública del módulo (la define La primera dependencia). Una advertencia honesta: el plugin ya añade estas dos librerías por su cuenta, así que el proyecto compilaría aunque borraras este bloque (se comprobó). Se escribe igualmente para que la versión salga de tu catálogo, la misma que el plugin, y no de lo que el plugin decida por su cuenta.

7 · Sync Now, y comprueba. Pulsa Sync Now; debe terminar sin errores. Dos fallos típicos, con lo que verás:

  1. Una versión que no existe. Con nativeCoroutines = "9.9.9": Plugin [id: 'com.rickclephas.kmp.nativecoroutines', version: '9.9.9', apply: false] was not found in any of the following sources. Revisa el número.
  2. Un alias mal escrito. Con libs.plugins.nativeCoroutine (sin la s): Unresolved reference 'nativeCoroutine', en el fichero y la línea. Para el resto, la caja «Si Sync sale en rojo» de Los ficheros de Gradle de tu proyecto.

Paso 3 · La anotación va en iosMain, no en la API común

Primero el fichero, vacío. PokedexIos.kt va en la carpeta de iOS, junto a Platform.ios.kt: clic derecho sobre shared → New → File y la ruta src/iosMain/kotlin/com/sebastiannarvaez/pokedex/PokedexIos.kt (plan B, touch con esa ruta en la Terminal). Con el fichero abierto, escribe. Es el fichero entero:

shared/src/iosMain/kotlin/com/sebastiannarvaez/pokedex/PokedexIos.kt
package com.sebastiannarvaez.pokedex
 
import com.rickclephas.kmp.nativecoroutines.NativeCoroutines
import kotlinx.coroutines.flow.Flow
 
/**
 * Los `Flow` que Swift puede recorrer.
 *
 * Las anotaciones viven aqui y no sobre la API comun a proposito: son cosa de
 * iOS, y no tienen por que ensuciar el codigo que comparten las dos apps.
 *
 * Kotlin/Native exporta un `Flow` como un tipo opaco que Swift no sabe
 * recorrer. El plugin genera, a partir de esta anotacion, un envoltorio que
 * `KMPNativeCoroutinesAsync` convierte en `AsyncSequence`.
 */
@NativeCoroutines
fun Pokedex.heartbeatForIos(): Flow<Int> = heartbeat()

Una extensión de una línea, en iosMain. El código que comparten las dos apps no se entera de que exista una librería de puente.

Compila (./gradlew :shared:compileKotlinIosSimulatorArm64). Verás un aviso, no un error:

w: Pokedex.kt:49:22 Flow type is exposed to ObjC

(La línea puede ser otra en tu fichero, según los comentarios que hayas escrito.) Es el plugin avisando de que heartbeat() es un Flow público que Objective-C ve sin envoltorio. Es verdad, y no hace daño: nadie llama a heartbeat() desde Swift, solo a heartbeatForIos(). Mientras exista, el aviso se queda en la salida.

Y mira cómo cambia el header (Shared.h, regenerado): ahora Pokedex tiene un método nuevo, heartbeatForIos, cuyo tipo es una función enorme hecha de closures. No hace falta leerlo; es el formato que entiende asyncSequence(for:), la función de la librería de Swift que lo convierte en algo recorrible.

Paso 4 · La segunda mitad: instala el paquete de Swift

El plugin genera envoltorios; quien los convierte en AsyncSequence es un paquete de Swift que se añade en Xcode, y tiene que estar en la misma versión.

Es la receta de instalar un paquete de Swift de La app, sus escenas y el paquete Torneo, con una diferencia: aquel paquete vivía en una carpeta de tu disco (Add Local…); este vive en internet, así que Xcode lo descarga, y apunta qué versión descargó. (Los nombres exactos de los botones pueden variar un poco entre versiones de Xcode; lo que no cambia es qué hay que decidir: la dirección, la versión exacta y el producto.)

1 · Qué instalas y por qué ahora. KMPNativeCoroutines es el paquete de Swift de la misma librería de Kotlin: la versión de Swift de lo que acabas de instalar en Gradle. Hace falta ya porque el próximo paso usa asyncSequence(for:), que vive ahí.

2 · Añádelo. Con el proyecto abierto en Xcode: File → Add Package Dependencies…. Se abre la ventana de paquetes. Arriba a la derecha hay un campo de búsqueda: pega la dirección del paquete (la dirección de internet de donde Xcode lo descarga).

https://github.com/rickclephas/KMP-NativeCoroutines.git

3 · Fija la versión exacta. A la derecha, en Dependency Rule (la regla de versión), elige Exact Version (versión exacta) y escribe 1.0.6: el mismo número que nativeCoroutines en el catálogo. Con otras reglas (Up to Next Major Version, la que sale por defecto) Xcode puede subir solo a una versión más nueva el día que alguien la publique, y las dos mitades dejarían de coincidir. Comprueba que Add to Project diga el nombre de tu proyecto de Xcode (el icono azul del panel izquierdo) y pulsa Add Package. Xcode tarda unos segundos en descargarlo.

4 · Qué producto y a qué target. Un paquete ofrece productos (lo que otros pueden usar), como viste con Torneo. Este ofrece cuatro: KMPNativeCoroutinesCore, KMPNativeCoroutinesAsync (para async/await), KMPNativeCoroutinesCombine y KMPNativeCoroutinesRxSwift (para otros estilos de programar que este curso no usa). Xcode pregunta cuáles añadir y a qué target. Marca solo KMPNativeCoroutinesAsync, y en la columna Add to Target elige el target de tu app (no los de pruebas). Si algún otro producto aparece con un target, déjalo en None. Pulsa Add Package.

5 · Dónde se ve. Dos sitios, como con Torneo. En el panel izquierdo (Project navigator), abajo del todo, aparece una sección Package Dependencies con el paquete KMPNativeCoroutines (el nombre con el que se anuncia el propio paquete, sin guion) en la versión 1.0.6 y también RxSwift 6.10.2: no lo has pedido tú, es una dependencia del propio paquete (la necesita su producto de RxSwift), y Xcode la baja aunque tú no la uses. Y selecciona el icono azul del proyecto → pestaña Package Dependencies (junto a Build Settings): el mismo listado, con la regla Exact Version 1.0.6. En el target de tu app → General → Frameworks, Libraries, and Embedded Content aparece el producto KMPNativeCoroutinesAsync.

Xcode anota esa dirección y la regla exactVersion 1.0.6 en el fichero interno del proyecto (project.pbxproj); no hace falta abrirlo.

6 · Package.resolved, ahora de verdad. Aquel fichero lo conociste con un ejemplo; este es el primero que genera Xcode en tu proyecto, porque el paquete es remoto (uno local no lo necesita). Está dentro del paquete iosApp.xcodeproj, que el Finder trata como un fichero: para verlo, clic derecho sobre iosApp.xcodeproj → Mostrar contenido del paquete y sigue project.xcworkspace/xcshareddata/swiftpm/Package.resolved. Lista los dos paquetes que descargó, también el que viene por dentro (aquí kmp-nativecoroutines 1.0.6 y rxswift 6.10.2). Si lo borras, Xcode lo vuelve a generar. Puedes pedir la resolución desde la Terminal, sin abrir Xcode:

xcodebuild -resolvePackageDependencies -project iosApp/iosApp.xcodeproj

La salida incluye esto (el orden de los dos paquetes puede cambiar de una ejecución a otra):

Resolved source packages:
  RxSwift: https://github.com/ReactiveX/RxSwift.git @ 6.10.2
  KMPNativeCoroutines: https://github.com/rickclephas/KMP-NativeCoroutines.git @ 1.0.6
 
resolved source packages: RxSwift, KMPNativeCoroutines

7 · Comprueba. Todavía no hay código Swift que use el paquete. La comprobación llegará en el paso siguiente, cuando escribas import KMPNativeCoroutinesAsync y Xcode lo reconozca.

Las dos mitades, a la misma versión. La versión del plugin (nativeCoroutines, en el catálogo) y la del paquete (Exact Version) son el mismo número, escrito dos veces. Con una diferencia pequeña, a veces compila (con el plugin en 1.0.6 y el paquete en 1.0.0, por ejemplo, compiló) y a veces no (con 0.13.3 el error es el de más abajo). Cuando no coinciden, el error habla de símbolos que no se encuentran y no menciona versiones por ningún lado: es de los más caros de diagnosticar del ecosistema, y se evita con una regla simple: el mismo número, escrito dos veces.

La versión 1.0.6 es la que se publicó para Kotlin 2.4.20, que es exactamente la versión de Kotlin del proyecto.

Paso 5 · Consumirlo desde SwiftUI

En Xcode, iosApp/iosApp/ContentView.swift. Sustituye todo su contenido por este (la vista guarda el Pokedex y el número del latido):

iosApp/iosApp/ContentView.swift
import SwiftUI
import Shared
// El paquete del paso anterior: trae `asyncSequence(for:)`.
import KMPNativeCoroutinesAsync
 
struct ContentView: View {
    // Un solo simbolo del modulo compartido. La plataforma se resuelve dentro,
    // donde no ensucia la API que ve Swift.
    private let pokedex = Pokedex()
 
    // El ultimo numero recibido. @State: al cambiar, SwiftUI redibuja.
    @State private var latido = 0
 
    var body: some View {
        VStack(spacing: 12) {
            Text(pokedex.greeting())
                .font(.title2)
            Text("Latido \(latido)")
                .font(.footnote)
                .foregroundStyle(.secondary)
        }
        .padding()
        // .task se cancela solo cuando la vista desaparece, y el plugin
        // propaga esa cancelacion a Kotlin: el Flow es frio y deja de emitir.
        .task {
            do {
                // `asyncSequence(for:)` convierte el Flow envuelto en una AsyncSequence.
                for try await valor in asyncSequence(for: pokedex.heartbeatForIos()) {
                    latido = valor.intValue   // el Int de Kotlin llega en una caja: KotlinInt
                }
            } catch {
                // Si el Flow fallara con una excepcion de Kotlin, llegaria aqui.
                // Cancelar NO pasa por aqui: el bucle simplemente termina.
            }
        }
    }
}
 
#Preview {
    ContentView()
}

Tres cosas que leer ahí, y las tres importan:

  • .task cancela al desaparecer la vista, y el plugin propaga esa cancelación a la corrutina de Kotlin (sin el plugin, como se adelantó en Las funciones que cruzan a Swift, no llegaría). Como el flujo es frío, deja de emitir y de gastar batería. No hay que escribir ningún onDisappear.
  • valor.intValue, no valor. El Int de Kotlin llega encajado en KotlinInt, porque el genérico Flow<Int> no admite números sueltos como Int (los «primitivos», que no son objetos) en Objective-C. Es exactamente la caja de Los tipos que cruzan a Swift, cobrándose por primera vez.
  • El catch no es pereza. for try await obliga a escribir try, y .task no puede lanzar errores, así que algo tiene que recogerlos. Cuando haya errores de verdad —en Errores que se pueden contar— ese catch dejará de estar vacío.

Si algo falla, cuatro errores reales y qué los causa:

  1. cannot find 'asyncSequence' in scope: falta el import KMPNativeCoroutinesAsync (o el paquete no está añadido).
  2. Unable to find module dependency: 'KMPNativeCoroutinesAsync', en la línea del propio import: el paquete está en el proyecto, pero el producto no está en tu target. Mira Frameworks, Libraries, and Embedded Content del target (paso 4, punto 5) y añade KMPNativeCoroutinesAsync con el botón +.
  3. value of type 'Pokedex' has no member 'heartbeatForIos', seguido de varios generic parameter … could not be inferred: el plugin no está aplicado en shared (revisa el paso 2) o no se ha vuelto a compilar el framework.
  4. Con un paquete de otra versión mayor (0.13.3), el mismo cannot find 'asyncSequence' in scope de la primera: el nombre existía en otra forma. Revisa Exact Version.

Paso 6 · Compruébalo

Arranca la app en el simulador (Cmd+R) y mira el contador durante unos segundos. Dos capturas con cinco segundos de diferencia dicen, por ejemplo, «Latido 3» y «Latido 8»: un número por segundo, sin saltos, viniendo de un while (true) que vive en commonMain. En el panel izquierdo de Xcode, Package Dependencies lista los dos paquetes.

Android sigue enseñando su saludo, sin contador: este Flow solo lo consume iOS.

Punto de control

  • En gradle/libs.versions.toml: nativeCoroutines = "1.0.6" en [versions], las dos librerías kmp-nativecoroutines-* en [libraries] y el plugin nativeCoroutines en [plugins].
  • El plugin está declarado apply false en el build.gradle.kts de la raíz y aplicado en shared/build.gradle.kts.
  • Las dependencias de KMP-NativeCoroutines están declaradas en iosMain.dependencies.
  • Existe PokedexIos.kt (en iosMain) con @NativeCoroutines fun Pokedex.heartbeatForIos().
  • En Xcode, Package Dependencies lista KMPNativeCoroutines con Exact Version 1.0.6 (el mismo número que el plugin) y RxSwift 6.10.2; el producto KMPNativeCoroutinesAsync está enlazado a tu target.
  • Existe Package.resolved, con los dos paquetes.
  • En el simulador el latido avanza un número por segundo.

Comprueba lo que entendiste

5 preguntas · necesitas 4 para completar la lección

  1. 1.¿Por qué se dice que la solución de KMP-NativeCoroutines tiene dos mitades?

  2. 2.La anotación @NativeCoroutines se pone sobre una extensión en iosMain, no sobre heartbeat() en commonMain. ¿Por qué?

  3. 3.En Swift se escribe latido = valor.intValue y no latido = valor. ¿Por qué?

  4. 4.Tras añadir KMP-NativeCoroutines en Xcode, Package.resolved lista también RxSwift 6.10.2. ¿Por qué?

  5. 5.Al compilar shared sale el aviso Flow type is exposed to ObjC. ¿Qué significa?

Faltan 5 de 5