Tema 6. Integración Room + Retrofit2: Arquitectura offline-first

  • Bloque: B3 — Persistencia y comunicación: ROOM + Retrofit2
  • Duración aproximada: 10 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-e Se han utilizado las técnicas de acceso a servicios de comunicación en red disponibles.
RA2-f Se han almacenado y recuperado datos utilizando algún mecanismo de persistencia.
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.

1. El problema de combinar dos fuentes de datos#

En T4 la app guardaba películas en Room. En T5 se consumió la API de TMDB. Ahora hay que resolver la pregunta más importante de la capa de datos: ¿cuándo mostramos datos locales y cuándo datos de la red? ¿Qué pasa si no hay conexión?

Sin una estrategia bien definida, el código se vuelve complejo y propenso a errores:

 1// ❌ Antipatrón — la UI decide de dónde vienen los datos
 2@Composable
 3fun PantallaListadoMal(viewModel: ViewModel) {
 4    if (hayConexion()) {
 5        mostrarDatosDeRed()    // ¿y si falla la petición?
 6    } else {
 7        mostrarDatosLocales()  // ¿y si la caché está vacía?
 8    }
 9    // Esta lógica no pertenece aquí — pertenece a la capa de datos
10}

La solución es la estrategia offline-first, en la que la capa de datos toma todas estas decisiones de forma transparente para la UI.


2. La estrategia offline-first#

Offline-first significa que la aplicación funciona correctamente aunque no haya conexión a Internet. La UI siempre observa los datos locales (Room); la red se usa únicamente para actualizar esos datos locales.

┌─────────────────────────────────────────────────────────────┐
│                       UI (Composable)                       │
│                  observa StateFlow del ViewModel            │
└────────────────────────────┬────────────────────────────────┘
                             │ collectAsStateWithLifecycle
┌─────────────────────────────────────────────────────────────┐
│                        ViewModel                            │
│         stateIn() sobre el Flow del Repository              │
└────────────────────────────┬────────────────────────────────┘
                             │ Flow<List<Pelicula>>
┌─────────────────────────────────────────────────────────────┐
│                       Repository                            │
│                                                             │
│    observarPeliculas() ──► LocalDataSource ──► Room DAO     │
│                                    ▲                        │
│   sincronizar()                    │ INSERT/UPDATE          │
│   ├─ remoteDataSource.obtener()    │ (sin tocar esFavorita) │
│   └─ localDataSource.upsert() ─────┘                        │
└─────────────────────────────────────────────────────────────┘

Principios de la estrategia offline-first#

Room es la única fuente de verdad. La UI nunca recibe datos directamente de la red; siempre los lee de Room. La red solo sirve para actualizar Room.

La sincronización es transparente. El ViewModel llama a sincronizar() en el repositorio, que descarga los datos, los guarda en Room y termina. La UI se actualiza sola porque observa un Flow de Room que re-emite al detectar cambios.

Los errores de red no destruyen el estado local. Si sincronizar() falla (sin conexión, error del servidor), Room no se modifica y la UI sigue mostrando los últimos datos disponibles.


3. AppContainer: inicialización completa del Bloque 3#

