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#
1// build.gradle.kts (módulo app) — añadir a las dependencias de T4
2dependencies {
3 // Retrofit — cliente HTTP con soporte nativo a corrutinas
4 implementation("com.squareup.retrofit2:retrofit:2.11.0")
5 implementation("com.squareup.retrofit2:converter-gson:2.11.0")
6
7 // OkHttp — cliente HTTP subyacente + interceptor de logs para desarrollo
8 implementation("com.squareup.okhttp3:okhttp:4.12.0")
9 implementation("com.squareup.okhttp3:logging-interceptor:4.12.0")
10
11 // Gson — deserialización de JSON a objetos Kotlin/Java
12 implementation("com.google.code.gson:gson:2.11.0")
13
14 // Coil — carga asíncrona de imágenes en Compose
15 implementation("io.coil-kt.coil3:coil-compose:3.2.0")
16 implementation("io.coil-kt.coil3:coil-network-okhttp:3.2.0")
17}Añadir el permiso de Internet en AndroidManifest.xml:
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: 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.
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://api.tmdb.../") interface TmdbApiService {
val conn = url.openConnection() @GET("movie/popular")
conn.connect() suspend fun obtenerPopulares(
val stream = conn.inputStream @Query("api_key") key: String
val json = stream.bufferedReader() ): PeliculaResponse
.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 app Android moderna con Retrofit el flujo de una petición HTTP es:
Composable → ViewModel → Repository → RemoteDataSource
│
[Retrofit convierte la interfaz]
│
TmdbApiService
│
OkHttpClient ← gestiona conexiones, caché, logs
│
Internet ──► api.themoviedb.org2. La API de TMDB: registro y obtención de la clave#
TMDB (The Movie Database) es una base de datos colaborativa de películas y series con una API REST gratuita. Es ideal para proyectos de aprendizaje porque tiene documentación clara, no requiere tarjeta de crédito y permite un número de peticiones generoso en el plan gratuito.
Pasos para obtener la API Key#
Para usar la API de TMDB se necesita una clave de autenticación:
- Crear una cuenta gratuita en themoviedb.org
- Ir a Cuenta → Configuración → API
- Solicitar una API Key seleccionando “Desarrollador” y tipo de uso “Personal/Educativo”
- Completar el formulario breve (nombre de la app, descripción del uso)
- Anotar el valor de API Key (v3 auth) — es una cadena hexadecimal de 32 caracteres
La API Key debe tratarse como un secreto: nunca debe incluirse directamente en el código fuente ni subirse a repositorios públicos. En el Anexo A2 se explica cómo almacenarla de forma segura usando
local.propertiesyBuildConfig.
Endpoints principales de TMDB para AppFlix#
La URL base de la API es https://api.themoviedb.org/3/. Todos los endpoints aceptan el parámetro language=es-ES para recibir títulos y descripciones en español cuando están disponibles.
| Endpoint | URL relativa | Devuelve |
|---|---|---|
| Películas populares | movie/popular |
Lista paginada de películas |
| Búsqueda | search/movie |
Películas que coinciden con la búsqueda |
| Detalle de película | movie/{id} |
Película con todos los campos |
| URL de póster | https://image.tmdb.org/t/p/w500{poster_path} |
Imagen JPEG |
Ejemplo de respuesta JSON de movie/popular#
1{
2 "page": 1,
3 "results": [
4 {
5 "id": 693134,
6 "title": "Dune: Parte Dos",
7 "overview": "Paul Atreides continúa su viaje en Arrakis...",
8 "vote_average": 8.1,
9 "poster_path": "/8b8R8l88Qje9dn9OE8PY05Nxl1X.jpg",
10 "genre_ids": [878, 12]
11 }
12 ],
13 "total_results": 40000,
14 "total_pages": 2000
15}Los nombres de los campos JSON usan snake_case (por ejemplo, vote_average), mientras que en Kotlin se usa camelCase (votacionMedia). La anotación @SerializedName de Gson realiza el mapeo automáticamente.
3. Modelo de datos: el mismo data class para Room y Retrofit#
Una de las decisiones arquitectónicas de este curso es reutilizar el mismo data class Pelicula tanto para Room como para Retrofit. Esto es posible porque Room ignora las anotaciones de Gson (@SerializedName) y Gson ignora las anotaciones de Room (@Entity, @ColumnInfo).
1// data/model/Pelicula.kt — versión completa compatible con Room Y Retrofit/Gson
2import androidx.room.ColumnInfo
3import androidx.room.Entity
4import androidx.room.PrimaryKey
5import com.google.gson.annotations.SerializedName
6
7@Entity(tableName = "peliculas")
8data class Pelicula(
9
10 // id es la misma clave primaria en Room y el identificador de TMDB en la API
11 @PrimaryKey
12 @SerializedName("id")
13 val id: Int,
14
15 // @ColumnInfo personaliza el nombre en SQLite
16 // @SerializedName mapea el campo JSON al nombre de la propiedad Kotlin
17 @ColumnInfo(name = "titulo")
18 @SerializedName("title")
19 val titulo: String,
20
21 @ColumnInfo(name = "sinopsis")
22 @SerializedName("overview")
23 val sinopsis: String = "",
24
25 @ColumnInfo(name = "puntuacion")
26 @SerializedName("vote_average")
27 val puntuacion: Double = 0.0,
28
29 // TMDB devuelve el poster como un path relativo: "/8b8R8l88Qje9dn9OE8PY05Nxl1X.jpg"
30 // La URL completa es: https://image.tmdb.org/t/p/w500{poster_path}
31 @ColumnInfo(name = "poster_url")
32 @SerializedName("poster_path")
33 val posterUrl: String? = null,
34
35 // genre_ids en TMDB es una lista de enteros; en Room se almacena como JSON
36 // Se necesita un TypeConverter para este campo (ver T4, sección 5)
37 @ColumnInfo(name = "genero_ids")
38 @SerializedName("genre_ids")
39 val generoIds: List<Int> = emptyList(),
40
41 // Campo exclusivamente local — no existe en el JSON de TMDB
42 // Gson lo ignorará al deserializar (no hay @SerializedName para este campo)
43 @ColumnInfo(name = "es_favorita")
44 val esFavorita: Boolean = false
45)
46
47// Wrapper de la respuesta paginada de TMDB
48// Este data class NO necesita @Entity — no se almacena en Room
49data class PeliculaResponse(
50 @SerializedName("page") val pagina: Int,
51 @SerializedName("results") val resultados: List<Pelicula>,
52 @SerializedName("total_results") val totalResultados: Int,
53 @SerializedName("total_pages") val totalPaginas: Int
54)Limitación del enfoque compartido: en proyectos grandes o con APIs con estructuras JSON muy distintas a las entidades de Room, es habitual usar DTOs (Data Transfer Objects) separados para la red y entidades separadas para Room, con un mapeo explícito entre ellos. En este curso se omite esta capa para reducir la complejidad; en el Bloque 3 se explicará el trade-off.
4. Definir la interfaz de la API#
La interfaz TmdbApiService declara los endpoints de TMDB que usará la app. Retrofit genera automáticamente la implementación a partir de las anotaciones.
1// data/datasource/remote/TmdbApiService.kt
2import retrofit2.http.GET
3import retrofit2.http.Path
4import retrofit2.http.Query
5
6interface TmdbApiService {
7
8 // Películas populares — paginadas
9 // GET https://api.themoviedb.org/3/movie/popular?api_key=...&language=es-ES&page=1
10 @GET("movie/popular")
11 suspend fun obtenerPopulares(
12 @Query("api_key") apiKey: String,
13 @Query("language") idioma: String = "es-ES",
14 @Query("page") pagina: Int = 1
15 ): PeliculaResponse
16
17 // Búsqueda por texto
18 // GET https://api.themoviedb.org/3/search/movie?api_key=...&query=dune&language=es-ES
19 @GET("search/movie")
20 suspend fun buscarPeliculas(
21 @Query("api_key") apiKey: String,
22 @Query("query") consulta: String,
23 @Query("language") idioma: String = "es-ES",
24 @Query("page") pagina: Int = 1
25 ): PeliculaResponse
26
27 // Detalle de una película por su id
28 // GET https://api.themoviedb.org/3/movie/693134?api_key=...&language=es-ES
29 @GET("movie/{id}")
30 suspend fun obtenerDetalle(
31 @Path("id") peliculaId: Int,
32 @Query("api_key") apiKey: String,
33 @Query("language") idioma: String = "es-ES"
34 ): Pelicula
35}Anotaciones de Retrofit más utilizadas#
| Anotación | Uso | Ejemplo |
|---|---|---|
@GET("ruta") |
Petición HTTP GET | @GET("movie/popular") |
@POST("ruta") |
Petición HTTP POST | @POST("auth/login") |
@PUT("ruta") |
Petición HTTP PUT | @PUT("user/{id}") |
@DELETE("ruta") |
Petición HTTP DELETE | @DELETE("watchlist/{id}") |
@Path("nombre") |
Sustituye {nombre} en la URL |
@Path("id") peliculaId: Int |
@Query("nombre") |
Añade ?nombre=valor a la URL |
@Query("page") pagina: Int |
@Body |
Envía un objeto como cuerpo JSON | @Body pelicula: Pelicula |
@Header("nombre") |
Añade una cabecera HTTP | @Header("Authorization") token: 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("movie/popular")
3fun obtenerPopularesLegacy(@Query("api_key") key: String): Call<PeliculaResponse>
4
5// Estilo moderno — suspend, se llama directamente en una corrutina ✅
6@GET("movie/popular")
7suspend fun obtenerPopulares(@Query("api_key") key: String): PeliculaResponseCon 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.
5. 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 el interceptor de logs durante el desarrollo.
1// data/datasource/remote/RetrofitClient.kt
2// (En producción, esta lógica va en el AppContainer)
3
4import com.google.gson.GsonBuilder
5import okhttp3.OkHttpClient
6import okhttp3.logging.HttpLoggingInterceptor
7import retrofit2.Retrofit
8import retrofit2.converter.gson.GsonConverterFactory
9import java.util.concurrent.TimeUnit
10
11object RetrofitClient {
12
13 private const val BASE_URL = "https://api.themoviedb.org/3/"
14
15 // HttpLoggingInterceptor imprime en Logcat las peticiones y respuestas HTTP
16 // Level.BODY: muestra URL, headers y cuerpo completo (solo en debug)
17 // Level.NONE: no registra nada (para release)
18 private val loggingInterceptor = HttpLoggingInterceptor().apply {
19 level = if (BuildConfig.DEBUG) HttpLoggingInterceptor.Level.BODY
20 else HttpLoggingInterceptor.Level.NONE
21 }
22
23 val okHttpClient: OkHttpClient = OkHttpClient.Builder()
24 .connectTimeout(30, TimeUnit.SECONDS) // tiempo máximo para conectar
25 .readTimeout(30, TimeUnit.SECONDS) // tiempo máximo para leer la respuesta
26 .writeTimeout(30, TimeUnit.SECONDS) // tiempo máximo para enviar datos
27 .addInterceptor(loggingInterceptor)
28 .build()
29
30 // Gson con configuración por defecto — acepta valores null en campos no presentes en JSON
31 private val gson = GsonBuilder()
32 .serializeNulls()
33 .create()
34
35 private val retrofit: Retrofit = Retrofit.Builder()
36 .baseUrl(BASE_URL)
37 .client(okHttpClient)
38 .addConverterFactory(GsonConverterFactory.create(gson))
39 .build()
40
41 // Crear la implementación de la interfaz de la API
42 val tmdbApiService: TmdbApiService = retrofit.create(TmdbApiService::class.java)
43}En el proyecto AppFlix, la creación de
OkHttpClientyRetrofitse realiza dentro delAppContainer(ver T6, sección 3) para garantizar que la misma instancia se comparte entre Retrofit y Coil.
6. RemoteDataSource: wrapper del servicio API#
Al igual que LocalDataSource encapsula el DAO, RemoteDataSource encapsula el servicio Retrofit. El repositorio llama a RemoteDataSource, no al servicio directamente:
1// data/datasource/remote/RemoteDataSource.kt
2import java.io.IOException
3
4class RemoteDataSource(private val apiService: TmdbApiService) {
5
6 // La API Key se almacena en BuildConfig (ver Anexo A2 para la configuración segura)
7 private val apiKey = BuildConfig.TMDB_API_KEY
8
9 // Las funciones son suspend: se deben llamar desde una corrutina
10 // Si la petición falla, Retrofit lanza IOException (sin red) o HttpException (error HTTP)
11 // El manejo de esas excepciones se realiza en el Repository
12
13 suspend fun obtenerPopulares(pagina: Int = 1): List<Pelicula> {
14 return apiService.obtenerPopulares(
15 apiKey = apiKey,
16 pagina = pagina
17 ).resultados
18 }
19
20 suspend fun buscarPeliculas(consulta: String, pagina: Int = 1): List<Pelicula> {
21 return apiService.buscarPeliculas(
22 apiKey = apiKey,
23 consulta = consulta,
24 pagina = pagina
25 ).resultados
26 }
27
28 suspend fun obtenerDetalle(id: Int): Pelicula {
29 return apiService.obtenerDetalle(
30 peliculaId = id,
31 apiKey = apiKey
32 )
33 }
34}7. 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, 401 Unauthorized, 500 Server Error |
1// Manejo de errores en el Repository — el ViewModel recibe solo UiState
2class PeliculasRepository(
3 private val localDataSource: LocalDataSource,
4 private val remoteDataSource: RemoteDataSource
5) {
6 // La sincronización captura los errores y los transforma en UiState.Error
7 // De esta forma, el ViewModel no necesita conocer IOException ni HttpException
8 suspend fun sincronizar(): Result<Unit> {
9 return try {
10 val peliculas = remoteDataSource.obtenerPopulares()
11 localDataSource.upsertConservandoFavorita(peliculas)
12 Result.success(Unit)
13 } catch (e: IOException) {
14 // Sin red: los datos locales siguen disponibles
15 Result.failure(Exception("Sin conexión a internet: ${e.message}"))
16 } catch (e: retrofit2.HttpException) {
17 // Error del servidor (4xx, 5xx)
18 val mensajeError = when (e.code()) {
19 401 -> "API Key inválida o caducada"
20 404 -> "Recurso no encontrado"
21 429 -> "Demasiadas peticiones. Intenta más tarde."
22 in 500..599 -> "Error del servidor (${e.code()})"
23 else -> "Error HTTP ${e.code()}"
24 }
25 Result.failure(Exception(mensajeError))
26 }
27 }
28}8. Carga de imágenes con Coil#
Coil (Coroutine Image Loader) es la biblioteca de carga de imágenes recomendada para Compose. Descarga imágenes de forma asíncrona, las cachea en disco y en memoria, y proporciona el composable AsyncImage para mostrarlas.
Configurar Coil en Application#
Para compartir el OkHttpClient entre Retrofit y Coil (evitando dos clientes HTTP independientes), se configura un ImageLoader personalizado en la clase Application:
1// AppFlixApplication.kt
2import coil3.ImageLoader
3import coil3.PlatformContext
4import coil3.SingletonImageLoader
5import coil3.network.okhttp.OkHttpNetworkFetcherFactory
6import coil3.request.crossfade
7
8class AppFlixApplication : Application(), SingletonImageLoader.Factory {
9
10 lateinit var container: AppContainer
11
12 override fun onCreate() {
13 super.onCreate()
14 container = DefaultAppContainer(this)
15 }
16
17 // SingletonImageLoader.Factory: Coil llama a este método para crear
18 // el ImageLoader singleton que usará AsyncImage en toda la app
19 override fun newImageLoader(context: PlatformContext): ImageLoader {
20 return ImageLoader.Builder(context)
21 .components {
22 // OkHttpNetworkFetcherFactory: usa el mismo OkHttpClient que Retrofit
23 // para que ambos compartan el pool de conexiones y la caché
24 add(OkHttpNetworkFetcherFactory(
25 callFactory = { container.okHttpClient }
26 ))
27 }
28 .crossfade(true) // animación de fundido al cargar las imágenes
29 .build()
30 }
31}AsyncImage: mostrar el póster de una película#
1// Composable para mostrar el póster de una película con Coil
2@Composable
3fun PosterPelicula(
4 posterUrl: String?,
5 titulo: String,
6 modifier: Modifier = Modifier
7) {
8 // Construir la URL completa del póster de TMDB
9 // posterUrl llega como path relativo: "/8b8R8l88Qje9dn9OE8PY05Nxl1X.jpg"
10 val urlCompleta = posterUrl?.let { "https://image.tmdb.org/t/p/w500$it" }
11
12 AsyncImage(
13 model = ImageRequest.Builder(LocalContext.current)
14 .data(urlCompleta)
15 .crossfade(true)
16 .build(),
17 // placeholder: imagen mientras se carga (puede ser un drawable o un composable)
18 placeholder = painterResource(R.drawable.ic_placeholder),
19 // error: imagen si la carga falla
20 error = painterResource(R.drawable.ic_broken_image),
21 contentDescription = "Póster de $titulo",
22 contentScale = ContentScale.Crop,
23 modifier = modifier
24 .fillMaxWidth()
25 .aspectRatio(2f / 3f) // proporción estándar de póster de cine
26 .clip(RoundedCornerShape(8.dp))
27 )
28}
29
30// Uso en la tarjeta de película
31@Composable
32fun TarjetaPelicula(
33 pelicula: Pelicula,
34 onClickItem: () -> Unit,
35 onToggleFavorita: () -> Unit
36) {
37 Card(
38 onClick = onClickItem,
39 modifier = Modifier.fillMaxWidth()
40 ) {
41 Row(modifier = Modifier.fillMaxWidth()) {
42 // Póster a la izquierda
43 PosterPelicula(
44 posterUrl = pelicula.posterUrl,
45 titulo = pelicula.titulo,
46 modifier = Modifier.width(80.dp)
47 )
48
49 // Información a la derecha
50 Column(
51 modifier = Modifier
52 .weight(1f)
53 .padding(12.dp)
54 ) {
55 Text(pelicula.titulo, style = MaterialTheme.typography.titleSmall,
56 maxLines = 2, overflow = TextOverflow.Ellipsis)
57 Spacer(modifier = Modifier.height(4.dp))
58 Text(
59 text = "★ ${"%.1f".format(pelicula.puntuacion)}",
60 style = MaterialTheme.typography.bodySmall,
61 color = MaterialTheme.colorScheme.secondary
62 )
63 }
64
65 // Botón de favorita
66 IconButton(onClick = onToggleFavorita) {
67 Icon(
68 imageVector = if (pelicula.esFavorita) Icons.Default.Favorite
69 else Icons.Default.FavoriteBorder,
70 contentDescription = if (pelicula.esFavorita) "Quitar de favoritos"
71 else "Añadir a favoritos",
72 tint = if (pelicula.esFavorita) MaterialTheme.colorScheme.error
73 else MaterialTheme.colorScheme.onSurfaceVariant
74 )
75 }
76 }
77 }
78}Tamaños de imagen disponibles en TMDB#
La URL de imagen de TMDB tiene el formato: https://image.tmdb.org/t/p/{tamaño}{poster_path}
| Tamaño | Dimensiones | Uso recomendado |
|---|---|---|
w92 |
~92px | Miniaturas, iconos |
w185 |
~185px | Listas compactas |
w342 |
~342px | Listas normales |
w500 |
~500px | Detalle, tarjetas grandes ✅ |
w780 |
~780px | Pantalla completa |
original |
Original | Descarga, zoom |
9. Gson vs kotlinx.serialization#
El curso usa Gson por su sencillez y porque no requiere ningún plugin adicional. 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 (nullable, etc.) | Nativo |
| Multiplataforma | No | Sí (KMP) |
Integración con @Serializable de Navigation |
No | Sí (misma anotación) |
| Complejidad para principiantes | Baja ✅ | Media |
1// Con kotlinx.serialization (alternativa — NO se usa en este curso)
2// Requiere: plugin "org.jetbrains.kotlin.plugin.serialization"
3// Dependencia: "org.jetbrains.kotlinx:kotlinx-serialization-json:1.8.0"
4// Dependencia: "com.jakewharton.retrofit:retrofit2-kotlinx-serialization-converter:1.0.0"
5
6import kotlinx.serialization.SerialName
7import kotlinx.serialization.Serializable
8
9@Serializable
10data class PeliculaSerial(
11 val id: Int,
12 @SerialName("title") val titulo: String,
13 @SerialName("vote_average") val puntuacion: Double = 0.0
14)10. Ejemplo funcional: búsqueda con debounce#
Un patrón muy común en apps de búsqueda es el debounce: esperar un tiempo antes de lanzar la petición de red, para evitar hacer una petición por cada tecla que el usuario escribe.
1// ui/busqueda/BusquedaViewModel.kt
2class BusquedaViewModel(
3 private val repository: PeliculasRepository
4) : ViewModel() {
5
6 private val _consulta = MutableStateFlow("")
7 val consulta: StateFlow<String> = _consulta.asStateFlow()
8
9 // flatMapLatest: cuando llega una nueva consulta, cancela la petición anterior
10 // debounce: espera 500ms sin nuevas consultas antes de lanzar la petición
11 // Resultado: solo se hace una petición de red por "pausa" en el tecleo
12 val resultados: StateFlow<List<Pelicula>> = _consulta
13 .debounce(500)
14 .filter { it.length >= 2 } // no buscar con menos de 2 caracteres
15 .flatMapLatest { consulta ->
16 if (consulta.isBlank()) {
17 flowOf(emptyList()) // si la consulta está vacía, resultado vacío
18 } else {
19 repository.buscar(consulta)
20 .catch { emit(emptyList()) } // en caso de error, lista vacía
21 }
22 }
23 .stateIn(
24 scope = viewModelScope,
25 started = SharingStarted.WhileSubscribed(5_000),
26 initialValue = emptyList()
27 )
28
29 private val _estaBuscando = MutableStateFlow(false)
30 val estaBuscando: StateFlow<Boolean> = _estaBuscando.asStateFlow()
31
32 fun actualizarConsulta(texto: String) {
33 _consulta.value = texto
34 }
35
36 companion object {
37 val Factory: ViewModelProvider.Factory = viewModelFactory {
38 initializer {
39 val app = checkNotNull(
40 this[ViewModelProvider.AndroidViewModelFactory.APPLICATION_KEY]
41 ) as AppFlixApplication
42 BusquedaViewModel(app.container.peliculasRepository)
43 }
44 }
45 }
46}
47
48// ui/busqueda/PantallaBusqueda.kt
49@OptIn(ExperimentalMaterial3Api::class)
50@Composable
51fun PantallaBusqueda(
52 viewModel: BusquedaViewModel = viewModel(factory = BusquedaViewModel.Factory),
53 onNavegaADetalle: (Int) -> Unit
54) {
55 val consulta by viewModel.consulta.collectAsStateWithLifecycle()
56 val resultados by viewModel.resultados.collectAsStateWithLifecycle()
57 val estaBuscando by viewModel.estaBuscando.collectAsStateWithLifecycle()
58
59 Scaffold(
60 topBar = {
61 SearchBar(
62 inputField = {
63 SearchBarDefaults.InputField(
64 query = consulta,
65 onQueryChange = viewModel::actualizarConsulta,
66 onSearch = {},
67 expanded = true,
68 onExpandedChange = {},
69 placeholder = { Text("Buscar película...") },
70 leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) },
71 trailingIcon = {
72 if (consulta.isNotEmpty()) {
73 IconButton(onClick = { viewModel.actualizarConsulta("") }) {
74 Icon(Icons.Default.Clear, contentDescription = "Borrar")
75 }
76 }
77 }
78 )
79 },
80 expanded = true,
81 onExpandedChange = {},
82 modifier = Modifier.fillMaxWidth()
83 ) { }
84 }
85 ) { paddingValues ->
86 if (estaBuscando) {
87 Box(Modifier.fillMaxSize().padding(paddingValues),
88 contentAlignment = Alignment.Center) {
89 CircularProgressIndicator()
90 }
91 } else if (resultados.isEmpty() && consulta.length >= 2) {
92 Box(Modifier.fillMaxSize().padding(paddingValues),
93 contentAlignment = Alignment.Center) {
94 Text("Sin resultados para \"$consulta\"")
95 }
96 } else {
97 LazyColumn(
98 contentPadding = PaddingValues(16.dp),
99 verticalArrangement = Arrangement.spacedBy(8.dp),
100 modifier = Modifier.padding(paddingValues)
101 ) {
102 items(resultados, key = { it.id }) { pelicula ->
103 TarjetaPelicula(
104 pelicula = pelicula,
105 onClickItem = { onNavegaADetalle(pelicula.id) },
106 onToggleFavorita = { /* se implementa en T6 */ }
107 )
108 }
109 }
110 }
111 }
112}Actividades prácticas#
Actividad 5.1 — Conexión con TMDB (5 h)
Registrarse en TMDB, obtener una API Key y configurarla de forma segura en el proyecto (usando local.properties — ver Anexo A2). Implementar TmdbApiService, RemoteDataSource y verificar con el HttpLoggingInterceptor en Logcat que las peticiones se realizan correctamente y el JSON se deserializa bien.
Actividad 5.2 — Listado con imágenes de Coil (4 h)
Modificar la pantalla de listado de AppFlix para mostrar los pósters de TMDB usando AsyncImage. Verificar que las imágenes se cargan, que el placeholder aparece durante la carga y que el ícono de error aparece si la URL es nula.
Actividad 5.3 — Hito vertebrador H5 (3 h) Demostración en clase: la app muestra en tiempo real los resultados de búsqueda de TMDB con debounce, los pósters se cargan asíncronamente y los errores de red (desactivar WiFi) se gestionan sin cerrar la app.
Pruebas de evaluación#
Prueba T5.1 — Diseño de la interfaz de la API (25 min)
Dado el enunciado “App del tiempo: obtener el tiempo actual de una ciudad, el pronóstico de 5 días, y las ciudades guardadas como favoritas”, el alumno diseña en papel la interfaz Retrofit (@GET, @Path, @Query) con los endpoints necesarios, los data class de respuesta y la gestión de errores en el Repository.
Prueba T5.2 — Análisis de código Retrofit (20 min)
Se entrega un TmdbApiService con cuatro problemas: una función sin suspend que bloquearía el hilo principal, un @Path mal usado donde debería ser @Query, un @SerializedName incorrecto que causa que siempre devuelva null, y un OkHttpClient con Level.BODY activado en release. El alumno identifica y explica cada problema.
Prueba T5.3 — Defensa oral (5 min)
El profesor pregunta: "¿Por qué usamos debounce antes de hacer la petición de búsqueda?", "¿Qué diferencia hay entre IOException y HttpException? Pon un ejemplo de cada una.", "¿Por qué Coil y Retrofit comparten el mismo OkHttpClient?"