Tema 5. Retrofit2: Consumo de APIs REST

  • Bloque: B3 — Persistencia y comunicación: ROOM + Retrofit2
  • Duración aproximada: 12 horas
  • RA2 — Desarrolla aplicaciones para dispositivos móviles analizando y empleando las tecnologías y librerías específicas.
  • RA3 — Desarrolla programas que integran contenidos multimedia analizando y empleando las tecnologías y librerías específicas.
Código Criterio
RA2-a Se ha generado la estructura de clases necesaria para la aplicación.
RA2-b Se han analizado y utilizado las clases que modelan ventanas, menús, alertas y controles.
RA2-e Se han utilizado las técnicas de acceso a servicios de comunicación en red disponibles.
RA2-g Se han realizado pruebas de interacción usuario-aplicación para optimizar las aplicaciones desarrolladas.
RA2-i Se han documentado los procesos necesarios para el desarrollo de las aplicaciones.
RA3-c Se han utilizado clases para la conversión de datos multimedia de un formato a otro.
RA3-d Se han utilizado clases para procesar datos multimedia.
RA3-e Se han utilizado clases para el control de eventos, tipos de media y excepciones, entre otros.
RA3-h Se han depurado y documentado los programas desarrollados.

Dependencias necesarias#

Se continúa desde de la última versión de AppDummy, si no la tienes, puedes descargarla desde v6. Ejemplo práctico Tema 4 .

El proyecto utiliza un catálogo de versiones (gradle/libs.versions.toml), así que las nuevas bibliotecas se declaran primero ahí y después se referencian desde el módulo app:

 1# gradle/libs.versions.toml — añadir a lo que ya existe desde T4
 2
 3[versions]
 4retrofit = "3.0.0"
 5okhttp = "5.4.0"
 6gson = "2.14.0"
 7
 8[libraries]
 9retrofit = { module = "com.squareup.retrofit2:retrofit", version.ref = "retrofit" }
10retrofit-converter-gson = { module = "com.squareup.retrofit2:converter-gson", version.ref = "retrofit" }
11okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" }
12okhttp-logging-interceptor = { module = "com.squareup.okhttp3:logging-interceptor", version.ref = "okhttp" }
13gson = { module = "com.google.code.gson:gson", version.ref = "gson" }
 1// build.gradle.kts (módulo app) — añadir a las dependencias de T4
 2
 3android {
 4    // ...
 5    buildFeatures {
 6        compose = true
 7        buildConfig = true   // necesario para acceder a BuildConfig.DEBUG desde el código
 8    }
 9}
10
11dependencies {
12    // ...
13
14    // Retrofit — cliente HTTP con soporte nativo a corrutinas
15    implementation(libs.retrofit)
16    implementation(libs.retrofit.converter.gson)
17
18    // OkHttp — cliente HTTP subyacente + interceptor de logs para desarrollo
19    implementation(libs.okhttp)
20    implementation(libs.okhttp.logging.interceptor)
21
22    // Gson — deserialización de JSON a objetos Kotlin/Java (ya añadido en T4)
23    implementation(libs.gson)
24
25    // Coil — carga asíncrona de imágenes en Compose (ya añadido en bloques anteriores)
26    implementation(libs.coil.compose)
27    implementation(libs.coil.network.okhttp)
28}

Añadir el permiso de Internet en AndroidManifest.xml si no lo añadiste previante:

1<!-- AndroidManifest.xml — dentro de <manifest>, fuera de <application> -->
2<uses-permission android:name="android.permission.INTERNET" />

Versiones y compatibilidad: Retrofit incluye OkHttp como dependencia transitiva, pero añadirlo explícitamente garantiza usar la versión específica deseada. Las versiones de retrofit y converter-gson siempre deben coincidir. Coil 3.x cambió las coordenadas de grupo: se usa io.coil-kt.coil3 (con el 3 al final), no io.coil-kt (versión 2.x); para carga de imágenes desde red, coil-network-okhttp es necesario además de coil-compose. Por último, desde AGP 8.0 la clase BuildConfig no se genera si no se activa buildConfig = true en buildFeatures.


1. ¿Qué es Retrofit y por qué usarlo?#

Retrofit es un cliente HTTP para Android y la JVM que convierte una interfaz Kotlin en llamadas HTTP. En lugar de escribir manualmente el código de conexión, parseo de JSON y gestión de errores, solo se define una interfaz con anotaciones y Retrofit genera la implementación:

Sin Retrofit (URLConnection manual)            Con Retrofit
────────────────────────────────               ────────────────────
val url = URL("https://openlibrary...")        interface OpenLibraryApiService {
val conn = url.openConnection()                  @GET("search.json")
conn.connect()                                   suspend fun buscarPorTitulo(
val stream = conn.inputStream                      @Query("title") titulo: String
val json = stream.bufferedReader()               ): BusquedaResponseDto
    .use { it.readText() }                     }
// parseo manual del JSON...
// gestión manual de errores...                // Retrofit genera la implementación ↑
// gestión manual de hilos...                  // con soporte automático a corrutinas

El stack de red en Android#

En una aplicación Android moderna con Retrofit el flujo de una petición HTTP es:

Composable → ViewModel → Repository → RemoteDataSource
                                  [Retrofit convierte la interfaz]
                                  OpenLibraryApiService
                                   OkHttpClient   ← gestiona conexiones, caché, cabeceras, logs
                                      Internet ──► openlibrary.org

Observa que la estructura es idéntica a la de Room en el T4: la UI nunca conoce Retrofit, igual que nunca conocía el DAO. El RemoteDataSource es al servicio de red lo que el LocalDataSource era al DAO.


2. La API de Open Library: uso, límites y buenas prácticas#

Open Library es el catálogo abierto de libros de Internet Archive. Su API REST es muy adecuada para proyectos educativos porque:

  • No requiere API Key ni registro: las peticiones son anónimas.
  • Devuelve JSON directamente añadiendo .json a casi cualquier URL del sitio.
  • Incluye una API de carátulas (covers), una URL, que permite obtener la portada de un libro a partir de su ISBN.

Sin API Key ≠ sin responsabilidad. Open Library declara explícitamente que sus APIs están pensadas para consultas en tiempo real hechas en nombre de una persona, no para descargas masivas. Sus normas de uso piden: cachear las respuestas siempre que sea posible, identificar la aplicación con una cabecera User-Agent y no repartir el tráfico entre múltiples IPs. AppDummy cumple lo primero gracias a Room (los libros se guardan localmente) y lo segundo mediante un interceptor de OkHttp (sección 6).

Límites de peticiones (rate limits)#

Tipo de petición Límite
Peticiones anónimas 1 petición / segundo
Peticiones identificadas con User-Agent + email de contacto 3 peticiones / segundo
API de carátulas por ISBN / OCLC / LCCN 100 peticiones / 5 min y por IP

Superar estos límites provoca respuestas HTTP 403 o HTTP 429. Esto justifica dos decisiones de diseño que se aplicarán más adelante: el debounce en el campo de búsqueda (sección 10) y el uso de la caché de OkHttp/Coil.

Endpoints utilizados en AppDummy#

La URL base de la API es https://openlibrary.org/.

Uso URL relativa Devuelve
Búsqueda por título (en inglés) search.json?title={texto} example Lista paginada de obras (works)
Búsqueda por ISBN search.json?q=isbn:{isbn13} example Obra que contiene esa edición
Búsqueda libre search.json?q={texto} example Obras que coinciden con la consulta
Carátula por identificador de portada https://covers.openlibrary.org/b/id/{cover_i}-L.jpg example Imagen JPEG
Carátula por ISBN https://covers.openlibrary.org/b/isbn/{isbn}-L.jpg example Imagen JPEG

El parámetro fields permite limitar los campos que devuelve el servidor. Es muy recomendable usarlo: la respuesta completa de search.json puede superar los 100 KB por documento, mientras que pidiendo solo los seis campos que necesita AppDummy se queda en unos pocos cientos de bytes.

https://openlibrary.org/search.json ?title=proyecto+hail+mary &fields=key,title,author_name,first_publish_year,isbn,cover_i &limit=1

Ejemplo de respuesta JSON de search.json#

 1{
 2    "numFound":6,
 3    "start":0,
 4    "numFoundExact":true,
 5    "num_found":6,
 6    "documentation_url":"https://openlibrary.org/dev/docs/api/search",
 7    "q":"",
 8    "offset":null,
 9    "docs":[
10        {
11            "author_name":["Andy Weir"],
12            "cover_i":11200092,
13            "first_publish_year":2021,
14            "isbn":[
15                "0593135229",
16                ...
17                "9780593135204",
18                "8418037016",
19                "9786073802536"
20            ],
21            "key":"/works/OL21745884W",
22            "title":"Project Hail Mary"
23        }
24    ]
25}