El AppContainer es el contenedor manual de dependencias que centraliza la creación de todos los componentes de la capa de datos. En T4 solo tenía Room; ahora se añade Retrofit y se establece el orden correcto de inicialización:

 1// data/di/AppContainer.kt
 2
 3interface AppContainer {
 4    val okHttpClient: OkHttpClient       // expuesto para que Coil lo comparta
 5    val peliculasRepository: PeliculasRepository
 6}
 7
 8class DefaultAppContainer(private val context: Context) : AppContainer {
 9
10    // ── 1. OkHttpClient ───────────────────────────────────────────────────────
11    // Compartido entre Retrofit y Coil — un único pool de conexiones HTTP
12    override val okHttpClient: OkHttpClient by lazy {
13        val loggingInterceptor = HttpLoggingInterceptor().apply {
14            level = if (BuildConfig.DEBUG) HttpLoggingInterceptor.Level.BODY
15                    else HttpLoggingInterceptor.Level.NONE
16        }
17        OkHttpClient.Builder()
18            .connectTimeout(30, TimeUnit.SECONDS)
19            .readTimeout(30, TimeUnit.SECONDS)
20            .writeTimeout(30, TimeUnit.SECONDS)
21            .addInterceptor(loggingInterceptor)
22            .build()
23    }
24
25    // ── 2. Retrofit ───────────────────────────────────────────────────────────
26    private val retrofit: Retrofit by lazy {
27        Retrofit.Builder()
28            .baseUrl("https://api.themoviedb.org/3/")
29            .client(okHttpClient)               // usa el OkHttpClient compartido
30            .addConverterFactory(GsonConverterFactory.create())
31            .build()
32    }
33
34    // ── 3. TmdbApiService ─────────────────────────────────────────────────────
35    private val tmdbApiService: TmdbApiService by lazy {
36        retrofit.create(TmdbApiService::class.java)
37    }
38
39    // ── 4. RemoteDataSource ───────────────────────────────────────────────────
40    private val remoteDataSource: RemoteDataSource by lazy {
41        RemoteDataSource(tmdbApiService)
42    }
43
44    // ── 5. AppDatabase (Room) ─────────────────────────────────────────────────
45    private val database: AppDatabase by lazy {
46        Room.databaseBuilder(
47            context.applicationContext,
48            AppDatabase::class.java,
49            "appflix_database"
50        )
51        .fallbackToDestructiveMigration(dropAllTables = true)
52        .build()
53    }
54
55    // ── 6. LocalDataSource ────────────────────────────────────────────────────
56    private val localDataSource: LocalDataSource by lazy {
57        LocalDataSource(database.peliculaDao())
58    }
59
60    // ── 7. Repository (expuesto) ──────────────────────────────────────────────
61    // Recibe ambos DataSources — puede coordinarlos en la estrategia offline-first
62    override val peliculasRepository: PeliculasRepository by lazy {
63        PeliculasRepository(localDataSource, remoteDataSource)
64    }
65}

El orden de inicialización importa conceptualmente pero no en el código gracias a by lazy: cada objeto se crea solo cuando se accede por primera vez. Si el Repository se crea antes que LocalDataSource, Kotlin resolverá automáticamente la cadena de dependencias. Sin embargo, mantener el orden visual en el código mejora la legibilidad.


4. Repository: el corazón de la arquitectura#

El Repository integrado con ambas fuentes de datos implementa la estrategia offline-first descrita. Es la única clase que conoce tanto LocalDataSource como RemoteDataSource:

 1// data/repository/PeliculasRepository.kt
 2class PeliculasRepository(
 3    private val localDataSource: LocalDataSource,
 4    private val remoteDataSource: RemoteDataSource
 5) {
 6    // ── Observación (siempre desde Room) ─────────────────────────────────────
 7
 8    fun observarPeliculas(): Flow<List<Pelicula>> = localDataSource.observarTodas()
 9
10    fun observarFavoritas(): Flow<List<Pelicula>> = localDataSource.observarFavoritas()
11
12    fun observarPorId(id: Int): Flow<Pelicula?> = localDataSource.observarPorId(id)
13
14    fun buscarLocalmente(consulta: String): Flow<List<Pelicula>> =
15        localDataSource.observarPorTitulo(consulta)
16
17    // ── Sincronización con la red ─────────────────────────────────────────────
18    // Patrón: descarga de la API → guarda en Room → Room Flow notifica a la UI
19    // Si la red falla, lanza la excepción para que el ViewModel la gestione
20
21    suspend fun sincronizar() {
22        val peliculasRemotas = remoteDataSource.obtenerPopulares()
23        // upsertConservandoFavorita: actualiza los datos de la API sin tocar esFavorita
24        localDataSource.upsertConservandoFavorita(peliculasRemotas)
25    }
26
27    suspend fun sincronizarDetalle(id: Int) {
28        val peliculaRemota = remoteDataSource.obtenerDetalle(id)
29        localDataSource.upsertConservandoFavorita(listOf(peliculaRemota))
30    }
31
32    suspend fun buscarEnRed(consulta: String) {
33        val resultados = remoteDataSource.buscarPeliculas(consulta)
34        // Guardar los resultados de búsqueda en Room para que estén disponibles offline
35        localDataSource.upsertConservandoFavorita(resultados)
36    }
37
38    // ── Operaciones solo locales ──────────────────────────────────────────────
39
40    suspend fun toggleFavorita(id: Int) = localDataSource.toggleFavorita(id)
41}

