Saltar al contenido
K
Apéndice: más allá de la appPaso a paso110 min de lectura · 30 min de máquina Intermedio

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.

:applo último, o nunca
:torneolo primero: no depende de nadie
Se migra de abajo arriba. Cada nivel solo se toca cuando el de abajo ya compila.

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:testGratisDebugUnitTest

Debe 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.html

Verá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.

Gradle Scripts
libs.versions.toml (Version Catalog "libs")se modifica: [plugins]
build.gradle.kts (Project: Marcador)se modifica: apply false
Los dos ficheros de esta instalación.

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):

libs.versions.toml: línea nueva en [plugins]
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:

build.gradle.kts (Project: Marcador): línea nueva
    alias(libs.plugins.kotlin.multiplatform) apply false

apply 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:

build.gradle.kts (Module :torneo): antes
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 commonMain se compila para todos; el de jvmMain, solo para la JVM. Cada source set tiene su propio bloque de dependencias, y por eso testImplementation desaparece: los tests comunes tienen el suyo, commonTest.dependencies.

Son cuatro cambios sobre lo que ya tienes, no un fichero nuevo:

  1. En plugins, quita alias(libs.plugins.jetbrains.kotlin.jvm) y application, y añade alias(libs.plugins.kotlin.multiplatform).
  2. Dentro de kotlin { … } se queda jvmToolchain(17) y añades el bloque jvm { … }, con mainRun, que ocupa el lugar del bloque application { … } (bórralo).
  3. Borra el bloque dependencies { … } y escribe sus librerías en sourceSets, dentro de kotlin.
  4. Borra el bloque tasks.test { … }: el destino jvm trae 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.

build.gradle.kts (Module :torneo): solo con la JVM
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 -p crea una carpeta, y con -p también las intermedias que falten (y no se queja si ya existe).
  • mv origen destino mueve (o renombra) un fichero o una carpeta.
  • rmdir borra una carpeta vacía; se niega si tiene algo dentro, y eso te protege de borrar de más.
  • touch fichero crea 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/test

En 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).

torneo/
build.gradle.ktsya convertido
src/commonMain/kotlin/com/sebastiannarvaez/marcador/torneo/antes src/main/java · 24 ficheros (tres se mudan a jvmMain más abajo)
src/commonTest/kotlin/com/sebastiannarvaez/marcador/torneo/antes src/test/java · 22 ficheros (21 sin CancelacionTest)
Tus carpetas, en la vista Project. Los paquetes (com.sebastiannarvaez.marcador.torneo) siguen igual por dentro.

Pulsa Sync Now si aparece la barra amarilla (Android Studio detecta las carpetas nuevas) y ejecuta los tests de la JVM:

./gradlew :torneo:jvmTest

No 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:

ErroresEnCorrutinasTest.kt: antes y después
// 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=kotlin

Imprime 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:

libs.versions.toml: línea nueva en [plugins]
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:

build.gradle.kts (Project: Marcador): línea nueva
    alias(libs.plugins.android.kmp.library) apply false

5 · 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:

build.gradle.kts (Module :torneo): plugins
    alias(libs.plugins.android.kmp.library)

Segundo, el destino. Añade este bloque dentro de kotlin { … }, entre el bloque jvm { … } y sourceSets:

build.gradle.kts (Module :torneo): destino android
    // 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:testGratisDebugUnitTest

Debe 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:

build.gradle.kts (Module :torneo): destinos de iOS
    // 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:compileKotlinIosSimulatorArm64

Falla, 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:

DeACómo lo sabes
String.formatpadStart, padEnd y plantillas de textoLo dice el compilador de iOS
java.io.Filefunciones expect/actual (la lección siguiente)Lo dice el compilador de iOS
Thread, AtomicInteger, CopyOnWriteArrayListse quedan en la JVM (jvmMain, jvmTest)Lo dice el compilador de iOS
measureTimeMilliskotlin.time.measureTimeLo dice el compilador de iOS
RxJava, la librería reactiva anterior a las corrutinaskotlinx-coroutines y Flow, antes de empezar la migraciónDocumentado en la guía de JetBrains
Dagger/Hilt, la inyección de dependencias de Google, dentro del módulo que se migraKoin, 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)
JUnitkotlin-testDocumentado 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):

ejemplo mínimo: padStart, padEnd y una extensión privada
// 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
Formato.kt: versión para escribir
// 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, borra import java.io.File y las dos funciones del final, guardar y cargarTorneo.
  • En SegundoPlano.kt, borra import java.io.File, los imports de async y coroutineScope, y las tres funciones que reciben un File: guardarEnSegundoPlano, cargarTorneoEnSegundoPlano y cerrarJornada. Se queda tablaEnSegundoPlano, 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 con SegundoPlanoEnFichero. (Si el menú no te ofrece Kotlin en esa carpeta, crea los ficheros desde la Terminal con touch torneo/src/jvmMain/kotlin/com/sebastiannarvaez/marcador/torneo/GuardadoEnFichero.kt y la misma línea con SegundoPlanoEnFichero.kt, y ábrelos con doble clic.)
  • Pega en cada uno las funciones que cortaste, con package com.sebastiannarvaez.marcador.torneo arriba y los imports que necesitan.

