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 retrofit y converter-gson siempre deben coincidir. Coil 3.x cambió las coordenadas de grupo: 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.


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 corrutinas

El 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.org

2. 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:

  1. Crear una cuenta gratuita en themoviedb.org
  2. Ir a Cuenta → Configuración → API
  3. Solicitar una API Key seleccionando “Desarrollador” y tipo de uso “Personal/Educativo”
  4. Completar el formulario breve (nombre de la app, descripción del uso)
  5. 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.properties y BuildConfig.

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): PeliculaResponse

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.


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 OkHttpClient y Retrofit se realiza dentro del AppContainer (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?"


Referencias#

Calendar  Última modificación: viernes, 26 de junio de 2026