5. ViewModel integrado: sync + estado + pull-to-refresh#

El ViewModel de la pantalla principal coordina la observación reactiva de Room y la sincronización con la red. Expone dos StateFlow: uno para el contenido de la UI y otro para el estado de la sincronización (útil para el indicador de pull-to-refresh):

 1// ui/listado/PeliculasViewModel.kt
 2class PeliculasViewModel(
 3    private val repository: PeliculasRepository
 4) : ViewModel() {
 5
 6    // ── Estado de sincronización (para el indicador de carga/refresh) ─────────
 7    private val _estaSincronizando = MutableStateFlow(false)
 8    val estaSincronizando: StateFlow<Boolean> = _estaSincronizando.asStateFlow()
 9
10    // ── Estado de búsqueda ────────────────────────────────────────────────────
11    private val _busqueda = MutableStateFlow("")
12    val busqueda: StateFlow<String> = _busqueda.asStateFlow()
13
14    // ── Estado principal de la UI ─────────────────────────────────────────────
15    // stateIn convierte el Flow frío de Room en un StateFlow caliente
16    val uiState: StateFlow<PeliculasUiState> =
17        repository.observarPeliculas()
18            .map<List<Pelicula>, PeliculasUiState> { PeliculasUiState.Exito(it) }
19            .catch { error -> emit(PeliculasUiState.Error(error.message ?: "Error")) }
20            .stateIn(
21                scope = viewModelScope,
22                started = SharingStarted.WhileSubscribed(5_000),
23                initialValue = PeliculasUiState.Cargando
24            )
25
26    // Al crear el ViewModel, sincronizar con la red si hay datos disponibles
27    init {
28        sincronizar()
29    }
30
31    fun sincronizar() {
32        viewModelScope.launch {
33            _estaSincronizando.value = true
34            try {
35                repository.sincronizar()
36                // Room emite automáticamente la nueva lista → uiState se actualiza solo
37            } catch (e: IOException) {
38                // Sin red — los datos locales siguen en pantalla, no se muestra error grave
39                // Solo se podría emitir un evento de Snackbar informativo (ver B2-T2)
40            } catch (e: HttpException) {
41                // Error del servidor — igual que sin red, datos locales intactos
42            } finally {
43                _estaSincronizando.value = false
44            }
45        }
46    }
47
48    fun actualizarBusqueda(texto: String) {
49        _busqueda.value = texto
50    }
51
52    fun toggleFavorita(id: Int) {
53        viewModelScope.launch {
54            repository.toggleFavorita(id)
55            // No es necesario actualizar manualmente: el Flow de Room detecta el cambio
56        }
57    }
58
59    companion object {
60        val Factory: ViewModelProvider.Factory = viewModelFactory {
61            initializer {
62                val app = checkNotNull(
63                    this[ViewModelProvider.AndroidViewModelFactory.APPLICATION_KEY]
64                ) as AppFlixApplication
65                PeliculasViewModel(app.container.peliculasRepository)
66            }
67        }
68    }
69}

6. Vista completa: PantallaListado con pull-to-refresh#