Conviene fijarse en tres detalles que condicionan el modelo de datos:

  1. key es una cadena con formato de ruta (/works/OL21745884W), no un entero. Por eso en T4 la clave primaria de Libro se definió como String.
  2. author_name e isbn son arrays, mientras que la entidad Libro guarda un único autor y un único ISBN.
  3. cover_i es un entero (el identificador interno de la portada), no una URL. La URL hay que construirla.

Esta discrepancia entre la forma del JSON y la forma de la entidad se resuelve en la sección 4.


3. El modelo de datos: la entidad Libro#

Libro es la entidad de Room definida en T4: describe una tabla de la base de datos y no necesita ninguna modificación para incorporar Retrofit. Conviene repasarla antes de comparar su forma con la del JSON que devuelve la API:

 1import androidx.room.ColumnInfo
 2import androidx.room.Entity
 3import androidx.room.Index
 4import androidx.room.PrimaryKey
 5
 6// ─── data/model/Libro.kt ─────────────────────────────────────────────────────────────────────────
 7// La MISMA clase que en T4: no se añade ni una anotación nueva
 8@Entity(
 9    tableName = "libros",
10    indices = [
11        Index(value = ["titulo"]),
12        Index(value = ["autor", "isbn"], unique = false)
13    ]
14)
15data class Libro(
16    // Se corresponde con "key" en el JSON: "/works/OL20933765W"
17    @PrimaryKey
18    val id: String,
19
20    @ColumnInfo(name = "titulo")          // "title"
21    val titulo: String,
22
23    @ColumnInfo(name = "autor")           // "author_name" (array → se aplana)
24    val autor: String,
25
26    @ColumnInfo(name = "year")            // "first_publish_year"
27    val year: Int? = 1900,
28
29    @ColumnInfo(name = "isbn")            // "isbn" (array → se elige el ISBN-13)
30    val isbn: String,
31
32    @ColumnInfo(name = "cover")           // "cover_i" (entero → se construye la URL)
33    val cover: String? = null,
34
35    // Campos exclusivamente locales: no existen en la API de Open Library
36    @ColumnInfo(name = "es_favorito")
37    val esFavorito: Boolean = false,
38
39    @ColumnInfo(name = "leido")
40    val leido: Boolean = false
41)

El problema: el JSON no encaja campo a campo#

En una API “amable” bastaría con anotar cada propiedad con @SerializedName y Gson haría el resto. Con Open Library eso no es suficiente:

Propiedad de Libro Campo JSON ¿Encaja con @SerializedName?
id: String key: String ✅ Sí
titulo: String title: String ✅ Sí
autor: String author_name: List<String> ❌ Tipos distintos
year: Int? first_publish_year: Int ✅ Sí
isbn: String isbn: List<String> ❌ Tipos distintos
cover: String? cover_i: Int ❌ Hay que construir la URL

Existen tres formas de resolverlo:

  1. Crear un data class de red separado (un DTO) con los tipos exactos del JSON y una función de mapeo toLibro().
  2. Añadir propiedades auxiliares a Libro marcadas con @Ignore para que Room las descarte.
  3. Enseñar a Gson cómo construir un Libro a partir de un documento JSON, mediante un deserializador personalizado (JsonDeserializer).

En AppDummy se elige la primera opción, la que se usa habitualmente en proyectos profesionales. Las razones son cuatro:

  • La entidad de Room queda intacta. Libro describe una tabla de la base de datos y nada más. No hereda la forma del JSON ni cambia si mañana Open Library renombra un campo.
  • El contrato de la API queda explícito. Al leer LibroDto se ve exactamente qué devuelve el servidor, sin tener que deducirlo de anotaciones repartidas entre varias clases.
  • La conversión es código Kotlin normal. Un JsonDeserializer obliga a manipular JsonObject, JsonElement y comprobaciones de isJsonNull; un mapeador es una función corriente con ?:, firstOrNull() y joinToString().
  • El mapeador se puede probar. Es una función pura: se le pasa un DTO y devuelve un Libro. Se comprueba con un test unitario de JUnit, sin emulador ni red (sección 4.3).

El precio es una clase más y una función de mapeo. A cambio, la capa de datos queda dividida en dos mitades independientes: lo que habla el servidor (DTO) y lo que almacena la app (entidad).

   JSON de Open Library          LibroDto              Libro (@Entity)
  ┌────────────────────┐    ┌──────────────────┐   ┌──────────────────┐
  │ "key": "/works/.." │───▶│ key: String?     │──▶│ id: String       │
  │ "author_name": [..]│───▶│ authorName:      │──▶│ autor: String    │
  │ "cover_i": 11200092│───▶│   List<String>?  │   │ cover: String?   │
  └────────────────────┘    │ coverId: Int?    │   │ esFavorito: ...  │
       Gson  ───────────────┘                  └───┤ leido: ...       │
                                  toLibro()        └──────────────────┘
                            forma del SERVIDOR      forma de la BASE DE DATOS

¿Y la tercera opción? Un JsonDeserializer<Libro> registrado en el GsonBuilder permitiría mantener un único modelo, y es una técnica que conviene conocer porque resulta muy útil cuando solo hay que ajustar un campo concreto (por ejemplo, un formato de fecha poco habitual). Pero acopla la entidad de Room al formato del JSON y obliga a trabajar con el árbol de objetos de Gson, más complejo y más difícil de probar que una función de mapeo.


4. DTOs y mapeadores: la frontera entre la red y el modelo#

Un DTO (Data Transfer Object) es una clase cuyo único objetivo es transportar los datos tal y como los envía el servidor. No tiene lógica, no tiene anotaciones de Room y no se guarda en ninguna base de datos: nace cuando Gson deserializa la respuesta y muere en cuanto el mapeador ha construido la entidad.

4.1. Los DTOs de Open Library#

Se crean en su propio subpaquete, data/datasource/remote/dto, para dejar claro que pertenecen a la capa de red:

 1import com.google.gson.annotations.SerializedName
 2
 3// ─── data/datasource/remote/dto/LibroDto.kt ──────────────────────────────────────────────────────
 4// Representa EXACTAMENTE un documento del array "docs" de search.json.
 5// Todas las propiedades son nullable y tienen valor por defecto: Open Library
 6// OMITE los campos que no tiene, en lugar de enviarlos con valor null.
 7data class LibroDto(
 8
 9    // "key": "/works/OL21745884W" — identificador de la obra
10    @SerializedName("key")
11    val key: String? = null,
12
13    // "title": "Project Hail Mary"
14    @SerializedName("title")
15    val title: String? = null,
16
17    // "author_name": ["Andy Weir"] — array, puede tener varios autores
18    @SerializedName("author_name")
19    val authorName: List<String>? = null,
20
21    // "first_publish_year": 2021
22    @SerializedName("first_publish_year")
23    val firstPublishYear: Int? = null,
24
25    // "isbn": ["0593135202", "9780593135204", ...] — mezcla ISBN-10 e ISBN-13
26    @SerializedName("isbn")
27    val isbn: List<String>? = null,
28
29    // "cover_i": 11200092 — identificador interno de la portada, NO una URL
30    @SerializedName("cover_i")
31    val coverId: Int? = null
32)
 1import com.google.gson.annotations.SerializedName
 2
 3// ─── data/datasource/remote/dto/BusquedaResponseDto.kt ───────────────────────────────────────────
 4// Envuelve la respuesta de search.json: los resultados llegan dentro de "docs"
 5data class BusquedaResponseDto(
 6
 7    @SerializedName("numFound")
 8    val numFound: Int? = null,
 9
10    @SerializedName("start")
11    val start: Int? = null,
12
13    @SerializedName("docs")
14    val docs: List<LibroDto>? = null
15)

Por qué TODAS las propiedades del DTO son nullable. Gson no llama al constructor de Kotlin: crea el objeto por reflexión y rellena los campos uno a uno. Eso significa que una propiedad declarada como val title: String (no nullable) puede acabar valiendo null en tiempo de ejecución si el JSON no trae ese campo, y el error no aparecerá al deserializar, sino más tarde, al usarla, con un NullPointerException difícil de localizar. Los valores por defecto tampoco se aplican por el mismo motivo. La regla práctica es simple: en un DTO de Gson, todo nullable. El mapeador es el punto donde esos nulos se convierten en valores seguros.

4.2. El mapeador: de DTO a entidad#

