Tema 6. Integración Room + Retrofit2: arquitectura offline-first
- Bloque: B3 — Persistencia y comunicación: ROOM + Retrofit2
- Duración aproximada: 4 horas
- Aplicación de referencia: AppDummy biblioteca personal — proyecto vertebrador.
- 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 añadió en los T4 y T5. Solo hay que comprobar una cosa en el proyecto y añadir un permiso si no está ya declarado.
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 (no "material3"), 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 obliga a 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-A1 se explica cómo escribir unaMigrationreal para no perder los datos.
1. El problema: dos fuentes de datos y ninguna autoridad#
Al terminar el 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 aplicación 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 lo 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 no tiene)
4. Anotar la fecha de la comprobación
5. Esperar 1,1 segundos 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 // ... resto de la clase sin cambios ...
11}¿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 del 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 // Escritura — delega directamente en el DAO - Modificación T6
29 suspend fun upsertConservandoFavorito(libros: List<Libro>, instante: Long) =
30 dao.upsertConservandoFavorito(libros, instante)
31}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 // replay = 0: los eventos no se repiten para nuevos colectores
34 // extraBufferCapacity = 1: evita suspensión si no hay colector en ese instante
35 private val _eventos = MutableSharedFlow<LibrosEvento>(
36 replay = 0,
37 extraBufferCapacity = 1
38 )
39 val eventos: SharedFlow<LibrosEvento> = _eventos.asSharedFlow()
40
41 // Estado del campo de búsqueda — independiente del UiState principal
42 private val _autores = MutableStateFlow(listOf("Todos"))
43 val autores: StateFlow<List<String>> = _autores.asStateFlow()
44
45 // ─── Búsqueda por autor ─────────────────────────────────────────────────
46 private val _autorSeleccionado = MutableStateFlow("Todos")
47 val autorSeleccionado: StateFlow<String> = _autorSeleccionado.asStateFlow()
48
49 // ─── Conectividad: la UI la usa para avisar, NO para elegir la fuente ───
50 val hayConexion: StateFlow<Boolean> = observadorConectividad.hayConexion
51 .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), true)
52
53 // ─── Instante de la última sincronización, observado desde Room ─────────
54 val ultimaSincronizacion: StateFlow<Long?> = repository.observarUltimaSincronizacion()
55 .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)
56
57 // ─── Estado principal: la búsqueda se resuelve en SQL ───────────────────
58 // flatMapLatest sustituye el Flow anterior por uno nuevo cada vez que cambia el
59 // texto: al escribir se cancela la consulta previa y se abre otra sobre Room.
60 // El resultado sigue siendo reactivo (si cambia la tabla, vuelve a emitir).
61 val uiState: StateFlow<LibrosUiState> = _busqueda
62 .debounce(300.milliseconds) // no consultar en cada pulsación
63 .distinctUntilChanged()
64 .flatMapLatest { texto ->
65 if (texto.isBlank()) repository.observarLibros()
66 else repository.observarPorTitulo(texto)
67 }
68 .map<List<Libro>, LibrosUiState> {
69 cargarAutores() // cargar autores para el filtro de la UI
70 LibrosUiState.Exito(it)
71 }
72 .catch { error -> emit(LibrosUiState.Error(error.message ?: "Error")) }
73 .stateIn(
74 scope = viewModelScope,
75 started = SharingStarted.WhileSubscribed(5_000),
76 initialValue = LibrosUiState.Cargando
77 )
78
79 init {
80 // Sincronización silenciosa al crear el ViewModel: solo los caducados
81 sincronizar(forzar = false)
82 }
83
84 fun cargarAutores() {
85 viewModelScope.launch {
86 val autores = repository.obtenerAutores() ?: emptyList()
87 _autores.value = listOf("Todos") + autores
88 }
89 }
90
91 fun actualizarAutorSeleccionado(autor: String) {
92 _autorSeleccionado.value = autor
93 }
94
95 fun actualizarBusqueda(texto: String) {
96 _busqueda.value = texto
97 }
98
99 fun toggleFavorito(libro: Libro) {
100 viewModelScope.launch {
101 repository.toggleFavorito(libro.id)
102 // No es necesario actualizar el uiState manualmente:
103 // el Flow de Room detecta el cambio y emite la nueva lista automáticamente
104
105 _eventos.emit(
106 LibrosEvento.MostrarSnackbar(
107 if (libro.esFavorito) "\"${libro.titulo}\" eliminado de favoritos"
108 else "\"${libro.titulo}\" añadido a favoritos"
109 )
110 )
111 }
112 }
113
114 fun toggleLeido(libro: Libro) {
115 viewModelScope.launch {
116 repository.toggleLeido(libro.id)
117 _eventos.emit(
118 LibrosEvento.MostrarSnackbar(
119 if (libro.leido) "\"${libro.titulo}\" marcado como no leído"
120 else "\"${libro.titulo}\" marcado como leído"
121 )
122 )
123 }
124 }
125
126 // ─── Sincronización ─────────────────────────────────────────────────────
127
128 fun sincronizar(forzar: Boolean = true) {
129 // Guarda de reentrada: evita lanzar dos sincronizaciones simultáneas si el
130 // usuario arrastra la lista mientras ya se está sincronizando
131 if (_estaSincronizando.value) return
132
133 viewModelScope.launch {
134 if (!hayConexion.value) {
135 _eventos.emit(
136 LibrosEvento.MostrarSnackbar("Sin conexión: se muestran los datos guardados.")
137 )
138 return@launch
139 }
140
141 _estaSincronizando.value = true
142 try {
143 val resultado = repository.sincronizarBiblioteca(forzar)
144 // La sincronización automática del init solo avisa si ha hecho algo
145 if (forzar || resultado.comprobados > 0 || resultado.haFallado) {
146 _eventos.emit(LibrosEvento.MostrarSnackbar(mensajeDe(resultado)))
147 }
148 } finally {
149 // finally garantiza que el indicador se apaga aunque haya excepción
150 _estaSincronizando.value = false
151 }
152 }
153 }
154
155 private fun mensajeDe(resultado: ResultadoSincronizacion): String = when {
156 resultado.haFallado -> resultado.error ?: "No se ha podido sincronizar."
157 resultado.comprobados == 0 -> "La biblioteca ya está actualizada."
158 resultado.actualizados == 0 -> "Comprobados ${resultado.comprobados} libros, sin cambios."
159 else -> "Actualizados ${resultado.actualizados} de ${resultado.comprobados} libros."
160 }
161
162 companion object {
163 val Factory: ViewModelProvider.Factory = viewModelFactory {
164 initializer {
165 val app = checkNotNull(
166 this[ViewModelProvider.AndroidViewModelFactory.APPLICATION_KEY]
167 ) as AppDummyApplication
168 LibrosViewModel(
169 repository = app.container.librosRepository,
170 observadorConectividad = app.container.observadorConectividad
171 )
172 }
173 }
174 }
175}¿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.LazyRow
6import androidx.compose.foundation.lazy.grid.*
7import androidx.compose.foundation.lazy.items
8import androidx.compose.material.icons.Icons
9import androidx.compose.material.icons.automirrored.filled.MenuBook
10import androidx.compose.material.icons.filled.*
11import androidx.compose.material3.*
12import androidx.compose.material3.pulltorefresh.PullToRefreshBox
13import androidx.compose.runtime.*
14import androidx.compose.ui.*
15import androidx.compose.ui.tooling.preview.Preview
16import androidx.compose.ui.unit.dp
17import androidx.lifecycle.compose.collectAsStateWithLifecycle
18import androidx.lifecycle.viewmodel.compose.viewModel
19import com.ejemplo.appdummy.screens.componentes.ItemLibro
20import kotlinx.coroutines.*
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 = {}, // callback de navegación (se conecta al NavHost en T3)
28 onNavegaANuevoLibro: () -> Unit = {} // ← nuevo callback
29) {
30 val uiState by viewModel.uiState.collectAsStateWithLifecycle()
31 val busqueda by viewModel.busqueda.collectAsStateWithLifecycle()
32 val autorSeleccionado by viewModel.autorSeleccionado.collectAsStateWithLifecycle()
33 val autores by viewModel.autores.collectAsStateWithLifecycle()
34 val estaSincronizando by viewModel.estaSincronizando.collectAsStateWithLifecycle()
35 val hayConexion by viewModel.hayConexion.collectAsStateWithLifecycle()
36 val ultimaSincronizacion by viewModel.ultimaSincronizacion.collectAsStateWithLifecycle()
37
38 val scope = rememberCoroutineScope()
39 val snackbarHostState = remember { SnackbarHostState() }
40 var snackbarJob by remember { mutableStateOf<Job?>(null) }
41
42 // Consumo de los eventos de un solo uso emitidos por el ViewModel.
43 // LaunchedEffect(Unit): la corrutina se lanza una vez y vive mientras el
44 // composable permanezca en la composición.
45 LaunchedEffect(Unit) {
46 viewModel.eventos.collect { evento ->
47 when (evento) {
48 is LibrosEvento.MostrarSnackbar -> {
49 // Cancelar snackbar previo si existe
50 snackbarJob?.cancel()
51 // Descarta el Snackbar visible en pantalla
52 snackbarHostState.currentSnackbarData?.dismiss()
53 // Lanzar un nuevo Snackbar
54 snackbarJob = scope.launch {
55 snackbarHostState.showSnackbar(evento.mensaje)
56 }
57 }
58 }
59 }
60 }
61
62 Scaffold(
63 topBar = {
64 TopAppBar(
65 title = {
66 Column {
67 Text("AppDummy")
68 Text(
69 text = textoUltimaSincronizacion(ultimaSincronizacion),
70 style = MaterialTheme.typography.labelSmall,
71 color = MaterialTheme.colorScheme.onSurfaceVariant
72 )
73 }
74 },
75 actions = {
76 IconButton(
77 onClick = { viewModel.sincronizar() },
78 enabled = !estaSincronizando
79 ) {
80 Icon(Icons.Default.Refresh, contentDescription = "Sincronizar")
81 }
82 IconButton(onClick = { }) {
83 Icon(Icons.Default.AccountCircle, contentDescription = "Perfil")
84 }
85 }
86 )
87 },
88 floatingActionButton = {
89 if (snackbarHostState.currentSnackbarData == null)
90 AnimatedVisibility(snackbarHostState.currentSnackbarData == null) {
91 FloatingActionButton(onClick = onNavegaANuevoLibro) {
92 Icon(Icons.Default.Add, contentDescription = "Añadir libro")
93 }
94 }
95 },
96 snackbarHost = { SnackbarHost(snackbarHostState) }
97 ) { paddingValues ->
98 Column(modifier = Modifier.padding(paddingValues)) {
99
100 // ─── Aviso de falta de conexión ─────────────────────────────────
101 AnimatedVisibility(visible = !hayConexion) { BannerSinConexion() }
102
103 // ─── Búsqueda (el filtrado lo resuelve Room) ────────────────────
104 OutlinedTextField(
105 value = busqueda,
106 onValueChange = viewModel::actualizarBusqueda,
107 modifier = Modifier
108 .fillMaxWidth()
109 .padding(horizontal = 16.dp, vertical = 8.dp),
110 placeholder = { Text("Buscar en mi biblioteca...") },
111 leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) },
112 trailingIcon = {
113 AnimatedVisibility(visible = busqueda.isNotEmpty()) {
114 IconButton(onClick = { viewModel.actualizarBusqueda("") }) {
115 Icon(Icons.Default.Clear, contentDescription = "Borrar búsqueda")
116 }
117 }
118 },
119 singleLine = true
120 )
121
122 // ─── Chips de autores ───────────────────────────────────────────
123 LazyRow(
124 contentPadding = PaddingValues(horizontal = 16.dp),
125 horizontalArrangement = Arrangement.spacedBy(8.dp),
126 modifier = Modifier.padding(bottom = 8.dp)
127 ) {
128 items(autores) { autor ->
129 FilterChip(
130 selected = autor == autorSeleccionado,
131 onClick = { viewModel.actualizarAutorSeleccionado(autor) },
132 label = { Text(autor) }
133 )
134 }
135 }
136
137 // ─── Contenido con pull-to-refresh ──────────────────────────────
138 PullToRefreshBox(
139 isRefreshing = estaSincronizando,
140 onRefresh = { viewModel.sincronizar(forzar = true) },
141 modifier = Modifier.weight(1f)
142 ) {
143 // when exhaustivo sobre la sealed class
144 when (val estado = uiState) {
145 is LibrosUiState.Cargando ->
146 Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
147 CircularProgressIndicator()
148 }
149
150 is LibrosUiState.Error ->
151 Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
152 Text(estado.mensaje)
153 }
154
155 is LibrosUiState.Exito -> {
156 // IMPORTANTE: el contenido de PullToRefreshBox debe ser
157 // desplazable para que el gesto se detecte. Por eso el
158 // estado vacío también va dentro del LazyColumn.
159
160 // Filtrado de libros según la búsqueda y el autor seleccionado en UI,
161 // aunque la búsqueda ya se resuelve en SQL, el filtrado por autor
162 // se hace aquí en la UI. Esto permite que el filtrado por autor sea
163 // reactivo y no requiera una nueva consulta a la base de datos.
164 val librosFiltrados = estado.libros.filter {
165 val coincideBusqueda =
166 busqueda.isBlank() || it.titulo.contains(
167 busqueda,
168 ignoreCase = true
169 )
170 val coincideGenero =
171 autorSeleccionado == "Todos" || it.autor == autorSeleccionado
172 coincideBusqueda && coincideGenero
173 }
174 if (librosFiltrados.isEmpty()) {
175 LazyColumn(
176 contentPadding = PaddingValues(horizontal = 8.dp),
177 ) {
178 item {
179 EstadoVacio(
180 busqueda = busqueda,
181 onAnyadir = onNavegaANuevoLibro
182 )
183 }
184 }
185 } else {
186 LazyVerticalGrid(
187 contentPadding = PaddingValues(horizontal = 8.dp),
188 columns = GridCells.Fixed(2),
189 horizontalArrangement = Arrangement.spacedBy(8.dp),
190 verticalArrangement = Arrangement.spacedBy(8.dp)
191 ) {
192 items(librosFiltrados, key = { it.id }) { libro ->
193 ItemLibro(
194 libro = libro,
195 onClickItem = { onNavegaADetalle(libro.id) },
196 onToggleFavorito = { viewModel.toggleFavorito(libro) },
197 onToggleLeido = { viewModel.toggleLeido(libro) }
198 )
199 }
200 }
201 }
202 }
203 }
204 }
205 }
206 }
207}
208
209// Texto legible de la última sincronización. DateUtils.getRelativeTimeSpanString
210// devuelve cadenas como "hace 3 horas", ya traducidas por el sistema al idioma
211// del dispositivo, sin necesidad de formateadores ni de la API de java.time.
212private fun textoUltimaSincronizacion(instante: Long?): String =
213 if (instante == null || instante == 0L) "Sin sincronizar"
214 else "Actualizado ${DateUtils.getRelativeTimeSpanString(instante)}"
215
216@Composable
217private fun BannerSinConexion() {
218 Surface(
219 color = MaterialTheme.colorScheme.errorContainer,
220 modifier = Modifier.fillMaxWidth()
221 ) {
222 Row(
223 verticalAlignment = Alignment.CenterVertically,
224 horizontalArrangement = Arrangement.spacedBy(8.dp),
225 modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)
226 ) {
227 Icon(
228 imageVector = Icons.Default.CloudOff,
229 contentDescription = null,
230 tint = MaterialTheme.colorScheme.onErrorContainer
231 )
232 Text(
233 text = "Sin conexión. Se muestran los datos guardados.",
234 style = MaterialTheme.typography.bodySmall,
235 color = MaterialTheme.colorScheme.onErrorContainer
236 )
237 }
238 }
239}
240
241@Composable
242private fun EstadoVacio(busqueda: String, onAnyadir: () -> Unit) {
243 Column(
244 horizontalAlignment = Alignment.CenterHorizontally,
245 modifier = Modifier
246 .fillMaxWidth()
247 .padding(32.dp)
248 ) {
249 Icon(
250 imageVector = Icons.AutoMirrored.Filled.MenuBook,
251 contentDescription = null,
252 modifier = Modifier.size(64.dp),
253 tint = MaterialTheme.colorScheme.onSurfaceVariant
254 )
255 Spacer(Modifier.height(16.dp))
256 Text(
257 text = if (busqueda.isBlank()) "Tu biblioteca está vacía"
258 else "Ningún libro coincide con \"$busqueda\"",
259 style = MaterialTheme.typography.titleMedium
260 )
261 Spacer(Modifier.height(8.dp))
262 Text(
263 text = "Puedes buscarlo en Open Library y añadirlo a tu biblioteca.",
264 style = MaterialTheme.typography.bodySmall,
265 color = MaterialTheme.colorScheme.onSurfaceVariant
266 )
267 Spacer(Modifier.height(16.dp))
268 Button(onClick = onAnyadir) { Text("Añadir libro") }
269 }
270}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 lateinit var container: AppContainer
11
12 override fun onCreate() {
13 super.onCreate()
14 container = DefaultAppContainer(this)
15 }
16
17 override fun newImageLoader(context: PlatformContext): ImageLoader =
18 ImageLoader.Builder(context)
19 .components {
20 // Mismo OkHttpClient que Retrofit: un solo pool de conexiones, los
21 // mismos timeouts y la misma cabecera User-Agent (T5)
22 add(OkHttpNetworkFetcherFactory(callFactory = { container.okHttpClient }))
23 }
24 // Coil crea por defecto una caché de memoria (un porcentaje de la RAM
25 // disponible) y otra de disco. No hace falta configurarlas a mano.
26 .crossfade(true)
27 .build()
28}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 da cuenta.
Desarrollo práctico guiado: AppDummy offline-first 💻#
Al terminar el 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-A1 — Referencia: Room avanzado, migraciones, testing y API Key segura