La pantalla de listado final de AppFlix integra todo lo desarrollado en los tres bloques: MVVM, Navigation, Room y Retrofit. La característica clave es el pull-to-refresh: el usuario arrastra la lista hacia abajo para forzar una sincronización con TMDB.

  1// ui/listado/PantallaListado.kt
  2@OptIn(ExperimentalMaterial3Api::class)
  3@Composable
  4fun PantallaListado(
  5    viewModel: PeliculasViewModel = viewModel(factory = PeliculasViewModel.Factory),
  6    onNavegaADetalle: (Int) -> Unit
  7) {
  8    val uiState by viewModel.uiState.collectAsStateWithLifecycle()
  9    val estaSincronizando by viewModel.estaSincronizando.collectAsStateWithLifecycle()
 10    val busqueda by viewModel.busqueda.collectAsStateWithLifecycle()
 11
 12    Scaffold(
 13        topBar = {
 14            TopAppBar(
 15                title = { Text("AppFlix") },
 16                actions = {
 17                    // Indicador de sincronización en la barra superior
 18                    AnimatedVisibility(visible = estaSincronizando) {
 19                        CircularProgressIndicator(
 20                            modifier = Modifier.size(24.dp).padding(end = 4.dp),
 21                            strokeWidth = 2.dp
 22                        )
 23                    }
 24                }
 25            )
 26        }
 27    ) { paddingValues ->
 28        // PullToRefreshBox: detecta el gesto de arrastre y llama a onRefresh
 29        PullToRefreshBox(
 30            isRefreshing = estaSincronizando,
 31            onRefresh = { viewModel.sincronizar() },
 32            modifier = Modifier.padding(paddingValues)
 33        ) {
 34            Column(modifier = Modifier.fillMaxSize()) {
 35
 36                // Campo de búsqueda
 37                OutlinedTextField(
 38                    value = busqueda,
 39                    onValueChange = viewModel::actualizarBusqueda,
 40                    modifier = Modifier
 41                        .fillMaxWidth()
 42                        .padding(horizontal = 16.dp, vertical = 8.dp),
 43                    placeholder = { Text("Buscar en catálogo...") },
 44                    leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) },
 45                    trailingIcon = {
 46                        AnimatedVisibility(visible = busqueda.isNotEmpty()) {
 47                            IconButton(onClick = { viewModel.actualizarBusqueda("") }) {
 48                                Icon(Icons.Default.Clear, contentDescription = "Borrar")
 49                            }
 50                        }
 51                    },
 52                    singleLine = true
 53                )
 54
 55                when (val estado = uiState) {
 56                    is PeliculasUiState.Cargando -> {
 57                        // Solo mostrar el indicador de carga si Room está vacío
 58                        // (la primera vez que se instala la app)
 59                        Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
 60                            Column(horizontalAlignment = Alignment.CenterHorizontally) {
 61                                CircularProgressIndicator()
 62                                Spacer(modifier = Modifier.height(16.dp))
 63                                Text("Cargando catálogo...")
 64                            }
 65                        }
 66                    }
 67
 68                    is PeliculasUiState.Exito -> {
 69                        // Filtrar por búsqueda en la propia UI (búsqueda local)
 70                        val peliculasFiltradas = if (busqueda.isBlank()) estado.peliculas
 71                            else estado.peliculas.filter {
 72                                it.titulo.contains(busqueda, ignoreCase = true)
 73                            }
 74
 75                        if (peliculasFiltradas.isEmpty()) {
 76                            Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
 77                                Text("Sin resultados para \"$busqueda\"")
 78                            }
 79                        } else {
 80                            LazyVerticalGrid(
 81                                columns = GridCells.Adaptive(minSize = 160.dp),
 82                                contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp),
 83                                horizontalArrangement = Arrangement.spacedBy(8.dp),
 84                                verticalArrangement = Arrangement.spacedBy(8.dp)
 85                            ) {
 86                                items(peliculasFiltradas, key = { it.id }) { pelicula ->
 87                                    TarjetaPeliculaGrid(
 88                                        pelicula = pelicula,
 89                                        onClickItem = { onNavegaADetalle(pelicula.id) },
 90                                        onToggleFavorita = { viewModel.toggleFavorita(pelicula.id) }
 91                                    )
 92                                }
 93                            }
 94                        }
 95                    }
 96
 97                    is PeliculasUiState.Error -> {
 98                        Box(Modifier.fillMaxSize().padding(32.dp),
 99                            contentAlignment = Alignment.Center) {
100                            Column(horizontalAlignment = Alignment.CenterHorizontally) {
101                                Icon(Icons.Default.CloudOff, contentDescription = null,
102                                    modifier = Modifier.size(64.dp),
103                                    tint = MaterialTheme.colorScheme.onSurfaceVariant)
104                                Spacer(modifier = Modifier.height(16.dp))
105                                Text("Sin datos disponibles", style = MaterialTheme.typography.titleMedium)
106                                Text(estado.mensaje, style = MaterialTheme.typography.bodySmall,
107                                    color = MaterialTheme.colorScheme.onSurfaceVariant)
108                                Spacer(modifier = Modifier.height(16.dp))
109                                Button(onClick = { viewModel.sincronizar() }) {
110                                    Text("Reintentar")
111                                }
112                            }
113                        }
114                    }
115                }
116            }
117        }
118    }
119}
120
121@Composable
122fun TarjetaPeliculaGrid(
123    pelicula: Pelicula,
124    onClickItem: () -> Unit,
125    onToggleFavorita: () -> Unit
126) {
127    Card(
128        onClick = onClickItem,
129        modifier = Modifier.fillMaxWidth()
130    ) {
131        Column {
132            Box {
133                PosterPelicula(
134                    posterUrl = pelicula.posterUrl,
135                    titulo = pelicula.titulo,
136                    modifier = Modifier.fillMaxWidth().aspectRatio(2f / 3f)
137                )
138                // Botón de favorita superpuesto sobre el póster
139                IconButton(
140                    onClick = onToggleFavorita,
141                    modifier = Modifier.align(Alignment.TopEnd).padding(4.dp)
142                ) {
143                    Icon(
144                        imageVector = if (pelicula.esFavorita) Icons.Default.Favorite
145                                      else Icons.Default.FavoriteBorder,
146                        contentDescription = null,
147                        tint = if (pelicula.esFavorita) Color.Red else Color.White
148                    )
149                }
150            }
151            Column(modifier = Modifier.padding(horizontal = 8.dp, vertical = 6.dp)) {
152                Text(
153                    text = pelicula.titulo,
154                    style = MaterialTheme.typography.labelMedium,
155                    maxLines = 2,
156                    overflow = TextOverflow.Ellipsis
157                )
158                Text(
159                    text = "★ ${"%.1f".format(pelicula.puntuacion)}",
160                    style = MaterialTheme.typography.labelSmall,
161                    color = MaterialTheme.colorScheme.secondary
162                )
163            }
164        }
165    }
166}

