Corrutinas en commonMain
Al terminar: Escribir código asíncrono compartido con dispatchers inyectados y sin bloquear el hilo principal.
Las corrutinas son lo más portable de Kotlin: el mismo suspend, el mismo Flow y las mismas reglas de cancelación funcionan igual en Android, en iOS y en la JVM. Las viste en Marcador (Corrutinas y suspend, Dispatchers y withContext, Flow frío), y aquí cambia solo dónde viven: en commonMain, el código que compilan a la vez Android, iOS y la JVM.
Hay una piedra en ese camino: Dispatchers.IO, escrito tal cual, no compila en commonMain. Esta lección mide dónde está esa pared, ve el arreglo de una línea, y deja los dispatchers como una dependencia que se inyecta en vez de una constante escrita a pelo. Para ello se añade la segunda frontera internal expect del proyecto, y la lección dice con franqueza si hacía falta.
Y por el camino aparece la trampa que más desconcierta al empezar con iOS: los argumentos por defecto de Kotlin no existen al otro lado.
Los comandos se escriben en la Terminal de Android Studio (View → Tool Windows → Terminal), en ~/Pokedex-KMP. Para crear ficheros, clic derecho sobre el módulo shared → New → File y la ruta completa desde la carpeta del módulo (lo viste en El módulo compartido y la prueba de humo); plan B, touch en la Terminal con esa misma ruta.
La pared: Dispatchers.IO en commonMain
En vez de fiarse de lo que se lee por ahí, se mide. Un fichero de una línea en cada source set (la carpeta de código con su propio conjunto de dependencias; lo definió Qué es KMP y qué no) y se compila para ver el mensaje. Es un ejemplo para leer, no para escribir (se hizo con las corrutinas ya instaladas, que instalas tú en el Paso 1):
package com.sebastiannarvaez.pokedex
import kotlinx.coroutines.Dispatchers
// `internal`: visible solo dentro del modulo, no sale en el header de Swift.
internal val sonda = Dispatchers.IOCompilada con corrutinas 1.11.0 en los dos lados que importan, el código común y iOS (./gradlew --continue :shared:compileCommonMainKotlinMetadata :shared:compileKotlinIosSimulatorArm64: la primera tarea compila el código común y la segunda el de iOS; --continue hace que Gradle siga con la otra aunque la primera falle), el resultado según dónde se escriba la línea es:
| Dónde se escribe la línea | Resultado |
|---|---|
commonMain | falla |
iosMain | falla |
androidMain | compila |
jvmMain | compila |
El arreglo es una línea. Dispatchers.IO existe para todos los destinos que tienen hilos: Android, la JVM e iOS, que son los de este proyecto. Por eso commonMain puede usarla. Está declarada como propiedad de extensión y, como cualquier cosa de otro paquete, hay que importarla:
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.IO // la propiedad de extension: sin ella solo se ve el miembro `internal`Con ese import, el mismo comando termina en BUILD SUCCESSFUL y compila para los tres destinos.
En el código de la librería, esa propiedad está declarada en la carpeta que comparten la JVM y Native, no en la API común:
public expect val Dispatchers.IO: CoroutineDispatcherSi algún día el proyecto añadiera la web (JavaScript o Wasm), commonMain dejaría de verla. Para no liarse, así son los tres «IO» que aparecen en esta lección:
| Dónde | Qué es |
|---|---|
Dentro de Dispatchers, en común y en iOS | un miembro internal: el que ve la sonda y no se puede usar |
En commonMain, con import kotlinx.coroutines.IO | la propiedad de extensión pública: la que sí se puede usar |
En androidMain y jvmMain | un miembro público de Dispatchers: no hace falta ningún import |
Entonces, ¿por qué una frontera expect si basta un import? Hay dos caminos y los dos funcionan: usar Dispatchers.IO con el import en todas partes, o esconder la elección tras una función que cada plataforma implementa. Este proyecto sigue el segundo: deja en un solo sitio qué dispatcher usa cada plataforma para el trabajo que bloquea. La lección expect/actual frente a interfaces dijo que el expect es la excepción y la interfaz es la regla. Con ese filtro, la respuesta honesta es que aquí el expect no hacía falta: el import también resuelve el problema, y por eso el proyecto ya no tiene «una sola» frontera. Se deja para tener la elección del dispatcher en un solo fichero, porque es una decisión por plataforma que puede cambiar al medir (lo verás justo debajo), y para que veas una frontera con tres actual de una línea. En un proyecto tuyo, el import bastaría.
Los dispatchers, como dependencia y no como constante
La razón de que AppDispatchers sea una interfaz y no una constante es el test. Un ejemplo para leer (el AppDispatchers real lo escribes en el Paso 2): un AppDispatchers de mentira, cuyos tres dispatchers apuntan al de prueba, y un test que usa el reloj virtual.
package com.sebastiannarvaez.pokedex
import com.sebastiannarvaez.pokedex.core.AppDispatchers
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.time.Duration.Companion.seconds
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.delay
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.currentTime
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.withContext
// Un AppDispatchers de mentira: los tres apuntan al dispatcher de prueba.
private class DispatchersDeMentira(d: CoroutineDispatcher) : AppDispatchers {
override val io = d
override val default = d
override val main = d
}
class AppDispatchersTest {
@Test
fun elTiempoEsVirtual() = runTest {
// `testScheduler` es el reloj virtual de runTest, y
// `StandardTestDispatcher` es un dispatcher que obedece a ese reloj.
val dispatchers = DispatchersDeMentira(StandardTestDispatcher(testScheduler))
// Un trabajo que "tarda" 2 segundos (`2.seconds` es una duracion)
// en el dispatcher de IO...
withContext(dispatchers.io) { delay(2.seconds) }
// ...y el reloj virtual avanza 2000 ms (`currentTime` lo cuenta en
// milisegundos; la `L` es un entero largo) sin esperar ni uno de verdad.
assertEquals(2000L, currentTime)
}
}Ejecutado en la JVM y en iOS (./gradlew :shared:jvmTest :shared:iosSimulatorArm64Test), pasa en milisegundos: medido, 0,06 s en la JVM y 0,001 s en iOS. Con Dispatchers.IO escrito dentro de la función, ese mismo delay(2.seconds) tardó 2,0 s reales (2013 ms) y el reloj virtual se quedó en 0. Por eso se inyecta. No es código del proyecto, y por eso no lo escribes: los tests de verdad con dispatchers de mentira llegarán cuando haya una clase de datos que los reciba, y de ahí que el número de tests del proyecto no cambie hoy.
Por qué en iOS basta con Default
En el paso 2 verás que el actual de iOS devuelve Dispatchers.Default y los de Android y la JVM, Dispatchers.IO. ¿Esa diferencia es un problema? Se midió: 200 tareas que bloquean su hilo 300 milisegundos cada una, en un Mac de 11 núcleos, contando cuántas corrían a la vez.
| Plataforma | Default | IO |
|---|---|---|
| JVM y Android | 11 a la vez (tantas como núcleos) | 64 |
| iOS (simulador) | 64 | 64 |
Así que el problema real es el contrario del que parece: usar Default para trabajo que bloquea en Android o en la JVM agota los hilos reservados para cálculo (tantos como núcleos tiene el procesador) con unas pocas llamadas, y se para también el trabajo de cálculo que debería seguir corriendo. Por eso allí io es Dispatchers.IO. En iOS, Default ya aguanta ese bloqueo, y la elección no cambia nada.
Si algún día midieras algo distinto, cambias el actual de iOS y no se entera nadie más: la decisión vive en un solo sitio.
La trampa: los argumentos por defecto no cruzan
Esta es la lección de KMP que más desconcierta. El primer intento, cuando Pokedex reciba los dispatchers, es el obvio: un valor por defecto, para no tener que pasarlos.
// `= PlatformAppDispatchers()`: un valor por defecto, para no tener que pasarlo.
class Pokedex(internal val dispatchers: AppDispatchers = PlatformAppDispatchers()) {En Kotlin vale: Pokedex() compila. Pero Xcode, en la línea de ContentView.swift que llama a Pokedex(), dice:
El arreglo es un constructor secundario: un segundo constructor, además del principal, que se declara con constructor(…) y tiene que delegar en otro con : this(…). Un ejemplo mínimo para leer:
// Constructor principal: va en la cabecera de la clase.
class Caja(val ancho: Int) {
// Secundario: sin parametros. `: this(10)` lo delega en el principal con 10.
constructor() : this(10)
}
// Caja(5).ancho vale 5
// Caja().ancho vale 10 (llama al secundario, que llama al principal)En el caso de Pokedex, el principal pasa a ser internal y el secundario sin parámetros es el que usan las apps. La regla que conviene guardarte, que vale para cualquier función y no solo para constructores: cada combinación de parámetros que quieras poder llamar desde Swift necesita su propia sobrecarga en Kotlin (una sobrecarga es otra versión de la misma función con otros parámetros).
Reto · Ejercicio: dos formas de crear un temporizador
En una subcarpeta nueva temporizador dentro de shared/src/commonMain/kotlin/com/sebastiannarvaez/pokedex/ejercicios/ (junto a calculadora), crea una clase Temporizador que guarde unos segundos y tenga una función que los describa («Temporizador de N s»). Desde Swift tiene que poder crearse de dos formas: sin argumentos, y entonces son 10 segundos, y con los segundos que tú le pases.
Escríbelo primero con un valor por defecto, compila el framework (./gradlew :shared:linkDebugFrameworkIosSimulatorArm64) y crea en la carpeta Ejercicios de Xcode (iosApp/iosApp/Ejercicios/), en una subcarpeta Temporizador, una vista TemporizadorVista que use las dos formas: Xcode protestará en una. Arréglalo en Kotlin, no en Swift. La vista previa debe enseñar:
Temporizador de 10 s
Temporizador de 5 sVer solución
El error del primer intento es el de arriba, 'init()' is unavailable: el header solo trae init(segundos:). Con el arreglo, trae init() e init(segundos:).
package com.sebastiannarvaez.pokedex.ejercicios.temporizador
// Constructor principal: con los segundos.
class Temporizador(val segundos: Int) {
// Sin argumentos: 10 segundos. Un argumento por defecto no llegaria a Swift.
constructor() : this(10)
fun describir(): String = "Temporizador de $segundos s"
}import SwiftUI
import Shared
struct TemporizadorVista: View {
var body: some View {
VStack(spacing: 8) {
Text(Temporizador().describir())
Text(Temporizador(segundos: 5).describir())
}
.padding()
}
}
#Preview {
TemporizadorVista()
}Paso 1 · Instala las corrutinas en el módulo compartido
Objetivo: que el código de shared vea kotlinx.coroutines. Es la receta de instalar una librería de Los ficheros de Gradle de tu proyecto y de la de corrutinas de Corrutinas y suspend, aplicada a un módulo multiplataforma: cambia el sitio de las dependencias, no el método.
1 · Qué instalas y por qué ahora. kotlinx-coroutines-core trae delay, launch, withContext, Flow y los Dispatchers; kotlinx-coroutines-test trae runTest y el reloj virtual. Hacen falta ya porque esta lección escribe los dispatchers del proyecto, y los tests de las próximas los usarán. (Los ejemplos de arriba se midieron con ellas ya instaladas.)
2 · La versión, en [versions]. Una sola línea para las dos librerías: se publican juntas y suben a la vez. Abre gradle/libs.versions.toml y añade al final de [versions]:
coroutines = "1.11.0"3 · Las librerías, en [libraries]. Al final de esa sección (la cabecera [libraries] ya existe; no la repitas):
kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" }
kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutines" }Cada cosa en su sección: un número suelto nunca va en [libraries].
4 · No es un plugin, así que [plugins] y el build.gradle.kts de la raíz no se tocan.
5 · La dependencia, en el módulo que la usa. Abre shared/build.gradle.kts. En un módulo multiplataforma las dependencias no están en un dependencies { } suelto, sino dentro del source set que las necesita, en sourceSets { … }: commonMain es lo que compilan todas las plataformas y commonTest, los tests comunes. Añade una línea en cada uno de los que ya existen:
sourceSets {
commonMain.dependencies {
// implementation: las apps no escriben ningun tipo de corrutinas
// (la explicacion, debajo del bloque).
implementation(libs.kotlinx.coroutines.core)
// … lo que ya habia (kermit con api) se queda igual
}
commonTest.dependencies {
// Ya estamos dentro de commonTest, que solo existe para tests:
// por eso aqui basta `implementation` (`testImplementation` es la
// forma de decirlo en un modulo que mezcla codigo y tests).
implementation(libs.kotlinx.coroutines.test)
// … lo que ya habia (kotlin-test) se queda igual
}
}api frente a implementation lo viste en La primera dependencia, donde también se define la firma pública. Con las corrutinas hay un matiz que conviene decir con franqueza: AppDispatchers (el Paso 2) es pública y sus propiedades son de tipo CoroutineDispatcher, así que ese tipo asoma en la firma de shared. Con implementation funciona igualmente por dos razones: ninguna app llama a esas propiedades (usan Pokedex(), que no las enseña) y la app de Android ya recibe las corrutinas por otras librerías (Compose las trae). Si una app tuviera que escribir CoroutineDispatcher, lo correcto sería api.
6 · Sync Now, y comprueba. Pulsa Sync Now en el aviso que sale arriba del editor. Debe terminar sin errores rojos. Si sale en rojo, mira la caja «Si Sync sale en rojo» de Los ficheros de Gradle de tu proyecto; aquí lo habitual es un alias mal escrito (libs.kotlinx.coroutines.cor) o una versión que no existe. Para comprobar que la librería está, pídele a Gradle la lista de lo que usa el código de la JVM al ejecutarse:
./gradlew :shared:dependencies --configuration jvmRuntimeClasspathDebe aparecer org.jetbrains.kotlinx:kotlinx-coroutines-core:1.11.0 y, debajo, su versión para la JVM, kotlinx-coroutines-core-jvm:1.11.0, que Gradle elige sola.
Paso 2 · Los dispatchers de la app
Se crean cuatro ficheros, uno en común y tres de una línea, uno por plataforma. Primero los ficheros, vacíos, y luego lo que lleva cada uno.
La carpeta core ya existe en commonMain (ahí vive Log.kt, de La primera dependencia), pero no en androidMain, iosMain ni jvmMain: al escribir la ruta completa en New → File, Android Studio crea las carpetas que falten. Crea los cuatro ficheros (clic derecho sobre shared → New → File y cada ruta, como en la lista de abajo). Plan B desde la Terminal: mkdir -p crea las carpetas y touch, los ficheros vacíos:
# `mkdir -p` crea tambien las carpetas intermedias; `touch` crea un fichero vacio
mkdir -p shared/src/{commonMain,androidMain,iosMain,jvmMain}/kotlin/com/sebastiannarvaez/pokedex/core
touch shared/src/commonMain/kotlin/com/sebastiannarvaez/pokedex/core/AppDispatchers.kt
touch shared/src/androidMain/kotlin/com/sebastiannarvaez/pokedex/core/Dispatchers.android.kt
touch shared/src/iosMain/kotlin/com/sebastiannarvaez/pokedex/core/Dispatchers.ios.kt
touch shared/src/jvmMain/kotlin/com/sebastiannarvaez/pokedex/core/Dispatchers.jvm.ktAbre AppDispatchers.kt y escribe. Tiene tres cosas: la interfaz, la implementación real y la frontera internal expect:
package com.sebastiannarvaez.pokedex.core
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
/**
* Los tres dispatchers de la app, como dependencia y no como constante.
*
* Se inyecta en vez de llamar a `Dispatchers` directamente por una razon muy
* concreta: en un test se sustituye por el dispatcher de prueba y `runTest`
* puede adelantar el tiempo virtual. Con `Dispatchers.IO` escrito a pelo dentro
* de un repositorio, ese test tiene que esperar de verdad.
*/
interface AppDispatchers {
/** Trabajo que bloquea el hilo: disco y red. */
val io: CoroutineDispatcher
/** Trabajo que consume CPU: parsear, ordenar, transformar listas. */
val default: CoroutineDispatcher
/** El hilo de la interfaz. */
val main: CoroutineDispatcher
}
internal class PlatformAppDispatchers : AppDispatchers {
override val io: CoroutineDispatcher = ioDispatcher()
override val default: CoroutineDispatcher = Dispatchers.Default
override val main: CoroutineDispatcher = Dispatchers.Main
}
/**
* El dispatcher para el trabajo que bloquea, elegido por plataforma. Es una
* funcion `expect` y no `Dispatchers.IO` a pelo para que la eleccion viva en
* un solo sitio y se pueda cambiar sin tocar el dominio.
*/
internal expect fun ioDispatcher(): CoroutineDispatcherLa interfaz tiene tres propiedades, una por tipo de trabajo. PlatformAppDispatchers es la implementación de verdad: Default y Main salen tal cual de Dispatchers, y el de entrada/salida lo decide ioDispatcher(). Esa función no tiene cuerpo, porque es la parte expect: lo que sigue son sus tres actual, de una línea. El de Android:
package com.sebastiannarvaez.pokedex.core
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
internal actual fun ioDispatcher(): CoroutineDispatcher = Dispatchers.IOEl de la JVM es idéntico, en jvmMain (el paquete y el código no cambian):
package com.sebastiannarvaez.pokedex.core
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
internal actual fun ioDispatcher(): CoroutineDispatcher = Dispatchers.IOY el de iOS es el que lleva la explicación (la tabla de arriba): usa Default.
package com.sebastiannarvaez.pokedex.core
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
/**
* En iOS se usa `Default`. Con corrutinas 1.11.0, `Default` admite ahi
* hasta 64 tareas bloqueadas a la vez, igual que `IO`, asi que no hace falta
* otro: la diferencia entre los dos solo existe en Android y en la JVM.
*/
internal actual fun ioDispatcher(): CoroutineDispatcher = Dispatchers.Default(Aquí no hace falta el import kotlinx.coroutines.IO de la sonda: es la tercera fila de la tabla de arriba.)
Compila los tres destinos para comprobar que cada actual encaja con su expect:
./gradlew :shared:compileKotlinJvm :shared:compileAndroidMain :shared:compileKotlinIosSimulatorArm64ioDispatcher() sigue la misma regla que currentPlatform(): mínima, internal, y nadie la llama desde el dominio.
Paso 3 · Los dispatchers entran por la puerta
Ahora los dispatchers tienen que entrar en la puerta de la lección anterior, sin tropezar con la trampa de arriba. Abre Pokedex.kt (el de commonMain) y sustitúyelo por este, que es el fichero entero:
package com.sebastiannarvaez.pokedex
import com.sebastiannarvaez.pokedex.core.AppDispatchers
import com.sebastiannarvaez.pokedex.core.PlatformAppDispatchers
/**
* 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 la funcion de nivel superior que resuelve
* la plataforma, cuyo nombre en Swift sale del fichero donde vive el `actual`.
*
* Aqui dentro iran, segun avance el curso, el cliente de red, la base de datos
* y los repositorios.
*/
class Pokedex internal constructor(
internal val dispatchers: AppDispatchers,
) {
/**
* El constructor que usan las dos apps.
*
* Es un constructor secundario y no un argumento por defecto porque **los
* argumentos por defecto no cruzan a Swift**: con `dispatchers` por defecto,
* el header solo declara `init(dispatchers:)` y `Pokedex()` responde
* «'init()' is unavailable».
*/
constructor() : this(PlatformAppDispatchers())
private val platform: Platform = currentPlatform()
fun greeting(): String = Greeting(platform).greet()
}Compila el framework (./gradlew :shared:linkDebugFrameworkIosSimulatorArm64) y vuelve a Xcode (Cmd+B): sin errores. Ahora el header declara el constructor sin parámetros (y el de los dispatchers, internal, ya no sale):
- (instancetype)init __attribute__((swift_name("init()"))) __attribute__((objc_designated_initializer));El constructor principal es internal: las apps solo pueden llamar al de cero argumentos, y quien pasa dispatchers de mentira son los tests, que ven lo internal del mismo módulo.
Punto de control
- El catálogo tiene
coroutines = "1.11.0"en[versions]y las dos librerías,coreytest, en[libraries];sharedlas usa encommonMainycommonTest. ./gradlew :shared:dependencies --configuration jvmRuntimeClasspathlistakotlinx-coroutines-core:1.11.0.- Existe
core/AppDispatchers.ktcon la interfaz,PlatformAppDispatchersy elinternal expect fun ioDispatcher(). - Hay tres
actualdeioDispatcher():Dispatchers.IOen Android y en la JVM,Dispatchers.Defaulten iOS. Pokedextiene constructor primariointernalcondispatchersy un constructor secundario sin argumentos.- En
Shared.h,Pokedexdeclarainit(). Si solo declarainit(dispatchers:), te ha quedado el argumento por defecto. ./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) y con 5 si no.- Las dos apps siguen arrancando y enseñando su saludo.
Comprueba lo que entendiste
7 preguntas · necesitas 5 para completar la lección
1.Con corrutinas 1.11.0, ¿qué responde el compilador de iOS al usar
Dispatchers.IOdesdecommonMain, sin ningúnimportextra?2.La sonda
internal val sonda = Dispatchers.IOse compiló en los cuatro source sets. ¿Dónde falló?3.¿Por qué
AppDispatcherses una interfaz inyectada y no una constante llamada dentro de cada clase?4.
class Pokedex(internal val dispatchers: AppDispatchers = PlatformAppDispatchers())compila en Kotlin, y Swift responde'init()' is unavailable. ¿Por qué?5.¿Cuál es la regla general que se saca de esa trampa, más allá de los constructores?
6.En iOS, el
actualdeioDispatcher()devuelveDispatchers.Default. ¿Por qué no es un problema?7.Un
Pokedextiene un constructor principalinternalcon los dispatchers y un constructor secundario sin parámetros. ¿Qué constructor ve Swift enShared.h?