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 elRepositoryse crea antes queLocalDataSource, 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.kt9. 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ículasActividades 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?"