7. Pantalla de detalle integrada con Room + Retrofit#

La pantalla de detalle consulta Room primero. Si la película ya está en caché, se muestra inmediatamente. En segundo plano, sincroniza con la API para actualizar los datos:

  1// ui/detalle/DetalleViewModel.kt
  2class DetalleViewModel(
  3    private val peliculaId: Int,
  4    private val repository: PeliculasRepository
  5) : ViewModel() {
  6
  7    val uiState: StateFlow<DetalleUiState> =
  8        repository.observarPorId(peliculaId)
  9            .map<Pelicula?, DetalleUiState> { pelicula ->
 10                if (pelicula != null) DetalleUiState.Exito(pelicula)
 11                else DetalleUiState.NoEncontrado
 12            }
 13            .catch { emit(DetalleUiState.Error(it.message ?: "Error")) }
 14            .stateIn(
 15                scope = viewModelScope,
 16                started = SharingStarted.WhileSubscribed(5_000),
 17                initialValue = DetalleUiState.Cargando
 18            )
 19
 20    init {
 21        // Sincronizar el detalle con la API en segundo plano
 22        // Si ya está en Room, la UI muestra el dato local inmediatamente
 23        // Si la API devuelve datos actualizados, el Flow de Room los emitirá
 24        viewModelScope.launch {
 25            try {
 26                repository.sincronizarDetalle(peliculaId)
 27            } catch (e: Exception) {
 28                // Si falla la red, el dato local ya está mostrándose — no hay error grave
 29            }
 30        }
 31    }
 32
 33    fun toggleFavorita() {
 34        viewModelScope.launch {
 35            repository.toggleFavorita(peliculaId)
 36        }
 37    }
 38
 39    companion object {
 40        fun factoryConId(id: Int): ViewModelProvider.Factory = viewModelFactory {
 41            initializer {
 42                val app = checkNotNull(
 43                    this[ViewModelProvider.AndroidViewModelFactory.APPLICATION_KEY]
 44                ) as AppFlixApplication
 45                DetalleViewModel(
 46                    peliculaId = id,
 47                    repository = app.container.peliculasRepository
 48                )
 49            }
 50        }
 51    }
 52}
 53
 54// ui/detalle/PantallaDetalle.kt
 55@OptIn(ExperimentalMaterial3Api::class)
 56@Composable
 57fun PantallaDetalle(
 58    peliculaId: Int,
 59    onVolver: () -> Unit,
 60    viewModel: DetalleViewModel = viewModel(factory = DetalleViewModel.factoryConId(peliculaId))
 61) {
 62    val uiState by viewModel.uiState.collectAsStateWithLifecycle()
 63
 64    Scaffold(
 65        topBar = {
 66            TopAppBar(
 67                title = {
 68                    val titulo = (uiState as? DetalleUiState.Exito)?.pelicula?.titulo ?: ""
 69                    Text(titulo, maxLines = 1, overflow = TextOverflow.Ellipsis)
 70                },
 71                navigationIcon = {
 72                    IconButton(onClick = onVolver) {
 73                        Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Volver")
 74                    }
 75                },
 76                actions = {
 77                    if (uiState is DetalleUiState.Exito) {
 78                        val pelicula = (uiState as DetalleUiState.Exito).pelicula
 79                        IconButton(onClick = { viewModel.toggleFavorita() }) {
 80                            Icon(
 81                                imageVector = if (pelicula.esFavorita) Icons.Default.Favorite
 82                                              else Icons.Default.FavoriteBorder,
 83                                contentDescription = "Favorita",
 84                                tint = if (pelicula.esFavorita) MaterialTheme.colorScheme.error
 85                                       else MaterialTheme.colorScheme.onSurface
 86                            )
 87                        }
 88                    }
 89                }
 90            )
 91        }
 92    ) { padding ->
 93        when (val estado = uiState) {
 94            is DetalleUiState.Cargando ->
 95                Box(Modifier.fillMaxSize().padding(padding), Alignment.Center) {
 96                    CircularProgressIndicator()
 97                }
 98
 99            is DetalleUiState.Exito -> {
100                val pelicula = estado.pelicula
101                LazyColumn(
102                    modifier = Modifier.fillMaxSize().padding(padding),
103                    contentPadding = PaddingValues(bottom = 32.dp)
104                ) {
105                    item {
106                        PosterPelicula(
107                            posterUrl = pelicula.posterUrl,
108                            titulo = pelicula.titulo,
109                            modifier = Modifier.fillMaxWidth().aspectRatio(16f / 9f)
110                        )
111                    }
112                    item {
113                        Column(modifier = Modifier.padding(16.dp)) {
114                            Text(pelicula.titulo, style = MaterialTheme.typography.headlineSmall)
115                            Spacer(Modifier.height(8.dp))
116                            Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
117                                AssistChip(
118                                    onClick = {},
119                                    label = { Text("★ ${"%.1f".format(pelicula.puntuacion)}") }
120                                )
121                            }
122                            Spacer(Modifier.height(16.dp))
123                            if (pelicula.sinopsis.isNotBlank()) {
124                                Text("Sinopsis", style = MaterialTheme.typography.titleMedium)
125                                Spacer(Modifier.height(8.dp))
126                                Text(pelicula.sinopsis, style = MaterialTheme.typography.bodyMedium)
127                            }
128                        }
129                    }
130                }
131            }
132
133            is DetalleUiState.Error, is DetalleUiState.NoEncontrado ->
134                Box(Modifier.fillMaxSize().padding(padding), Alignment.Center) {
135                    Column(horizontalAlignment = Alignment.CenterHorizontally) {
136                        Text("Película no disponible")
137                        Button(onClick = onVolver) { Text("Volver") }
138                    }
139                }
140        }
141    }
142}

