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

Cancelar desde Swift

Al terminar: Entender los nombres que genera el plugin y cancelar bien un Flow de Kotlin desde Swift.

Un Flow hasta Swift dejó el latido funcionando: un Flow de commonMain que se recorre desde SwiftUI con for await. Esta lección cierra lo que quedó pendiente: qué nombres genera el plugin (para saber leer, en Swift, lo que escribiste en Kotlin), cómo se recorre un Flow propio (con un ejercicio) y, sobre todo, qué pasa al cancelar. Esa es la promesa que queda por demostrar: que cancelar en Swift apaga el trabajo de Kotlin, y por qué hace falta la librería para ello.

Los ficheros nuevos de Kotlin se crean como siempre: 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). Lo de Swift, en Xcode.

Cómo se llaman las cosas que genera el plugin

Aquí se fijan los nombres que verás en el resto del curso. Son tres reglas:

  1. ForIos lo ponemos nosotros. Es el sufijo que usa esta guía para las extensiones de iosMain que existen solo para Swift (heartbeatForIos). La librería no lo exige: es una convención para saber de un vistazo qué cruza el puente.
  2. Un Flow o una función suspend con @NativeCoroutines a secas conserva su nombre (sobre un Flow o una función, solo la secuencia; sobre un StateFlow, además el valor actual, que se llama …Value). heartbeatForIos() se llama igual en Swift, pero su tipo ya no es el Flow: es un envoltorio ilegible a propósito. Nadie lo lee ni lo escribe a mano: se le pasa a asyncSequence(for:), que lo convierte en una AsyncSequence (algo que se recorre con for await). Para una función suspend, asyncFunction(for:) hace lo mismo y la vuelve async.
  3. Un StateFlow con @NativeCoroutinesState genera dos propiedades. Un StateFlow siempre tiene un valor actual, así que el plugin da las dos cosas: estado (el valor de ahora mismo, con su tipo normal, para leerlo sin esperar) y estadoFlow (el sufijo Flow marca la secuencia de cambios, para asyncSequence(for:)). Con una propiedad llamada uiStateForIos, Swift ve uiStateForIos (el valor) y uiStateForIosFlow (la secuencia).
ejemplo: las dos formas, resumidas (no se escribe)
// @NativeCoroutines sobre un Flow o una función: mismo nombre
for try await v in asyncSequence(for: pokedex.heartbeatForIos()) { /* … */ }
 
// @NativeCoroutinesState sobre un StateFlow: valor + secuencia con sufijo Flow
let ahora = vm.uiStateForIos                          // el valor actual
for try await v in asyncSequence(for: vm.uiStateForIosFlow) { /* … */ }   // los cambios

Un Flow que termina

Hay un detalle que el latido no enseña, porque nunca termina: qué pasa cuando el Flow acaba solo. Se ve con un ejercicio.

Reto · Ejercicio: una cuenta atrás

Esta vez el ejercicio va en el source set de iOS, porque la anotación solo existe ahí: crea la carpeta ejercicios dentro de shared/src/iosMain/kotlin/com/sebastiannarvaez/pokedex/ (aún no existe en iosMain) y, dentro, una subcarpeta cuenta_atras. En ella, una clase CuentaAtras con una función que reciba un número y devuelva un Flow que emita ese número, el anterior… hasta 0, uno cada medio segundo, y luego termine. Anótala para Swift como el latido.

En la carpeta Ejercicios de Xcode (la que creaste en Las funciones que cruzan a Swift), crea una subcarpeta CuentaAtras con una vista CuentaAtrasVista: un texto grande que empiece en «Preparados…», enseñe cada número que llega desde 3, y cuando el flujo termine diga «¡Despegue!». Compila el framework (./gradlew :shared:linkDebugFrameworkIosSimulatorArm64). En la vista previa verás 3, 2, 1, 0 y al final:

¡Despegue!

Pista sin código: un for que cuenta hacia atrás se escribe con downTo (de Bucles y tu primer test).

Ver solución
shared/src/iosMain/kotlin/com/sebastiannarvaez/pokedex/ejercicios/cuenta_atras/CuentaAtras.kt
package com.sebastiannarvaez.pokedex.ejercicios.cuenta_atras
 
import com.rickclephas.kmp.nativecoroutines.NativeCoroutines
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
 