En primer lugar, las URLs de las carátulas y el resto de constantes de la API se centralizan en un objeto:

 1// ─── data/datasource/remote/OpenLibrary.kt ───────────────────────────────────────────────────────
 2object OpenLibrary {
 3
 4    const val BASE_URL = "https://openlibrary.org/"
 5    const val COVERS_URL = "https://covers.openlibrary.org/b/"
 6
 7    // Campos que se solicitan al servidor: reduce drásticamente el tamaño de la respuesta
 8    const val CAMPOS = "key,title,author_name,first_publish_year,isbn,cover_i"
 9
10    // Identifica la app ante Open Library (triplica el límite de peticiones)
11    const val USER_AGENT = "AppDummy/1.0 (tu_correo@correo.com)"
12
13    // Tamaños disponibles: S (small), M (medium), L (large)
14    // ?default=false hace que el servidor devuelva 404 en lugar de una imagen en blanco,
15    // lo que permite a Coil mostrar la imagen de error en vez de un rectángulo vacío
16    fun urlCaratulaPorId(coverId: Int?, tamanyo: String = "L"): String? =
17        coverId?.let { "${COVERS_URL}id/$it-$tamanyo.jpg?default=false" }
18
19    fun urlCaratulaPorIsbn(isbn: String?, tamanyo: String = "L"): String? =
20        isbn?.takeIf { it.isNotBlank() }
21            ?.let { "${COVERS_URL}isbn/$it-$tamanyo.jpg?default=false" }
22}

El mapeador se implementa como funciones de extensión sobre el DTO. Devuelve Libro? porque un documento sin key o sin title no se puede almacenar en Room: id es la clave primaria y titulo es una columna no nula.

 1import com.ejemplo.appdummy.data.datasource.remote.OpenLibrary
 2import com.ejemplo.appdummy.data.model.Libro
 3
 4// ─── data/datasource/remote/dto/LibroMapper.kt ───────────────────────────────────────────────────
 5
 6// Convierte un LibroDto en la entidad Libro de Room.
 7// Devuelve null si el documento no tiene los datos mínimos imprescindibles.
 8//
 9// isbnBuscado: cuando la búsqueda se ha hecho por ISBN, se conserva el que
10// introdujo el usuario. Una obra (work) agrupa muchas ediciones, y la API puede
11// devolver primero el ISBN de una edición distinta a la que se estaba buscando.
12fun LibroDto.toLibro(isbnBuscado: String? = null): Libro? {
13
14    // Sin identificador no hay clave primaria posible → se descarta el documento
15    val idObra = key ?: return null
16    val tituloObra = title?.takeIf { it.isNotBlank() } ?: return null
17
18    // Del array de ISBN se prioriza el de 13 dígitos (formato actual)
19    val isbn13 = isbnBuscado
20        ?: isbn?.firstOrNull { it.length == 13 }
21        ?: isbn?.firstOrNull()
22        ?: ""
23
24    return Libro(
25        id = idObra,
26        titulo = tituloObra,
27        // El array de autores se aplana en una única cadena
28        autor = authorName
29            ?.filter { it.isNotBlank() }
30            ?.joinToString(", ")
31            ?.takeIf { it.isNotBlank() }
32            ?: "Autor desconocido",
33        year = firstPublishYear,
34        isbn = isbn13,
35        // Se prefiere la portada por cover_i; si no existe, se intenta por ISBN
36        cover = OpenLibrary.urlCaratulaPorId(coverId)
37            ?: OpenLibrary.urlCaratulaPorIsbn(isbn13),
38        // esFavorito y leido NO se tocan: son datos locales que la API desconoce.
39        // Se quedan con el valor por defecto (false) definido en la entidad.
40    )
41}
42
43// mapNotNull aplica toLibro() a cada documento y descarta automáticamente
44// los que han devuelto null, sin necesidad de un filter previo
45fun List<LibroDto>.toLibros(): List<Libro> = mapNotNull { it.toLibro() }

4.3. Ventaja añadida: el mapeador se puede probar#

toLibro() es una función pura: mismos datos de entrada, mismo resultado, sin acceso a red ni a base de datos ni a nada de Android. Eso permite verificarla con un test unitario normal (app/src/test), que se ejecuta en la JVM en milisegundos y sin emulador.

 1import com.ejemplo.appdummy.data.datasource.remote.dto.LibroDto
 2import com.ejemplo.appdummy.data.datasource.remote.dto.toLibro
 3import com.ejemplo.appdummy.data.datasource.remote.dto.toLibros
 4import org.junit.Assert.assertEquals
 5import org.junit.Assert.assertNull
 6import org.junit.Assert.assertTrue
 7import org.junit.Test
 8
 9// ─── LibroMapperTest.kt - com.ejemplo.appdummy (test) ────────────────────────────────────────────
10class LibroMapperTest {
11
12    @Test
13    fun `un documento sin key se descarta`() {
14        val dto = LibroDto(key = null, title = "Sin identificador")
15        assertNull(dto.toLibro())
16    }
17
18    @Test
19    fun `se prioriza el ISBN de 13 digitos`() {
20        val dto = LibroDto(
21            key = "/works/OL1W",
22            title = "Project Hail Mary",
23            isbn = listOf("0593135202", "9780593135204")   // ISBN-10 primero
24        )
25        assertEquals("9780593135204", dto.toLibro()?.isbn)
26    }
27
28    @Test
29    fun `varios autores se concatenan separados por comas`() {
30        val dto = LibroDto(
31            key = "/works/OL2W",
32            title = "Buenos presagios",
33            authorName = listOf("Terry Pratchett", "Neil Gaiman")
34        )
35        assertEquals("Terry Pratchett, Neil Gaiman", dto.toLibro()?.autor)
36    }
37
38    @Test
39    fun `sin author_name se usa el valor por defecto`() {
40        val dto = LibroDto(key = "/works/OL3W", title = "Anónimo")
41        assertEquals("Autor desconocido", dto.toLibro()?.autor)
42    }
43
44    @Test
45    fun `sin cover_i la caratula se construye a partir del ISBN`() {
46        val dto = LibroDto(
47            key = "/works/OL4W",
48            title = "Sin portada indexada",
49            isbn = listOf("9788418037016"),
50            coverId = null
51        )
52        assertTrue(dto.toLibro()?.cover?.contains("isbn/9788418037016") == true)
53    }
54
55    @Test
56    fun `los campos locales no se rellenan desde la API`() {
57        val libro = LibroDto(key = "/works/OL5W", title = "Cualquiera").toLibro()
58        assertEquals(false, libro?.esFavorito)
59        assertEquals(false, libro?.leido)
60    }
61
62    @Test
63    fun `una lista con documentos invalidos solo devuelve los validos`() {
64        val documentos = listOf(
65            LibroDto(key = "/works/OL6W", title = "Válido"),
66            LibroDto(key = null, title = "Sin key"),
67            LibroDto(key = "/works/OL7W", title = null)
68        )
69        assertEquals(1, documentos.toLibros().size)
70    }
71}

Este bloque de tests cubre el criterio de evaluación RA2-g (pruebas para optimizar las aplicaciones desarrolladas) sin necesidad de dispositivo. Es, de hecho, el argumento más fuerte a favor de los DTOs: la lógica que más fácilmente se rompe cuando cambia una API queda aislada en una función de diez líneas y protegida por tests que tardan un segundo en ejecutarse.


5. Definir la interfaz de la API#

La interfaz OpenLibraryApiService declara los endpoints que usará la app. Retrofit genera automáticamente la implementación a partir de las anotaciones. Observa que el tipo de retorno es el DTO de respuesta, no la entidad: la interfaz habla el idioma del servidor.

 1import com.ejemplo.appdummy.data.datasource.remote.dto.BusquedaResponseDto
 2import retrofit2.http.GET
 3import retrofit2.http.Query
 4
 5// ─── data/datasource/remote/OpenLibraryApiService.kt ─────────────────────────────────────────────
 6interface OpenLibraryApiService {
 7
 8    // Búsqueda por título (el parámetro title restringe la coincidencia al título de la obra)
 9    // GET https://openlibrary.org/search.json?title=hail+mary&fields=...&limit=10&page=1
10    @GET("search.json")
11    suspend fun buscarPorTitulo(
12        @Query("title")  titulo: String,
13        @Query("fields") campos: String = OpenLibrary.CAMPOS,
14        @Query("limit")  limite: Int = 10,
15        @Query("page")   pagina: Int = 1
16    ): BusquedaResponseDto
17
18    // Búsqueda genérica sobre el índice Solr de Open Library: admite sintaxis de campo, como "isbn:9780593135204"
19    // GET https://openlibrary.org/search.json?q=isbn:9780593135204&fields=...&limit=1
20    @GET("search.json")
21    suspend fun buscar(
22        @Query("q")      consulta: String,
23        @Query("fields") campos: String = OpenLibrary.CAMPOS,
24        @Query("limit")  limite: Int = 10,
25        @Query("page")   pagina: Int = 1
26    ): BusquedaResponseDto
27}

Retrofit no interpreta los valores por defecto de Kotlin: es el compilador quien los rellena en el punto de llamada antes de invocar al proxy generado. Por eso buscar(consulta = "isbn:...") funciona sin más, pero esos valores por defecto no existirían si la interfaz se llamara desde Java.

Anotaciones de Retrofit más utilizadas#

