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:
ForIoslo ponemos nosotros. Es el sufijo que usa esta guía para las extensiones deiosMainque existen solo para Swift (heartbeatForIos). La librería no lo exige: es una convención para saber de un vistazo qué cruza el puente.- Un
Flowo una funciónsuspendcon@NativeCoroutinesa secas conserva su nombre (sobre unFlowo una función, solo la secuencia; sobre unStateFlow, además el valor actual, que se llama…Value).heartbeatForIos()se llama igual en Swift, pero su tipo ya no es elFlow: es un envoltorio ilegible a propósito. Nadie lo lee ni lo escribe a mano: se le pasa aasyncSequence(for:), que lo convierte en unaAsyncSequence(algo que se recorre confor await). Para una funciónsuspend,asyncFunction(for:)hace lo mismo y la vuelveasync. - Un
StateFlowcon@NativeCoroutinesStategenera dos propiedades. UnStateFlowsiempre 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) yestadoFlow(el sufijoFlowmarca la secuencia de cambios, paraasyncSequence(for:)). Con una propiedad llamadauiStateForIos, Swift veuiStateForIos(el valor) yuiStateForIosFlow(la secuencia).
// @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 cambiosUn 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
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)
}
}
}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.
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.
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:
// `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
}.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 laTaskde 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, eldelayse interrumpe («KOTLIN: cancelada») y laTasktermina. - Con el flujo: Swift deja de recibir latidos y Kotlin imprime «flow detenido». El bucle
for try awaittermina sin error: la cancelación no se ve como un fallo, solo como el final. Es lo mismo que pasa cuando.taskcancela 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(eniosMain, dentro deejercicios/cuenta_atras) yCuentaAtrasVista.swift(enEjercicios/CuentaAtras), y la vista previa acaba diciendo «¡Despegue!». - Ya no existen
Prueba.ktni el código del experimento (probary la segunda.task). - En el simulador el latido sigue avanzando un número por segundo.
./gradlew :shared:jvmTest :shared:testAndroidHostTest :shared:iosSimulatorArm64Testsigue con 7 tests en cada plataforma si tienes tus ejercicios (los 2 deGreetingTest, los 3 dePokemonTesty los 2 de la batería) o con 5 si no, yxcodebuildcompila la app.
Comprueba lo que entendiste
4 preguntas · necesitas 3 para completar la lección
1.En el experimento, la
Taskde Swift que recorrelatidosse cancela al cabo de un segundo. ¿Cómo termina elfor try await?2.Cancelas la
Taskde Swift que espera una funciónsuspendde Kotlin llamada **sin** la librería. ¿Qué hace la corrutina de Kotlin?3.Una propiedad
@NativeCoroutinesState val uiStateForIosde unStateFlow: ¿qué dos cosas ve Swift?4.En el ejercicio de la cuenta atrás, ¿dónde se pone «¡Despegue!»?