class CuentaAtras {
    // Emite desde, desde-1, ... 0, uno cada medio segundo, y termina.
    @NativeCoroutines
    fun contar(desde: Int): Flow<Int> = flow {
        for (n in desde downTo 0) {
            emit(n)
            delay(500)
        }
    }
}
iosApp/iosApp/Ejercicios/CuentaAtras/CuentaAtrasVista.swift
import SwiftUI
import Shared
import KMPNativeCoroutinesAsync
 
struct CuentaAtrasVista: View {
    @State private var texto = "Preparados…"
 
    var body: some View {
        Text(texto)
            .font(.largeTitle)
            .task {
                do {
                    for try await n in asyncSequence(for: CuentaAtras().contar(desde: 3)) {
                        texto = "\(n.intValue)"
                    }
                    // El flujo termino solo: el bucle sale sin error.
                    texto = "¡Despegue!"
                } catch {
                    texto = "Error"
                }
            }
    }
}
 
#Preview {
    CuentaAtrasVista()
}

Un Flow que termina también sale del for try await sin error, igual que uno cancelado. Por eso «¡Despegue!» va después del bucle y no en el catch. Swift la ve como contar(desde:); n.intValue saca el número de su caja.

Paso 1 · La cancelación, demostrada

Queda una promesa por demostrar: que cancelar en Swift apaga el trabajo de Kotlin. Y por qué hace falta la librería. Un experimento corto, que se borra.

shared/src/iosMain/kotlin/com/sebastiannarvaez/pokedex/Prueba.ktse crea y se borra
iosApp/iosApp/ContentView.swiftse modifica y se restaura
Un fichero de Kotlin que se crea y uno de Swift que se toca; los dos desaparecen al final.

Crea Prueba.kt en iosMain (junto a PokedexIos.kt: clic derecho sobre shared → New → File y la ruta src/iosMain/kotlin/com/sebastiannarvaez/pokedex/Prueba.kt): la anotación vive aquí por la misma razón de siempre.

Prueba.kt: el experimento (se borra al final)
package com.sebastiannarvaez.pokedex
 
import com.rickclephas.kmp.nativecoroutines.NativeCoroutines
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
 
class Prueba {
    // Una funcion `suspend` lenta que avisa si la cancelan.
    suspend fun lenta(): String {
        try {
            delay(5000)
            return "fin normal"
        } catch (e: CancellationException) {
            // Cancelar una corrutina lanza esta excepcion dentro de ella.
            println("KOTLIN: cancelada")
            // Se relanza siempre: si se tragara, la corrutina creeria que nadie
            // la ha parado y seguiria como si nada.
            throw e
        }
    }
 
    // La misma, pero con el envoltorio de la libreria.
    @NativeCoroutines
    suspend fun lentaNc(): String = lenta()
 
    // Un flujo que cuenta cuando lo detienen.
    @NativeCoroutines
    val latidos: Flow<Int> = flow {
        try {
            var i = 0
            while (true) {
                emit(i)
                i++              // sube el contador en uno
                delay(300)
            }
        } finally {
            // `finally` corre siempre al salir del bloque, tambien si cancelan.
            println("KOTLIN: flow detenido")
        }
    }
}

Y en ContentView.swift, arriba del struct ContentView, una función que lanza un trabajo, lo cancela al segundo y apunta cuándo termina de verdad; y, debajo de la .task que ya hay, otra .task que la usa:

ContentView.swift: el experimento (se borra al final)
// `probar` lanza un trabajo, lo cancela al segundo y cuenta cuando termina de verdad.
// `trabajo` es una closure (una funcion que se pasa como parametro): no recibe nada,
// puede esperar y fallar (`async throws`) y devuelve un String. Es la llamada a Kotlin.
// `@escaping` avisa de que Swift la guarda para llamarla mas tarde, dentro de la Task.
func probar(_ nombre: String, _ trabajo: @escaping () async throws -> String) async {
    // `Date()` es el instante de ahora: se apunta para saber cuántos segundos pasaron.
    let t0 = Date()
    // Una función dentro de otra: `timeIntervalSince(t0)` da los segundos desde t0
    // y `String(format: "%.1f s", …)` los escribe con un decimal, como «1.0 s».
    func segundos() -> String { String(format: "%.1f s", Date().timeIntervalSince(t0)) }
    // `Task { … }` lanza el trabajo por su cuenta, para poder cancelarlo desde fuera.
    let tarea = Task {
        // Si el trabajo acaba solo, imprime su resultado y el tiempo.
        do { print(nombre, "terminó:", try await trabajo(), segundos()) }
        // Si lo cancelan, `try await` lanza un error y cae aquí.
        catch { print(nombre, "cancelado:", segundos()) }
    }
    try? await Task.sleep(nanoseconds: 1_000_000_000)   // espera 1 s
    tarea.cancel()                                       // y cancela la Task de Swift
    try? await Task.sleep(nanoseconds: 5_000_000_000)   // deja tiempo para ver el resultado
}
ContentView.swift: segunda .task del experimento
.task {
    // La funcion suspend tal cual (sin libreria).
    await probar("estándar") { try await Prueba().lenta() }
    // La misma, con el envoltorio. `asyncFunction(for:)` es a una función `suspend`
    // lo que `asyncSequence(for:)` es a un `Flow`: la vuelve `async` y propaga la cancelación.
    await probar("librería") { try await asyncFunction(for: Prueba().lentaNc()) }
    // El flujo: se recorre un segundo y se cancela.
    let flujo = Task {
        for try await v in asyncSequence(for: Prueba().latidos) { print("SWIFT recibe", v) }
        print("SWIFT: el bucle terminó sin error")
    }
    try? await Task.sleep(nanoseconds: 1_000_000_000)
    flujo.cancel()
    try? await Task.sleep(nanoseconds: 1_000_000_000)
}

Cmd+R y mira la consola de Xcode (Debug area, abajo; View → Debug Area → Activate Console si no la ves):

estándar terminó: fin normal 5.0 s
KOTLIN: cancelada
librería cancelado: 1.0 s
SWIFT recibe 0
SWIFT recibe 1
SWIFT recibe 2
SWIFT recibe 3
KOTLIN: flow detenido
SWIFT: el bucle terminó sin error

(Las décimas pueden variar: en una ejecución salió 1.1 s.) Es la demostración de por qué existe la librería. Fíjate en que Kotlin relanza la CancellationException (el throw e): así es como la cancelación sigue su camino hasta Swift.

  • Sin ella (estándar), cancelar la Task de Swift no cancela a Kotlin: la función siguió cinco segundos hasta terminar, como si nadie hubiera dicho nada. Con la librería, el plugin propaga la cancelación.
  • Con ella (librería), la cancelación llega a Kotlin: al segundo, el delay se interrumpe («KOTLIN: cancelada») y la Task termina.
  • Con el flujo: Swift deja de recibir latidos y Kotlin imprime «flow detenido». El bucle for try await termina sin error: la cancelación no se ve como un fallo, solo como el final. Es lo mismo que pasa cuando .task cancela al desaparecer la vista, y lo mismo que viste en el ejercicio cuando el flujo acaba solo.

Borra Prueba.kt (clic derecho sobre el fichero → Delete) y quita la función probar y la segunda .task de ContentView.swift. Vuelve a ejecutar: solo el latido.

Punto de control

  • Existen CuentaAtras.kt (en iosMain, dentro de ejercicios/cuenta_atras) y CuentaAtrasVista.swift (en Ejercicios/CuentaAtras), y la vista previa acaba diciendo «¡Despegue!».
  • Ya no existen Prueba.kt ni el código del experimento (probar y la segunda .task).
  • En el simulador el latido sigue avanzando un número por segundo.
  • ./gradlew :shared:jvmTest :shared:testAndroidHostTest :shared:iosSimulatorArm64Test sigue con 7 tests en cada plataforma si tienes tus ejercicios (los 2 de GreetingTest, los 3 de PokemonTest y los 2 de la batería) o con 5 si no, y xcodebuild compila la app.

Comprueba lo que entendiste

4 preguntas · necesitas 3 para completar la lección

  1. 1.En el experimento, la Task de Swift que recorre latidos se cancela al cabo de un segundo. ¿Cómo termina el for try await?

  2. 2.Cancelas la Task de Swift que espera una función suspend de Kotlin llamada **sin** la librería. ¿Qué hace la corrutina de Kotlin?

  3. 3.Una propiedad @NativeCoroutinesState val uiStateForIos de un StateFlow: ¿qué dos cosas ve Swift?

  4. 4.En el ejercicio de la cuenta atrás, ¿dónde se pone «¡Despegue!»?

Faltan 4 de 4