Migrar un proyecto Android existente
Al terminar: Migrar :torneo, el módulo de Marcador, de módulo JVM a Kotlin Multiplatform con destinos JVM, Android e iOS, destino a destino y con el compilador como guía.
Marcador ya funciona en Android y en iOS, y la lógica del torneo (equipos, partidos, tabla, JSON, corrutinas) la has escrito dos veces: en Kotlin, dentro del módulo :torneo, y en Swift, dentro del paquete Torneo. En esta lección conviertes :torneo en un módulo de Kotlin Multiplatform (KMP), la tecnología con la que Kotlin compila el mismo código para varias plataformas, sin romper nada de lo que ya funciona. No tocas :app ni el paquete de Swift.
Al terminar, :torneo compilará para cuatro destinos (targets): jvm (tu ordenador, para la consola y los tests), android, iosArm64 (un iPhone físico) e iosSimulatorArm64 (el simulador de un Mac con Apple Silicon). Tendrás 138 tests en la JVM (132 si te saltaste el opcional CancelacionTest), de los que 116 (110) correrán también en Android y en el simulador de iOS; los 22 restantes se quedan en la JVM, y verás por qué.
Necesitas un Mac con Xcode, como en Instalar Xcode y el simulador: los destinos de iOS solo compilan ahí. La primera vez que Gradle compile para iOS descargará el compilador de Kotlin/Native (el que ya conoces de la Pokédex: produce código nativo para iOS, sin JVM) y sus dependencias, varios cientos de megabytes, así que ten internet y paciencia.
Temas: migración a KMP, plugins nuevos y source sets.
La app no se convierte: el código compartido vive en un módulo de librería
Sobre convertir el módulo de la app, Google es tajante:
Marcador ya está así: :app es la aplicación y :torneo es un módulo de librería aparte que :app usa con implementation(project(":torneo")). Un módulo de librería sí puede cambiar de plugin. Por eso :app no se toca en toda la lección, y solo :torneo cambia por dentro.
:app ya trae su propia red desde Retrofit y la primera llamada: Retrofit y OkHttp, los árbitros guardados en Room y la crónica que se publica con un POST, y Hilt, que monta todas esas piezas (Hilt: instalarlo y el primer módulo). Todo eso es de Android y se queda en :app mientras migras :torneo; esta migración no lo toca. Hilt no tiene destinos para iOS, así que no puede entrar en :torneo. Ktor, la librería de red que sí funciona en varias plataformas, ya la conociste en la Pokédex, en HTTP y Ktor: el cliente de PokeAPI.
El orden: las hojas primero
Empiezas por las hojas del grafo de dependencias y subes: un módulo del que nadie depende puede migrarse y romperse sin arrastrar a nadie.
Paso · Anota la línea base
Objetivo: saber cuántos tests pasan y que la app compila antes de tocar nada. Al final tienen que ser los mismos (más uno, como verás), y si algo falla sabrás que lo ha metido la migración.
Abre la Terminal de Android Studio (View → Tool Windows → Terminal; se abre en la carpeta del proyecto, la que tiene el fichero gradlew) y escribe:
./gradlew :torneo:test :app:assembleGratisDebug :app:testGratisDebugUnitTestDebe terminar en BUILD SUCCESSFUL. Gradle no dice cuántos tests ha corrido; para verlo, abre el informe de :torneo (open es el comando de macOS que abre un fichero con su programa por defecto, aquí el navegador):
open torneo/build/reports/tests/test/index.htmlVerás 137 tests (131 si no hiciste CancelacionTest) y 0 fallos. Apunta la cifra. :torneo:test es la tarea del plugin de la JVM; con KMP se llamará :torneo:jvmTest.
Paso · Instala el plugin de Kotlin Multiplatform
Objetivo: que el catálogo y la raíz del proyecto conozcan el plugin que convierte un módulo en multiplataforma. Es la receta de la primera lección, aplicada a un plugin nuevo; esta vez solo declaras el plugin, y en el paso siguiente lo aplicas a :torneo.
1 · Qué instalas y por qué ahora. El plugin org.jetbrains.kotlin.multiplatform deja que un mismo módulo de Gradle compile el mismo código Kotlin para varios destinos. Sustituirá en :torneo al plugin jetbrains-kotlin-jvm, que solo sabía compilar para la JVM y que escribió el asistente cuando creaste el módulo.
2 · La versión, en [versions]. Es un plugin del propio compilador de Kotlin, y se publica con su misma versión: ese número ya está, es kotlin. No añades nada en [versions].
3 · El plugin, en [plugins]. Añade esta línea al final de la sección [plugins] de libs.versions.toml (la de jetbrains-kotlin-jvm todavía no la borres: :torneo la sigue usando hasta el próximo paso):
kotlin-multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" }4 · La declaración en la raíz, con apply false. En build.gradle.kts (Project: Marcador), añade esta línea dentro del bloque plugins, debajo de la de jetbrains-kotlin-jvm:
alias(libs.plugins.kotlin.multiplatform) apply falseapply false significa «carga este plugin para todo el proyecto, con una única versión, pero no lo apliques a la raíz». Ya lo hiciste con los demás plugins; el motivo de fondo se ve en la caja de errores del paso siguiente.
5 · Sync Now, y comprueba. Pulsa Sync Now en la barra amarilla. Debe terminar sin errores rojos: :torneo aún no usa el plugin nuevo. Si sale rojo, mira la caja «Si Sync sale en rojo» de Los ficheros de Gradle de tu proyecto.
Paso · Convierte build.gradle.kts (Module :torneo)
Objetivo: que :torneo sea un módulo KMP con un solo destino, la JVM, y siga pasando todos sus tests. Los pasos más pequeños posibles: primero la JVM, después Android, después iOS.
build.gradle.kts (Module :torneo) es la hoja de instrucciones del módulo, como la receta de un plato: qué plugins usa (los utensilios), para qué plataformas se compila (los platos que salen) y de qué librerías depende (los ingredientes). Lo tienes así, de la primera lección y de las que instalaron serialización y corrutinas:
plugins {
alias(libs.plugins.jetbrains.kotlin.jvm)
alias(libs.plugins.kotlin.serialization)
application
}
kotlin {
jvmToolchain(17)
}
application {
mainClass.set("com.sebastiannarvaez.marcador.torneo.MainKt")
}
dependencies {
implementation(libs.kotlinx.serialization.json)
implementation(libs.kotlinx.coroutines.core)
testImplementation(libs.kotlin.test)
testImplementation(libs.kotlinx.coroutines.test)
testImplementation(libs.turbine)
}
tasks.test {
useJUnitPlatform()
}Con KMP el fichero cambia de forma, no de contenido. Antes de escribirlo, dos palabras nuevas:
- Destino (target): una plataforma para la que se compila el módulo. Se declara dentro de
kotlin { … }con una función por destino:jvm { … },android { … },iosArm64()… - Source set (conjunto de fuentes): una carpeta de código que se compila para unos destinos. El código que va en
commonMainse compila para todos; el dejvmMain, solo para la JVM. Cada source set tiene su propio bloque de dependencias, y por esotestImplementationdesaparece: los tests comunes tienen el suyo,commonTest.dependencies.
Son cuatro cambios sobre lo que ya tienes, no un fichero nuevo:
- En
plugins, quitaalias(libs.plugins.jetbrains.kotlin.jvm)yapplication, y añadealias(libs.plugins.kotlin.multiplatform). - Dentro de
kotlin { … }se quedajvmToolchain(17)y añades el bloquejvm { … }, conmainRun, que ocupa el lugar del bloqueapplication { … }(bórralo). - Borra el bloque
dependencies { … }y escribe sus librerías ensourceSets, dentro dekotlin. - Borra el bloque
tasks.test { … }: el destinojvmtrae su propio ejecutor de tests (y no es el mismo, como verás enseguida).
Al terminar, el fichero tiene que quedar así. Los comentarios explican cada línea; no hace falta copiarlos.
plugins {
// El plugin de KMP: un módulo, varios destinos. Sustituye a los dos de antes
// (`jetbrains-kotlin-jvm` y `application`).
alias(libs.plugins.kotlin.multiplatform)
// El plugin de serialización que ya instalaste: no cambia.
alias(libs.plugins.kotlin.serialization)
}
kotlin {
// Compila y ejecuta con JDK 17, como antes.
jvmToolchain(17)
// Destino 1: la JVM. Sirve para la consola y para los tests en tu ordenador.
jvm {
// Un permiso explícito: esta API de Gradle aún puede cambiar en versiones futuras.
@OptIn(org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi::class)
// Sustituye al plugin `application`: crea la tarea `jvmRun`.
mainRun {
// Dónde arranca el programa (la función `main` de `Main.kt`).
mainClass.set("com.sebastiannarvaez.marcador.torneo.MainKt")
}
}
sourceSets {
// Las dependencias del código común (`commonMain`).
commonMain.dependencies {
// JSON: es un detalle interno de `:torneo`, nadie más lo ve.
implementation(libs.kotlinx.serialization.json)
// `api` y no `implementation`: `Flow` y `StateFlow` aparecen en las firmas
// públicas de `:torneo`, y `:app` los usa; con `api` también las ve `:app`.
api(libs.kotlinx.coroutines.core)
}
// Las dependencias de los tests comunes (`commonTest`): sin el prefijo `test`.
commonTest.dependencies {
implementation(libs.kotlin.test)
implementation(libs.kotlinx.coroutines.test)
implementation(libs.turbine)
}
}
}La diferencia entre implementation y api ya la viste en Gradle: ficheros, catálogo y dependencias: implementation esconde la librería a quien use :torneo, y api se la deja ver. Antes, con el plugin de la JVM, :app ya declaraba sus corrutinas por su cuenta; ahora :torneo las expone él mismo, que es lo correcto porque sus firmas las usan.
Para terminar, limpia lo que ya no usa nadie. En libs.versions.toml, sección [plugins], borra la línea jetbrains-kotlin-jvm = …. En build.gradle.kts (Project: Marcador), borra alias(libs.plugins.jetbrains.kotlin.jvm) apply false. Pulsa Sync Now: debe terminar sin errores rojos.
Paso · Las carpetas de un módulo multiplataforma
Objetivo: que el código de :torneo esté donde el plugin de KMP lo busca.
En esta lección y en la siguiente cambias el panel del proyecto a la vista Project: el selector de arriba a la izquierda del panel, que ahora pone «Android» (elige Project). Es una excepción justificada: lo que enseñan estas dos lecciones son los source sets como carpetas (commonMain, jvmMain…), y la vista Android las agrupa y las esconde. Volverás a Android con el mismo selector al terminar la lección siguiente. Mientras tanto, para crear un fichero con ruta completa en un módulo: clic derecho sobre la carpeta de destino → New → Kotlin Class/File, o touch en la Terminal si el menú no te ofrece Kotlin allí.
El plugin de KMP ya no lee src/main/java ni src/test/java: busca src/<source set>/kotlin. Con la Terminal de Android Studio, desde la carpeta del proyecto, mueve el código de producción a commonMain y los tests a commonTest. Son cuatro comandos de macOS, y los vas a usar varias veces:
mkdir -pcrea una carpeta, y con-ptambién las intermedias que falten (y no se queja si ya existe).mv origen destinomueve (o renombra) un fichero o una carpeta.rmdirborra una carpeta vacía; se niega si tiene algo dentro, y eso te protege de borrar de más.touch ficherocrea un fichero vacío (lo usarás más abajo).
mkdir -p torneo/src/commonMain torneo/src/commonTest
mv torneo/src/main/java torneo/src/commonMain/kotlin
mv torneo/src/test/java torneo/src/commonTest/kotlin
rmdir torneo/src/main torneo/src/testEn orden: crea las dos carpetas nuevas, mueve el código de producción, mueve los tests y borra las dos carpetas viejas, ya vacías (si rmdir falla, mira qué quedaba dentro).
Pulsa Sync Now si aparece la barra amarilla (Android Studio detecta las carpetas nuevas) y ejecuta los tests de la JVM:
./gradlew :torneo:jvmTestNo termina en verde. Verás algo así:
ErroresEnCorrutinasTest[jvm] > initializationError[jvm] FAILED
org.junit.runners.model.InvalidTestClassError at ParentRunner.java:525
134 tests completed, 1 failed(Si te saltaste CancelacionTest, serán 128 tests completed, 1 failed: todas las cifras de esta lección bajan en 6.)
Abre el informe (open torneo/build/reports/tests/jvmTest/index.html) y entra en ErroresEnCorrutinasTest: el motivo es Method unTryCatchAlrededorDeLaunchNoAtrapaElError() should be void.
Qué ha pasado. Un test debe ser una función que no devuelve nada. Ese test se escribió = runBlocking { … } y devuelve lo que devuelva la última línea del bloque, así que sí devuelve algo. El plugin de la JVM ejecutaba los tests con JUnit 5 (tú lo pediste con useJUnitPlatform()), y JUnit 5 ignora en silencio un test que devuelve un valor: ese test nunca se había ejecutado. El destino jvm de KMP usa por defecto JUnit 4 (la versión anterior de la misma librería de tests), que no lo ignora: se niega a cargar la clase entera. Es la primera vez que te encuentras un test que llevaba tiempo sin correr.
El arreglo es decirle a runBlocking que su bloque devuelve Unit (nada): runBlocking<Unit> { … }. En torneo → src → commonTest → kotlin → com.sebastiannarvaez.marcador.torneo, abre ErroresEnCorrutinasTest.kt y cambia la primera línea de ese test:
// antes
fun unTryCatchAlrededorDeLaunchNoAtrapaElError() = runBlocking {
// después: `<Unit>` fija el tipo que devuelve el bloque, y así el test no devuelve nada
fun unTryCatchAlrededorDeLaunchNoAtrapaElError() = runBlocking<Unit> {Vuelve a ejecutar ./gradlew :torneo:jvmTest. Ahora termina en BUILD SUCCESSFUL con 138 tests (132 sin CancelacionTest): uno más que antes, el que no se ejecutaba.
Comprueba también la consola. En la migración cambia el nombre de la tarea: :torneo:run ya no existe, y si la escribes Gradle dice Cannot locate tasks that match ':torneo:run' as task 'run' not found in project ':torneo'. La nueva se llama jvmRun:
./gradlew :torneo:jvmRun --args=kotlinImprime la demo de Kotlin, como antes.
Paso · Android entra como destino
Objetivo: que :torneo también compile como librería de Android y que :app siga funcionando sin haberla tocado.
Android es un destino más, con su propio plugin. Es otra instalación con la receta de plugin:
1 · Qué instalas y por qué ahora. com.android.kotlin.multiplatform.library es el plugin del Android Gradle Plugin (AGP) para librerías KMP: es el que añade el destino android al bloque kotlin { … }. Sustituye a com.android.library, el plugin habitual para librerías de Android (tu :torneo no lo usaba: era una librería de la JVM), que desde AGP 9.0 no se puede combinar con KMP.
2 · La versión, en [versions]. Lo publica Google con la versión del AGP: ya existe como agp. No añades nada.
3 · [plugins]. Añade al final de la sección:
android-kmp-library = { id = "com.android.kotlin.multiplatform.library", version.ref = "agp" }4 · La raíz. En build.gradle.kts (Project: Marcador), otra línea con apply false debajo de la de kotlin.multiplatform:
alias(libs.plugins.android.kmp.library) apply false5 · El módulo. En build.gradle.kts (Module :torneo), dos cambios. Primero, el plugin, en el bloque plugins, entre el de KMP y el de serialización:
alias(libs.plugins.android.kmp.library)Segundo, el destino. Añade este bloque dentro de kotlin { … }, entre el bloque jvm { … } y sourceSets:
// Destino 2: Android, como librería que consume `:app`.
android {
// El identificador del módulo. No es un `applicationId`: ese solo lo tiene una app instalable.
namespace = "com.sebastiannarvaez.marcador.torneo"
// Contra qué nivel de la API de Android se compila y desde cuál funciona:
// los mismos números que verás en `build.gradle.kts (Module :app)`.
compileSdk = 37
minSdk = 24
// Activa los tests de Android que corren en tu ordenador (el «host»), sin emulador.
// Los lanza la tarea `testAndroidHostTest`.
withHostTest {}
}6 · Sync Now, y comprueba. Pulsa Sync Now y ejecuta:
./gradlew :torneo:testAndroidHostTest :app:assembleGratisDebug :app:testGratisDebugUnitTestDebe terminar en BUILD SUCCESSFUL: 138 tests de :torneo (132 sin CancelacionTest) corriendo ahora como tests de Android, y la app y sus 16 tests de Marcador (19 si hiciste el ejercicio del temporizador) sin haber tocado una línea de :app. (:app sigue diciendo project(":torneo"): ahora :torneo ofrece una versión para cada destino, y Gradle llama a cada una variante; elige solo la de Android, que es la que le sirve.)
Paso · iOS entra como destino, y el compilador dice qué no se comparte
Objetivo: añadir los dos destinos de iOS y descubrir qué parte de :torneo solo existe en la JVM. Este es el paso que de verdad prueba que el código es compartible.
Hasta ahora todo compilaba a la primera por una razón: para el compilador, la JVM y Android ven la misma librería de Java, y el código común todavía podía usar java.io.File, String.format… En cuanto añades iOS, que no usa Java y solo tiene la librería estándar de Kotlin y las librerías multiplataforma, el compilador revisa el código común contra los tres.
Añade este bloque dentro de kotlin { … }, entre android { … } y sourceSets:
// Destinos 3 y 4: iOS. Una función por destino, y el mismo bloque para los dos.
listOf(iosArm64(), iosSimulatorArm64()).forEach { ios ->
// Un framework es el paquete que Xcode enlaza con una app de iOS.
ios.binaries.framework {
// Con este nombre lo importaría Swift.
baseName = "Torneo"
// Estático: se funde con la app cuando Xcode la «enlaza» (une las piezas compiladas en un solo
// programa), en vez de viajar como un fichero aparte.
isStatic = true
}
}El framework Torneo no choca con el paquete Swift Torneo de tu app de iOS: ninguno importa al otro, y esta lección no los conecta. Queda compilado, pero sin enchufar.
Pulsa Sync Now y compila el código de producción para el simulador de iOS (la primera vez descarga el compilador de Kotlin/Native y tarda varios minutos):
./gradlew :torneo:compileKotlinIosSimulatorArm64Falla, y es lo que querías. Estos son los errores reales, agrupados (hay una decena de líneas por fichero; aquí, las que importan):
e: …/Formato.kt:13:49 Unresolved reference 'format' on receiver of type 'String'.
e: …/Guardado.kt:3:8 Unresolved reference 'java'.
e: …/Guardado.kt:134:29 Unresolved reference 'File'.
e: …/SegundoPlano.kt:21:51 Unresolved reference 'File'.
e: …/Exportar.kt:9:35 Unresolved reference 'File'.
e: …/DemoKotlin.kt:123:19 Unresolved reference 'File'.
e: …/Main.kt:166:28 Unresolved reference 'Thread'.
e: …/Main.kt:36:22 'fun measureTimeMillis(block: () -> Unit): Long' is deprecated. Use measureTime() instead.
e: …/SegundoPlano.kt:25:29 Cannot access 'val IO: CoroutineDispatcher': it is internal in 'kotlinx.coroutines.Dispatchers'.Los números de línea de tu Main.kt serán otros, porque el tuyo no es idéntico al del autor: guíate por el fichero y el mensaje. Son cuatro causas. El último error, el de Dispatchers.IO, lo ignoras por ahora: sale de las funciones de disco, se va solo cuando esas funciones pasen a la JVM y volverá, con su explicación, en la lección siguiente. Y measureTimeMillis sale como error (e:) y no como aviso porque en Kotlin/Native la deprecación está declarada con nivel de error; la JVM la deja pasar, y por eso no te enteraste hasta ahora. Esta tabla las junta con lo que dice la guía de JetBrains sobre otras sustituciones habituales; la última columna dice de dónde sale cada fila, porque no todas tienen el mismo respaldo:
| De | A | Cómo lo sabes |
|---|---|---|
String.format | padStart, padEnd y plantillas de texto | Lo dice el compilador de iOS |
java.io.File | funciones expect/actual (la lección siguiente) | Lo dice el compilador de iOS |
Thread, AtomicInteger, CopyOnWriteArrayList | se quedan en la JVM (jvmMain, jvmTest) | Lo dice el compilador de iOS |
measureTimeMillis | kotlin.time.measureTime | Lo dice el compilador de iOS |
| RxJava, la librería reactiva anterior a las corrutinas | kotlinx-coroutines y Flow, antes de empezar la migración | Documentado en la guía de JetBrains |
| Dagger/Hilt, la inyección de dependencias de Google, dentro del módulo que se migra | Koin, que hace lo mismo y funciona en todas las plataformas (ya lo usaste en la Pokédex). En Marcador no hace falta: Hilt vive en :app y se queda ahí | Documentado en la guía de JetBrains (en su app de ejemplo) |
| JUnit | kotlin-test | Documentado en la guía de JetBrains |
:torneo ya cumplía tres de las filas de abajo: usa Flow y no RxJava, no usa Hilt (el de :app no cuenta: no se migra) y ya usa kotlin-test. Por eso los errores de arriba son solo los de las cuatro primeras. Lo que no se migra: las clases de Android (Context, Activity…) se quedan en :app, y commonMain no admite código Java. Para lo que no tiene equivalente común queda expect/actual, la excepción, que usarás en la lección siguiente.
Los arreglas por orden.
1 · String.format en Formato.kt
String.format es de Java. padStart(n) rellena con espacios por la izquierda hasta n caracteres, y padEnd(n) por la derecha: sirven para alinear las columnas de la tabla igual que antes. Abre Formato.kt (en commonMain) y cambia aTexto.
Las dos piezas, en otra tabla (pruébalo en un scratch de Android Studio o en el Kotlin Playground):
// Extensión privada: solo se ve en este fichero. `ancho` es cuántos caracteres debe ocupar.
private fun Int.relleno(ancho: Int) = toString().padStart(ancho) // espacios a la IZQUIERDA
fun fila(nombre: String, edad: Int, puntos: Int): String =
// padEnd(10): el nombre ocupa 10 caracteres, con espacios a la DERECHA.
"${nombre.padEnd(10)}|${edad.relleno(4)}|${puntos.relleno(5)}"
fun main() {
println(fila("Rosa", 31, 120)) // "Rosa | 31| 120"
println(fila("Leo", 7, 5)) // "Leo | 7| 5"
}Intenta escribirlo tú: una extensión privada de los enteros, alineado, que convierte el número en texto y lo rellena con espacios por la izquierda hasta el ancho que reciba; y aTexto, que la usa en lugar de format para dar exactamente el mismo texto que antes: cada número con su ancho de siempre y el nombre del equipo rellenado por la derecha hasta 8 caracteres. Lo que debes ver: la tabla impresa igual que antes, con las columnas alineadas.
Intenta primero. Cuando termines, compara con la solución.Mostrar el código
// Un Int, como texto de `ancho` caracteres, con espacios a la izquierda.
private fun Int.alineado(ancho: Int) = toString().padStart(ancho)
fun FilaDePosicion.aTexto(puesto: Int): String =
// Cada número con su ancho (`2`, `3`…); el nombre, rellenado a la derecha a 8.
"${puesto.alineado(2)} ${equipo.nombre.padEnd(8)} ${jugados.alineado(2)} ${ganados.alineado(2)} " +
"${empatados.alineado(2)} ${perdidos.alineado(2)} ${golesAFavor.alineado(3)} " +
"${golesEnContra.alineado(2)} ${diferencia.alineado(3)} ${puntos.alineado(3)}"alineado va justo antes de aTexto, y el resto del fichero no cambia.
2 · Lo que usa Java va a jvmMain
Main.kt, DemoKotlin.kt y Exportar.kt usan File, Thread o measureTimeMillis, y son programa de consola, no librería: se quedan en la JVM, en el source set jvmMain. Con la Terminal, desde la carpeta del proyecto, crea la carpeta de destino y mueve los tres ficheros (uno por línea):
mkdir -p torneo/src/jvmMain/kotlin/com/sebastiannarvaez/marcador/torneo
mv torneo/src/commonMain/kotlin/com/sebastiannarvaez/marcador/torneo/Main.kt torneo/src/jvmMain/kotlin/com/sebastiannarvaez/marcador/torneo/
mv torneo/src/commonMain/kotlin/com/sebastiannarvaez/marcador/torneo/DemoKotlin.kt torneo/src/jvmMain/kotlin/com/sebastiannarvaez/marcador/torneo/
mv torneo/src/commonMain/kotlin/com/sebastiannarvaez/marcador/torneo/Exportar.kt torneo/src/jvmMain/kotlin/com/sebastiannarvaez/marcador/torneo/Cada mv lleva el fichero a la carpeta con el mismo paquete dentro de jvmMain; el package de dentro no cambia.
Quedan las funciones que reciben un File dentro de ficheros que sí son comunes: guardar y cargarTorneo (al final de Guardado.kt) y guardarEnSegundoPlano, cargarTorneoEnSegundoPlano y cerrarJornada (en SegundoPlano.kt). Se cortan de allí y se pegan en dos ficheros nuevos de jvmMain:
- En
Guardado.kt, borraimport java.io.Filey las dos funciones del final,guardarycargarTorneo. - En
SegundoPlano.kt, borraimport java.io.File, los imports deasyncycoroutineScope, y las tres funciones que reciben unFile:guardarEnSegundoPlano,cargarTorneoEnSegundoPlanoycerrarJornada. Se quedatablaEnSegundoPlano, que no toca el disco. - En la vista Project, clic derecho sobre
torneo/src/jvmMain/kotlin/com/sebastiannarvaez/marcador/torneo→ New → Kotlin Class/File → File →GuardadoEnFichero. Repite conSegundoPlanoEnFichero. (Si el menú no te ofrece Kotlin en esa carpeta, crea los ficheros desde la Terminal contouch torneo/src/jvmMain/kotlin/com/sebastiannarvaez/marcador/torneo/GuardadoEnFichero.kty la misma línea conSegundoPlanoEnFichero.kt, y ábrelos con doble clic.) - Pega en cada uno las funciones que cortaste, con
package com.sebastiannarvaez.marcador.torneoarriba y los imports que necesitan.
Intenta escribirlo tú antes de mirar.
Intenta primero. Cuando termines, compara con la solución.Mostrar el código
package com.sebastiannarvaez.marcador.torneo
import java.io.File
// Guardar y cargar con java.io.File: solo existe en la JVM (y en Android, que es una JVM),
// no en iOS. Se queda en jvmMain; el resto de Guardado.kt (JSON <-> Torneo) es comun.
fun Torneo.guardar(fichero: File): Result<Unit> = runCatching { fichero.writeText(aJson()) }
fun cargarTorneo(fichero: File): Result<Torneo> = runCatching { fichero.readText() }.mapCatching {
torneoDesdeJson(it).getOrThrow()
}package com.sebastiannarvaez.marcador.torneo
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.withContext
import java.io.File
// Escribir el JSON es esperar al disco: IO.
suspend fun Torneo.guardarEnSegundoPlano(fichero: File, dispatcher: CoroutineDispatcher = Dispatchers.IO): Result<Unit> =
withContext(dispatcher) { guardar(fichero) }
suspend fun cargarTorneoEnSegundoPlano(fichero: File): Result<Torneo> =
withContext(Dispatchers.IO) { cargarTorneo(fichero) }
// Las dos cosas a la vez, cada una en su dispatcher. Si el guardado falla, se
// lanza el error (`getOrThrow`) al esperar el resultado.
suspend fun Torneo.cerrarJornada(fichero: File): List<FilaDePosicion> = coroutineScope {
val guardado = async { guardarEnSegundoPlano(fichero) }
val tabla = async { tablaEnSegundoPlano() }
guardado.await().getOrThrow()
tabla.await()
}Lo de jvmMain no llega a :app: jvm y android son destinos distintos y :app consume el de Android. Da igual, porque :app nunca llama a esas funciones.
3 · Un fallo del compilador al partir el módulo
Compila otra vez, ahora la JVM:
./gradlew :torneo:compileKotlinJvmEl arreglo, en otra función con el mismo problema (una lambda suspend por defecto que llama a otra función suspend):
import kotlinx.coroutines.delay
import kotlinx.coroutines.runBlocking
suspend fun prepararCafe(taza: String): String { delay(10); return "café en $taza" }
// La versión corta: la que se llama sin pasar `preparar`. Antes, eso era el valor por defecto.
suspend fun servirTodos(tazas: List<String>): List<String> =
servirTodos(tazas) { prepararCafe(it) } // llama a la completa pasando la lambda a mano
// La versión completa, SIN valor por defecto: `preparar` es obligatorio.
suspend fun servirTodos(
tazas: List<String>,
preparar: suspend (String) -> String, // una lambda suspend como parámetro
): List<String> = tazas.map { preparar(it) }
fun main() = runBlocking {
println(servirTodos(listOf("roja", "azul"))) // usa la versión corta
println(servirTodos(listOf("verde")) { "té en $it" }) // pasa otra lambda
}Abre Errores.kt (en commonMain) y cambia la firma de jugarJornadaSupervisada. Intenta escribirlo tú: la versión completa pierde el valor por defecto de jugar, que pasa a ser obligatorio, y encima se añade una sobrecarga que solo recibe la lista de partidos y llama a la completa diciéndole que cada partido se juegue con jugarPartido. Quien llama la usa igual que antes. Lo que debes ver: ./gradlew :torneo:compileKotlinJvm termina sin el BackendException.
Intenta primero. Cuando termines, compara con la solución.Mostrar el código
// La versión corta: la que se llama sin pasar `jugar`. Antes era el valor por defecto.
suspend fun jugarJornadaSupervisada(partidos: List<Partido>): List<Result<Partido>> =
jugarJornadaSupervisada(partidos) { jugarPartido(it) }
// La versión completa, SIN valor por defecto: el parámetro `jugar` ya es obligatorio.
suspend fun jugarJornadaSupervisada(
partidos: List<Partido>,
jugar: suspend (Partido) -> Partido,
): List<Result<Partido>> = supervisorScope {Sustituye la firma anterior; el resto de la función no cambia.
Comprueba que el código de producción compila ya para los cuatro destinos:
./gradlew :torneo:compileKotlinJvm :torneo:compileAndroidMain :torneo:compileKotlinIosArm64 :torneo:compileKotlinIosSimulatorArm64Debe terminar en BUILD SUCCESSFUL. Un aviso que no es un error: Marcador.kt sigue avisando de que @FlowPreview está en fase de vista previa.
Faltan los tests de iOS, que no compilan por las mismas razones.
Paso · Los tests, para tres destinos
Objetivo: que los tests comunes corran en la JVM, en Android y en el simulador de iOS, y que los que dependen de Java se queden en la JVM.
Compila los tests de iOS y verás una lista larga:
./gradlew :torneo:compileTestKotlinIosSimulatorArm64Las mismas causas que en producción, ahora en los tests:
e: …/CancelacionTest.kt:46:18 'fun measureTimeMillis(block: () -> Unit): Long' is deprecated. Use measureTime() instead.
e: …/ErroresEnCorrutinasTest.kt:53:23 Unresolved reference 'CopyOnWriteArrayList'.
e: …/FlowFrioTest.kt:36:25 Unresolved reference 'AtomicInteger'.
e: …/GuardadoTest.kt:56:23 Unresolved reference 'createTempDirectory'.
e: …/SegundoPlanoTest.kt:13:36 Unresolved reference 'File'.La primera es una deprecación, y sale como e: y no como w:: en los tests de iOS ese aviso es un error (la JVM no se queja). Las demás son tipos de Java sin equivalente común. Tres arreglos:
1 · measureTimeMillis → measureTime, en tres tests
kotlin.time.measureTime { … } está en la librería estándar común y devuelve una duración (Duration); .inWholeMilliseconds la convierte en el número de milisegundos que devolvía la función antigua. Tres ficheros de commonTest la usan: CancelacionTest.kt (si lo tienes), JornadaTest.kt y TiempoVirtualTest.kt. En cada uno cambia el import y las llamadas:
// El import:
import kotlin.time.measureTime // antes: import kotlin.system.measureTimeMillis
// Cada llamada: el bloque se queda igual y se añade `.inWholeMilliseconds` después de la llave de cierre.
val ms = measureTime { calculo.cancelAndJoin() }.inWholeMilliseconds // CancelacionTest
val real = measureTime { /* … */ }.inWholeMilliseconds // TiempoVirtualTest
val ms = measureTime { estadisticasDe(Ejemplo.rayoContraToros, msPorConsulta = 100) }.inWholeMilliseconds // JornadaTest (dos llamadas)El compilador te señala cada línea: una en CancelacionTest, una en TiempoVirtualTest y dos en JornadaTest. En un bloque de varias líneas, el .inWholeMilliseconds va justo detrás de la llave que cierra.
2 · Tres tests enteros se quedan en la JVM
ErroresEnCorrutinasTest usa CopyOnWriteArrayList, FlowFrioTest usa AtomicInteger (los dos de java.util.concurrent) y SegundoPlanoTest usa File y Thread. No tienen equivalente común, así que van a jvmTest. Desde la carpeta del proyecto:
mkdir -p torneo/src/jvmTest/kotlin/com/sebastiannarvaez/marcador/torneo
mv torneo/src/commonTest/kotlin/com/sebastiannarvaez/marcador/torneo/ErroresEnCorrutinasTest.kt torneo/src/jvmTest/kotlin/com/sebastiannarvaez/marcador/torneo/
mv torneo/src/commonTest/kotlin/com/sebastiannarvaez/marcador/torneo/FlowFrioTest.kt torneo/src/jvmTest/kotlin/com/sebastiannarvaez/marcador/torneo/
mv torneo/src/commonTest/kotlin/com/sebastiannarvaez/marcador/torneo/SegundoPlanoTest.kt torneo/src/jvmTest/kotlin/com/sebastiannarvaez/marcador/torneo/(ErroresEnCorrutinasTest es el mismo que arreglaste con runBlocking<Unit>: ahora vive en jvmTest y solo corre en la JVM.)
3 · Los tests de disco salen de dos ficheros comunes
GuardadoTest y ErroresTest mezclan tests comunes con tests que crean una carpeta temporal con java.io. Los de disco (dos en cada uno) se mudan a dos ficheros nuevos de jvmTest: los de GuardadoTest a GuardadoEnFicheroTest, y los de ErroresTest a ExportarTest. Los comunes se quedan.
Primero, borra de lo viejo:
- En
GuardadoTest.kt, borra elimport kotlin.io.path.createTempDirectoryy los dos testscargaDesdeFicheroyunFicheroQueNoExisteEsFailure. - En
ErroresTest.kt, borra los tres imports de arriba (java.io.File,java.io.IOExceptionykotlin.io.path.createTempDirectory) y los dos tests del final,exportarTablaEscribeYCierraElFicheroyexportarEnUnaCarpetaQueNoExisteDevuelveFailure.
Después, los dos ficheros nuevos no enseñan nada que no sepas (son esos mismos tests con otro nombre de clase), así que se copian con el botón Copiar fichero. Al pegar el nuevo GuardadoEnFicheroTest, esos dos tests ya no están en GuardadoTest, y lo mismo pasa con ExportarTest y ErroresTest.
- En la vista Project, clic derecho sobre
torneo/src/jvmTest/kotlin/com/sebastiannarvaez/marcador/torneo→ New → Kotlin Class/File → File → escribeGuardadoEnFicheroTesty pulsa Intro. (Plan B:touch torneo/src/jvmTest/kotlin/com/sebastiannarvaez/marcador/torneo/GuardadoEnFicheroTest.kten la Terminal.) - Pulsa Copiar fichero en el bloque de abajo.
- En el editor, selecciona todo y pega: Cmd+A / Cmd+V. El texto copiado trae su propia línea
package.
Y lo mismo con ExportarTest (crea el fichero con ese nombre en la misma carpeta, y pega):
Pulsa Sync Now y ejecuta las tres suites:
./gradlew :torneo:allTests :torneo:testAndroidHostTestallTests ejecuta los tests de la JVM y los del simulador de iOS (la primera vez arranca un simulador y tarda); testAndroidHostTest los de Android en tu ordenador. Debe terminar en BUILD SUCCESSFUL. En el informe de cada destino (se abren con open, como antes) verás:
- JVM: 138 tests (116 de
commonTesty 22 dejvmTest). Informe:torneo/build/reports/tests/jvmTest/index.html. - iOS (simulador): 116. Informe:
torneo/build/reports/tests/iosSimulatorArm64Test/index.html. - Android (en tu ordenador): 116. Informe:
torneo/build/reports/tests/testAndroidHostTest/index.html.
Si te saltaste CancelacionTest, resta 6 a cada cifra: 132, 110 y 110.
El simulador lo elige el plugin de Kotlin: pregunta a Xcode (xcrun simctl list devices available) y usa uno de los iPhone que tengas instalados, sin que tú le digas cuál. Si allTests falla antes de ejecutar nada, con un error sobre el dispositivo o el simulador, ejecuta ese mismo comando en la Terminal: si la lista de iOS sale vacía, instala un simulador desde Xcode como en Instalar Xcode y el simulador y vuelve a lanzar la tarea.
Un detalle: los tests de iOS corren de verdad, en el simulador, no solo se compilan.
Comprobación final
Punto de control
build.gradle.kts (Module :torneo)tiene los tres plugins (kotlin.multiplatform,android.kmp.library,kotlin.serialization), un bloquekotlin { … }conjvm,androidy los dos destinos de iOS, ysourceSetsconcommonMain.dependenciesycommonTest.dependencies. El catálogo ya no tienejetbrains-kotlin-jvm.- En la Terminal,
./gradlew :torneo:allTests :torneo:testAndroidHostTesttermina enBUILD SUCCESSFUL: 138 tests en la JVM, 116 en el simulador de iOS y 116 en Android (132 / 110 / 110 si te saltasteCancelacionTest). Los 22 de diferencia son los dejvmTest. ./gradlew :torneo:compileKotlinIosArm64 :torneo:linkDebugFrameworkIosSimulatorArm64termina en verde.compile…comprueba que el dominio compila para un iPhone físico;linkDebugFramework…es la tarea que enlaza: junta lo compilado en el framework estáticoTorneo, el paquete que Xcode usaría para una app de iOS (nadie lo usa todavía)../gradlew :app:assembleGratisDebug :app:testGratisDebugUnitTestsigue en verde, sin haber tocado nada deapp/. Ejecuta la app en el emulador y se ve exactamente igual (antes, en el selector de configuraciones de la barra superior, eligeapp: la configuraciónMainKtde la lección de errores ya no existe, porque:torneodejó de ser una aplicación de JVM)../gradlew :torneo:jvmRun --args=kotlinimprime la demo de Kotlin y dejatorneo/torneo.json.- El único cambio de comportamiento: un test más que antes en la JVM,
unTryCatchAlrededorDeLaunchNoAtrapaElError, que con JUnit 5 nunca se ejecutaba.
El código del autor en GitHub (enlace de abajo) tiene el mismo estado. Allí los alias de plugins se llaman kotlinMultiplatform y androidKmpLibrary; los tuyos, kotlin-multiplatform y android-kmp-library, son los que sigue tu catálogo.
Cuánto se comparte de verdad
Cuenta las líneas de Kotlin de :torneo:
| Source set | Ficheros | Líneas |
|---|---|---|
commonMain | 21 | 1 124 |
jvmMain (consola, demos y disco) | 5 | 414 |
commonTest | 19 | 1 239 |
jvmTest | 5 | 279 |
- Producción de
:torneo: 1 124 de 1 538 líneas están encommonMain: el 73 %. - Tests de
:torneo: 1 239 de 1 518 líneas, el 82 %, corren también en Android y en iOS. - Contando también la interfaz de Android (unas 2 500 líneas de
app/src/main): 1 124 de unas 4 080, el 28 %. Es bajo porque Marcador comparte lógica y no comparte interfaz: la interfaz es nativa (Compose en Android, SwiftUI en iOS) y es la parte más grande.
Reto · ¿A qué source set va cada fichero?
Te doy cinco ficheros por lo que usan. Di a qué source set va cada uno (commonMain, jvmMain, commonTest, jvmTest o se queda en :app) y por qué.
Ranking.kt: usasortedByDescendingyRegex.Recordatorio.kt: importaandroid.app.NotificationManager.Reloj.kt: llama aSystem.currentTimeMillis()para dar la hora en milisegundos.ContadorTest.kt: usajava.util.concurrent.atomic.AtomicInteger.PosicionesTest.kt: solo usakotlin.testyrunBlocking.
Ver solución
commonMain:sortedByDescendingyRegexson de la librería estándar de Kotlin y existen en todos los destinos.- Se queda en
:app:NotificationManageres del SDK de Android y solo existe ahí. No es código para compartir. - No va a
commonMaintal cual:Systemes de Java y no existe en iOS. Dos respuestas válidas:jvmMain, si solo lo usa la consola; o, si el dominio lo necesita, reescribirlo con el reloj de la librería estándar,Clock.System.now().toEpochMilliseconds()(import kotlin.time.Clock), y entonces va acommonMain. jvmTest:AtomicIntegeres dejava.util.concurrenty no existe en iOS, como enFlowFrioTest.commonTest:kotlin.testes común yrunBlockingexiste en todos los destinos de este módulo, así que corre en la JVM, en Android y en iOS.
Comprueba lo que entendiste
5 preguntas · necesitas 4 para completar la lección
1.Google dice que no hay un reemplazo directo para convertir un módulo con
com.android.applicationen multiplataforma. ¿Qué hay que hacer?2.Marcador tiene dos módulos,
:appy:torneo.:appdepende de:torneo. ¿Cuál se migra primero?3.¿Por qué
String.format("%3d", n)se cambia porn.toString().padStart(3)al mover código acommonMain?4.Al migrar
:torneoa KMP,ErroresEnCorrutinasTestfalla conMethod … should be voidy antes pasaba. ¿Qué había ocurrido?5.Al compilar el destino JVM, el compilador se cae con
Backend Internal error: Exception during IR lowering, sin ninguna línea de tu código. El detonante es un parámetrosuspendcon una lambda por defecto que llama a otrasuspend. ¿Qué arreglo aplicas?