8. Estructura completa de AppFlix al final del Bloque 3#

com.ejemplo.appflix/
├── AppFlixApplication.kt     ← Application: AppContainer + ImageLoader de Coil
├── MainActivity.kt
├── data/
│   ├── model/
│   │   └── Pelicula.kt       ← @Entity + @SerializedName (compartida por Room y Retrofit)
│   │
│   ├── datasource/
│   │   ├── local/
│   │   │   ├── AppDatabase.kt        ← @Database(entities=[Pelicula::class], version=1)
│   │   │   ├── PeliculaDao.kt        ← @Dao con Flow<> y suspend
│   │   │   ├── LocalDataSource.kt    ← wrapper del DAO
│   │   │   └── Converters.kt         ← @TypeConverter para List<Int>
│   │   └── remote/
│   │       ├── TmdbApiService.kt     ← interfaz Retrofit con @GET, @Query, @Path
│   │       └── RemoteDataSource.kt   ← wrapper del servicio API
│   │
│   ├── repository/
│   │   └── PeliculasRepository.kt    ← offline-first: Flow de Room + sync de Retrofit
│   │
│   └── di/
│       └── AppContainer.kt           ← DefaultAppContainer: inicialización con lazy
└── ui/
    ├── listado/
    │   ├── PantallaListado.kt        ← PullToRefreshBox + LazyVerticalGrid
    │   ├── PeliculasViewModel.kt     ← stateIn + sincronizar() + toggleFavorita()
    │   └── PeliculasUiState.kt
    ├── detalle/
    │   ├── PantallaDetalle.kt
    │   ├── DetalleViewModel.kt
    │   └── DetalleUiState.kt
    ├── favoritos/
    │   ├── PantallaFavoritos.kt
    │   └── FavoritosViewModel.kt
    └── navegacion/
        ├── AppNavigation.kt
        └── Rutas.kt