Intenta escribirlo tú antes de mirar.

Intenta primero. Cuando termines, compara con la solución.Mostrar el código
GuardadoEnFichero.kt: versión para escribir
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()
}
SegundoPlanoEnFichero.kt: versión para escribir
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:compileKotlinJvm

El arreglo, en otra función con el mismo problema (una lambda suspend por defecto que llama a otra función suspend):

ejemplo mínimo: una sobrecarga en lugar de una lambda suspend por defecto
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
Errores.kt: versión para escribir
// 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:compileKotlinIosSimulatorArm64

Debe 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:compileTestKotlinIosSimulatorArm64

Las 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 mismo cambio en los tres tests
// 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 el import kotlin.io.path.createTempDirectory y los dos tests cargaDesdeFichero y unFicheroQueNoExisteEsFailure.
  • En ErroresTest.kt, borra los tres imports de arriba (java.io.File, java.io.IOException y kotlin.io.path.createTempDirectory) y los dos tests del final, exportarTablaEscribeYCierraElFichero y exportarEnUnaCarpetaQueNoExisteDevuelveFailure.

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.

  1. En la vista Project, clic derecho sobre torneo/src/jvmTest/kotlin/com/sebastiannarvaez/marcador/torneo → New → Kotlin Class/File → File → escribe GuardadoEnFicheroTest y pulsa Intro. (Plan B: touch torneo/src/jvmTest/kotlin/com/sebastiannarvaez/marcador/torneo/GuardadoEnFicheroTest.kt en la Terminal.)
  2. Pulsa Copiar fichero en el bloque de abajo.
  3. En el editor, selecciona todo y pega: Cmd+A / Cmd+V. El texto copiado trae su propia línea package.
GuardadoEnFicheroTest.kt

Y lo mismo con ExportarTest (crea el fichero con ese nombre en la misma carpeta, y pega):

ExportarTest.kt

Pulsa Sync Now y ejecuta las tres suites:

./gradlew :torneo:allTests :torneo:testAndroidHostTest

allTests 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 commonTest y 22 de jvmTest). 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 bloque kotlin { … } con jvm, android y los dos destinos de iOS, y sourceSets con commonMain.dependencies y commonTest.dependencies. El catálogo ya no tiene jetbrains-kotlin-jvm.
  • En la Terminal, ./gradlew :torneo:allTests :torneo:testAndroidHostTest termina en BUILD SUCCESSFUL: 138 tests en la JVM, 116 en el simulador de iOS y 116 en Android (132 / 110 / 110 si te saltaste CancelacionTest). Los 22 de diferencia son los de jvmTest.
  • ./gradlew :torneo:compileKotlinIosArm64 :torneo:linkDebugFrameworkIosSimulatorArm64 termina 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ático Torneo, el paquete que Xcode usaría para una app de iOS (nadie lo usa todavía).
  • ./gradlew :app:assembleGratisDebug :app:testGratisDebugUnitTest sigue en verde, sin haber tocado nada de app/. Ejecuta la app en el emulador y se ve exactamente igual (antes, en el selector de configuraciones de la barra superior, elige app: la configuración MainKt de la lección de errores ya no existe, porque :torneo dejó de ser una aplicación de JVM).
  • ./gradlew :torneo:jvmRun --args=kotlin imprime la demo de Kotlin y deja torneo/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 setFicherosLíneas
commonMain211 124
jvmMain (consola, demos y disco)5414
commonTest191 239
jvmTest5279
  • Producción de :torneo: 1 124 de 1 538 líneas están en commonMain: 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é.

  1. Ranking.kt: usa sortedByDescending y Regex.
  2. Recordatorio.kt: importa android.app.NotificationManager.
  3. Reloj.kt: llama a System.currentTimeMillis() para dar la hora en milisegundos.
  4. ContadorTest.kt: usa java.util.concurrent.atomic.AtomicInteger.
  5. PosicionesTest.kt: solo usa kotlin.test y runBlocking.
Ver solución
  1. commonMain: sortedByDescending y Regex son de la librería estándar de Kotlin y existen en todos los destinos.
  2. Se queda en :app: NotificationManager es del SDK de Android y solo existe ahí. No es código para compartir.
  3. No va a commonMain tal cual: System es 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 a commonMain.
  4. jvmTest: AtomicInteger es de java.util.concurrent y no existe en iOS, como en FlowFrioTest.
  5. commonTest: kotlin.test es común y runBlocking existe 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. 1.Google dice que no hay un reemplazo directo para convertir un módulo con com.android.application en multiplataforma. ¿Qué hay que hacer?

  2. 2.Marcador tiene dos módulos, :app y :torneo. :app depende de :torneo. ¿Cuál se migra primero?

  3. 3.¿Por qué String.format("%3d", n) se cambia por n.toString().padStart(3) al mover código a commonMain?

  4. 4.Al migrar :torneo a KMP, ErroresEnCorrutinasTest falla con Method … should be void y antes pasaba. ¿Qué había ocurrido?

  5. 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ámetro suspend con una lambda por defecto que llama a otra suspend. ¿Qué arreglo aplicas?

Faltan 5 de 5