Saltar al contenido
K
Kotlin: el lenguajePaso a paso28 min de lectura · 5 min de máquina Intermedio

Guardar y leer JSON con Json

Al terminar: Convertir objetos a JSON y de vuelta con la clase Json, y entender por qué se guardan clases aparte.

Con la serialización instalada en la lección anterior, el torneo ya puede guardarse como JSON. En esta aprenderás a usarlo con un ejemplo pequeño (los platos y pedidos de un restaurante) y entenderás por qué Marcador guardará su torneo con clases aparte. El fichero torneo.json de Marcador lo escribirás en la lección siguiente; esta no toca el proyecto.

Al terminar sabrás convertir una clase a JSON y de vuelta con Json, escribir un JSON a mano con comillas dentro de un texto y explicar por qué los objetos que se señalan unos a otros no se guardan tal cual. Temas: clases guardadas, Json { … }, encodeToString y decodeFromString, las comillas dentro de un texto.

Por qué se guardan clases «aparte»

En Marcador, un Gol guarda un Equipo y un Jugador, y el Equipo guarda a sus jugadores. Los objetos se señalan unos a otros, formando una red: el mismo Equipo aparece dentro de cada gol que marcó.

JSON no tiene esa idea de «apuntar a otro objeto». Solo tiene árboles: datos dentro de datos, y cada dato escrito donde se usa. Si guardaras el Gol tal cual, el Equipo se copiaría entero dentro de cada gol, y al leerlo habría un equipo repetido por cada gol en lugar de uno compartido.

La solución es tener unas clases aparte, hechas para guardar, donde en vez de un Equipo entero se escribe solo su nombre. Al leer el fichero se buscan los equipos por su nombre y se reconstruyen las clases de Marcador (Equipo, Jugador, Gol…). Esas clases «guardadas» las escribirás en la lección siguiente. Antes, en esta, ves la herramienta con un ejemplo que no tiene nada que ver con el torneo.

Guardar y leer JSON con Json

Este ejemplo es solo para leer: no es parte de Marcador y necesita la librería de serialización, que está instalada en :torneo pero no en el Kotlin Playground. Está ejecutado de verdad y la salida que ves es la real. La primera vez que escribirás tú Json, encodeToString y decodeFromString será en la lección siguiente, dentro del proyecto, donde la librería sí existe; el ejercicio de esta lección es a mano, para que veas qué trabajo te ahorra.

Piezas nuevas:

  • @Serializable sobre una data class: el plugin genera la conversión. Una propiedad con valor por defecto, como picante: Int? = null, es opcional en el JSON.
  • Una sealed interface serializable. Cada variante lleva @SerialName("…"), que es el nombre con el que se escribe en el JSON. Y override val mesa en el constructor de la variante: la interfaz declara una propiedad mesa y cada clase que la cumple la declara otra vez con override, que significa «esta es la propiedad que la interfaz pedía».
  • Json { … }: crea el conversor y recibe un bloque de configuración. Dentro de las llaves this es esa configuración (una lambda con receptor, como en Funciones de alcance), y por eso se asignan sus propiedades sin prefijo. Va en un private val a nivel de fichero, fuera de cualquier clase, y por eso solo se ve dentro de ese fichero.
  • encodeToString y decodeFromString<Tipo>, que ya viste en la lección anterior, y writeText y readText, que escriben y leen un fichero entero.
ejemplo mínimo: guardar y leer JSON (con comentarios)
import java.io.File
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
 
@Serializable                                   // el plugin genera su conversión a JSON y de vuelta
data class Plato(val nombre: String, val picante: Int? = null)   // con valor por defecto: opcional en el JSON
 
@Serializable
sealed interface Pedido {                       // una interfaz sellada también se puede guardar
    val mesa: Int
}
 
@Serializable
@SerialName("local")                            // así se llama esta variante dentro del JSON
data class EnLocal(override val mesa: Int, val platos: List<Plato>) : Pedido
 
@Serializable
@SerialName("llevar")
data class ParaLlevar(override val mesa: Int, val cliente: String) : Pedido
 
// private val a nivel de fichero: solo se ve dentro de este fichero
// Json { … } es una función que recibe un bloque de configuración
private val json = Json {
    prettyPrint = true                          // dentro de las llaves, `this` es la configuración: por eso asignas sus propiedades sin prefijo
    classDiscriminator = "tipo"                 // el campo que dice qué variante es
    ignoreUnknownKeys = true                    // al leer, ignora los campos que no conozca
}
 
fun main() {
    val pedidos: List<Pedido> = listOf(EnLocal(4, listOf(Plato("Sopa"), Plato("Curry", 3))), ParaLlevar(0, "Leo"))
 
    val texto = json.encodeToString(pedidos)    // de objeto a texto JSON
    val fichero = File.createTempFile("pedidos", ".json")
    fichero.writeText(texto)                    // escribe el fichero entero
    println(fichero.readText())                 // lo lee entero
 
    // de texto a objeto: <List<Pedido>> dice qué tipo reconstruir
    val leidos = runCatching { json.decodeFromString<List<Pedido>>(fichero.readText()) }
    println(leidos.getOrNull())                 // getOrNull(): el valor, o null si fue un fallo
    fichero.delete()
}

Al ejecutarlo, imprime:

[
    {
        "tipo": "local",
        "mesa": 4,
        "platos": [
            {
                "nombre": "Sopa"
            },
            {
                "nombre": "Curry",
                "picante": 3
            }
        ]
    },
    {
        "tipo": "llevar",
        "mesa": 0,
        "cliente": "Leo"
    }
]
[EnLocal(mesa=4, platos=[Plato(nombre=Sopa, picante=null), Plato(nombre=Curry, picante=3)]), ParaLlevar(mesa=0, cliente=Leo)]

