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
retrofityconverter-gsonsiempre deben coincidir. Coil 3.x cambió las coordenadas de grupo: se usaio.coil-kt.coil3(con el3al final), noio.coil-kt(versión 2.x); para carga de imágenes desde red,coil-network-okhttpes necesario además decoil-compose. Por último, desde AGP 8.0 la claseBuildConfigno se genera si no se activabuildConfig = trueenbuildFeatures.
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 corrutinasEl 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.orgObserva 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
.jsona 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-Agenty 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/.
El parámetro
fieldspermite limitar los campos que devuelve el servidor. Es muy recomendable usarlo: la respuesta completa desearch.jsonpuede superar los 100 KB por documento, mientras que pidiendo solo los seis campos que necesita AppDummy se queda en unos pocos cientos de bytes.
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:
keyes una cadena con formato de ruta (/works/OL21745884W), no un entero. Por eso en T4 la clave primaria deLibrose definió comoString.author_nameeisbnson arrays, mientras que la entidadLibroguarda un único autor y un único ISBN.cover_ies 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:
- Crear un
data classde red separado (un DTO) con los tipos exactos del JSON y una función de mapeotoLibro(). - Añadir propiedades auxiliares a
Libromarcadas con@Ignorepara que Room las descarte. - Enseñar a Gson cómo construir un
Libroa 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.
Librodescribe 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
LibroDtose 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
JsonDeserializerobliga a manipularJsonObject,JsonElementy comprobaciones deisJsonNull; un mapeador es una función corriente con?:,firstOrNull()yjoinToString(). - 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 elGsonBuilderpermitirí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 valiendonullen 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 unNullPointerExceptiondifí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): BusquedaResponseDtoCon 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
trueen compilaciones de debug yfalseen 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 carpetabuildde 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.
LibroDtopertenece aremotey no debe aparecer en ningúnimportfuera 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.Resultobliga 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 deItemLibro.
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}
debounceestá 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-jsondesde T3, porque las rutas tipadas de Navigation Compose lo necesitan. Usar Gson para la red ykotlinx.serializationpara 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 controlY 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 → entidadFíjate en la simetría de la capa de datos:
localguarda el DAO y su wrapper,remoteguarda 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.ktslas dependencias de Retrofit y OkHttp, activabuildConfig = truey declara el permisoINTERNETen el manifiesto. - Crea el paquete
data/datasource/remoteconOpenLibrary,OpenLibraryApiService,RetrofitClientyRemoteDataSource, y dentro de él el subpaquetedtoconLibroDto,BusquedaResponseDtoyLibroMapper. Recuerda que la entidadLibrono 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) enLibroDto, busca un libro sin ese campo y observa dónde se produce el fallo. - Escribe
LibroMapperTestenapp/src/testy 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
LibroDtono aparece en ningúnimportfuera del paqueteremote. Si se cuela en el repositorio o en un ViewModel, la separación de capas se ha roto. - Amplía
LocalDataSourceconguardar(libro)yLibrosRepositoryconguardarLibro(),buscarEnApiPorTitulo()ybuscarEnApiPorIsbn(). Todas las llamadas de red deben devolverResult<T>; ninguna excepción de red debe llegar al ViewModel. - Actualiza
AppContainerpara que construyaRemoteDataSourcey exponga elOkHttpClient, y haz queAppDummyApplicationimplementeSingletonImageLoader.Factorypara que Coil reutilice ese mismo cliente. - Elimina la lista estática de libros de
LibrosRepositoryy el métodogetLibros(): a partir de ahora los datos entran en la app únicamente a través del formulario. Ajusta en consecuenciacargarLibros()deLibrosViewModel. - Crea el paquete
screens/nuevocon el estado, el ViewModel y el composable del formulario, y la rutaNuevoLibroenRutas.ktyAppNavigation.kt. - Sustituye en
ItemLibrola comprobación conPatterns.WEB_URLpor elplaceholder/errorde Coil, extrayendo el composableCaratulaLibropara reutilizarlo en el listado, el detalle y el formulario. - Comprueba en Logcat, con
HttpLoggingInterceptorenLevel.BODY, que las peticiones incluyen la cabeceraUser-Agenty el parámetrofields, 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#
- Retrofit — documentación oficial Square
- OkHttp — documentación oficial Square
- Open Library — Índice de APIs y normas de uso
- Open Library — Search API
- Open Library — Covers API
- Open Library — Sandbox interactivo (OpenAPI)
- Coil — documentación oficial
- Coil — AsyncImage en Compose
- Gson — repositorio GitHub
- Capa de datos — Guía de arquitectura de Android
- Estado en Compose — Android Developers
- Corrutinas y Flow en Android — Android Developers
- Anexo B3-A4 — Referencia: Room avanzado, migraciones, testing y API Key segura