Anotación Uso Ejemplo
@GET("ruta") Petición HTTP GET @GET("search.json")
@POST("ruta") Petición HTTP POST @POST("account/login")
@PUT("ruta") Petición HTTP PUT @PUT("books/{olid}")
@DELETE("ruta") Petición HTTP DELETE @DELETE("lists/{id}")
@Path("nombre") Sustituye {nombre} en la URL @Path("olid") olid: String
@Query("nombre") Añade ?nombre=valor a la URL @Query("limit") limite: Int
@Body Envía un objeto como cuerpo JSON @Body libro: LibroDto
@Header("nombre") Añade una cabecera HTTP @Header("User-Agent") ua: String

suspend vs Call<T>#

Retrofit soporta dos estilos para definir las funciones de la API:

1// Estilo antiguo — Call<T>, requiere enqueue() o execute()
2@GET("search.json")
3fun buscarLegacy(@Query("title") titulo: String): Call<BusquedaResponseDto>
4
5// Estilo moderno — suspend, se llama directamente en una corrutina ✅
6@GET("search.json")
7suspend fun buscarPorTitulo(@Query("title") titulo: String): BusquedaResponseDto

Con suspend, Retrofit ejecuta la petición en un hilo de I/O automáticamente y devuelve el resultado (o lanza una excepción) cuando la respuesta llega. Es la forma recomendada en proyectos nuevos con Kotlin.


6. Configurar OkHttpClient y Retrofit#

OkHttpClient es el cliente HTTP subyacente que gestiona las conexiones, la caché, los interceptores y los timeouts. Retrofit lo usa internamente; configurarlo explícitamente permite añadir la cabecera User-Agent que pide Open Library y el interceptor de logs durante el desarrollo.

Al haber optado por DTOs, la configuración de Gson es la de serie: no hay que registrar ningún adaptador porque cada campo del JSON se corresponde uno a uno con una propiedad del DTO.

 1import com.ejemplo.appdummy.BuildConfig
 2import okhttp3.Interceptor
 3import okhttp3.OkHttpClient
 4import okhttp3.logging.HttpLoggingInterceptor
 5import retrofit2.Retrofit
 6import retrofit2.converter.gson.GsonConverterFactory
 7import java.util.concurrent.TimeUnit
 8
 9// ─── data/datasource/remote/RetrofitClient.kt ────────────────────────────────────────────────────
10// (En AppDummy esta lógica acabará viviendo en el AppContainer — ver más abajo)
11object RetrofitClient {
12
13    // Interceptor: añade la cabecera User-Agent a TODAS las peticiones.
14    // Open Library triplica el límite de peticiones a las apps identificadas.
15    private val userAgentInterceptor = Interceptor { chain ->
16        val peticion = chain.request().newBuilder()
17            .header("User-Agent", OpenLibrary.USER_AGENT)
18            .build()
19        chain.proceed(peticion)
20    }
21
22    // HttpLoggingInterceptor imprime en Logcat las peticiones y respuestas HTTP
23    // Level.BODY: muestra URL, cabeceras y cuerpo completo (SOLO en debug)
24    // Level.NONE: no registra nada (obligatorio en release)
25    private val loggingInterceptor = HttpLoggingInterceptor().apply {
26        level = if (BuildConfig.DEBUG) HttpLoggingInterceptor.Level.BODY
27                else HttpLoggingInterceptor.Level.NONE
28    }
29
30    val okHttpClient: OkHttpClient = OkHttpClient.Builder()
31        .connectTimeout(30, TimeUnit.SECONDS)    // tiempo máximo para conectar
32        .readTimeout(30, TimeUnit.SECONDS)       // tiempo máximo para leer la respuesta
33        .writeTimeout(30, TimeUnit.SECONDS)      // tiempo máximo para enviar datos
34        .addInterceptor(userAgentInterceptor)    // el orden importa: primero cabeceras...
35        .addInterceptor(loggingInterceptor)      // ...y después los logs, para verlas en Logcat
36        .build()
37
38    private val retrofit: Retrofit = Retrofit.Builder()
39        .baseUrl(OpenLibrary.BASE_URL)
40        .client(okHttpClient)
41        // Gson por defecto: los DTOs no necesitan ninguna configuración especial
42        .addConverterFactory(GsonConverterFactory.create())
43        .build()
44
45    // Se crea la implementación de la interfaz de la API
46    val openLibraryApiService: OpenLibraryApiService =
47        retrofit.create(OpenLibraryApiService::class.java)
48}

BuildConfig.DEBUG es una constante generada automáticamente por Gradle: vale true en compilaciones de debug y false en release. Permite activar/desactivar el registro de logs sin tocar el código. No estará disponible hasta que se construya el proyecto, si se limpia el proyecto o se borra la carpeta build de forma manual, habrá que reconstruirlo para que aparezca.

Integración en el AppContainer#

Siguiendo el patrón de inyección manual de T4, todo lo anterior se traslada al AppContainer. Así una única instancia de OkHttpClient se comparte entre Retrofit y Coil:

 1// ─── data/di/AppContainer.kt ─────────────────────────────────────────────────────────────────────
 2interface AppContainer {
 3    val librosRepository: LibrosRepository
 4    val okHttpClient: OkHttpClient   // se expone para compartirlo con Coil
 5}
 6
 7class DefaultAppContainer(context: Context) : AppContainer {
 8
 9    // ─── Capa local (T4) ────────────────────────────────────────────────────────
10    // Room — singleton de la base de datos
11    // by lazy: se crea una sola vez la primera vez que se accede
12    private val database by lazy { AppDatabase.getDatabase(context) }
13    private val localDataSource: LocalDataSource by lazy {
14        LocalDataSource(database.libroDao())
15    }
16
17    // ─── Capa remota (T5) ───────────────────────────────────────────────────────
18    override val okHttpClient: OkHttpClient by lazy { RetrofitClient.okHttpClient }
19
20    private val remoteDataSource: RemoteDataSource by lazy {
21        RemoteDataSource(RetrofitClient.openLibraryApiService)
22    }
23
24    // El repositorio pasa a recibir DOS orígenes de datos
25    override val librosRepository: LibrosRepository by lazy {
26        LibrosRepository(localDataSource, remoteDataSource)
27    }
28}

7. RemoteDataSource: wrapper del servicio API#

Al igual que LocalDataSource encapsula el DAO, RemoteDataSource encapsula el servicio Retrofit. El repositorio llama a RemoteDataSource, nunca al servicio directamente.

Es además el punto exacto donde se aplica el mapeo: entra un BusquedaResponseDto y sale una lista de Libro. A partir de aquí, los DTOs desaparecen; ni el repositorio, ni el ViewModel, ni la UI llegan a saber que existen.

 1import com.ejemplo.appdummy.data.datasource.remote.dto.toLibro
 2import com.ejemplo.appdummy.data.datasource.remote.dto.toLibros
 3import com.ejemplo.appdummy.data.model.Libro
 4
 5// ─── data/datasource/remote/RemoteDataSource.kt ──────────────────────────────────────────────────
 6class RemoteDataSource(private val apiService: OpenLibraryApiService) {
 7
 8    // Las funciones son suspend: se deben llamar desde una corrutina.
 9    // Si la petición falla, Retrofit lanza IOException (sin red) o HttpException (error HTTP).
10    // El manejo de esas excepciones se realiza en el Repository (sección 8).
11
12    suspend fun buscarPorTitulo(titulo: String, limite: Int = 10): List<Libro> =
13        apiService.buscarPorTitulo(titulo = titulo, limite = limite)
14            .docs                 // List<LibroDto>? — puede ser null si no hay resultados
15            .orEmpty()
16            .toLibros()           // ← frontera: aquí se deja de hablar en DTOs
17
18    suspend fun buscarPorIsbn(isbn: String): Libro? =
19        apiService.buscar(consulta = "isbn:$isbn", limite = 1)
20            .docs
21            ?.firstOrNull()
22            // Se conserva el ISBN que escribió el usuario: la obra puede devolver
23            // primero el de otra edición distinta a la buscada
24            ?.toLibro(isbnBuscado = isbn)
25}

Dónde se mapea importa. El mapeo podría hacerse en el repositorio o incluso en el ViewModel, pero entonces los DTOs se filtrarían hacia capas superiores y se perdería buena parte de la ventaja. La regla es: cada tipo debe morir en la capa que lo entiende. LibroDto pertenece a remote y no debe aparecer en ningún import fuera de ese paquete.


8. Manejo de errores de red#

Cuando se usa suspend con Retrofit, los errores se traducen en excepciones que hay que capturar. Existen dos tipos principales:

Excepción Cuándo ocurre Causa típica
java.io.IOException Error de red Sin conexión, timeout, DNS
retrofit2.HttpException Respuesta HTTP con error 404 Not Found, 403 Forbidden, 429 Too Many Requests, 500 Server Error

