Guardado.kt en Marcador
Al terminar: Escribir Guardado.kt: convertir el torneo a clases guardadas y de vuelta, y guardarlo en torneo.json.
Ahora aplicas en Marcador lo que viste con los pedidos del restaurante: el torneo entero se guardará en un fichero torneo.json y se volverá a cargar desde él.
Al terminar, main habrá escrito torneo.json en la carpeta del módulo torneo y habrá leído de vuelta un torneo con el mismo líder. Temas: las clases «guardadas» de Marcador, convertir el torneo a ellas y de vuelta, y Guardado.kt.
Cómo será el fichero
Un extracto de torneo.json, para que sepas adónde vas:
{
"nombre": "Copa Barrio",
"equipos": [
{
"nombre": "Rayo FC",
"plantilla": [
{
"nombre": "Ana",
"dorsal": 9
},
{
"nombre": "Luis"
}
],
"capitan": "Ana"
}
]
}Fíjate en Luis: no tiene "dorsal". Es lo que viste con la sopa sin picante: un campo que vale null y tiene = null como valor por defecto no se escribe.
Piezas nuevas de este paso
Casi todo es lo que ya viste con los pedidos. Quedan cuatro cosas que aparecen al leer el fichero y reconstruir el torneo. Un ejemplo pequeño con platos las reúne:
class Plato(val nombre: String, val precio: Int)
fun main() {
val platos = listOf(Plato("Sopa", 120), Plato("Curry", 150))
// associateBy: de lista a mapa; la lambda devuelve la clave de cada elemento
val porNombre = platos.associateBy { it.nombre }
println(porNombre["Curry"]?.precio) // con corchetes: el valor, o null si no hay clave
println(porNombre.getValue("Sopa").precio) // getValue: el valor, o lanza una excepción si no hay clave
// first { … }: el primer elemento que cumple la condición; si ninguno la cumple, lanza una excepción
println(platos.first { it.precio > 130 }.nombre)
// runCatching { … }.mapCatching { … }: como map, pero si el bloque lanza, el resultado es un fallo
val ok = runCatching { "7" }.mapCatching { it.toInt() }
println(ok.getOrNull()) // getOrNull(): el valor, o null si fue un fallo
val roto = runCatching { platos.size }.mapCatching { it * 100 / 0 } // dividir entre cero lanza
println(roto.isFailure) // isFailure: true si el resultado es un fallo
// la excepción de getValue, recogida en un Result
println(runCatching { porNombre.getValue("Pizza") }.exceptionOrNull()?.message)
}Al ejecutarlo, imprime:
150
120
Curry
7
true
Key Pizza is missing in the map.Además, aPartido usará una función local: una función escrita dentro de otra, que solo existe ahí y puede usar sus variables. Y verás la trampa de this dentro de apply, que ya conoces de Funciones de alcance.
Dos palabras antes del paso
- Dominio son las clases de Marcador que ya conoces:
Torneo,Equipo,Jugador,Partido,Gol… (se llama así porque modelan el tema del programa, el torneo). - Un mapeo es una función que convierte una clase en otra: aquí, de una clase de Marcador a su gemela «guardada» y de vuelta. Se escriben como extensiones,
Equipo.aGuardado(), y las llamaremos así.
También hace falta una idea del sistema: la carpeta de trabajo es la carpeta desde la que se ejecuta un programa; la tarea run de Gradle usa la del módulo, así que una ruta relativa como File("torneo.json") (cuenta desde esa carpeta) acaba dentro de torneo/.
Paso · Escribe Guardado.kt
Objetivo. Escribir Guardado.kt, que convierte el torneo a JSON y de vuelta, y hacer que main guarde y cargue el torneo. Al ejecutar aparecerán dos líneas nuevas: dónde se guardó el fichero y qué torneo se leyó de vuelta.
Cómo crear Guardado.kt. En la vista Android, clic derecho sobre el paquete com.sebastiannarvaez.marcador.torneo (el que no lleva (test)) → New → Kotlin Class/File → File → Guardado.
Guardado.kt tiene unas 140 líneas, y la mayor parte es mecánica. Por eso el paso se reparte: pegas tres trozos (las clases «guardadas» y los dos grupos de mapeos, que son largos y repetitivos) y escribes lo que enseña esta lección: la configuración de Json, aJson() y las tres funciones que reconstruyen, guardan y cargan. Se alternan en este orden, de arriba abajo, y cada trozo solo usa lo que ya está escrito encima.
Intenta escribirlo tú. Las partes que escribes son estas:
- El conversor de JSON, que solo se ve dentro de
Guardado.kt: escribe el JSON con sangrías para leerlo a ojo, distingue las variantes de evento con un campo llamado «tipo» e ignora los campos que no conozca. aJson(): convierte un torneo en texto JSON, pasando por las clases guardadas (TorneoGuardado,PartidoGuardado) y los mapeosaGuardado().torneoDesdeJson(texto)reconstruye el torneo registrando cada partido y devuelve el torneo o el error;guardar(fichero)ycargarTorneo(fichero)escriben y leen el fichero, y también devuelven el resultado o el error.
Después, en main, guarda el torneo en un fichero torneo.json, cárgalo de vuelta y muestra qué se leyó. Tras las líneas de antes, debe verse (en lugar de «…», la ruta de tu proyecto):
Guardado en …/torneo/torneo.json
Cargado: Copa Barrio, 4 partidos, líder Rayo FCQué se cambia y dónde: el bloque nuevo de Main.kt va al final de main, después de las líneas de by lazy y antes de la llave de cierre. La anotación del enum ColorDeTarjeta ya la pusiste al instalar el plugin.
1 · Pega los imports y las clases «guardadas» (la forma del fichero JSON; cada equipo o jugador se nombra por su nombre):
import java.io.File
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
// El JSON no habla de objetos con referencias (un Gol apunta a un Equipo), sino
// de árboles de datos. Por eso el fichero usa unas clases «guardadas» aparte,
// donde cada equipo o jugador se nombra por su nombre, y el dominio se
// reconstruye al cargar. @Serializable le pide al plugin que genere el código
// de conversión de cada clase.
@Serializable
data class JugadorGuardado(val nombre: String, val dorsal: Int? = null)
@Serializable
data class EquipoGuardado(
val nombre: String,
val plantilla: List<JugadorGuardado>,
val capitan: String? = null,
)
// Una interfaz sellada serializable: cada variante se identifica en el JSON
// con el campo «tipo», cuyo valor sale de @SerialName.
@Serializable
sealed interface EventoGuardado {
val minuto: Int
}
@Serializable
@SerialName("gol")
data class GolGuardado(override val minuto: Int, val jugador: String, val equipo: String) : EventoGuardado
@Serializable
@SerialName("tarjeta")
data class TarjetaGuardada(override val minuto: Int, val jugador: String, val color: ColorDeTarjeta) : EventoGuardado
@Serializable
@SerialName("cambio")
data class CambioGuardado(override val minuto: Int, val sale: String, val entra: String) : EventoGuardado
@Serializable
data class PartidoGuardado(val local: String, val visitante: String, val eventos: List<EventoGuardado>)
@Serializable
data class TorneoGuardado(
val nombre: String,
val equipos: List<EquipoGuardado>,
val partidos: List<PartidoGuardado>,
)2 · Escribe la configuración de Json, debajo de las clases:
Intenta primero. Cuando termines, compara con la solución.Mostrar el código
// ColorDeTarjeta viaja como texto («AMARILLA», «ROJA»): basta con anotar el
// enum con @Serializable en EventoDePartido.kt.
private val json = Json {
// Sangrías y saltos de línea, para poder leer el fichero a ojo.
prettyPrint = true
// El campo que distingue las variantes de EventoGuardado.
classDiscriminator = "tipo"
// Al leer, ignora campos que no conozca en vez de fallar (versiones futuras).
ignoreUnknownKeys = true
}3 · Pega los mapeos de dominio a guardado, debajo del conversor:
// ---- Dominio -> guardado ----
private fun Equipo.aGuardado() = EquipoGuardado(
nombre = nombre,
plantilla = plantilla.map { JugadorGuardado(it.nombre, it.dorsal) },
capitan = capitan?.nombre,
)
// `when` exhaustivo sobre el evento del dominio: si aparece uno nuevo, esto no compila.
private fun EventoDePartido.aGuardado(): EventoGuardado = when (this) {
is Gol -> GolGuardado(minuto, jugador.nombre, equipo.nombre)
is Tarjeta -> TarjetaGuardada(minuto, jugador.nombre, color)
is Cambio -> CambioGuardado(minuto, sale.nombre, entra.nombre)
}4 · Escribe aJson(), debajo de los mapeos:
Intenta primero. Cuando termines, compara con la solución.Mostrar el código
fun Torneo.aJson(): String = json.encodeToString(
TorneoGuardado(
nombre = nombre,
equipos = equipos.map { it.aGuardado() },
partidos = partidos.map { partido ->
PartidoGuardado(partido.local.nombre, partido.visitante.nombre, partido.eventos.map { it.aGuardado() })
},
),
)5 · Pega los mapeos de guardado a dominio, debajo de aJson:
// ---- Guardado -> dominio ----
private fun EquipoGuardado.aEquipo(): Equipo {
val jugadores = plantilla.map { Jugador(it.nombre, it.dorsal) }
// Se guarda en una `val` con otro nombre: dentro de `apply`, `this` es el
// Equipo nuevo, y su `capitan` (un Jugador?) taparía al `capitan` de EquipoGuardado (un String?).
val nombreDelCapitan = capitan
return Equipo(nombre, jugadores).apply {
nombreDelCapitan?.let { buscado -> nombrarCapitan(jugadores.first { it.nombre == buscado }) }
}
}
// Las referencias por nombre se resuelven aquí: si falta un equipo o un jugador,
// `first` o `getValue` lanzan y el Result de `torneoDesdeJson` lo cuenta.
private fun PartidoGuardado.aPartido(equiposPorNombre: Map<String, Equipo>): Partido {
val equipoLocal = equiposPorNombre.getValue(local)
val equipoVisitante = equiposPorNombre.getValue(visitante)
val jugadores = equipoLocal.plantilla + equipoVisitante.plantilla
fun jugador(nombre: String): Jugador = jugadores.first { it.nombre == nombre }
return Partido(
local = equipoLocal,
visitante = equipoVisitante,
eventos = eventos.map { evento ->
when (evento) {
is GolGuardado -> Gol(evento.minuto, jugador(evento.jugador), equiposPorNombre.getValue(evento.equipo))
is TarjetaGuardada -> Tarjeta(evento.minuto, jugador(evento.jugador), evento.color)
is CambioGuardado -> Cambio(evento.minuto, jugador(evento.sale), jugador(evento.entra))
}
},
)
}6 · Escribe las tres últimas funciones, al final del fichero. Un detalle: registrar y torneoDesdeJson ya devuelven un Result, y dentro de otro runCatching se abren con getOrThrow(), que si era un fallo lo relanza para que el Result de fuera lo recoja (así no quedan dos Result anidados):
Intenta primero. Cuando termines, compara con la solución.Mostrar el código
// Leer un JSON puede fallar de muchas formas (texto roto, equipo que no existe…):
// el resultado es un Result, no una excepción suelta.
fun torneoDesdeJson(texto: String): Result<Torneo> = runCatching {
val guardado = json.decodeFromString<TorneoGuardado>(texto)
val equipos = guardado.equipos.map { it.aEquipo() }
val porNombre = equipos.associateBy { it.nombre }
Torneo(guardado.nombre, equipos).apply {
guardado.partidos.forEach { registrar(it.aPartido(porNombre)).getOrThrow() }
}
}
fun Torneo.guardar(fichero: File): Result<Unit> = runCatching { fichero.writeText(aJson()) }
fun cargarTorneo(fichero: File): Result<Torneo> = runCatching { fichero.readText() }.mapCatching {
torneoDesdeJson(it).getOrThrow()
}7 · En Main.kt, escribe el bloque de guardar y cargar (absolutePath es la ruta completa del fichero):
Intenta primero. Cuando termines, compara con la solución.Mostrar el código
// kotlinx.serialization: el torneo entero, a torneo.json y de vuelta.
val fichero = File("torneo.json")
torneo.guardar(fichero)
.onSuccess { println("Guardado en ${fichero.absolutePath}") }
.onFailure { println("No se pudo guardar: ${it.message}") }
cargarTorneo(fichero)
.onSuccess { println("Cargado: ${it.nombre}, ${it.partidos.size} partidos, líder ${it.tablaDePosiciones().first().equipo.nombre}") }
.onFailure { println("No se pudo cargar: ${it.message}") }Si algo no compila y no ves por qué, puedes sustituir tu fichero por el Guardado.kt completo (el botón copia al portapapeles):
- Abre tu
Guardado.ktcon doble clic. - Pulsa Copiar fichero.
- Selecciona todo y pega encima: Cmd+A / Cmd+V.
Cuando compile, entrega el test. GuardadoTest.kt tiene siete tests de ida y vuelta, de fichero, de JSON roto y de by lazy. No enseña nada nuevo, así que se copia con el botón:
- En la vista Android, clic derecho sobre el paquete
com.sebastiannarvaez.marcador.torneo (test)→ New → Kotlin Class/File → File → escribeGuardadoTesty Intro. - Pulsa Copiar fichero.
- En el editor, selecciona todo y pega: Cmd+A / Cmd+V. El texto copiado trae su propio
package.
Los siete tests comprueban la forma del JSON, la ida y vuelta, el fichero, el fichero inexistente, el JSON roto, el equipo inexistente y que by lazy calcula una sola vez.
Cómo se lee lo que pegaste
| Trozo | Qué es |
|---|---|
equiposPorNombre.getValue(nombre) | Busca en el mapa y, si la clave no existe, lanza una excepción; runCatching la convierte en Result.failure |
fun jugador(nombre: String) dentro de aPartido | Una función local: solo existe dentro de esa función y puede usar sus variables (jugadores) |
Result<Unit> en guardar | Unit es «ningún dato»: la función no devuelve nada útil y el Result solo dice si salió bien |
equipoLocal.plantilla + equipoVisitante.plantilla | El + entre dos listas crea una lista nueva con los elementos de las dos |
En aPartido, local y visitante son los nombres (textos) que trae PartidoGuardado; las variables nuevas se llaman equipoLocal y equipoVisitante y guardan el Equipo ya buscado, para no confundir un nombre con el equipo que designa.
Ejecuta, en la Terminal de Android Studio:
./gradlew :torneo:test :torneo:runEl programa escribe torneo.json en la carpeta del módulo torneo, no en la raíz del proyecto.
La vista Android no enseña ese fichero (no es código ni recurso), pero puedes leerlo desde la Terminal, que está en la raíz del proyecto. cat muestra el contenido de un fichero en la Terminal:
cat torneo/torneo.jsonSi en cambio sale No se pudo guardar: Serializer for class 'TorneoGuardado' is not found., a :torneo le falta el plugin: revisa los pasos 5 y 6 de la instalación.
Para contar los tests, abre el informe como en Bucles y tu primer test:
open torneo/build/reports/tests/test/index.htmlPunto de control
- En la Terminal de Android Studio,
./gradlew :torneo:test :torneo:runtermina enBUILD SUCCESSFUL. Si un test falla, Gradle lo dice conFAILEDy no llega aBUILD SUCCESSFUL. - La salida trae
Guardado en …/torneo/torneo.jsony, debajo,Cargado: Copa Barrio, 4 partidos, líder Rayo FC. Los cuatro partidos son los tres de la jornada y el partido válido (golValido) que registró Result: errores como valores; el gol del intruso y el partido de Lobos contra sí mismo fallaron y no se guardan. - La línea
Partidos en el torneo: 3, más arriba, sigue diciendo 3 porque se imprime antes de los registros de esa lección; no es un fallo. cat torneo/torneo.jsonenseña el texto sangrado, y el jugador Luis no tiene campodorsal.- El informe de tests muestra arriba 82 tests (75 de antes más los 7 nuevos de
GuardadoTest). - En el paquete
(test)estáGuardadoTest.kt.
Comprueba lo que entendiste
4 preguntas · necesitas 3 para completar la lección
1.El programa hace
File("torneo.json")y se ejecuta con./gradlew :torneo:run. ¿Dónde queda el fichero?2.En
aEquipose escribeval nombreDelCapitan = capitanantes delapply. ¿Por qué?3.En
aPartido,equiposPorNombre.getValue("Lobos")y el equipo «Lobos» no existe en el mapa. ¿Qué ocurre?4.
torneoDesdeJson(texto)recibe un JSON roto. ¿Qué devuelve?