Migrar el disco con expect/actual
Al terminar: Devolver a commonMain las funciones de disco de :torneo con dos declaraciones expect y un actual por plataforma.
En la lección anterior, :torneo pasó a ser un módulo multiplataforma, pero dejaste fuera lo que toca el disco: guardar el torneo en un fichero, cargarlo, exportar la tabla. Esas funciones usaban java.io.File, y java.io no existe en iOS. Es lo primero que la JVM no comparte.
Al terminar, guardar, cargarTorneo, exportarTabla, guardarEnSegundoPlano, cargarTorneoEnSegundoPlano y cerrarJornada habrán vuelto a commonMain, con una ruta de texto en vez de un File. Y en todo el módulo habrá dos declaraciones expect y nada más: leerTexto y escribirTexto. Tendrás 138 tests en la JVM y 117 en Android y en el simulador de iOS (con CancelacionTest; 132 y 111 si te lo saltaste).
Como en la lección anterior, sigues en la vista Project del panel (el selector de arriba a la izquierda): lo que enseña esta lección son los source sets como carpetas (commonMain, jvmMain, androidMain, iosMain), y la vista Android las agrupa y las esconde. Para crear un fichero en una carpeta concreta: clic derecho sobre la carpeta → New → Kotlin Class/File → File, y el nombre sin .kt. Si ese menú no te ofrece Kotlin, touch en la Terminal lo crea (touch ruta crea un fichero vacío en la ruta que le des). Al final de la lección vuelves a la vista Android.
Esta lección y la anterior asumen un Mac, porque el destino de iOS solo se compila en Mac; los comandos ./gradlew de JVM y Android valen en cualquier sistema.
Temas: expect/actual mínimo y errores de compilación de iOS.
Paso · El fichero Ficheros.kt: lo que el disco le pide a la plataforma
Objetivo: declarar, en el código común, las dos únicas cosas que :torneo necesita del disco.
Lo que :torneo le pide al disco es poco: leer un texto entero y escribir un texto entero. Con eso se puede guardar, cargar y exportar. Crea el fichero que las declara.
En la vista Project, clic derecho sobre torneo/src/commonMain/kotlin/com/sebastiannarvaez/marcador/torneo → New → Kotlin Class/File → File → Ficheros y Intro. (Si el menú no te ofrece Kotlin en esa carpeta, en la Terminal: touch torneo/src/commonMain/kotlin/com/sebastiannarvaez/marcador/torneo/Ficheros.kt, y ábrelo con doble clic.) Escribe:
package com.sebastiannarvaez.marcador.torneo // el mismo paquete que el resto de :torneo
// `expect` («se espera»): dice QUÉ hace falta, sin cuerpo. Cada plataforma escribirá su `actual`.
// Leer un texto entero: devuelve el contenido del fichero.
expect fun leerTexto(ruta: String): String
// Escribir un texto entero: crea el fichero o lo sustituye.
expect fun escribirTexto(ruta: String, texto: String)Compila los tres destinos:
./gradlew :torneo:compileKotlinJvm :torneo:compileAndroidMain :torneo:compileKotlinIosSimulatorArm64 --continue(--continue hace que Gradle no se detenga en el primer fallo y enseñe los de cada destino.) Falla en todos con el mismo mensaje:
e: …/Ficheros.kt:3:1 The 'expect' declaration 'leerTexto' has no 'actual' declaration in module '<commonMain> for JVM'.
e: …/Ficheros.kt:3:1 The 'expect' declaration 'leerTexto' has no 'actual' declaration in module '<commonMain> for Native'.(Android dice «for JVM», porque Android es una JVM.) El compilador no admite una declaración expect sin su pareja en cada destino: es lo que significa «esperada».
expect y actual
Dos consecuencias que verás en el código: se pueden declarar funciones y propiedades, y el fichero de cada plataforma lleva el mismo package que el común. Un ejemplo mínimo, que no es de Marcador, con las tres plataformas:
// En commonMain: la forma, sin cuerpo.
expect fun nombreDeLaPlataforma(): String
// En jvmMain: la JVM la rellena a su manera.
actual fun nombreDeLaPlataforma(): String = "JVM"
// En androidMain: Android, a la suya (es otro destino, aunque sea una JVM).
actual fun nombreDeLaPlataforma(): String = "Android"
// En iosMain: iOS, a la suya.
actual fun nombreDeLaPlataforma(): String = "iOS"Qué le toca hacer a cada plataforma:
| Pieza | JVM y Android | iOS |
|---|---|---|
leerTexto(ruta) | File(ruta).readText() | NSString.stringWithContentsOfFile |
escribirTexto(ruta, texto) | File(ruta).writeText(texto) | writeToFile de NSString |
NSString es la clase de texto de Foundation, la librería base de Apple, la misma que usarías desde Swift. Foundation está escrita en Objective-C, el lenguaje que Apple usaba antes de Swift, y sus métodos no lanzan excepciones: devuelven null o false y, si les pasas un hueco, dejan en él un NSError (el objeto que describe el fallo). Piensa en un formulario con un recuadro «motivo del error» en blanco: tú se lo entregas, y Foundation solo lo rellena si algo sale mal. (En Swift, String(contentsOfFile:) sí lanza con try; pero Kotlin no ve esa versión, sino el método de Objective-C tal cual, con ese hueco como parámetro.) Por eso el actual de iOS lanza una IllegalStateException a mano: así todas las plataformas fallan igual y runCatching lo recoge.
Paso · Los tres actual
Objetivo: que cada destino tenga su pareja de leerTexto y escribirTexto, y que los tres compilen.
Las carpetas siguen el patrón src/<destino>Main/kotlin/com/sebastiannarvaez/marcador/torneo/. androidMain e iosMain existen como source sets en cuanto declaraste esos destinos en build.gradle.kts (Module :torneo) (que no cambia en toda la lección), pero sus carpetas hay que crearlas. Desde la Terminal, en la carpeta del proyecto:
mkdir -p torneo/src/androidMain/kotlin/com/sebastiannarvaez/marcador/torneo
mkdir -p torneo/src/iosMain/kotlin/com/sebastiannarvaez/marcador/torneomkdir crea una carpeta; con -p crea también todas las intermedias que falten (src/androidMain, kotlin, com…), así que basta una orden por carpeta final.
Pulsa Sync Now (Android Studio detecta las carpetas nuevas y las marca como código).
El patrón, con otra función de disco que Marcador no tiene (dos ficheros distintos, uno en cada source set):
// ── Fichero de commonMain ──
// La forma, sin cuerpo.
expect fun existeFichero(ruta: String): Boolean
// ── Fichero de jvmMain (en androidMain sería igual) ──
import java.io.File // la clase de Java para rutas de disco: aquí sí existe
// La pareja: misma firma, `actual` delante y un cuerpo con lo que tiene esta plataforma.
actual fun existeFichero(ruta: String): Boolean = File(ruta).exists()
// Las dos llamadas que vas a necesitar: File(ruta).readText() lee el fichero entero
// y File(ruta).writeText(texto) lo crea o lo sustituye entero.El de la JVM. Intenta escribirlo tú: en jvmMain, un fichero Ficheros.jvm.kt con los dos actual, que usen File (la clase de Java para rutas de disco) para leer y escribir. El nombre lleva el sufijo del destino y, al crearlo, va sin .kt (Android Studio añade la extensión).
Intenta primero. Cuando termines, compara con la solución.Mostrar el código
package com.sebastiannarvaez.marcador.torneo // el mismo paquete que el `expect`
import java.io.File // existe en la JVM
// `actual`: la pareja del `expect`, con la MISMA firma. Esta lee con `File`.
actual fun leerTexto(ruta: String): String = File(ruta).readText()
// Y esta escribe con `File`.
actual fun escribirTexto(ruta: String, texto: String) = File(ruta).writeText(texto)El de Android. Android es una JVM, así que su actual es idéntico. Aun así se escribe dos veces: jvm y android son dos destinos distintos, y Kotlin solo comparte código entre varios destinos si existe un source set intermedio: una carpeta de código que compilan a la vez varios destinos, pero no todos (como iosMain, que el plugin crea para los dos de iOS). Habría que crear uno para jvm y android, y para tres líneas no compensa. Crea Ficheros.android en torneo/src/androidMain/kotlin/com/sebastiannarvaez/marcador/torneo (clic derecho → New → Kotlin Class/File → File; o touch en la Terminal) y escribe lo mismo:
Intenta primero. Cuando termines, compara con la solución.Mostrar el código
package com.sebastiannarvaez.marcador.torneo // el mismo paquete que el `expect`
import java.io.File // Android es una JVM: `File` existe
// Android es una JVM: el `actual` es el mismo que el de jvmMain. Kotlin no lo comparte solo
// (jvm y android son dos destinos distintos), así que se repite.
actual fun leerTexto(ruta: String): String = File(ruta).readText()
actual fun escribirTexto(ruta: String, texto: String) = File(ruta).writeText(texto)El de iOS. Llama a Foundation desde Kotlin con la maquinaria de interoperación con Objective-C, que no has estudiado y no hace falta entender para seguir; por eso se copia con el botón Copiar fichero (el mismo gesto de Clases y objetos: crear el fichero, pulsar el botón, seleccionar todo y pegar). Crea Ficheros.ios en torneo/src/iosMain/kotlin/com/sebastiannarvaez/marcador/torneo y pega:
El fichero tiene las mismas dos actual fun. Lo único que merece una mirada es cómo se parece a las otras por fuera y cómo se diferencia por dentro:
// La misma firma que el `expect`, con `actual` delante.
actual fun leerTexto(ruta: String): String = memScoped { // memoria nativa temporal
val error = alloc<ObjCObjectVar<NSError?>>() // el hueco donde Foundation dejará un error
NSString.stringWithContentsOfFile(ruta, NSUTF8StringEncoding, error.ptr) // Foundation lee el fichero
?: throw IllegalStateException("No se pudo leer $ruta") // Foundation devolvió null: lo convertimos en excepción
}Dos detalles de ese fichero: lleva @OptIn(ExperimentalForeignApi::class, BetaInteropApi::class), un permiso explícito para usar las APIs de interoperación con C y Objective-C, que aún pueden cambiar (solo aparece en iosMain); y un único Ficheros.ios.kt sirve a iosArm64 y a iosSimulatorArm64, porque iosMain no es un destino, sino un source set intermedio que el plugin crea por defecto encima de esos dos destinos, sin que lo pidas (lo viste en Anatomía del proyecto).
Compila otra vez los tres destinos:
./gradlew :torneo:compileKotlinJvm :torneo:compileAndroidMain :torneo:compileKotlinIosSimulatorArm64Debe terminar en BUILD SUCCESSFUL: cada expect ya tiene su actual en cada destino.
Una excepción consciente
La lección de expect/actual frente a interfaces desaconseja que el dominio llame a un expect directamente. En el paso siguiente el dominio sí lo hace: guardar, cargarTorneo y exportarTabla llamarán a leerTexto y escribirTexto. Es una excepción consciente a esa regla, y se hace por una razón práctica: que la migración cruce hasta iOS con dos funciones mínimas de E/S y cambiando solo el tipo de un parámetro (de File a String) en las firmas que ya existen.
El diseño preferible al crecer, o en cuanto quieras probar con un fake, es el que enseñó aquella lección: una interfaz Disco y un solo expect fun discoDeLaPlataforma() que la devuelve, igual que currentPlatform(). Con él, el disco llega como parámetro con valor por defecto y a quien llama las firmas le siguen valiendo:
interface Disco {
fun leer(ruta: String): String
fun escribir(ruta: String, texto: String)
}
// La única declaración expect, como currentPlatform().
expect fun discoDeLaPlataforma(): Disco
fun Torneo.guardar(ruta: String, disco: Disco = discoDeLaPlataforma()): Result<Unit> =
runCatching { disco.escribir(ruta, aJson()) }Con eso, un test pasa un DiscoEnMemoria y no toca el disco. Marcador no da ese paso porque sus tests de disco escriben en una carpeta temporal de verdad y no necesitan un disco falso; el Reto de esta lección es justo ese paso.
Paso · Las funciones de disco vuelven a commonMain
Objetivo: que :torneo no tenga ningún java.io fuera de los actual, que las funciones de disco vuelvan a commonMain con una ruta de texto en lugar de un File, y que todos los destinos compilen y pasen sus tests.
Piezas nuevas de este paso:
ruta: String: la API pública pasa de recibir unFilea recibir el camino del fichero como texto, porqueStringexiste en todas partes.:appnunca llamaba a estas funciones, así que no cambia.- Una propiedad de extensión, que es como una función de extensión (la viste en Extensiones) pero para una propiedad: una propiedad añadida a un tipo desde fuera. Aparecerá en el error de
Dispatchers.IOmás abajo.
1 · guardar y cargarTorneo, otra vez en Guardado.kt
Abre Guardado.kt (en commonMain) y añade al final las dos funciones, ahora con ruta y usando leerTexto y escribirTexto. Borra el fichero GuardadoEnFichero.kt de jvmMain: ya no hace falta (clic derecho → Delete).
Intenta escribirlo tú: guardar, una extensión de Torneo que recibe la ruta como texto y escribe en ella el torneo como JSON; y cargarTorneo, que recibe la ruta, lee el texto y lo convierte en un torneo. Las dos devuelven un Result: si el disco o el JSON fallan, el fallo llega como valor, no como caída.
Intenta primero. Cuando termines, compara con la solución.Mostrar el código
// Guardar: convierte el torneo a JSON (aJson) y lo escribe con el `expect` de escritura.
// El resultado es un Result: si el disco falla, llega como valor, no como caída.
fun Torneo.guardar(ruta: String): Result<Unit> = runCatching { escribirTexto(ruta, aJson()) }
// Cargar: lee el texto con el `expect` de lectura y lo convierte de JSON a Torneo.
fun cargarTorneo(ruta: String): Result<Torneo> = runCatching { leerTexto(ruta) }.mapCatching {
torneoDesdeJson(it).getOrThrow()
}2 · Exportar.kt sube a commonMain
Exportar.kt usa un escritor de Java (bufferedWriter). Pasa por escribirTexto, que cierra el fichero por dentro. Primero súbelo de carpeta, desde la Terminal (mv origen destino mueve un fichero; si el destino es una carpeta, lo deja dentro con el mismo nombre):
mv torneo/src/jvmMain/kotlin/com/sebastiannarvaez/marcador/torneo/Exportar.kt torneo/src/commonMain/kotlin/com/sebastiannarvaez/marcador/torneo/Y reescríbelo entero. Intenta escribirlo tú: exportarTabla, una extensión de Torneo que recibe la ruta como texto y escribe en ella la tabla como texto, con un salto de línea al final, devolviendo el fallo (carpeta inexistente, disco lleno…) como un Result.
Intenta primero. Cuando termines, compara con la solución.Mostrar el código
package com.sebastiannarvaez.marcador.torneo
// Escribe la tabla en un fichero de texto, con el `expect` de escritura.
// Todo va dentro de `runCatching` para que el fallo (carpeta inexistente, disco lleno…)
// llegue como Result.
fun Torneo.exportarTabla(ruta: String): Result<Unit> = runCatching {
escribirTexto(ruta, tablaComoTexto() + "\n") // la tabla, más un salto de línea final
}3 · SegundoPlanoEnFichero.kt sube a commonMain
Súbelo igual:
mv torneo/src/jvmMain/kotlin/com/sebastiannarvaez/marcador/torneo/SegundoPlanoEnFichero.kt torneo/src/commonMain/kotlin/com/sebastiannarvaez/marcador/torneo/En ese fichero cambia fichero: File por ruta: String en las tres funciones (y fichero por ruta al llamar a guardar, cargarTorneo, guardarEnSegundoPlano), y borra import java.io.File. Compila ahora los destinos de iOS:
./gradlew :torneo:compileKotlinIosSimulatorArm64Añade el import y deja el fichero así. Intenta escribirlo tú: las tres funciones de antes, ahora con la ruta como texto en lugar del fichero y con el import de la extensión de IO. Lo que debes ver: ./gradlew :torneo:compileKotlinIosSimulatorArm64 termina sin el error de internal.
Intenta primero. Cuando termines, compara con la solución.Mostrar el código
package com.sebastiannarvaez.marcador.torneo
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.IO // la propiedad de extensión: sin ella, `Dispatchers.IO` da error en iOS
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.withContext
// Escribir el JSON es esperar al disco: IO. Ahora recibe una ruta de texto.
suspend fun Torneo.guardarEnSegundoPlano(ruta: String, dispatcher: CoroutineDispatcher = Dispatchers.IO): Result<Unit> =
withContext(dispatcher) { guardar(ruta) }
suspend fun cargarTorneoEnSegundoPlano(ruta: String): Result<Torneo> =
withContext(Dispatchers.IO) { cargarTorneo(ruta) }
// 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(ruta: String): List<FilaDePosicion> = coroutineScope {
val guardado = async { guardarEnSegundoPlano(ruta) }
val tabla = async { tablaEnSegundoPlano() }
guardado.await().getOrThrow()
tabla.await()
}4 · La consola pasa rutas
Compila la JVM. Las demos de consola (Main.kt y DemoKotlin.kt, en jvmMain) todavía llaman a las funciones con un File:
./gradlew :torneo:compileKotlinJvme: …/DemoKotlin.kt:124:26 Argument type mismatch: actual type is 'File!', but 'String' was expected.
e: …/DemoKotlin.kt:137:20 Argument type mismatch: actual type is 'File', but 'String' was expected.
e: …/DemoKotlin.kt:140:18 Argument type mismatch: actual type is 'File', but 'String' was expected.
e: …/Main.kt:169:38 Argument type mismatch: actual type is 'File', but 'String' was expected.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 llamadas. Cada una pasa la ruta como texto: en lugar del File, su path (la ruta con la que lo creaste) o su absolutePath (la ruta completa desde la raíz del disco). En DemoKotlin.kt, la exportación:
// antes
torneo.exportarTabla(destino)
// después: `.path` es la ruta del fichero temporal, como texto
torneo.exportarTabla(destino.path)En DemoKotlin.kt, guardar y cargar (la ruta se calcula una vez y se pasa a las dos):
// después
val ruta = File("torneo.json").absolutePath // la ruta completa, como texto
torneo.guardar(ruta)
.onSuccess { println("Guardado en $ruta") }
.onFailure { println("No se pudo guardar: ${it.message}") }
cargarTorneo(ruta)
.onSuccess { println("Cargado: ${it.nombre}, ${it.partidos.size} partidos, líder ${it.tablaDePosiciones().first().equipo.nombre}") }
.onFailure { println("No se pudo cargar: ${it.message}") }Y en Main.kt, dentro de demoDispatchers: val ruta = "torneo.json" en lugar de val fichero = File("torneo.json"), torneo.cerrarJornada(ruta), y "Guardado en $ruta; líder: …" en el println. Después de ese cambio, Main.kt ya no usa File: borra su import java.io.File (Android Studio lo pinta en gris).
5 · Los tests
Un test con la misma forma, sobre otra cosa que puede fallar:
import kotlin.test.Test
import kotlin.test.assertTrue
class DivisionTest {
@Test
fun dividirPorCeroEsFailure() {
val divisor = 0
// runCatching convierte la excepción en un Result.failure en vez de dejarla escapar.
val resultado = runCatching { 10 / divisor }
assertTrue(resultado.isFailure) // isFailure: true si el Result guarda un error
assertTrue(runCatching { 10 / 2 }.isSuccess) // e isSuccess, si guarda un valor
}
}Un test nuevo, escrito por ti. En GuardadoTest.kt (commonTest) añade un test, unaRutaQueNoExisteEsFailure, que compruebe, con tres comprobaciones, que cargar un torneo de una ruta dentro de una carpeta que no existe (por ejemplo, /no/existe/torneo.json), guardar el torneo de ejemplo en ella y exportar su tabla a /no/existe/tabla.txt devuelven un fallo y no una excepción. Como ya no necesita un File, corre en los tres destinos. torneoDeEjemplo() ya existe en ese fichero (viene del GuardadoTest.kt que copiaste en JSON y serialización). El test equivalente que había en jvmTest desaparecerá: al pegar el nuevo GuardadoEnFicheroTest (justo debajo), ese test ya no está. Intenta escribirlo tú.
Intenta primero. Cuando termines, compara con la solución.Mostrar el código
@Test
fun unaRutaQueNoExisteEsFailure() {
// Cargar una ruta que no existe: el Result es failure, no una excepción.
assertTrue(cargarTorneo("/no/existe/torneo.json").isFailure)
// Guardar en una carpeta que no existe.
assertTrue(torneoDeEjemplo().guardar("/no/existe/torneo.json").isFailure)
// Exportar la tabla a una carpeta que no existe.
assertTrue(torneoDeEjemplo().exportarTabla("/no/existe/tabla.txt").isFailure)
}Tres tests de jvmTest, por botón. ExportarTest, GuardadoEnFicheroTest y SegundoPlanoTest solo cambian File por .path al llamar a las funciones, y no enseñan nada nuevo: se actualizan con el botón Copiar fichero. Para cada uno: abre el fichero existente en torneo/src/jvmTest/kotlin/com/sebastiannarvaez/marcador/torneo, pulsa su botón, haz clic dentro del fichero, selecciona todo con Cmd+A y pega encima con Cmd+V. El texto copiado trae su propia línea package.
Los tests que crean una carpeta temporal siguen en jvmTest porque la crean con java.io. Así, en iOS solo se prueba el camino de fallo (la ruta que no existe); que guardar y cargar funcionan de verdad en iOS no lo comprueba ningún test de Marcador.
6 · Todo, junto
Ejecuta las tres suites:
./gradlew :torneo:allTests :torneo:testAndroidHostTestDebe terminar en BUILD SUCCESSFUL. Los tests de iOS los ejecuta un simulador de iPhone que elige el plugin de Kotlin entre los que tienes instalados en Xcode (en un Mac con un iPhone 17 Pro instalado, usa ese). Si en su lugar falla con Invalid device: … (el nombre elegido no existe en tu Mac), mira los nombres que tienes con xcrun simctl list devices available y fija uno al final de build.gradle.kts (Module :torneo):
// Cada tarea de tests de iOS (simulador) usa este iPhone; cambia el nombre por uno de tu lista.
tasks.withType<org.jetbrains.kotlin.gradle.targets.native.tasks.KotlinNativeSimulatorTest>().configureEach {
device.set("iPhone 17")
}Cuánto se comparte ahora
Las líneas de Kotlin de :torneo. Cuéntalas con find torneo/src/commonMain -name '*.kt' | xargs wc -l y lo mismo con las otras carpetas: find busca ficheros (aquí, los .kt dentro de esa carpeta), | pasa su lista a la orden siguiente, y xargs wc -l hace que wc -l (contar líneas) los cuente todos y dé un total.
| Source set | Ficheros | Líneas |
|---|---|---|
commonMain | 24 | 1 168 |
jvmMain (consola y demos) | 3 | 371 |
androidMain | 1 | 9 |
iosMain | 1 | 33 |
commonTest | 19 | 1 246 |
jvmTest | 5 | 274 |
(Tus cifras pueden variar unas líneas según tus comentarios.)
- Producción de
:torneo: 1 168 de 1 581 líneas están encommonMain: el 74 %. Solo 42 líneas (androidMainmásiosMain) son específicas de una plataforma de producto; las 371 dejvmMainson consola de la JVM, que no es la app y no se publica. - Tests de
:torneo: 1 246 de 1 520 líneas, el 82 %, corren en la JVM, en Android y en el simulador de iOS. - Contando también la interfaz de Android (unas 3 060 líneas de
app/src/main, sin contar la carpetaejercicios): 1 168 de unas 4 640, el 25 %. Es bajo porque la interfaz es nativa y no se comparte, y es la parte más grande.
Los tests, por destino: 138 en la JVM (117 de commonTest y 21 de jvmTest), 117 en Android y 117 en el simulador de iOS (132, 111 y 111 sin CancelacionTest). En la lección anterior eran 116 en Android y en iOS y 22 en jvmTest, con los mismos 138 en la JVM. Ese test de más no se «gana»: se mueve. unFicheroQueNoExisteEsFailure sale de GuardadoEnFicheroTest (de jvmTest) y se reescribe en GuardadoTest (de commonTest) con tres comprobaciones; como ya no necesita un File, corre en los tres destinos.
Vuelve a la vista Android
Ya no necesitas ver los source sets como carpetas. Vuelve el selector del panel (arriba a la izquierda) a Android, como estaba antes de Migrar un proyecto Android existente. Desde aquí, las lecciones usan esa vista. Para crear un fichero con ruta completa en un módulo como :torneo, el método es: clic derecho sobre el módulo torneo → New → File y escribe la ruta desde la carpeta del módulo, por ejemplo src/commonTest/kotlin/com/sebastiannarvaez/marcador/torneo/MiTest.kt. Si ese menú no te lo ofrece, touch en la Terminal crea el fichero (después de un mkdir -p de su carpeta si no existe).
Punto de control
- En la Terminal,
./gradlew :torneo:allTeststermina conBUILD SUCCESSFUL: 138 tests en la JVM y 117 en el simulador de iOS, ninguno fallido (132 y 111 sinCancelacionTest). Los de iOS corren de verdad, en un simulador.allTestscubre la JVM y el simulador de iOS; no incluye Android. ./gradlew :torneo:testAndroidHostTest(Android en tu ordenador) termina en verde con 117 tests../gradlew :torneo:compileKotlinIosSimulatorArm64 :torneo:compileKotlinIosArm64y./gradlew :torneo:linkDebugFrameworkIosSimulatorArm64 :torneo:linkDebugFrameworkIosArm64terminan en verde../gradlew :app:assembleGratisDebug :app:testGratisDebugUnitTestsigue en verde, sin haber tocadoapp/../gradlew :torneo:jvmRun --args=kotlinescribe y vuelve a leertorneo.jsony, entre la salida, verás una línea que empieza por «Cargado:».ls torneo/srcen la Terminal (lslista el contenido de una carpeta) muestra seis carpetas de código:androidMain,commonMain,commonTest,iosMain,jvmMainyjvmTest, y en todo:torneono queda unjava.iofuera dejvmMain,jvmTestyFicheros.android.kt.- El selector del panel pone Android.
Reto · Probar el disco sin tocar el disco
Es el paso hacia el diseño preferible. En el Kotlin Playground (play.kotlinlang.org), fuera de tu proyecto, escribe:
- Una interfaz,
Disco, con dos funciones:leer, que recibe una ruta y devuelve su texto, yescribir, que recibe una ruta y un texto. - Una clase,
DiscoEnMemoria, que la implemente guardando los textos en un mapa y que, al leer una ruta que no existe, lance una excepción. - Una función,
copiar, que reciba un disco, una ruta de origen y una de destino, y copie el texto de una a otra.
- Escribe el texto Copa Barrio en a.txt, cópialo a b.txt y comprueba que b.txt tiene el mismo texto.
- Comprueba que copiar no-existe.txt a c.txt falla. Pista: Kotlin puede ejecutar un bloque y decirte si falló, sin que la excepción pare el programa.
Si las dos comprobaciones pasan, imprime un mensaje. Lo que debe salir:
Las dos comprobaciones pasanVer solución
interface Disco {
fun leer(ruta: String): String
fun escribir(ruta: String, texto: String)
}
class DiscoEnMemoria : Disco {
private val ficheros = mutableMapOf<String, String>()
override fun leer(ruta: String): String =
ficheros[ruta] ?: throw IllegalStateException("No existe $ruta")
override fun escribir(ruta: String, texto: String) { ficheros[ruta] = texto }
}
fun copiar(disco: Disco, origen: String, destino: String) =
disco.escribir(destino, disco.leer(origen))
fun main() {
val disco = DiscoEnMemoria()
disco.escribir("a.txt", "Copa Barrio")
copiar(disco, "a.txt", "b.txt")
check(disco.leer("b.txt") == "Copa Barrio") { "la copia no coincide" }
check(runCatching { copiar(disco, "no-existe.txt", "c.txt") }.isFailure) { "debía fallar" }
println("Las dos comprobaciones pasan")
}Con una interfaz, el disco se sustituye en un test por uno de memoria y no se escribe ningún fichero. Con los expect directos de Marcador no se puede: se prueba el disco de verdad, en una carpeta temporal. Tu solución es el primer paso del diseño preferible de esta lección; el segundo es el expect fun discoDeLaPlataforma(): Disco que devuelve el disco real de cada plataforma, y disco: Disco = discoDeLaPlataforma() como parámetro por defecto de guardar.
Comprueba lo que entendiste
5 preguntas · necesitas 4 para completar la lección
1.
leerTextose declara conexpectencommonMainen vez de usarjava.io.Filedirectamente. ¿Por qué?2.Borras
Ficheros.ios.kty compilas todos los destinos. ¿Qué pasa?3.Al compilar para iOS ves
Cannot access 'val IO': it is internal in 'kotlinx.coroutines.Dispatchers'. ¿Cuál es el arreglo que usa Marcador?4.
Ficheros.android.ktrepite las mismas líneas queFicheros.jvm.kt. ¿Por qué no se comparten?5.El test
unaRutaQueNoExisteEsFailureestá encommonTesty no enjvmTest. ¿Qué lo permite?