El repositorio es el punto donde esas excepciones se convierten en algo que el ViewModel pueda mostrar. Se utiliza el tipo Result<T> de la biblioteca estándar de Kotlin, de forma que el ViewModel nunca ve una excepción de red:

 1import com.ejemplo.appdummy.data.datasource.local.LocalDataSource
 2import com.ejemplo.appdummy.data.datasource.remote.RemoteDataSource
 3import com.ejemplo.appdummy.data.model.Libro
 4import kotlinx.coroutines.flow.Flow
 5import retrofit2.HttpException
 6import java.io.IOException
 7
 8// ─── data/repository/LibrosRepository.kt ─────────────────────────────────────────────────────────
 9class LibrosRepository(
10    private val localDataSource: LocalDataSource,
11    private val remoteDataSource: RemoteDataSource
12) {
13    // ─── Lectura local (T4): la UI SIEMPRE observa desde Room ───────────────────
14    fun observarLibros(): Flow<List<Libro>> = localDataSource.observarTodos()
15    fun observarFavoritos(): Flow<List<Libro>> = localDataSource.observarFavoritos()
16    fun observarPorId(id: String): Flow<Libro?> = localDataSource.observarPorId(id)
17
18    suspend fun toggleFavorito(id: String) = localDataSource.toggleFavorito(id)
19    suspend fun toggleLeido(id: String) = localDataSource.toggleLeido(id)
20    suspend fun obtenerAutores(): List<String>? = localDataSource.obtenerAutores()
21    suspend fun obtenerPorId(id: String): Libro? = localDataSource.obtenerPorId(id)
22
23    // ─── Escritura local (T5): alta de un libro desde el formulario ─────────────
24    suspend fun guardarLibro(libro: Libro) = localDataSource.guardar(libro)
25
26    // ─── Lectura remota (T5) ────────────────────────────────────────────────────
27    suspend fun buscarEnApiPorTitulo(titulo: String): Result<List<Libro>> =
28        ejecutarPeticion { remoteDataSource.buscarPorTitulo(titulo) }
29
30    suspend fun buscarEnApiPorIsbn(isbn: String): Result<Libro?> =
31        ejecutarPeticion { remoteDataSource.buscarPorIsbn(isbn) }
32
33    // Función genérica de orden superior: centraliza el try/catch de TODAS las
34    // llamadas de red, evitando repetirlo en cada método del repositorio
35    private suspend fun <T> ejecutarPeticion(bloque: suspend () -> T): Result<T> =
36        try {
37            Result.success(bloque())
38        } catch (e: IOException) {
39            // Sin red: los datos locales de Room siguen disponibles
40            Result.failure(Exception("Sin conexión a internet. Comprueba la red."))
41        } catch (e: HttpException) {
42            Result.failure(Exception(mensajeDeError(e.code())))
43        }
44
45    private fun mensajeDeError(codigo: Int): String = when (codigo) {
46        403 -> "Acceso denegado: se ha superado el límite de peticiones."
47        404 -> "Recurso no encontrado en Open Library."
48        429 -> "Demasiadas peticiones. Inténtalo dentro de unos segundos."
49        in 500..599 -> "Error del servidor de Open Library ($codigo)."
50        else -> "Error HTTP $codigo"
51    }
52}

Result<T> frente a excepciones. Result obliga a quien llama a decidir qué hacer con el fallo (onSuccess / onFailure), en lugar de permitir que una excepción se propague hasta hacer que la app se cierre. Es especialmente útil en el ViewModel, donde un fallo de red no es una situación excepcional sino un estado más de la interfaz.

Para que guardarLibro() funcione hay que exponer la escritura individual, que en T4 solo existía en el DAO:

1// ─── data/datasource/local/LocalDataSource.kt — añadir este método ───────────────────────────────
2// @Upsert: si el id ya existe actualiza la fila, si no la inserta
3suspend fun guardar(libro: Libro) = dao.upsert(libro)

9. Carátulas con Coil y la Covers API#

Coil (Coroutine Image Loader) es la biblioteca de carga de imágenes recomendada para Compose. Descarga las imágenes de forma asíncrona, las cachea en disco y en memoria, y proporciona el composable AsyncImage.

Tamaños disponibles en la Covers API#

La URL de carátula tiene el formato https://covers.openlibrary.org/b/{clave}/{valor}-{tamaño}.jpg, donde clave puede ser id, olid, isbn, oclc o lccn.

Tamaño Dimensiones aprox. Uso recomendado
S ~90 px de ancho Miniaturas en listas compactas
M ~180 px de ancho Tarjetas del listado
L ~500 px de ancho Detalle y previsualización del formulario ✅

Añadir ?default=false hace que el servidor devuelva 404 cuando no hay portada, en lugar de una imagen en blanco de 1 píxel. Esto permite que Coil muestre el drawable de error en lugar de un hueco vacío.

Configurar Coil en Application#

Para compartir el OkHttpClient entre Retrofit y Coil (evitando dos clientes HTTP independientes, con sus dos pools de conexiones y sus dos cachés), se configura un ImageLoader personalizado en la clase Application:

 1// ─── AppDummyApplication.kt ──────────────────────────────────────────────────────────────────────
 2import android.app.Application
 3import coil3.ImageLoader
 4import coil3.PlatformContext
 5import coil3.SingletonImageLoader
 6import coil3.network.okhttp.OkHttpNetworkFetcherFactory
 7import coil3.request.crossfade
 8import com.ejemplo.appdummy.data.di.AppContainer
 9import com.ejemplo.appdummy.data.di.DefaultAppContainer
10
11class AppDummyApplication : Application(), SingletonImageLoader.Factory {
12
13    lateinit var container: AppContainer
14
15    override fun onCreate() {
16        super.onCreate()
17        container = DefaultAppContainer(this)
18    }
19
20    // SingletonImageLoader.Factory: Coil llama a este método para crear
21    // el ImageLoader singleton que usará AsyncImage en toda la app
22    override fun newImageLoader(context: PlatformContext): ImageLoader =
23        ImageLoader.Builder(context)
24            .components {
25                // OkHttpNetworkFetcherFactory: usa el mismo OkHttpClient que Retrofit,
26                // de modo que ambos comparten pool de conexiones, timeouts y User-Agent
27                add(OkHttpNetworkFetcherFactory(callFactory = { container.okHttpClient }))
28            }
29            .crossfade(true)   // animación de fundido al cargar las imágenes
30            .build()
31}

AsyncImage: mostrar la carátula de un libro#

En T4 el composable ItemLibro (en PantallaListado.kt) comprobaba con Patterns.WEB_URL si cover era una URL válida. Ahora que las URLs las construye el deserializador, esa comprobación se puede sustituir por el mecanismo nativo de Coil (placeholder / error), que además cubre el caso de que el servidor responda 404:

 1// ─── screens/componentes/CaratulaLibro.kt ────────────────────────────────────────────────────────
 2import androidx.compose.foundation.layout.aspectRatio
 3import androidx.compose.foundation.shape.RoundedCornerShape
 4import androidx.compose.runtime.Composable
 5import androidx.compose.ui.Modifier
 6import androidx.compose.ui.draw.clip
 7import androidx.compose.ui.layout.ContentScale
 8import androidx.compose.ui.platform.LocalContext
 9import androidx.compose.ui.res.painterResource
10import androidx.compose.ui.unit.dp
11import coil3.compose.AsyncImage
12import coil3.request.ImageRequest
13import coil3.request.crossfade
14import com.ejemplo.appdummy.R
15
16@Composable
17fun CaratulaLibro(
18    coverUrl: String?,
19    titulo: String,
20    modifier: Modifier = Modifier
21) {
22    AsyncImage(
23        model = ImageRequest.Builder(LocalContext.current)
24            .data(coverUrl)            // si es null, Coil muestra directamente el error
25            .crossfade(true)
26            .build(),
27        // placeholder: imagen mientras se descarga
28        placeholder = painterResource(R.drawable.nocover),
29        // error: imagen si la descarga falla o el servidor devuelve 404
30        error = painterResource(R.drawable.nocover),
31        contentDescription = "Portada de $titulo",
32        contentScale = ContentScale.Crop,
33        modifier = modifier
34            .aspectRatio(2f / 3f)      // proporción habitual de una portada de libro
35            .clip(RoundedCornerShape(8.dp))
36    )
37}

Al crear el package screens/componentes, se puede mover allí el resto de componentes que se repiten en varias pantallas, como el el caso de ItemLibro.


10. La pantalla “Añadir libro”: formulario con autocompletado#

