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-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-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. |
Dependencias necesarias#
Se continúa desde la última versión de AppDummy, si no la tienes, puedes descargarla desde v7. Ejemplo práctico Tema 5 .
Este tema no añade ninguna biblioteca nueva: todo lo necesario (Room, Retrofit, OkHttp, Gson, Coil y Material 3) se incorporó en T4 y T5. Solo hay que comprobar una cosa en el proyecto y añadir un permiso.
1// build.gradle.kts (módulo app) — comprobación, no hay que añadir nada nuevo
2
3dependencies {
4 // PullToRefreshBox forma parte de Material 3 desde la versión 1.3.0;
5 // si el catálogo de versiones apunta a una versión anterior, actualízalo
6 implementation(platform(libs.androidx.compose.bom))
7 implementation(libs.androidx.material3)
8
9 // collectAsStateWithLifecycle() — ya presente desde el Bloque 2
10 implementation(libs.androidx.lifecycle.runtime.compose)
11}El observador de conectividad de la sección 8 consulta el estado de la red del dispositivo, lo que exige declarar un permiso normal (no requiere confirmación del usuario en tiempo de ejecución):
1<!-- AndroidManifest.xml — junto al permiso de INTERNET añadido en T5 -->
2<uses-permission android:name="android.permission.INTERNET" />
3<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />En la sección 3 se añade una columna nueva a la entidad
Libro, lo que obliga a incrementar la versión de la base de datos a2. ComoAppDatabasese construye confallbackToDestructiveMigration(dropAllTables = true), Room borrará y recreará la base de datos: la biblioteca guardada durante las pruebas de T5 se perderá. Es el comportamiento esperado en un proyecto de aprendizaje; en el Anexo B3-A4 se explica cómo escribir unaMigrationreal para no perder los datos.
1. El problema: dos fuentes de datos y ninguna autoridad#
Al terminar T5 la aplicación ya sabe hacer las dos cosas por separado:
- Room guarda la biblioteca del usuario y la expone como
Flow(T4). - Retrofit consulta Open Library y devuelve
Libroenvuelto enResult<T>(T5).
Lo que todavía no está resuelto es cómo se relacionan ambas. Los datos de un libro cambian con el tiempo: Open Library corrige títulos mal escritos, añade portadas que antes no existían y completa autores. Un libro dado de alta hace seis meses puede tener hoy mejor información en el servidor. Y, al mismo tiempo, la aplicación debe seguir funcionando en el metro, sin cobertura.
Sin una estrategia explícita, esa decisión acaba en el peor sitio posible, la interfaz:
1// ❌ Antipatrón — la UI decide de dónde vienen los datos
2@Composable
3fun PantallaListadoMal(viewModel: LibrosViewModel) {
4 if (hayConexion()) {
5 mostrarLibrosDeLaApi() // ¿y si la petición falla a mitad?
6 } else {
7 mostrarLibrosDeRoom() // ¿y si Room está vacío?
8 }
9 // Esta lógica no pertenece aquí: pertenece a la capa de datos
10}Este código tiene tres defectos graves. Duplica el trabajo, porque hay dos caminos distintos que pintan la misma lista. Es imposible de probar sin un dispositivo con y sin red. Y produce parpadeos: la pantalla muestra primero los datos locales y los sustituye de golpe por los remotos.
La solución es la estrategia offline-first, en la que la capa de datos toma todas esas decisiones de forma transparente para la interfaz.
2. La estrategia offline-first#
Offline-first significa que la aplicación funciona correctamente aunque no haya conexión a Internet. La interfaz siempre observa los datos locales de Room; la red se usa exclusivamente para actualizar esos datos locales.
┌─────────────────────────────────────────────────────────────┐
│ UI (Composable) │
│ observa el StateFlow del ViewModel │
└────────────────────────────┬────────────────────────────────┘
│ collectAsStateWithLifecycle()
▼
┌─────────────────────────────────────────────────────────────┐
│ ViewModel │
│ stateIn() sobre el Flow del Repository │
└────────────────────────────┬────────────────────────────────┘
│ Flow<List<Libro>>
▼
┌─────────────────────────────────────────────────────────────┐
│ LibrosRepository │
│ │
│ observarLibros() ──────► LocalDataSource ──► Room DAO │
│ ▲ │
│ sincronizarBiblioteca() │ UPDATE selectivo │
│ ├─ remoteDataSource.buscarPorIsbn() (sin tocar │
│ └─ localDataSource.refrescarDesdeRed() es_favorito │
│ │ ni leido) │
│ └────────────────────────┤
└─────────────────────────────────────────────────────────────┘Los tres principios#
Room es la única fuente de verdad (single source of truth). La interfaz nunca recibe datos directamente de la red. Si un dato no está en Room, para la interfaz no existe. La red solo sirve para escribir en Room.
La sincronización es un efecto lateral, no una consulta. sincronizarBiblioteca() no devuelve la lista de libros: escribe en Room y termina. La pantalla se actualiza sola porque está observando un Flow de Room, que reemite en cuanto cambia la tabla. Esa es la razón de que el método devuelva un resumen (cuántos libros se han comprobado) y no datos.
Un fallo de red no destruye el estado local. Si la sincronización falla —sin cobertura, error 429 por exceso de peticiones, servidor caído—, Room no se modifica y la interfaz sigue mostrando exactamente lo que mostraba antes. El error se comunica con un Snackbar, no con una pantalla de error.
Offline-first no es lo mismo que “tener caché”. Una caché es una copia que se consulta por si acaso falla la fuente principal. En offline-first la relación se invierte: la copia local es la fuente principal, y la red es un proceso opcional que la mejora. La diferencia se aprecia en el código: no hay ni un solo
if (hayConexion)en el camino de lectura.
¿Qué significa “sincronizar” en AppDummy?#
Conviene detenerse aquí, porque la respuesta depende de cada aplicación. En una app de noticias, sincronizar es descargar los últimos titulares. En AppDummy no existe una lista de “libros populares” que descargar: la biblioteca la construye el usuario desde el formulario de T5.
Por tanto, sincronizar en AppDummy consiste en refrescar los metadatos de los libros que el usuario ya tiene guardados:
Para cada libro de la biblioteca que tenga ISBN y esté "caducado":
1. Consultar search.json?q=isbn:<isbn> en Open Library
2. Si hay resultado → actualizar titulo, autor, year, isbn y cover en Room
3. NO tocar es_favorito ni leido (son datos que la API desconoce)
4. Anotar la fecha de la comprobación
5. Esperar 1,1 s antes del siguiente libro (límite de 1 petición/segundo)Los pasos 3 y 5 son los que dan sentido al tema: el primero justifica el upsertConservandoFavorito que se escribió en T4 y el segundo obliga a razonar sobre los límites de una API real.
3. Preparar la entidad: la marca de tiempo de sincronización#
Para saber qué hay que refrescar hace falta saber cuándo se refrescó por última vez. Se añade una columna a la entidad Libro, la primera modificación que sufre desde T4:
1import androidx.room.ColumnInfo
2import androidx.room.Entity
3import androidx.room.Index
4import androidx.room.PrimaryKey
5
6// ─── data/model/Libro.kt ─────────────────────────────────────────────────────────────────────────
7@Entity(
8 tableName = "libros",
9 indices = [
10 Index(value = ["titulo"]),
11 Index(value = ["autor", "isbn"], unique = false)
12 ]
13)
14data class Libro(
15 @PrimaryKey
16 val id: String,
17
18 @ColumnInfo(name = "titulo")
19 val titulo: String,
20
21 @ColumnInfo(name = "autor")
22 val autor: String,
23
24 @ColumnInfo(name = "year")
25 val year: Int? = 1900,
26
27 @ColumnInfo(name = "isbn")
28 val isbn: String,
29
30 @ColumnInfo(name = "cover")
31 val cover: String? = null,
32
33 // ─── Campos exclusivamente locales ──────────────────────────────────────
34 @ColumnInfo(name = "es_favorito")
35 val esFavorito: Boolean = false,
36
37 @ColumnInfo(name = "leido")
38 val leido: Boolean = false,
39
40 // ─── NUEVO en T6 ────────────────────────────────────────────────────────
41 // Instante (epoch en milisegundos) de la última comprobación contra la API.
42 // 0L significa "nunca sincronizado": es el valor de los libros recién dados
43 // de alta desde el formulario y el de los creados manualmente.
44 @ColumnInfo(name = "actualizado_en")
45 val actualizadoEn: Long = 0L
46)Al cambiar el esquema hay que incrementar la versión de la base de datos:
1// ─── data/datasource/local/AppDatabase.kt ────────────────────────────────────────────────────────
2@Database(
3 entities = [Libro::class],
4 version = 2, // ← era 1 en T4: se ha añadido la columna actualizado_en
5 exportSchema = true
6)
7@TypeConverters(Converters::class)
8abstract class AppDatabase : RoomDatabase() {
9 abstract fun libroDao(): LibrosDao
10}¿Por qué un
Longy no unDate? Guardar un instante como número de milisegundos desde el 1 de enero de 1970 (epoch) evita necesitar unTypeConverter, se ordena y se compara directamente en SQL (WHERE actualizado_en < :limite) y no depende de la zona horaria del dispositivo. La conversión a texto legible es responsabilidad de la interfaz, no de la base de datos.
4. El DAO: consultas de sincronización#
El DAO de T4 ya contenía la pieza más delicada, actualizarDesdeRed, que actualiza solo las columnas procedentes de la API. Ahora se le añade la marca de tiempo y se incorporan tres consultas nuevas:
1import androidx.room.*
2import kotlinx.coroutines.flow.Flow
3
4// ─── data/datasource/local/LibrosDao.kt — añadir/modificar ───────────────────────────────────────
5@Dao
6interface LibrosDao {
7
8 // ... consultas de T4 y T5 sin cambios ...
9
10 // ─── Sincronización (T6) ────────────────────────────────────────────────
11
12 // Devuelve los libros candidatos a refrescarse:
13 // · deben tener ISBN (sin ISBN no se pueden localizar en Open Library)
14 // · deben estar "caducados": comprobados antes del instante :anteriorA
15 // Se ordenan del más antiguo al más reciente y se limita la cantidad para no
16 // encadenar decenas de peticiones en una sola sincronización.
17 @Query("""
18 SELECT * FROM libros
19 WHERE isbn <> '' AND actualizado_en < :anteriorA
20 ORDER BY actualizado_en ASC
21 LIMIT :maximo
22 """)
23 suspend fun obtenerCaducados(anteriorA: Long, maximo: Int): List<Libro>
24
25 // Instante de la sincronización más reciente de toda la tabla.
26 // Devuelve null si la tabla está vacía (MAX() sobre cero filas es NULL).
27 @Query("SELECT MAX(actualizado_en) FROM libros")
28 fun observarUltimaSincronizacion(): Flow<Long?>
29
30 // MODIFICADO respecto a T4: se añade la columna actualizado_en.
31 // Sigue sin tocar es_favorito ni leido: son propiedad del usuario.
32 @Query("""
33 UPDATE libros SET
34 titulo = :titulo,
35 autor = :autor,
36 year = :year,
37 isbn = :isbn,
38 cover = :cover,
39 actualizado_en = :actualizadoEn
40 WHERE id = :id
41 """)
42 suspend fun actualizarDesdeRed(
43 id: String,
44 titulo: String,
45 autor: String,
46 year: Int?,
47 isbn: String,
48 cover: String?,
49 actualizadoEn: Long
50 )
51
52 // Marca un libro como comprobado sin modificar sus datos. Se usa cuando Open
53 // Library no devuelve resultados para ese ISBN: el libro se ha comprobado, así
54 // que no debe volver a pedirse en la siguiente sincronización.
55 @Query("UPDATE libros SET actualizado_en = :instante WHERE id = :id")
56 suspend fun marcarComprobado(id: String, instante: Long)
57
58 // MODIFICADO respecto a T4: propaga la marca de tiempo a la actualización
59 @Transaction
60 suspend fun upsertConservandoFavorito(libros: List<Libro>, instante: Long) {
61 val resultados = insertarIgnorando(libros)
62 libros.forEachIndexed { indice, libro ->
63 if (resultados[indice] == -1L) {
64 actualizarDesdeRed(
65 id = libro.id,
66 titulo = libro.titulo,
67 autor = libro.autor,
68 year = libro.year,
69 isbn = libro.isbn,
70 cover = libro.cover,
71 actualizadoEn = instante
72 )
73 }
74 }
75 }
76}Las tres reglas que hacen segura la sincronización. Primera: se actualiza con un
UPDATEde columnas concretas, nunca con@Upsert, porque@Upsertreescribe la fila entera y borraríaes_favoritoyleido. Segunda: se actualiza por elidlocal, no por el identificador que devuelve la API; así un libro dado de alta manualmente (coniddel tipo/local/<uuid>) conserva su clave primaria aunque Open Library lo reconozca por su ISBN. Tercera: todo libro comprobado anota la fecha, tanto si se ha actualizado como si no, para que la siguiente sincronización no vuelva a pedirlo.
Todo esto se expone al repositorio a través de LocalDataSource, que sigue siendo un envoltorio sin lógica propia:
1// ─── data/datasource/local/LocalDataSource.kt — añadir ───────────────────────────────────────────
2class LocalDataSource(private val dao: LibrosDao) {
3
4 // ... métodos de T4 y T5 sin cambios ...
5
6 // ─── Sincronización (T6) ────────────────────────────────────────────────
7
8 suspend fun obtenerCaducados(anteriorA: Long, maximo: Int): List<Libro> =
9 dao.obtenerCaducados(anteriorA, maximo)
10
11 fun observarUltimaSincronizacion(): Flow<Long?> = dao.observarUltimaSincronizacion()
12
13 suspend fun marcarComprobado(id: String, instante: Long) =
14 dao.marcarComprobado(id, instante)
15
16 // Recibe el id LOCAL y los datos REMOTOS por separado: son dos cosas distintas
17 suspend fun refrescarDesdeRed(id: String, datos: Libro, instante: Long) =
18 dao.actualizarDesdeRed(
19 id = id,
20 titulo = datos.titulo,
21 autor = datos.autor,
22 year = datos.year,
23 isbn = datos.isbn,
24 cover = datos.cover,
25 actualizadoEn = instante
26 )
27
28 suspend fun upsertConservandoFavorito(libros: List<Libro>, instante: Long) =
29 dao.upsertConservandoFavorito(libros, instante)
30}5. El Repository: el corazón de la arquitectura#
LibrosRepository es la única clase que conoce a la vez LocalDataSource y RemoteDataSource, y por tanto la única que puede implementar la estrategia. Primero se define el tipo que resume el resultado de una sincronización:
1// ─── data/repository/ResultadoSincronizacion.kt ──────────────────────────────────────────────────
2// Resumen de lo ocurrido durante una sincronización. NO contiene libros: los datos
3// viajan a la UI por el Flow de Room, no por este objeto.
4data class ResultadoSincronizacion(
5 val actualizados: Int = 0, // libros cuyos datos ha devuelto la API
6 val sinCambios: Int = 0, // libros comprobados sin resultado en la API
7 val error: String? = null // mensaje si la sincronización se interrumpió
8) {
9 val comprobados: Int get() = actualizados + sinCambios
10 val haFallado: Boolean get() = error != null
11}Y a continuación, el repositorio completo del bloque:
1import com.ejemplo.appdummy.data.datasource.local.LocalDataSource
2import com.ejemplo.appdummy.data.datasource.remote.RemoteDataSource
3import com.ejemplo.appdummy.data.model.Libro
4import kotlinx.coroutines.delay
5import kotlinx.coroutines.flow.Flow
6import retrofit2.HttpException
7import java.io.IOException
8import kotlin.time.Duration.Companion.days
9import kotlin.time.Duration.Companion.milliseconds
10
11// ─── data/repository/LibrosRepository.kt ─────────────────────────────────────────────────────────
12class LibrosRepository(
13 private val localDataSource: LocalDataSource,
14 private val remoteDataSource: RemoteDataSource
15) {
16 companion object {
17 // Un libro se considera "caducado" pasada una semana desde su comprobación
18 private val PERIODO_VALIDEZ = 7.days
19
20 // Tope de peticiones por sincronización: con la pausa de 1,1 s, 20 libros
21 // son unos 22 segundos. El resto se refrescará en la siguiente pasada.
22 private const val MAXIMO_POR_SINCRONIZACION = 20
23
24 // Open Library admite 1 petición/segundo a las apps anónimas y 3 a las
25 // identificadas con User-Agent. Se deja margen sobre el límite estricto.
26 private val PAUSA_ENTRE_PETICIONES = 1_100.milliseconds
27 }
28
29 // ─── Lectura local: la UI SIEMPRE observa desde Room ────────────────────
30
31 fun observarLibros(): Flow<List<Libro>> = localDataSource.observarTodos()
32 fun observarFavoritos(): Flow<List<Libro>> = localDataSource.observarFavoritos()
33 fun observarPorId(id: String): Flow<Libro?> = localDataSource.observarPorId(id)
34
35 // NUEVO en T6: el filtrado por título deja de hacerse en la UI y pasa a SQL
36 fun observarPorTitulo(texto: String): Flow<List<Libro>> =
37 localDataSource.observarPorTitulo(texto)
38
39 fun observarUltimaSincronizacion(): Flow<Long?> =
40 localDataSource.observarUltimaSincronizacion()
41
42 // ─── Escritura local ────────────────────────────────────────────────────
43
44 suspend fun toggleFavorito(id: String) = localDataSource.toggleFavorito(id)
45 suspend fun toggleLeido(id: String) = localDataSource.toggleLeido(id)
46 suspend fun obtenerAutores(): List<String>? = localDataSource.obtenerAutores()
47 suspend fun obtenerPorId(id: String): Libro? = localDataSource.obtenerPorId(id)
48 suspend fun guardarLibro(libro: Libro) = localDataSource.guardar(libro)
49
50 // ─── Lectura remota (T5) ────────────────────────────────────────────────
51
52 suspend fun buscarEnApiPorTitulo(titulo: String): Result<List<Libro>> =
53 ejecutarPeticion { remoteDataSource.buscarPorTitulo(titulo) }
54
55 suspend fun buscarEnApiPorIsbn(isbn: String): Result<Libro?> =
56 ejecutarPeticion { remoteDataSource.buscarPorIsbn(isbn) }
57
58 // ─── Sincronización offline-first (T6) ──────────────────────────────────
59
60 // Refresca los metadatos de los libros guardados. No devuelve libros:
61 // escribe en Room y es el Flow de Room quien avisa a la interfaz.
62 //
63 // forzar = false → solo los libros caducados (uso normal, al abrir la app)
64 // forzar = true → todos los libros con ISBN (pull-to-refresh del usuario)
65 suspend fun sincronizarBiblioteca(forzar: Boolean = false): ResultadoSincronizacion {
66
67 val ahora = System.currentTimeMillis()
68 val limite = if (forzar) ahora else ahora - PERIODO_VALIDEZ.inWholeMilliseconds
69
70 val pendientes = localDataSource.obtenerCaducados(limite, MAXIMO_POR_SINCRONIZACION)
71 if (pendientes.isEmpty()) return ResultadoSincronizacion()
72
73 var actualizados = 0
74 var sinCambios = 0
75
76 for ((indice, libro) in pendientes.withIndex()) {
77
78 // Respeto del rate limit: se espera ANTES de cada petición menos la primera
79 if (indice > 0) delay(PAUSA_ENTRE_PETICIONES)
80
81 // getOrElse permite abandonar el bucle en cuanto falla la red. Lo ya
82 // escrito en Room se conserva: no hay nada que deshacer.
83 val remoto = ejecutarPeticion { remoteDataSource.buscarPorIsbn(libro.isbn) }
84 .getOrElse { error ->
85 return ResultadoSincronizacion(actualizados, sinCambios, error.message)
86 }
87
88 val instante = System.currentTimeMillis()
89
90 if (remoto == null) {
91 // La API no conoce ese ISBN: se anota la comprobación y se continúa
92 localDataSource.marcarComprobado(libro.id, instante)
93 sinCambios++
94 } else {
95 localDataSource.refrescarDesdeRed(
96 id = libro.id, // ← id LOCAL, no el de la API
97 // Si la API devolviese el ISBN vacío, se conserva el del usuario
98 datos = remoto.copy(isbn = remoto.isbn.ifBlank { libro.isbn }),
99 instante = instante
100 )
101 actualizados++
102 }
103 }
104
105 return ResultadoSincronizacion(actualizados, sinCambios)
106 }
107
108 // ─── Traducción de excepciones a Result (T5, sin cambios) ───────────────
109
110 private suspend fun <T> ejecutarPeticion(bloque: suspend () -> T): Result<T> =
111 try {
112 Result.success(bloque())
113 } catch (e: IOException) {
114 Result.failure(Exception("Sin conexión a internet. Comprueba la red."))
115 } catch (e: HttpException) {
116 Result.failure(Exception(mensajeDeError(e.code())))
117 }
118
119 private fun mensajeDeError(codigo: Int): String = when (codigo) {
120 403 -> "Acceso denegado: se ha superado el límite de peticiones."
121 404 -> "Recurso no encontrado en Open Library."
122 429 -> "Demasiadas peticiones. Inténtalo dentro de unos segundos."
123 in 500..599 -> "Error del servidor de Open Library ($codigo)."
124 else -> "Error HTTP $codigo"
125 }
126}Conviene fijarse en cuatro decisiones de diseño:
delay() en lugar de peticiones en paralelo. Sería tentador lanzar las veinte peticiones a la vez con async/awaitAll y terminar en un segundo. Con Open Library eso significa recibir un HTTP 429 casi con seguridad. Una sincronización lenta que funciona es mejor que una rápida que el servidor rechaza. Como delay() es una función suspend, no bloquea ningún hilo: la corrutina simplemente se aparta y el hilo queda libre para otras tareas.
El primer fallo aborta el proceso. Si la red se ha caído, insistir con los diecinueve libros restantes solo sirve para acumular timeouts. Se devuelve el resultado parcial —lo ya escrito en Room es válido— y se informa al usuario.
getOrElse en lugar de try/catch. El try/catch ya está encapsulado en ejecutarPeticion, de modo que el bucle trabaja con Result<T>. getOrElse es una función inline, así que el return de su lambda es un retorno no local: sale directamente de sincronizarBiblioteca(), que es justo lo que se busca.
El resultado no contiene libros. Es la consecuencia práctica del principio de la fuente única de verdad. Si sincronizarBiblioteca() devolviera List<Libro>, el ViewModel tendría dos orígenes posibles para la misma lista y volveríamos al antipatrón de la sección 1.
6. El ViewModel: estado de sincronización y búsqueda en SQL#
LibrosViewModel gana dos responsabilidades nuevas: exponer el estado de la sincronización (para el indicador de pull-to-refresh) y trasladar el filtro de búsqueda desde la interfaz hasta Room.
1import androidx.lifecycle.ViewModel
2import androidx.lifecycle.ViewModelProvider
3import androidx.lifecycle.viewModelScope
4import androidx.lifecycle.viewmodel.initializer
5import androidx.lifecycle.viewmodel.viewModelFactory
6import com.ejemplo.appdummy.AppDummyApplication
7import com.ejemplo.appdummy.data.model.Libro
8import com.ejemplo.appdummy.data.repository.LibrosRepository
9import com.ejemplo.appdummy.data.repository.ResultadoSincronizacion
10import com.ejemplo.appdummy.utils.ObservadorConectividad
11import kotlinx.coroutines.ExperimentalCoroutinesApi
12import kotlinx.coroutines.FlowPreview
13import kotlinx.coroutines.flow.*
14import kotlinx.coroutines.launch
15import kotlin.time.Duration.Companion.milliseconds
16
17// ─── screens/listado/LibrosViewModel.kt ──────────────────────────────────────────────────────────
18@OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class)
19class LibrosViewModel(
20 private val repository: LibrosRepository,
21 observadorConectividad: ObservadorConectividad
22) : ViewModel() {
23
24 // ─── Texto de búsqueda ──────────────────────────────────────────────────
25 private val _busqueda = MutableStateFlow("")
26 val busqueda: StateFlow<String> = _busqueda.asStateFlow()
27
28 // ─── Estado de la sincronización ────────────────────────────────────────
29 private val _estaSincronizando = MutableStateFlow(false)
30 val estaSincronizando: StateFlow<Boolean> = _estaSincronizando.asStateFlow()
31
32 // ─── Eventos de un solo consumo (Snackbar), heredado de T4 ──────────────
33 private val _eventos = MutableSharedFlow<LibrosEvento>()
34 val eventos: SharedFlow<LibrosEvento> = _eventos.asSharedFlow()
35
36 // ─── Conectividad: la UI la usa para avisar, NO para elegir la fuente ───
37 val hayConexion: StateFlow<Boolean> = observadorConectividad.hayConexion
38 .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), true)
39
40 // ─── Instante de la última sincronización, observado desde Room ─────────
41 val ultimaSincronizacion: StateFlow<Long?> = repository.observarUltimaSincronizacion()
42 .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)
43
44 // ─── Estado principal: la búsqueda se resuelve en SQL ───────────────────
45 // flatMapLatest sustituye el Flow anterior por uno nuevo cada vez que cambia el
46 // texto: al escribir se cancela la consulta previa y se abre otra sobre Room.
47 // El resultado sigue siendo reactivo (si cambia la tabla, vuelve a emitir).
48 val uiState: StateFlow<LibrosUiState> = _busqueda
49 .debounce(300.milliseconds) // no consultar en cada pulsación
50 .distinctUntilChanged()
51 .flatMapLatest { texto ->
52 if (texto.isBlank()) repository.observarLibros()
53 else repository.observarPorTitulo(texto)
54 }
55 .map<List<Libro>, LibrosUiState> { LibrosUiState.Exito(it) }
56 .catch { error -> emit(LibrosUiState.Error(error.message ?: "Error")) }
57 .stateIn(
58 scope = viewModelScope,
59 started = SharingStarted.WhileSubscribed(5_000),
60 initialValue = LibrosUiState.Cargando
61 )
62
63 init {
64 // Sincronización silenciosa al crear el ViewModel: solo los caducados
65 sincronizar(forzar = false)
66 }
67
68 fun actualizarBusqueda(texto: String) { _busqueda.value = texto }
69
70 fun toggleFavorito(libro: Libro) {
71 viewModelScope.launch {
72 repository.toggleFavorito(libro.id)
73 // No hay que refrescar nada: el Flow de Room emite la lista nueva
74 _eventos.emit(
75 LibrosEvento.MostrarSnackbar(
76 if (libro.esFavorito) "\"${libro.titulo}\" eliminado de favoritos"
77 else "\"${libro.titulo}\" añadido a favoritos"
78 )
79 )
80 }
81 }
82
83 // ─── Sincronización ─────────────────────────────────────────────────────
84
85 fun sincronizar(forzar: Boolean = true) {
86 // Guarda de reentrada: evita lanzar dos sincronizaciones simultáneas si el
87 // usuario arrastra la lista mientras ya se está sincronizando
88 if (_estaSincronizando.value) return
89
90 viewModelScope.launch {
91 if (!hayConexion.value) {
92 _eventos.emit(
93 LibrosEvento.MostrarSnackbar("Sin conexión: se muestran los datos guardados.")
94 )
95 return@launch
96 }
97
98 _estaSincronizando.value = true
99 try {
100 val resultado = repository.sincronizarBiblioteca(forzar)
101 // La sincronización automática del init solo avisa si ha hecho algo
102 if (forzar || resultado.comprobados > 0 || resultado.haFallado) {
103 _eventos.emit(LibrosEvento.MostrarSnackbar(mensajeDe(resultado)))
104 }
105 } finally {
106 // finally garantiza que el indicador se apaga aunque haya excepción
107 _estaSincronizando.value = false
108 }
109 }
110 }
111
112 private fun mensajeDe(resultado: ResultadoSincronizacion): String = when {
113 resultado.haFallado -> resultado.error ?: "No se ha podido sincronizar."
114 resultado.comprobados == 0 -> "La biblioteca ya está actualizada."
115 resultado.actualizados == 0 -> "Comprobados ${resultado.comprobados} libros, sin cambios."
116 else -> "Actualizados ${resultado.actualizados} de ${resultado.comprobados} libros."
117 }
118
119 companion object {
120 val Factory: ViewModelProvider.Factory = viewModelFactory {
121 initializer {
122 val app = checkNotNull(
123 this[ViewModelProvider.AndroidViewModelFactory.APPLICATION_KEY]
124 ) as AppDummyApplication
125 LibrosViewModel(
126 repository = app.container.librosRepository,
127 observadorConectividad = app.container.observadorConectividad
128 )
129 }
130 }
131 }
132}¿Por qué
flatMapLatesty no filtrar la lista en el composable? Filtrar en la interfaz obliga a traer toda la tabla a memoria y recorrerla confilteren cada recomposición. Con veinte libros da igual; con dos mil, no. Delegar el filtro en Room aprovecha el índice sobre la columnatitulocreado en T4 y devuelve solo las filas necesarias. Además, la lista filtrada sigue siendo reactiva: si mientras se busca “Weir” se marca un favorito, Room reemite y la pantalla se actualiza.
flatMapLatestestá marcado como@ExperimentalCoroutinesApiydebouncecomo@FlowPreview. Ambas son API estables en la práctica, pero el compilador exige declararlo con@OptIn.
7. La interfaz: pull-to-refresh y aviso de conexión#
PullToRefreshBox es el contenedor de Material 3 que detecta el gesto de arrastre hacia abajo y muestra el indicador circular. Recibe el estado (isRefreshing) y la acción (onRefresh); todo lo demás lo gestiona internamente.
1import android.text.format.DateUtils
2import androidx.compose.animation.AnimatedVisibility
3import androidx.compose.foundation.layout.*
4import androidx.compose.foundation.lazy.LazyColumn
5import androidx.compose.foundation.lazy.items
6import androidx.compose.material.icons.Icons
7import androidx.compose.material.icons.filled.Add
8import androidx.compose.material.icons.filled.Clear
9import androidx.compose.material.icons.filled.CloudOff
10import androidx.compose.material.icons.filled.MenuBook
11import androidx.compose.material.icons.filled.Refresh
12import androidx.compose.material.icons.filled.Search
13import androidx.compose.material3.*
14import androidx.compose.material3.pulltorefresh.PullToRefreshBox
15import androidx.compose.runtime.*
16import androidx.compose.ui.Alignment
17import androidx.compose.ui.Modifier
18import androidx.compose.ui.unit.dp
19import androidx.lifecycle.compose.collectAsStateWithLifecycle
20import androidx.lifecycle.viewmodel.compose.viewModel
21
22// ─── screens/listado/PantallaListado.kt ──────────────────────────────────────────────────────────
23@OptIn(ExperimentalMaterial3Api::class)
24@Composable
25fun PantallaListado(
26 viewModel: LibrosViewModel = viewModel(factory = LibrosViewModel.Factory),
27 onNavegaADetalle: (String) -> Unit = {},
28 onNavegaANuevoLibro: () -> Unit = {}
29) {
30 val uiState by viewModel.uiState.collectAsStateWithLifecycle()
31 val busqueda by viewModel.busqueda.collectAsStateWithLifecycle()
32 val estaSincronizando by viewModel.estaSincronizando.collectAsStateWithLifecycle()
33 val hayConexion by viewModel.hayConexion.collectAsStateWithLifecycle()
34 val ultimaSincronizacion by viewModel.ultimaSincronizacion.collectAsStateWithLifecycle()
35
36 val snackbarHostState = remember { SnackbarHostState() }
37
38 // Consumo de los eventos de un solo uso emitidos por el ViewModel.
39 // LaunchedEffect(Unit): la corrutina se lanza una vez y vive mientras el
40 // composable permanezca en la composición.
41 LaunchedEffect(Unit) {
42 viewModel.eventos.collect { evento ->
43 when (evento) {
44 is LibrosEvento.MostrarSnackbar ->
45 snackbarHostState.showSnackbar(evento.mensaje)
46 }
47 }
48 }
49
50 Scaffold(
51 topBar = {
52 TopAppBar(
53 title = {
54 Column {
55 Text("Mi biblioteca")
56 Text(
57 text = textoUltimaSincronizacion(ultimaSincronizacion),
58 style = MaterialTheme.typography.labelSmall,
59 color = MaterialTheme.colorScheme.onSurfaceVariant
60 )
61 }
62 },
63 actions = {
64 IconButton(
65 onClick = { viewModel.sincronizar() },
66 enabled = !estaSincronizando
67 ) {
68 Icon(Icons.Default.Refresh, contentDescription = "Sincronizar")
69 }
70 }
71 )
72 },
73 floatingActionButton = {
74 FloatingActionButton(onClick = onNavegaANuevoLibro) {
75 Icon(Icons.Default.Add, contentDescription = "Añadir libro")
76 }
77 },
78 snackbarHost = { SnackbarHost(snackbarHostState) }
79 ) { paddingValues ->
80 Column(modifier = Modifier.padding(paddingValues)) {
81
82 // ─── Aviso de falta de conexión ─────────────────────────────────
83 AnimatedVisibility(visible = !hayConexion) { BannerSinConexion() }
84
85 // ─── Búsqueda (el filtrado lo resuelve Room) ────────────────────
86 OutlinedTextField(
87 value = busqueda,
88 onValueChange = viewModel::actualizarBusqueda,
89 modifier = Modifier
90 .fillMaxWidth()
91 .padding(horizontal = 16.dp, vertical = 8.dp),
92 placeholder = { Text("Buscar en mi biblioteca...") },
93 leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) },
94 trailingIcon = {
95 AnimatedVisibility(visible = busqueda.isNotEmpty()) {
96 IconButton(onClick = { viewModel.actualizarBusqueda("") }) {
97 Icon(Icons.Default.Clear, contentDescription = "Borrar")
98 }
99 }
100 },
101 singleLine = true
102 )
103
104 // ─── Contenido con pull-to-refresh ──────────────────────────────
105 PullToRefreshBox(
106 isRefreshing = estaSincronizando,
107 onRefresh = { viewModel.sincronizar(forzar = true) },
108 modifier = Modifier.weight(1f)
109 ) {
110 when (val estado = uiState) {
111
112 is LibrosUiState.Cargando ->
113 Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
114 CircularProgressIndicator()
115 }
116
117 is LibrosUiState.Error ->
118 Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
119 Text(estado.mensaje)
120 }
121
122 is LibrosUiState.Exito ->
123 // IMPORTANTE: el contenido de PullToRefreshBox debe ser
124 // desplazable para que el gesto se detecte. Por eso el
125 // estado vacío también va dentro del LazyColumn.
126 LazyColumn(modifier = Modifier.fillMaxSize()) {
127 if (estado.libros.isEmpty()) {
128 item {
129 EstadoVacio(
130 busqueda = busqueda,
131 onAnyadir = onNavegaANuevoLibro
132 )
133 }
134 } else {
135 items(estado.libros, key = { it.id }) { libro ->
136 ItemLibro(
137 libro = libro,
138 onClickItem = { onNavegaADetalle(libro.id) },
139 onToggleFavorito = { viewModel.toggleFavorito(libro) }
140 )
141 }
142 }
143 }
144 }
145 }
146 }
147 }
148}
149
150// Texto legible de la última sincronización. DateUtils.getRelativeTimeSpanString
151// devuelve cadenas como "hace 3 horas", ya traducidas por el sistema al idioma
152// del dispositivo, sin necesidad de formateadores ni de la API de java.time.
153private fun textoUltimaSincronizacion(instante: Long?): String =
154 if (instante == null || instante == 0L) "Sin sincronizar"
155 else "Actualizado ${DateUtils.getRelativeTimeSpanString(instante)}"
156
157@Composable
158private fun BannerSinConexion() {
159 Surface(
160 color = MaterialTheme.colorScheme.errorContainer,
161 modifier = Modifier.fillMaxWidth()
162 ) {
163 Row(
164 verticalAlignment = Alignment.CenterVertically,
165 horizontalArrangement = Arrangement.spacedBy(8.dp),
166 modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)
167 ) {
168 Icon(
169 imageVector = Icons.Default.CloudOff,
170 contentDescription = null,
171 tint = MaterialTheme.colorScheme.onErrorContainer
172 )
173 Text(
174 text = "Sin conexión. Se muestran los datos guardados.",
175 style = MaterialTheme.typography.bodySmall,
176 color = MaterialTheme.colorScheme.onErrorContainer
177 )
178 }
179 }
180}
181
182@Composable
183private fun EstadoVacio(busqueda: String, onAnyadir: () -> Unit) {
184 Column(
185 horizontalAlignment = Alignment.CenterHorizontally,
186 modifier = Modifier
187 .fillMaxWidth()
188 .padding(32.dp)
189 ) {
190 Icon(
191 imageVector = Icons.Default.MenuBook,
192 contentDescription = null,
193 modifier = Modifier.size(64.dp),
194 tint = MaterialTheme.colorScheme.onSurfaceVariant
195 )
196 Spacer(Modifier.height(16.dp))
197 Text(
198 text = if (busqueda.isBlank()) "Tu biblioteca está vacía"
199 else "Ningún libro coincide con \"$busqueda\"",
200 style = MaterialTheme.typography.titleMedium
201 )
202 Spacer(Modifier.height(8.dp))
203 Text(
204 text = "Puedes buscarlo en Open Library y añadirlo a tu biblioteca.",
205 style = MaterialTheme.typography.bodySmall,
206 color = MaterialTheme.colorScheme.onSurfaceVariant
207 )
208 Spacer(Modifier.height(16.dp))
209 Button(onClick = onAnyadir) { Text("Añadir libro") }
210 }
211}Dos detalles fáciles de pasar por alto. El primero:
PullToRefreshBoxnecesita que su contenido sea desplazable verticalmente; si el estado vacío se pinta con unBoxfuera delLazyColumn, el gesto no se detecta y parece que el componente está roto. El segundo: el aviso de conexión y el botón de sincronizar son lo único que depende dehayConexion. La lista no cambia de origen, ni se oculta, ni muestra un error. Ese es exactamente el comportamiento offline-first.
8. Observar la conectividad con callbackFlow#
Android informa de los cambios de red mediante ConnectivityManager y una clase de callbacks. Para integrarlo en la arquitectura hay que convertir esos callbacks en un Flow, y la herramienta para ello es el constructor callbackFlow.
1import android.content.Context
2import android.net.ConnectivityManager
3import android.net.Network
4import android.net.NetworkCapabilities
5import kotlinx.coroutines.channels.awaitClose
6import kotlinx.coroutines.flow.Flow
7import kotlinx.coroutines.flow.callbackFlow
8import kotlinx.coroutines.flow.conflate
9import kotlinx.coroutines.flow.distinctUntilChanged
10
11// ─── utils/ObservadorConectividad.kt ─────────────────────────────────────────────────────────────
12// Traduce los callbacks de ConnectivityManager a un Flow<Boolean>.
13// true → hay una red conectada y VALIDADA (con acceso real a internet)
14// false → no hay red, o la hay pero sin salida a internet (portal cautivo)
15class ObservadorConectividad(context: Context) {
16
17 private val connectivityManager =
18 context.getSystemService(ConnectivityManager::class.java)
19
20 val hayConexion: Flow<Boolean> = callbackFlow {
21
22 val callback = object : ConnectivityManager.NetworkCallback() {
23
24 override fun onAvailable(network: Network) { trySend(true) }
25
26 override fun onLost(network: Network) { trySend(false) }
27
28 override fun onUnavailable() { trySend(false) }
29
30 // NET_CAPABILITY_VALIDATED: el sistema ha comprobado que esta red tiene
31 // salida real a internet. Estar conectado a un wifi NO basta.
32 override fun onCapabilitiesChanged(
33 network: Network,
34 capacidades: NetworkCapabilities
35 ) {
36 trySend(
37 capacidades.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)
38 )
39 }
40 }
41
42 // Valor inicial: el Flow debe emitir el estado actual sin esperar a que algo
43 // cambie, o la UI mostraría "sin conexión" hasta el primer evento del sistema
44 trySend(estadoActual())
45
46 connectivityManager.registerDefaultNetworkCallback(callback)
47
48 // awaitClose es OBLIGATORIO en callbackFlow: mantiene viva la corrutina y,
49 // al cancelarse el Flow (por ejemplo, cuando el ViewModel muere), ejecuta el
50 // bloque para dar de baja el callback y no filtrar memoria.
51 awaitClose { connectivityManager.unregisterNetworkCallback(callback) }
52 }
53 .distinctUntilChanged() // ignora repeticiones del mismo estado
54 .conflate() // si llegan varios cambios seguidos, solo el último
55
56 private fun estadoActual(): Boolean {
57 val red = connectivityManager.activeNetwork ?: return false
58 val capacidades = connectivityManager.getNetworkCapabilities(red) ?: return false
59 return capacidades.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)
60 }
61}
callbackFlowes el puente estándar entre las API de Android y las corrutinas. El patrón es siempre el mismo y merece la pena memorizarlo: registrar el listener, emitir contrySend()desde los callbacks —que se ejecutan en un hilo cualquiera, por eso no se usasend(), que es suspend— y liberar el recurso dentro deawaitClose { }. OlvidarawaitCloseprovoca un error en tiempo de ejecución:callbackFlowexige explícitamente que se llame.
El observador se registra en el AppContainer junto al resto de dependencias:
1import android.content.Context
2import okhttp3.OkHttpClient
3
4// ─── data/di/AppContainer.kt ─────────────────────────────────────────────────────────────────────
5interface AppContainer {
6 val librosRepository: LibrosRepository
7 val okHttpClient: OkHttpClient // compartido con Coil (T5)
8 val observadorConectividad: ObservadorConectividad // ← NUEVO en T6
9}
10
11class DefaultAppContainer(private val context: Context) : AppContainer {
12
13 // ─── Capa local (T4) ────────────────────────────────────────────────────
14 private val database by lazy { AppDatabase.getDatabase(context) }
15 private val localDataSource: LocalDataSource by lazy {
16 LocalDataSource(database.libroDao())
17 }
18
19 // ─── Capa remota (T5) ───────────────────────────────────────────────────
20 override val okHttpClient: OkHttpClient by lazy { RetrofitClient.okHttpClient }
21
22 private val remoteDataSource: RemoteDataSource by lazy {
23 RemoteDataSource(RetrofitClient.openLibraryApiService)
24 }
25
26 // ─── Repositorio: coordina ambos orígenes ───────────────────────────────
27 override val librosRepository: LibrosRepository by lazy {
28 LibrosRepository(localDataSource, remoteDataSource)
29 }
30
31 // ─── Servicios del sistema (T6) ─────────────────────────────────────────
32 // Se usa applicationContext: el observador vive tanto como la aplicación, así
33 // que guardar el contexto de una Activity provocaría una fuga de memoria.
34 override val observadorConectividad: ObservadorConectividad by lazy {
35 ObservadorConectividad(context.applicationContext)
36 }
37}9. ¿Y la caché HTTP? Room ya es la caché#
Es una pregunta razonable después de configurar OkHttpClient en T5: ¿no debería activarse también la caché en disco de OkHttp? La respuesta, en una arquitectura offline-first, es casi siempre no para los datos y sí para las imágenes.
| Contenido | ¿Quién lo cachea? | Motivo |
|---|---|---|
Respuestas JSON de search.json |
Room | Ya se guardan como filas de la tabla libros, consultables, filtrables y editables por el usuario. Una copia adicional del JSON sería redundante y podría contradecir a Room. |
Carátulas de covers.openlibrary.org |
Coil | Son binarios grandes que no tiene sentido meter en SQLite. Coil los guarda en memoria y en disco por sí solo. |
Añadir una caché HTTP para el JSON introduciría además un problema sutil: OkHttp podría devolver una respuesta cacheada mientras la aplicación cree estar comprobando datos frescos, de modo que actualizado_en registraría una comprobación que en realidad no ha llegado al servidor.
Con las imágenes ocurre lo contrario. Coil 3 activa la caché de memoria y de disco por defecto, así que la configuración de T5 ya es correcta y no hay que tocarla:
1import android.app.Application
2import coil3.ImageLoader
3import coil3.PlatformContext
4import coil3.SingletonImageLoader
5import coil3.network.okhttp.OkHttpNetworkFetcherFactory
6import coil3.request.crossfade
7
8// ─── AppDummyApplication.kt — sin cambios respecto a T5 ──────────────────────────────────────────
9class AppDummyApplication : Application(), SingletonImageLoader.Factory {
10
11 lateinit var container: AppContainer
12
13 override fun onCreate() {
14 super.onCreate()
15 container = DefaultAppContainer(this)
16 }
17
18 override fun newImageLoader(context: PlatformContext): ImageLoader =
19 ImageLoader.Builder(context)
20 .components {
21 // Mismo OkHttpClient que Retrofit: un solo pool de conexiones, los
22 // mismos timeouts y la misma cabecera User-Agent (T5)
23 add(OkHttpNetworkFetcherFactory(callFactory = { container.okHttpClient }))
24 }
25 // Coil crea por defecto una caché de memoria (un porcentaje de la RAM
26 // disponible) y otra de disco. No hace falta configurarlas a mano.
27 .crossfade(true)
28 .build()
29}Comprobación práctica. Con
HttpLoggingInterceptorenLevel.BODY, abre el listado dos veces seguidas y observa Logcat: las peticiones aopenlibrary.orgsolo aparecen durante la sincronización, y las decovers.openlibrary.orgdesaparecen a partir de la segunda vez. Si ves peticiones de carátulas en cada desplazamiento de la lista, es que elImageLoaderno se está compartiendo. Ten en cuenta que Coil 3 dejó de respetar las cabeceras de caché HTTP por defecto; para las carátulas de Open Library es justo lo que interesa, porque un libro sin portada devuelve 404 y no conviene reintentar la descarga en cada recomposición.
10. El recorrido completo: qué ocurre al abrir la app#
El usuario abre AppDummy
│
▼
AppDummyApplication.onCreate() → DefaultAppContainer(this)
│
▼
MainActivity.setContent { AppNavigation() } → PantallaListado
│
▼
LibrosViewModel creado (Factory desde el AppContainer)
│
├──► uiState = stateIn(flatMapLatest(busqueda) → Room)
│ │
│ └─► Room emite la biblioteca guardada ──► la lista aparece YA
│ (aunque no haya cobertura) LibrosUiState.Exito
│
├──► hayConexion = stateIn(ObservadorConectividad.hayConexion)
│ └─► si es false se muestra el banner y sincronizar() no llama a la red
│
└──► init { sincronizar(forzar = false) }
│
▼
_estaSincronizando = true
│
▼
repository.sincronizarBiblioteca(forzar = false)
│
├─ localDataSource.obtenerCaducados(hoy - 7 días, 20)
│ └─► SELECT ... WHERE isbn <> '' AND actualizado_en < :limite
│
└─ para cada libro caducado:
remoteDataSource.buscarPorIsbn(libro.isbn) ── GET a
│ openlibrary.org/search.json
▼
localDataSource.refrescarDesdeRed(id, datos, ahora)
│ └─► UPDATE titulo, autor, year, isbn, cover, actualizado_en
│ (es_favorito y leido intactos)
▼
delay(1,1 s) ← respeto del límite de peticiones
│
▼
Room detecta el cambio en la tabla
│
▼
el Flow reemite → uiState = LibrosUiState.Exito(lista actualizada)
│
▼
PantallaListado se recompone con los datos nuevos
│
▼
_estaSincronizando = false + Snackbar con el resumenSi en cualquier punto falla la red, el camino se interrumpe pero la rama de lectura nunca se ve afectada: la lista que se pintó en el segundo paso sigue en pantalla.
Estructura de paquetes al final del Bloque 3#
com.ejemplo.appdummy/
├── AppDummyApplication.kt ← AppContainer + ImageLoader de Coil
├── MainActivity.kt
│
├── data/
│ ├── datasource/
│ │ ├── local/
│ │ │ ├── AppDatabase.kt ← @Database(version = 2)
│ │ │ ├── Converters.kt
│ │ │ ├── LibrosDao.kt ← + obtenerCaducados, marcarComprobado
│ │ │ └── LocalDataSource.kt ← + refrescarDesdeRed
│ │ └── remote/
│ │ ├── dto/
│ │ │ ├── BusquedaResponseDto.kt
│ │ │ ├── LibroDto.kt
│ │ │ └── LibroMapper.kt
│ │ ├── OpenLibrary.kt
│ │ ├── OpenLibraryApiService.kt
│ │ ├── RemoteDataSource.kt
│ │ └── RetrofitClient.kt
│ ├── di/
│ │ └── AppContainer.kt ← + observadorConectividad
│ ├── model/
│ │ └── Libro.kt ← + columna actualizado_en
│ └── repository/
│ ├── LibrosRepository.kt ← + sincronizarBiblioteca()
│ └── ResultadoSincronizacion.kt ← NUEVO en T6
│
├── navegacion/
│ ├── AppDummyBottomBar.kt
│ ├── AppNavigation.kt
│ ├── ItemsNavegacion.kt
│ └── Rutas.kt
│
├── screens/
│ ├── componentes/
│ │ ├── CaratulaLibro.kt
│ │ └── ItemLibro.kt
│ ├── detalle/
│ ├── favoritos/
│ ├── listado/
│ │ ├── LibrosEvento.kt
│ │ ├── LibrosUiState.kt
│ │ ├── LibrosViewModel.kt ← + sincronizar() y flatMapLatest
│ │ └── PantallaListado.kt ← + PullToRefreshBox y banner
│ └── nuevo/
│ ├── NuevoLibroUiState.kt
│ ├── NuevoLibroViewModel.kt
│ └── PantallaNuevoLibro.kt
│
└── utils/
├── Isbn.kt
└── ObservadorConectividad.kt ← NUEVO en T6Fíjate en dónde han caído los cambios de este tema: casi todos en
data/. La interfaz solo ha ganado un contenedor (PullToRefreshBox) y un aviso. Cuando una decisión de arquitectura está bien colocada, la interfaz apenas se entera.
Desarrollo práctico guiado: AppDummy offline-first 💻#
Al terminar T5 la aplicación ya sabe leer de Room y escribir desde Open Library, pero cada cosa por su lado. En este tema se unen bajo una única estrategia. Estos son los cambios que debes realizar sobre la versión v7 :
- Añade la columna
actualizado_ena la entidadLibro, incrementa a2la versión de@Databasey declara el permisoACCESS_NETWORK_STATEen el manifiesto. Desinstala la app del emulador antes de volver a ejecutarla para partir de una base de datos limpia. - Amplía
LibrosDaoconobtenerCaducados(),observarUltimaSincronizacion()ymarcarComprobado(), y añade el parámetroactualizadoEnaactualizarDesdeRed()y aupsertConservandoFavorito(). Propaga esos métodos aLocalDataSource. - Implementa
sincronizarBiblioteca()enLibrosRepositoryjunto conResultadoSincronizacion. Comprueba que no devuelve libros: si sientes la tentación de devolver la lista, repasa la sección 2. - Verifica en Logcat que entre dos peticiones consecutivas transcurre algo más de un segundo. Prueba después a quitar el
delay()con quince libros en la biblioteca y observa la respuesta del servidor: es la forma más didáctica de ver un HTTP 429. - Sustituye el filtrado en memoria de
PantallaListadoporflatMapLatestsobreobservarPorTitulo(). ConLevel.BODYactivo verás que buscar ya no genera ninguna petición de red: la búsqueda es local. - Crea
ObservadorConectividady expónlo desde elAppContainer. Comprueba con el modo avión que el banner aparece y desaparece sin reiniciar la app y sin que la lista se vacíe ni un instante. - Envuelve el contenido del listado en
PullToRefreshBox. Prueba el gesto con la biblioteca vacía: si no se dispara, revisa que el estado vacío esté dentro delLazyColumn(sección 7). - Marca un libro como favorito y como leído, fuerza una sincronización y confirma con App Inspection de Android Studio que
es_favoritoyleidosiguen valiendo1mientrasactualizado_enha cambiado. Es la comprobación central del tema. - Repite la prueba anterior sustituyendo
actualizarDesdeRed()pordao.upsert(libro)y observa cómo se pierden los favoritos. Deshaz el cambio después: ahora ya sabes por qué existeupsertConservandoFavorito. - Da de alta un libro manualmente, sin usar la búsqueda, escribiendo solo un ISBN-13 válido y un título y un autor inventados. Sincroniza y comprueba que Open Library completa el año y la portada sin cambiar el
idlocal/local/<uuid>. - Prueba la app entera en modo avión: abrir, buscar, marcar favoritos, entrar al detalle y volver. Todo debe funcionar salvo la sincronización, que debe avisar con un
Snackbarsin bloquear nada. - Añade un test unitario en
app/src/testque verifique el mensaje devuelto pormensajeDe()para los cuatro casos posibles deResultadoSincronizacion. Es una función pura, no necesita emulador.
Ampliación opcional. Convierte la ruta
NuevoLibroendata class NuevoLibro(val titulo: String? = null)para que el botón del estado vacío abra el formulario con el texto de búsqueda ya escrito. Es un buen repaso de los argumentos tipados de Navigation Compose (T3) y del uso deSavedStateHandleen el ViewModel.
Referencias#
- Capa de datos — Guía de arquitectura de Android
- Offline-first — Guía de arquitectura de Android
- Inyección de dependencias manual — Android Developers
- PullToRefresh en Compose — Android Developers
- Supervisar el estado de la conectividad — Android Developers
- Corrutinas y Flow en Android — Android Developers
callbackFlow— documentación de kotlinx.coroutines- Open Library — Índice de APIs y normas de uso
- Coil — Imágenes de red y caché
- Guide to App Architecture — Android Developers
- Anexo B3-A4 — Referencia: Room avanzado, migraciones, testing y API Key segura