Fíjate en dos cosas del resultado:

  • La sopa no tiene el campo picante. Un campo que vale lo mismo que su valor por defecto (aquí null) no se escribe. Al leer el fichero, el campo que falta recibe ese valor por defecto: en la segunda salida, la sopa vuelve con picante=null.
  • Cada pedido lleva un campo "tipo" con el nombre de su @SerialName. Gracias a él, al leer, Json sabe si debe construir un EnLocal o un ParaLlevar.

Las tres opciones de Json { … } que acabas de usar:

Opción de Json { }Qué hace
prettyPrint = trueSangrías y saltos de línea, para leer el fichero a ojo
classDiscriminator = "tipo"El nombre del campo que distingue las variantes de una sealed interface
ignoreUnknownKeys = trueAl leer, ignora campos que no conozca en vez de fallar

Comillas dentro de un texto: \"

En Kotlin un texto va entre comillas dobles, así que una comilla doble dentro del texto lo cerraría antes de tiempo. Para escribirla dentro se pone una barra invertida delante: \" cuenta como una sola comilla. JSON usa comillas dobles en todo, así que lo necesitarás al escribir JSON a mano.

ejemplo mínimo: JSON a mano con comillas (con comentarios)
fun main() {
    // La barra invertida delante de la comilla dice: «esta no cierra el texto, es parte de él».
    val frase = "Ella dijo \"hola\""
    println(frase)
 
    // Un JSON a mano, con comillas en la clave y en el valor:
    val pan = "{\"nombre\":\"Pan\"}"
    println(pan)
 
    // joinToString(",") une los elementos de una lista en un solo texto, con una coma entre ellos.
    // La lambda dice cómo se escribe cada elemento; el `it` es el elemento.
    val nombres = listOf("Sopa", "Curry")
    println("[" + nombres.joinToString(",") { "\"$it\"" } + "]")
}

Al ejecutarlo, imprime:

Ella dijo "hola"
{"nombre":"Pan"}
["Sopa","Curry"]

Es justo lo que el plugin hace por ti con cada clase @Serializable: poner las comillas, las comas y los corchetes. En el ejercicio lo haces tú, una vez, para ver qué trabajo ahorra.

Reto · Ejercicio: el JSON a partir de los objetos

En el Kotlin Playground, fuera de tu proyecto. Sin la librería, el JSON lo construyes tú con comillas y comas. Parte de este código:

punto de partida
class Plato(val nombre: String, val picante: Int? = null)
 
fun main() {
    val platos = listOf(Plato("Sopa"), Plato("Curry", 3))
    // aquí va lo tuyo
}

Haz que el programa imprima, en una sola línea y sin espacios, el JSON de la lista platos. El campo picante solo se escribe cuando el plato tiene valor, como hace kotlinx.serialization. Los nombres de los platos tienen que salir de los objetos, no estar escritos dentro de ningún texto: si añades un tercer plato a la lista, tiene que aparecer en la salida sin tocar nada más. Pista: haz una función que reciba un Plato y devuelva su JSON, y úsala con joinToString.

Salida esperada con esos dos platos:

[{"nombre":"Sopa"},{"nombre":"Curry","picante":3}]
Ver solución
solución del reto
class Plato(val nombre: String, val picante: Int? = null)
 
// El JSON de un solo plato. Si picante es null, el campo no se escribe.
fun Plato.aJson(): String =
    if (picante == null) "{\"nombre\":\"$nombre\"}"
    else "{\"nombre\":\"$nombre\",\"picante\":$picante}"
 
fun main() {
    val platos = listOf(Plato("Sopa"), Plato("Curry", 3))
    println("[" + platos.joinToString(",") { it.aJson() } + "]")
}

Salida:

[{"nombre":"Sopa"},{"nombre":"Curry","picante":3}]

La función aJson convierte un plato; joinToString(",") los une con comas y los corchetes se ponen alrededor. Si añades Plato("Té") a la lista, aparece {"nombre":"Té"} sin tocar nada más. Este es el trabajo que el plugin de kotlinx.serialization hace por ti para cada clase @Serializable.

Paso · Comprueba que Marcador sigue igual

Esta lección no cambia el proyecto. Compruébalo con los tests, para llegar a la siguiente con todo en orden. En la Terminal de Android Studio (View → Tool Windows → Terminal):

./gradlew :torneo:test

Después abre el informe de los tests, como hiciste en Bucles y tu primer test:

open torneo/build/reports/tests/test/index.html

Punto de control

  • ./gradlew :torneo:test termina en BUILD SUCCESSFUL.
  • El informe de tests muestra arriba 75 tests, los mismos que antes: esta lección no añade ninguno.
  • No has creado ni cambiado ningún fichero del proyecto.

Comprueba lo que entendiste

5 preguntas · necesitas 4 para completar la lección

  1. 1.Un Gol guarda un Equipo y un Jugador. ¿Por qué no se guarda tal cual en JSON y se usan clases «guardadas» aparte con los nombres?

  2. 2.¿Cómo se escribe en Kotlin un texto que contiene una comilla doble, por ejemplo Ella dijo "hola"?

  3. 3.Con data class Plato(val nombre: String, val picante: Int? = null), ¿qué pasa con un plato sin picante al guardarlo?

  4. 4.En Json { classDiscriminator = "tipo" }, ¿para qué sirve el campo "tipo" que aparece en cada pedido del JSON?

  5. 5.En json.decodeFromString<List<Pedido>>(texto), ¿qué dice lo que va entre <>?

Faltan 5 de 5