Este es el objetivo vertebrador del tema: el FloatingActionButton del listado deja de cargar la lista estática y pasa a abrir un formulario de alta. El formulario puede consultar Open Library por ISBN-13 o por título y, si encuentra el libro, rellena automáticamente el resto de campos (incluida la URL de la carátula).

       ┌──────────── PantallaNuevoLibro ───────────────┐
       │  [ISBN-13            ]     ← búsqueda directa │
       │  [Título             ]     ← debounce 500 ms  │
       │  ┌── sugerencias de Open Library ─────────┐   │
       │  │ • Project Hail Mary — Andy Weir (2021) │   │
       │  └────────────────────────────────────────┘   │
       │  [Autor              ]  [Año   ]   portada    │
       │                              [ Guardar ]      │
       └───────────────────────────────────────────────┘
                 LibrosRepository.guardarLibro()
                   Room emite el nuevo Flow
              PantallaListado se actualiza sola ✅

10.1. La ruta de navegación#

Siguiendo la navegación tipada de T3, se añade una ruta sin argumentos:

 1// ─── navegacion/Rutas.kt ─────────────────────────────────────────────────────────────────────────
 2import kotlinx.serialization.Serializable
 3
 4@Serializable
 5data object Inicio
 6@Serializable
 7data object Listado
 8@Serializable
 9data class Detalle(val id: String)
10@Serializable
11data object Favoritos
12@Serializable
13data object NuevoLibro        // ← nueva ruta de T5
 1// ─── navegacion/AppNavigation.kt — cambios ───────────────────────────────────────────────────────
 2composable<Listado> {
 3    PantallaListado(
 4        onNavegaADetalle = { id -> 
 5            navController.navigate(Detalle(id = id))
 6        },
 7        // El FAB deja de recargar la lista estática y navega al formulario
 8        onNavegaANuevoLibro = { navController.navigate(NuevoLibro) }
 9    )
10}
11
12composable<NuevoLibro> {
13    PantallaNuevoLibro(
14        // Al guardar se vuelve al listado; Room notificará el cambio automáticamente
15        onGuardado = { navController.navigateUp() },
16        onCancelar = { navController.navigateUp() }
17    )
18}

10.2. Validación del ISBN-13#

Antes de gastar una petición de red conviene comprobar que el ISBN introducido es válido. El ISBN-13 lleva un dígito de control: se multiplican los 12 primeros dígitos alternando pesos 1 y 3, y la suma total (incluido el dígito de control) debe ser múltiplo de 10.

 1// ─── utils/Isbn.kt ────────────────────────────────────────────────────────────────────────────────
 2object Isbn {
 3
 4    // Elimina guiones y espacios: "978-84-1803-701-6" → "9788418037016"
 5    fun normalizar(texto: String): String = texto.filter { it.isDigit() }
 6
 7    fun esValido13(texto: String): Boolean {
 8        val digitos = normalizar(texto)
 9        if (digitos.length != 13) return false
10
11        // Pesos alternos 1, 3, 1, 3... sobre los 13 dígitos
12        val suma = digitos.mapIndexed { indice, caracter ->
13            (caracter - '0') * if (indice % 2 == 0) 1 else 3
14        }.sum()
15
16        return suma % 10 == 0
17    }
18}

10.3. El estado de la interfaz#

Para un formulario, un data class de estado es más apropiado que una sealed class: no hay estados mutuamente excluyentes (se puede estar buscando y tener ya campos rellenos), sino un conjunto de campos que evolucionan a la vez.

 1import com.ejemplo.appdummy.data.model.Libro
 2
 3// ─── screens/nuevo/NuevoLibroUiState.kt ──────────────────────────────────────────────────────────
 4data class NuevoLibroUiState(
 5    // Campos del formulario (todos String: es lo que devuelve un OutlinedTextField)
 6    val titulo: String = "",
 7    val autor: String = "",
 8    val year: String = "",
 9    val isbn: String = "",
10    val cover: String? = null,
11
12    // id de Open Library del libro seleccionado; null si el alta es manual
13    val idOpenLibrary: String? = null,
14
15    // Resultados de la búsqueda por título
16    val sugerencias: List<Libro> = emptyList(),
17
18    val buscando: Boolean = false,
19    val mensaje: String? = null,     // errores de red o de validación
20    val guardado: Boolean = false    // se pone a true tras insertar en Room
21) {
22    // Propiedad calculada: la UI la usa para habilitar o no el botón Guardar
23    val puedeGuardar: Boolean
24        get() = titulo.isNotBlank() && autor.isNotBlank() && !buscando
25}

10.4. El ViewModel: debounce y autocompletado#

El debounce es el patrón que evita lanzar una petición por cada tecla pulsada: se espera a que el usuario haga una pausa antes de consultar la API. Con los límites de Open Library (1 petición/segundo) no es una optimización opcional, es un requisito.

  1import androidx.lifecycle.ViewModel
  2import androidx.lifecycle.ViewModelProvider
  3import androidx.lifecycle.viewModelScope
  4import androidx.lifecycle.viewmodel.initializer
  5import androidx.lifecycle.viewmodel.viewModelFactory
  6import com.ejemplo.appdummy.AppDummyApplication
  7import com.ejemplo.appdummy.data.model.Libro
  8import com.ejemplo.appdummy.data.repository.LibrosRepository
  9import com.ejemplo.appdummy.utils.Isbn
 10import kotlinx.coroutines.FlowPreview
 11import kotlinx.coroutines.flow.*
 12import kotlinx.coroutines.launch
 13import java.util.UUID
 14import kotlin.time.Duration.Companion.milliseconds
 15
 16// ─── screens/nuevo/NuevoLibroViewModel.kt ────────────────────────────────────────────────────────
 17@OptIn(FlowPreview::class)
 18class NuevoLibroViewModel(
 19    private val repository: LibrosRepository
 20) : ViewModel() {
 21
 22    private val _uiState = MutableStateFlow(NuevoLibroUiState())
 23    val uiState: StateFlow<NuevoLibroUiState> = _uiState.asStateFlow()
 24
 25    // Flujo interno que recibe cada pulsación en el campo "Título"
 26    private val consultaTitulo = MutableStateFlow("")
 27
 28    init {
 29        viewModelScope.launch {
 30            consultaTitulo
 31                .debounce(500.milliseconds)  // espera 500 ms sin nuevas pulsaciones
 32                .distinctUntilChanged()          // ignora si el texto no ha cambiado
 33                .filter { it.length >= 3 }       // no buscar con menos de 3 caracteres
 34                // collectLatest cancela la búsqueda anterior si llega un texto nuevo:
 35                // así nunca se muestran resultados de una consulta ya obsoleta
 36                .collectLatest { texto -> buscarSugerencias(texto) }
 37        }
 38    }
 39
 40    // ─── Actualización de los campos del formulario ─────────────────────────────
 41
 42    fun actualizarTitulo(texto: String) {
 43        _uiState.update { it.copy(titulo = texto, mensaje = null) }
 44        consultaTitulo.value = texto             // dispara el debounce
 45    }
 46
 47    fun actualizarAutor(texto: String) = _uiState.update { it.copy(autor = texto) }
 48    fun actualizarYear(texto: String) =
 49        _uiState.update { it.copy(year = texto.filter { c -> c.isDigit() }.take(4)) }
 50
 51    fun actualizarIsbn(texto: String) =
 52        _uiState.update { it.copy(isbn = Isbn.normalizar(texto).take(13), mensaje = null) }
 53
 54    // ─── Búsqueda por ISBN: se dispara al pulsar la lupa ────────────────────────
 55
 56    fun buscarPorIsbn() {
 57        val isbn = _uiState.value.isbn
 58
 59        if (!Isbn.esValido13(isbn)) {
 60            _uiState.update { it.copy(mensaje = "El ISBN-13 no es válido.") }
 61            return
 62        }
 63
 64        viewModelScope.launch {
 65            _uiState.update { it.copy(buscando = true, mensaje = null) }
 66
 67            repository.buscarEnApiPorIsbn(isbn)
 68                .onSuccess { libro ->
 69                    if (libro == null) {
 70                        _uiState.update {
 71                            it.copy(
 72                                buscando = false,
 73                                mensaje = "Sin resultados para el ISBN $isbn."
 74                            )
 75                        }
 76                    } else {
 77                        rellenarCon(libro)
 78                    }
 79                }
 80                .onFailure { error ->
 81                    _uiState.update { it.copy(buscando = false, mensaje = error.message) }
 82                }
 83        }
 84    }
 85
 86    // ─── Búsqueda por título: la lanza el debounce del init ─────────────────────
 87
 88    private suspend fun buscarSugerencias(titulo: String) {
 89        _uiState.update { it.copy(buscando = true, mensaje = null) }
 90
 91        repository.buscarEnApiPorTitulo(titulo)
 92            .onSuccess { libros ->
 93                _uiState.update { it.copy(buscando = false, sugerencias = libros) }
 94            }
 95            .onFailure { error ->
 96                _uiState.update {
 97                    it.copy(buscando = false, sugerencias = emptyList(), mensaje = error.message)
 98                }
 99            }
100    }
101
102    // El usuario pulsa una sugerencia: se vuelca el libro en el formulario
103    fun seleccionarSugerencia(libro: Libro) = rellenarCon(libro)
104
105    private fun rellenarCon(libro: Libro) {
106        _uiState.update { estado ->
107            estado.copy(
108                titulo = libro.titulo,
109                autor = libro.autor,
110                year = libro.year?.toString().orEmpty(),
111                // Si la API no devuelve ISBN se conserva el que escribió el usuario
112                isbn = libro.isbn.ifBlank { estado.isbn },
113                cover = libro.cover,
114                idOpenLibrary = libro.id,
115                sugerencias = emptyList(),      // se ocultan las sugerencias
116                buscando = false,
117                mensaje = null
118            )
119        }
120        // Evita que el debounce vuelva a buscar con el título recién rellenado
121        consultaTitulo.value = libro.titulo
122    }
123
124    // ─── Alta en Room ───────────────────────────────────────────────────────────
125
126    fun guardar() {
127        val estado = _uiState.value
128        if (!estado.puedeGuardar) return
129
130        viewModelScope.launch {
131            val libro = Libro(
132                // Si el libro viene de la API se conserva su key ("/works/OL...").
133                // Si el alta es manual se genera un id local único, para no colisionar
134                // nunca con un identificador real de Open Library.
135                id = estado.idOpenLibrary ?: "/local/${UUID.randomUUID()}",
136                titulo = estado.titulo.trim(),
137                autor = estado.autor.trim(),
138                year = estado.year.toIntOrNull(),
139                isbn = estado.isbn,
140                cover = estado.cover
141            )
142            repository.guardarLibro(libro)
143            _uiState.update { it.copy(guardado = true) }
144        }
145    }
146
147    fun limpiarMensaje() = _uiState.update { it.copy(mensaje = null) }
148
149    companion object {
150        val Factory: ViewModelProvider.Factory = viewModelFactory {
151            initializer {
152                val app = checkNotNull(
153                    this[ViewModelProvider.AndroidViewModelFactory.APPLICATION_KEY]
154                ) as AppDummyApplication
155                NuevoLibroViewModel(app.container.librosRepository)
156            }
157        }
158    }
159}