9. Diagrama de flujo: ¿qué ocurre al abrir la app?#

Usuario abre AppFlix
MainActivity.setContent { AppNavigation() }
NavHost muestra PantallaListado
PeliculasViewModel creado (Factory del AppContainer)
       ├──► stateIn(observarPeliculas()) ──► Room ──► emit(PeliculasUiState.Cargando)
       │         [Room emite datos si hay]             [vacío la primera vez]
       └──► init { sincronizar() }
           viewModelScope.launch {
               _estaSincronizando = true
               remoteDataSource.obtenerPopulares()    ── petición HTTP
               │                                          a api.themoviedb.org
           localDataSource.upsertConservandoFavorita(peliculas)
           Room guarda las películas
           Flow de Room re-emite la nueva lista
           uiState = PeliculasUiState.Exito(listaDePeliculas)
           _estaSincronizando = false
           }
       PantallaListado se recompone con las películas

Actividades prácticas#

Actividad 6.1 — Integración completa de la capa de datos (6 h) Actualizar el AppContainer para incluir RemoteDataSource, actualizar PeliculasRepository con el método sincronizar(), añadir el indicador de estaSincronizando al ViewModel y configurar PullToRefreshBox en la pantalla de listado. Verificar que al arrastrar la lista hacia abajo se muestran los pósters de TMDB.

Actividad 6.2 — Prueba offline (2 h) Con la app ya sincronizada (datos en Room), desactivar el WiFi y reiniciar la app. Verificar que los datos locales se muestran correctamente y que el intento de sincronización falla silenciosamente (sin crash, sin pantalla de error, mostrando los datos cacheados). Documentar el comportamiento observado.

Actividad 6.3 — Hito vertebrador H6: AppFlix completa (2 h) Demostración final de AppFlix: listado desde TMDB con imágenes, búsqueda con filtro local, detalle con sincronización en segundo plano, toggle de favoritos persistente entre sesiones, funcionamiento offline con datos cacheados, y navegación completa con BottomBar.


Pruebas de evaluación#

Prueba T6.1 — Análisis de arquitectura en papel (25 min) Se proporciona el diagrama de flujo de la sección 9 con cinco elementos eliminados (los nombres de los métodos, el nombre del patrón, etc.). El alumno los identifica y los completa, explicando con sus palabras por qué la UI nunca llama directamente a RemoteDataSource.

Prueba T6.2 — Diseño de arquitectura para nueva app (25 min) Enunciado: “App de noticias: las noticias se obtienen de una API REST pública y deben estar disponibles offline; el usuario puede guardar noticias para leer más tarde.” El alumno diseña en papel el AppContainer, las clases de la capa de datos, el flujo de sincronización y el UiState de la pantalla principal.

Prueba T6.3 — Defensa oral del H6 (10 min por alumno) El profesor selecciona tres puntos del código entregado en A6.3 y pregunta: "¿Por qué sincronizar() no devuelve la lista de películas?", "¿Qué pasaría si upsertConservandoFavorita usase @Upsert directamente?", "¿Por qué stateIn usa WhileSubscribed(5_000) y no Eagerly?"


Referencias#

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