debounce está marcado como @FlowPreview. Es una API estable en la práctica y ampliamente utilizada, pero el compilador exige declarar explícitamente que se conoce su estado mediante @OptIn(FlowPreview::class). Omitir esa anotación produce un error de compilación, no un simple aviso.

10.5. El composable del formulario#

  1import androidx.compose.foundation.clickable
  2import androidx.compose.foundation.layout.*
  3import androidx.compose.foundation.lazy.LazyColumn
  4import androidx.compose.foundation.lazy.items
  5import androidx.compose.foundation.rememberScrollState
  6import androidx.compose.foundation.text.KeyboardOptions
  7import androidx.compose.foundation.verticalScroll
  8import androidx.compose.material.icons.Icons
  9import androidx.compose.material.icons.automirrored.filled.ArrowBack
 10import androidx.compose.material.icons.filled.Search
 11import androidx.compose.material3.*
 12import androidx.compose.runtime.*
 13import androidx.compose.ui.Alignment
 14import androidx.compose.ui.Modifier
 15import androidx.compose.ui.text.input.KeyboardType
 16import androidx.compose.ui.unit.dp
 17import androidx.lifecycle.compose.collectAsStateWithLifecycle
 18import androidx.lifecycle.viewmodel.compose.viewModel
 19
 20// ─── screens/nuevo/PantallaNuevoLibro.kt ─────────────────────────────────────────────────────────
 21@OptIn(ExperimentalMaterial3Api::class)
 22@Composable
 23fun PantallaNuevoLibro(
 24    viewModel: NuevoLibroViewModel = viewModel(factory = NuevoLibroViewModel.Factory),
 25    onGuardado: () -> Unit = {},
 26    onCancelar: () -> Unit = {}
 27) {
 28    val uiState by viewModel.uiState.collectAsStateWithLifecycle()
 29    val snackbarHostState = remember { SnackbarHostState() }
 30
 31    // Efecto de navegación: cuando el ViewModel confirma el guardado, se vuelve atrás.
 32    // La clave del LaunchedEffect es el propio flag, así solo se ejecuta al cambiar.
 33    LaunchedEffect(uiState.guardado) {
 34        if (uiState.guardado) onGuardado()
 35    }
 36
 37    // Muestra los errores de red o de validación y limpia el mensaje después
 38    LaunchedEffect(uiState.mensaje) {
 39        uiState.mensaje?.let {
 40            snackbarHostState.showSnackbar(it)
 41            viewModel.limpiarMensaje()
 42        }
 43    }
 44
 45    Scaffold(
 46        topBar = {
 47            TopAppBar(
 48                title = { Text("Añadir libro") },
 49                navigationIcon = {
 50                    IconButton(onClick = onCancelar) {
 51                        Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Volver")
 52                    }
 53                }
 54            )
 55        },
 56        snackbarHost = { SnackbarHost(snackbarHostState) }
 57    ) { paddingValues ->
 58        Column(
 59            modifier = Modifier
 60                .padding(paddingValues)
 61                .padding(16.dp)
 62                .verticalScroll(rememberScrollState()),
 63            verticalArrangement = Arrangement.spacedBy(12.dp)
 64        ) {
 65            // ─── Búsqueda por ISBN-13 ───────────────────────────────────────────
 66            OutlinedTextField(
 67                value = uiState.isbn,
 68                onValueChange = viewModel::actualizarIsbn,
 69                label = { Text("ISBN-13") },
 70                supportingText = { Text("13 dígitos; pulsa la lupa para buscar en Open Library") },
 71                keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
 72                trailingIcon = {
 73                    IconButton(onClick = viewModel::buscarPorIsbn) {
 74                        Icon(Icons.Default.Search, contentDescription = "Buscar por ISBN")
 75                    }
 76                },
 77                singleLine = true,
 78                modifier = Modifier.fillMaxWidth()
 79            )
 80
 81            // ─── Búsqueda por título (con debounce automático) ──────────────────
 82            OutlinedTextField(
 83                value = uiState.titulo,
 84                onValueChange = viewModel::actualizarTitulo,
 85                label = { Text("Título *") },
 86                supportingText = { Text("A partir de 3 caracteres se buscan sugerencias") },
 87                singleLine = true,
 88                modifier = Modifier.fillMaxWidth()
 89            )
 90
 91            if (uiState.buscando) {
 92                LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
 93            }
 94
 95            // ─── Sugerencias devueltas por la API ───────────────────────────────
 96            if (uiState.sugerencias.isNotEmpty()) {
 97                Card(modifier = Modifier.fillMaxWidth()) {
 98                    LazyColumn(modifier = Modifier.heightIn(max = 240.dp)) {
 99                        items(uiState.sugerencias, key = { it.id }) { libro ->
100                            ListItem(
101                                headlineContent = { Text(libro.titulo) },
102                                supportingContent = {
103                                    Text("${libro.autor}${libro.year ?: "s. f."}")
104                                },
105                                leadingContent = {
106                                    CaratulaLibro(
107                                        coverUrl = libro.cover,
108                                        titulo = libro.titulo,
109                                        modifier = Modifier.width(40.dp)
110                                    )
111                                },
112                                modifier = Modifier.clickable {
113                                    viewModel.seleccionarSugerencia(libro)
114                                }
115                            )
116                            HorizontalDivider()
117                        }
118                    }
119                }
120            }
121
122            // ─── Campos rellenados automáticamente (siguen siendo editables) ────
123            OutlinedTextField(
124                value = uiState.autor,
125                onValueChange = viewModel::actualizarAutor,
126                label = { Text("Autor *") },
127                singleLine = true,
128                modifier = Modifier.fillMaxWidth()
129            )
130
131            Row(
132                horizontalArrangement = Arrangement.spacedBy(16.dp),
133                verticalAlignment = Alignment.CenterVertically,
134                modifier = Modifier.fillMaxWidth()
135            ) {
136                OutlinedTextField(
137                    value = uiState.year,
138                    onValueChange = viewModel::actualizarYear,
139                    label = { Text("Año") },
140                    keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
141                    singleLine = true,
142                    modifier = Modifier.weight(1f)
143                )
144
145                // Previsualización de la carátula obtenida de la Covers API
146                CaratulaLibro(
147                    coverUrl = uiState.cover,
148                    titulo = uiState.titulo,
149                    modifier = Modifier.width(90.dp)
150                )
151            }
152
153            Spacer(modifier = Modifier.height(8.dp))
154
155            Button(
156                onClick = viewModel::guardar,
157                enabled = uiState.puedeGuardar,
158                modifier = Modifier.fillMaxWidth()
159            ) {
160                Text("Guardar en la biblioteca")
161            }
162        }
163    }
164}

10.6. Ajuste del FloatingActionButton#

 1// ─── screens/listado/PantallaListado.kt — cambios ────────────────────────────────────────────────
 2@Composable
 3fun PantallaListado(
 4    viewModel: LibrosViewModel = viewModel(factory = LibrosViewModel.Factory),
 5    onNavegaADetalle: (String) -> Unit = {},
 6    onNavegaANuevoLibro: () -> Unit = {}      // ← nuevo callback
 7) {
 8    // ...
 9    Scaffold(
10        floatingActionButton = {
11            // Antes: onClick = viewModel::cargarLibros (carga de la lista estática)
12            FloatingActionButton(onClick = onNavegaANuevoLibro) {
13                Icon(Icons.Default.Add, contentDescription = "Añadir libro")
14            }
15        }
16        // ...
17    )
18}

No hace falta nada más: PantallaListado observa repository.observarLibros(), que es un Flow de Room. En cuanto el formulario inserta la fila, Room emite una nueva lista y la pantalla se actualiza sola. Esta es la ventaja de haber mantenido a Room como única fuente de verdad, un principio que se formalizará en T6 con la arquitectura offline-first.


11. Gson vs kotlinx.serialization#

El curso usa Gson por su sencillez y porque no requiere ningún plugin adicional: con DTOs planos como los de la sección 4, basta con @SerializedName y el GsonConverterFactory por defecto. Como referencia, se muestra la alternativa con kotlinx.serialization, que es la opción más moderna y eficiente:

Aspecto Gson kotlinx.serialization
Configuración Solo dependencia Plugin del compilador + dependencia
Rendimiento Reflexión (más lento) Generación de código (más rápido)
Soporte tipos Kotlin Limitado: ignora la nulabilidad y los valores por defecto Nativo: los respeta ✅
Multiplataforma No Sí (KMP)
Integración con @Serializable de Navigation No Sí (misma anotación)
DTOs nullable obligatorios Sí (usa reflexión, no el constructor) No
Complejidad para principiantes Baja ✅ Media
 1// Con kotlinx.serialization (alternativa — NO se usa en este curso)
 2// Plugin: "org.jetbrains.kotlin.plugin.serialization" (ya presente por Navigation)
 3// Dependencia: "com.jakewharton.retrofit:retrofit2-kotlinx-serialization-converter:1.0.0"
 4
 5import kotlinx.serialization.SerialName
 6import kotlinx.serialization.Serializable
 7
 8// El mismo DTO de la sección 4, pero con kotlinx.serialization.
 9// Aquí los valores por defecto SÍ se aplican y la nulabilidad SÍ se respeta,
10// así que no hace falta declarar absolutamente todo como nullable.
11@Serializable
12data class LibroDto(
13    val key: String,
14    val title: String,
15    @SerialName("author_name")        val authorName: List<String> = emptyList(),
16    @SerialName("first_publish_year") val firstPublishYear: Int? = null,
17    @SerialName("cover_i")            val coverId: Int? = null
18)

Fíjate en que el proyecto ya incluye kotlinx-serialization-json desde T3, porque las rutas tipadas de Navigation Compose lo necesitan. Usar Gson para la red y kotlinx.serialization para la navegación no es incoherente: son dos usos independientes de JSON.


Estructura de paquetes al final de T5#

com.ejemplo.appdummy/
├── AppDummyApplication.kt            ← implementa SingletonImageLoader.Factory
├── MainActivity.kt
├── data/
│   ├── datasource/
│   │   ├── local/
│   │   │   ├── AppDatabase.kt
│   │   │   ├── Converters.kt
│   │   │   ├── LibrosDao.kt
│   │   │   └── LocalDataSource.kt           ← + guardar(libro)
│   │   └── remote/                          ← NUEVO en T5
│   │       ├── dto/
│   │       │   ├── BusquedaResponseDto.kt   ← envoltorio de search.json
│   │       │   ├── LibroDto.kt              ← forma exacta del JSON
│   │       │   └── LibroMapper.kt           ← toLibro() / toLibros()
│   │       ├── OpenLibrary.kt               ← URLs, campos y User-Agent
│   │       ├── OpenLibraryApiService.kt
│   │       ├── RemoteDataSource.kt          ← frontera DTO → entidad
│   │       └── RetrofitClient.kt
│   ├── di/
│   │   └── AppContainer.kt                  ← + okHttpClient y remoteDataSource
│   ├── model/
│   │   └── Libro.kt                         ← @Entity — SIN cambios respecto a T4
│   └── repository/
│       └── LibrosRepository.kt              ← Local + Remote, devuelve Result<T>
├── navegacion/
│   ├── AppDummyBottomBar.kt
│   ├── AppNavigation.kt              ← + composable<NuevoLibro>
│   ├── ItemsNavegacion.kt
│   └── Rutas.kt                      ← + data object NuevoLibro
├── screens/
│   ├── componentes/                  ← NUEVO en T5
│   │   └── CaratulaLibro.kt          ← AsyncImage reutilizable con placeholder/error
│   │   └── ItemLibro.kt              ← composable reutilizable que usa CaratulaLibro en lugar de Patterns.WEB_URL
│   ├── detalle/
│   ├── favoritos/
│   ├── listado/
│   │   └── PantallaListado.kt        ← el FAB navega a NuevoLibro
│   └── nuevo/                        ← NUEVO en T5
│       ├── NuevoLibroUiState.kt
│       ├── NuevoLibroViewModel.kt
│       └── PantallaNuevoLibro.kt
└── utils/
    └── Isbn.kt                       ← NUEVO: validación del dígito de control

Y en el conjunto de pruebas unitarias (app/src/test), que se ejecuta en la JVM sin emulador:

app/src/test/java/com/ejemplo/appdummy/
└── LibroMapperTest.kt                ← NUEVO: verifica el mapeo DTO → entidad

Fíjate en la simetría de la capa de datos: local guarda el DAO y su wrapper, remote guarda el servicio, los DTOs y su wrapper, y el repositorio combina ambos sin conocer ni Room ni Retrofit por dentro. Es exactamente el flujo UI ↔ ViewModel ↔ Repository ↔ DataSources ↔ Frameworks que se persigue en el curso.


Desarrollo práctico guiado: AppDummy con Open Library 💻#

Llegados a este punto, la app ya persiste datos con Room. En este tema se le añade la capacidad de obtener libros reales de Open Library y darlos de alta desde un formulario. Estos son los cambios que debes realizar sobre la versión v6 :

  • Añade al catálogo de versiones y al build.gradle.kts las dependencias de Retrofit y OkHttp, activa buildConfig = true y declara el permiso INTERNET en el manifiesto.
  • Crea el paquete data/datasource/remote con OpenLibrary, OpenLibraryApiService, RetrofitClient y RemoteDataSource, y dentro de él el subpaquete dto con LibroDto, BusquedaResponseDto y LibroMapper. Recuerda que la entidad Libro no se modifica.
  • Declara todas las propiedades de los DTOs como nullable y con valor por defecto. Comprueba tú mismo el motivo: pon val title: String (no nullable) en LibroDto, busca un libro sin ese campo y observa dónde se produce el fallo.
  • Escribe LibroMapperTest en app/src/test y ejecútalo antes de conectar nada a la red. Es la forma más rápida de verificar el mapeo sin depender de la conexión ni de los límites de peticiones de Open Library.
  • Asegúrate de que LibroDto no aparece en ningún import fuera del paquete remote. Si se cuela en el repositorio o en un ViewModel, la separación de capas se ha roto.
  • Amplía LocalDataSource con guardar(libro) y LibrosRepository con guardarLibro(), buscarEnApiPorTitulo() y buscarEnApiPorIsbn(). Todas las llamadas de red deben devolver Result<T>; ninguna excepción de red debe llegar al ViewModel.
  • Actualiza AppContainer para que construya RemoteDataSource y exponga el OkHttpClient, y haz que AppDummyApplication implemente SingletonImageLoader.Factory para que Coil reutilice ese mismo cliente.
  • Elimina la lista estática de libros de LibrosRepository y el método getLibros(): a partir de ahora los datos entran en la app únicamente a través del formulario. Ajusta en consecuencia cargarLibros() de LibrosViewModel.
  • Crea el paquete screens/nuevo con el estado, el ViewModel y el composable del formulario, y la ruta NuevoLibro en Rutas.kt y AppNavigation.kt.
  • Sustituye en ItemLibro la comprobación con Patterns.WEB_URL por el placeholder / error de Coil, extrayendo el composable CaratulaLibro para reutilizarlo en el listado, el detalle y el formulario.
  • Comprueba en Logcat, con HttpLoggingInterceptor en Level.BODY, que las peticiones incluyen la cabecera User-Agent y el parámetro fields, y que el JSON se deserializa correctamente.
  • Prueba los casos límite: un ISBN inválido, un ISBN válido inexistente en Open Library, un libro sin portada, un libro sin autor registrado y la app sin conexión a internet. En ninguno de ellos la aplicación debe cerrarse.

Referencias#

Calendar  Última modificación: miércoles, 12 de agosto de 2026