Carga de Datos Offline en Android: Implementando una Estrategia Robusta con Room y WorkManager
Este tutorial te guiará a través de la implementación de una estrategia de carga de datos offline robusta en Android. Utilizarás la biblioteca de persistencia Room para almacenar datos localmente y WorkManager para gestionar la sincronización en segundo plano, asegurando una experiencia de usuario fluida incluso sin conexión a internet.
🚀 Introducción: El Poder de las Aplicaciones Offline
En el mundo móvil actual, la conectividad no siempre está garantizada. Los usuarios esperan que sus aplicaciones funcionen de manera fluida, incluso cuando están en un túnel, en un avión o en una zona con mala señal. Implementar una estrategia de datos offline-first no solo mejora la experiencia del usuario, sino que también aumenta la resiliencia y el rendimiento de tu aplicación.
Este tutorial te proporcionará una guía completa para construir una aplicación Android que pueda cargar, mostrar y gestionar datos sin conexión, utilizando herramientas modernas de Jetpack: Room para la persistencia de datos local y WorkManager para la sincronización inteligente en segundo plano. Exploraremos cómo estas dos potentes bibliotecas se integran para ofrecer una solución robusta y eficiente.
¿Por qué "Offline-First"?
La filosofía offline-first prioriza la disponibilidad y funcionalidad de la aplicación incluso sin conexión. Esto implica almacenar una copia local de los datos esenciales y permitir que el usuario interactúe con ellos. Cuando la conexión se restablece, la aplicación puede sincronizar los cambios y obtener nuevas actualizaciones.
Beneficios clave:
- Experiencia de usuario mejorada: Sin interrupciones ni pantallas de carga interminables debido a problemas de red.
- Rendimiento optimizado: Carga de datos casi instantánea desde el almacenamiento local.
- Menor consumo de batería y datos: Al evitar llamadas de red repetitivas e innecesarias.
- Resiliencia: La aplicación sigue siendo útil en entornos de conectividad limitada o inexistente.
🛠️ Herramientas Fundamentales: Room y WorkManager
Para lograr nuestra meta, nos apoyaremos en dos pilares fundamentales del ecosistema Android Jetpack:
📖 Room Persistence Library
Room es una capa de abstracción sobre SQLite que facilita el uso de bases de datos en Android. Proporciona verificaciones en tiempo de compilación de las consultas SQL y devuelve objetos de datos Plain Old Java Objects (POJO). Se integra perfectamente con LiveData y Kotlin Flows, lo que la hace ideal para patrones de arquitectura reactivos.
Características destacadas de Room:
- Facilidad de uso: Menos boilerplate y código más limpio que SQLite directamente.
- Seguridad en tiempo de compilación: Valida las consultas SQL en tiempo de compilación, previniendo errores en tiempo de ejecución.
- Integración con Coroutines/Flows: Soporte nativo para programación asíncrona y reactiva.
- Migraciones: Herramientas para gestionar cambios en el esquema de la base de datos.
⚙️ WorkManager
WorkManager es una API para programar tareas en segundo plano que necesitan garantía de ejecución, incluso si la aplicación se cierra o el dispositivo se reinicia. Es la solución recomendada para tareas diferibles y garantizadas, como la sincronización de datos, la carga de imágenes o la limpieza de bases de datos.
Características destacadas de WorkManager:
- Persistencia: Las tareas persisten a través de reinicios del dispositivo y cierres de la aplicación.
- Restricciones flexibles: Permite definir cuándo debe ejecutarse una tarea (ej. solo con Wi-Fi, con batería baja, etc.).
- Compatibilidad: Funciona en todas las versiones de Android (API 14+).
- Observabilidad: Proporciona LiveData para observar el estado de las tareas.
🏗️ Arquitectura de la Solución
Nuestra arquitectura se basará en el patrón MVVM (Model-View-ViewModel), con un repositorio que actúa como la única fuente de verdad. La clave aquí es que el repositorio primero intentará cargar datos desde la base de datos local (Room) y luego, si es necesario, iniciará una tarea de sincronización para obtener los datos más recientes de una fuente remota (API).
📝 Implementación Paso a Paso
Vamos a construir una aplicación simple que muestra una lista de "artículos" que se sincronizan desde una API remota y se almacenan localmente.
Paso 1: Configuración del Proyecto y Dependencias
Crea un nuevo proyecto en Android Studio (Activity vacía) y añade las siguientes dependencias en tu archivo build.gradle.kts (Module :app):
dependencies {
// Core Android KTX
implementation("androidx.core:core-ktx:1.12.0")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.7.0")
implementation("androidx.activity:activity-compose:1.8.2") // Si usas Compose, si no activity-ktx
// Jetpack Compose (si lo usas)
implementation(platform("androidx.compose:compose-bom:2023.08.00"))
implementation("androidx.compose.ui:ui")
implementation("androidx.compose.ui:ui-graphics")
implementation("androidx.compose.ui:ui-tooling-preview")
implementation("androidx.compose.material3:material3")
// Room
val room_version = "2.6.1"
implementation("androidx.room:room-runtime:$room_version")
annotationProcessor("androidx.room:room-compiler:$room_version")
// Para Kotlin KAPT
kapt("androidx.room:room-compiler:$room_version")
// Room con Coroutines/Flow
implementation("androidx.room:room-ktx:$room_version")
// WorkManager
val work_version = "2.9.0"
implementation("androidx.work:work-runtime-ktx:$work_version")
// Retrofit para API calls (ejemplo)
implementation("com.squareup.retrofit2:retrofit:2.9.0")
implementation("com.squareup.retrofit2:converter-gson:2.9.0")
// Coroutines
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")
// Hilt (opcional, para inyección de dependencias)
// Si no usas Hilt, tendrás que crear instancias manualmente
implementation("com.google.dagger:hilt-android:2.48")
kapt("com.google.dagger:hilt-compiler:2.48")
kapt("androidx.hilt:hilt-compiler:1.1.0")
implementation("androidx.hilt:hilt-work:1.1.0")
// Test
testImplementation("junit:junit:4.13.2")
androidTestImplementation("androidx.test.ext:junit:1.1.5")
androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1")
androidTestImplementation(platform("androidx.compose:compose-bom:2023.08.00"))
androidTestImplementation("androidx.compose.ui:ui-test-junit4")
debugImplementation("androidx.compose.ui:ui-tooling")
debugImplementation("androidx.compose.ui:ui-test-manifest")
}
Asegúrate de aplicar los plugins de kotlin-kapt y dagger.hilt.android.plugin en la parte superior de tu archivo build.gradle.kts (Module :app):
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
id("com.google.devtools.ksp") version "1.9.0-1.0.13" apply false // Si usas KSP en lugar de KAPT
id("kotlin-kapt")
id("com.google.dagger.hilt.android")
}
Y en el build.gradle.kts (Project):
plugins {
id("com.android.application") version "8.2.0" apply false
id("org.jetbrains.kotlin.android") version "1.9.0" apply false
id("com.google.dagger.hilt.android") version "2.48" apply false
}
También, añade los permisos de internet en AndroidManifest.xml:
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
Paso 2: Definición de Entidades y DAO de Room
Primero, crearemos una entidad para nuestros artículos. Esta entidad representará una fila en nuestra base de datos local y también el objeto que recibimos de la API.
package com.example.offlineapp.data.local.entities
import androidx.room.Entity
import androidx.room.PrimaryKey
@Entity(tableName = "articles")
data class ArticleEntity(
@PrimaryKey
val id: String,
val title: String,
val body: String,
val author: String,
val imageUrl: String?,
val lastSync: Long = System.currentTimeMillis() // Marca de tiempo para la sincronización
)
Ahora, el DAO (Data Access Object) para interactuar con la tabla articles.
package com.example.offlineapp.data.local.dao
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import kotlinx.coroutines.flow.Flow
@Dao
interface ArticleDao {
@Query("SELECT * FROM articles ORDER BY lastSync DESC")
fun getAllArticles(): Flow<List<ArticleEntity>>
@Query("SELECT * FROM articles WHERE id = :articleId")
suspend fun getArticleById(articleId: String): ArticleEntity?
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertArticles(articles: List<ArticleEntity>)
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertArticle(article: ArticleEntity)
@Query("DELETE FROM articles")
suspend fun deleteAllArticles()
}
Finalmente, la base de datos Room.
package com.example.offlineapp.data.local
import androidx.room.Database
import androidx.room.RoomDatabase
import com.example.offlineapp.data.local.dao.ArticleDao
import com.example.offlineapp.data.local.entities.ArticleEntity
@Database(entities = [ArticleEntity::class], version = 1, exportSchema = false)
abstract class AppDatabase : RoomDatabase() {
abstract fun articleDao(): ArticleDao
}
Paso 3: Interfaz y Servicio de API Remota (Retrofit)
Definimos un modelo de datos que recibiremos de la API y un servicio para realizar las llamadas de red. Para este ejemplo, simularemos una API simple.
package com.example.offlineapp.data.remote.models
data class ArticleDto(
val id: String,
val title: String,
val body: String,
val author: String,
val imageUrl: String? = null
)
fun ArticleDto.toEntity(): ArticleEntity {
return ArticleEntity(
id = id,
title = title,
body = body,
author = author,
imageUrl = imageUrl
)
}
package com.example.offlineapp.data.remote.api
import com.example.offlineapp.data.remote.models.ArticleDto
import retrofit2.http.GET
interface ArticleApiService {
@GET("articles") // Suponiendo un endpoint /articles
suspend fun getArticles(): List<ArticleDto>
}
Para simular la API, podríamos usar una librería como MockWebServer de OkHttp o simplemente una URL de prueba. Aquí asumiremos que tenemos un servicio en BASE_URL.
Paso 4: El Worker de Sincronización (WorkManager)
Este es el corazón de nuestra estrategia de sincronización. Un Worker que se encarga de llamar a la API remota, obtener los datos y guardarlos en Room.
package com.example.offlineapp.data.worker
import android.content.Context
import androidx.hilt.work.HiltWorker
import androidx.work.CoroutineWorker
import androidx.work.WorkerParameters
import com.example.offlineapp.data.local.dao.ArticleDao
import com.example.offlineapp.data.remote.api.ArticleApiService
import com.example.offlineapp.data.remote.models.toEntity
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import timber.log.Timber // O cualquier logger que uses
@HiltWorker
class SyncArticlesWorker @AssistedInject constructor(
@Assisted context: Context,
@Assisted workerParams: WorkerParameters,
private val articleApiService: ArticleApiService,
private val articleDao: ArticleDao
) : CoroutineWorker(context, workerParams) {
override suspend fun doWork(): Result {
Timber.d("Iniciando SyncArticlesWorker...")
return withContext(Dispatchers.IO) {
try {
// 1. Obtener datos de la API remota
val remoteArticles = articleApiService.getArticles()
Timber.d("Artículos obtenidos de la API: ${remoteArticles.size}")
// 2. Mapear a entidades de Room y guardar en la base de datos local
val articleEntities = remoteArticles.map { it.toEntity() }
articleDao.insertArticles(articleEntities)
Timber.d("Artículos guardados en Room.")
// Si todo fue bien, retornamos éxito
Result.success()
} catch (e: Exception) {
Timber.e(e, "Error durante la sincronización de artículos")
// Podemos añadir lógica de reintento si es un error de red o similar
Result.retry()
}
}
}
companion object {
const val WORK_NAME = "SyncArticlesWorker"
}
}
Paso 5: El Repositorio (Single Source of Truth)
El repositorio será el intermediario entre el ViewModel, la base de datos local y la API remota (a través de WorkManager). Implementa la lógica para decidir si los datos deben ser cargados de la caché o si es necesario una sincronización.
package com.example.offlineapp.data.repository
import android.content.Context
import androidx.work.Constraints
import androidx.work.NetworkType
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.WorkInfo
import androidx.work.WorkManager
import com.example.offlineapp.data.local.dao.ArticleDao
import com.example.offlineapp.data.local.entities.ArticleEntity
import com.example.offlineapp.data.remote.api.ArticleApiService
import com.example.offlineapp.data.worker.SyncArticlesWorker
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import timber.log.Timber
import java.util.concurrent.TimeUnit
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class ArticleRepository @Inject constructor(
private val articleDao: ArticleDao,
private val articleApiService: ArticleApiService, // Usado para una carga inicial/refresco directo
@ApplicationContext private val context: Context
) {
private val workManager = WorkManager.getInstance(context)
fun getArticles(): Flow<List<ArticleEntity>> {
Timber.d("Repository: Solicitando artículos del DAO.")
// Siempre devolvemos los datos locales primero
return articleDao.getAllArticles()
}
suspend fun refreshArticles(force: Boolean = false) {
Timber.d("Repository: Intentando refrescar artículos (force: $force).")
// Aquí podemos añadir lógica para decidir si es necesario un refresco
// Por ejemplo, basándonos en la marca de tiempo 'lastSync' de los artículos existentes
// Para simplificar, siempre intentaremos sincronizar si se llama a este método.
// Podemos verificar el estado de la red antes de programar el trabajo
val constraints = Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED) // Requiere conexión a internet
// .setRequiresBatteryNotLow(true) // Opcional: no sincronizar con batería baja
.build()
val syncRequest = OneTimeWorkRequestBuilder<SyncArticlesWorker>()
.setConstraints(constraints)
// Establecer un retraso mínimo si queremos evitar sincronizaciones muy seguidas
.setInitialDelay(10, TimeUnit.SECONDS) // Ejemplo: espera 10s antes de ejecutar
.addTag(SyncArticlesWorker.WORK_NAME)
.build()
// Encolar el trabajo. REPLACE significa que si ya hay un trabajo con ese nombre, lo reemplaza.
// Esto es útil para evitar múltiples sincronizaciones simultáneas innecesarias.
workManager.enqueueUniqueWork(
SyncArticlesWorker.WORK_NAME,
androidx.work.ExistingWorkPolicy.REPLACE,
syncRequest
)
// Observar el estado del worker (opcional, para UI feedback)
workManager.getWorkInfosForUniqueWorkLiveData(SyncArticlesWorker.WORK_NAME)
.observeForever { workInfos ->
if (workInfos.isNullOrEmpty()) return@observeForever
val currentWorkInfo = workInfos.first()
when (currentWorkInfo.state) {
WorkInfo.State.SUCCEEDED -> {
Timber.d("WorkManager: Sincronización exitosa.")
// Podríamos emitir un evento a la UI si fuera necesario
}
WorkInfo.State.FAILED -> {
Timber.e("WorkManager: Sincronización fallida.")
}
WorkInfo.State.RUNNING -> {
Timber.d("WorkManager: Sincronización en curso...")
}
else -> {
// Otros estados (BLOCKED, ENQUEUED, CANCELLED)
Timber.d("WorkManager: Estado de sincronización: ${currentWorkInfo.state}")
}
}
}
}
// Función para una carga inicial directa (útil para el primer arranque o si WorkManager falla)
// Podría ser llamada desde el ViewModel si la base de datos está vacía
suspend fun fetchAndStoreInitialArticles() {
if (articleDao.getAllArticles().first().isEmpty()) {
Timber.d("No hay artículos locales, realizando carga inicial directa.")
try {
val remoteArticles = articleApiService.getArticles()
val articleEntities = remoteArticles.map { it.toEntity() }
articleDao.insertArticles(articleEntities)
Timber.d("Carga inicial directa completada y guardada.")
} catch (e: Exception) {
Timber.e(e, "Error en la carga inicial directa de artículos")
}
}
}
}
Paso 6: ViewModel para la UI
El ViewModel expondrá los datos del repositorio a la UI y gestionará la solicitud de refresco.
package com.example.offlineapp.presentation.viewmodel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.example.offlineapp.data.local.entities.ArticleEntity
import com.example.offlineapp.data.repository.ArticleRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import timber.log.Timber
import javax.inject.Inject
sealed interface ArticleListUiState {
object Loading : ArticleListUiState
data class Success(val articles: List<ArticleEntity>) : ArticleListUiState
data class Error(val message: String) : ArticleListUiState
}
@HiltViewModel
class ArticleListViewModel @Inject constructor(
private val repository: ArticleRepository
) : ViewModel() {
val uiState: StateFlow<ArticleListUiState> = repository.getArticles()
.map { articles ->
if (articles.isEmpty() && !isInitialLoadCompleted) {
// Si la DB está vacía y no hemos intentado una carga inicial, mostramos Loading
ArticleListUiState.Loading
} else if (articles.isEmpty()) {
// Si la DB está vacía después de intentar cargar, puede ser un error o no hay datos
ArticleListUiState.Error("No se encontraron artículos")
} else {
ArticleListUiState.Success(articles)
}
}
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5000),
initialValue = ArticleListUiState.Loading
)
private var isInitialLoadCompleted = false
init {
Timber.d("ViewModel init: Llamando a refreshArticles y fetchAndStoreInitialArticles")
viewModelScope.launch {
// Intenta una carga inicial si la DB está vacía
repository.fetchAndStoreInitialArticles()
isInitialLoadCompleted = true
// Programa la sincronización a través de WorkManager
repository.refreshArticles()
}
}
fun onRefreshClicked() {
Timber.d("UI refresh solicitado.")
viewModelScope.launch {
repository.refreshArticles(force = true)
}
}
// Para simplificar, no expondremos el estado del WorkManager directamente a la UI en este ejemplo,
// pero podría hacerse observando workManager.getWorkInfoByIdLiveData() desde el repositorio.
}
Paso 7: Interfaz de Usuario (Compose)
Crearemos una UI sencilla con Jetpack Compose para mostrar la lista de artículos y un indicador de carga/error.
package com.example.offlineapp.presentation.ui.screens
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material3.Card
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import com.example.offlineapp.data.local.entities.ArticleEntity
import com.example.offlineapp.presentation.viewmodel.ArticleListUiState
import com.example.offlineapp.presentation.viewmodel.ArticleListViewModel
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ArticleListScreen(viewModel: ArticleListViewModel = hiltViewModel()) {
val uiState by viewModel.uiState.collectAsState()
Scaffold(
topBar = {
TopAppBar(
title = { Text("Artículos Offline") },
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.primaryContainer,
titleContentColor = MaterialTheme.colorScheme.primary,
)
)
},
floatingActionButton = {
FloatingActionButton(onClick = { viewModel.onRefreshClicked() }) {
Icon(Icons.Default.Refresh, "Refrescar artículos")
}
}
) {\n paddingValues ->
Box(modifier = Modifier.fillMaxSize().padding(paddingValues)) {
when (uiState) {
ArticleListUiState.Loading -> {
Column(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
CircularProgressIndicator()
Text("Cargando artículos...", modifier = Modifier.padding(top = 16.dp))
}
}
is ArticleListUiState.Success -> {
val articles = (uiState as ArticleListUiState.Success).articles
if (articles.isEmpty()) {
Text(
"No hay artículos disponibles. ¡Toca el botón de refrescar para intentar sincronizar!",
modifier = Modifier.align(Alignment.Center).padding(horizontal = 16.dp)
)
} else {
LazyColumn(
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
items(articles) {
ArticleCard(article = it)
}
}
}
}
is ArticleListUiState.Error -> {
Text(
"Error: ${(uiState as ArticleListUiState.Error).message}",
color = MaterialTheme.colorScheme.error,
modifier = Modifier.align(Alignment.Center).padding(horizontal = 16.dp)
)
}
}
}
}
}
@Composable
fun ArticleCard(article: ArticleEntity) {
Card(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
text = article.title,
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold
)
Text(
text = "Por ${article.author}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(bottom = 8.dp)
)
Text(text = article.body, style = MaterialTheme.typography.bodyMedium)
// Podríamos añadir una imagen aquí con Coil/Glide
}
}
}
Paso 8: Configuración de Hilt (Inyección de Dependencias)
Para que todo funcione de manera limpia y modular, usaremos Hilt para la inyección de dependencias.
Crear la clase Application:
package com.example.offlineapp
import android.app.Application
import dagger.hilt.android.HiltAndroidApp
import timber.log.Timber
@HiltAndroidApp
class OfflineFirstApplication : Application() {
override fun onCreate() {
super.onCreate()
if (BuildConfig.DEBUG) {
Timber.plant(Timber.DebugTree())
}
}
}
Actualizar AndroidManifest.xml con la clase Application:
<application
android:name=".OfflineFirstApplication"
...>
<!-- ... otras configuraciones -->
</application>
Módulos de Hilt:
package com.example.offlineapp.di
import android.content.Context
import androidx.room.Room
import androidx.work.WorkManager
import com.example.offlineapp.data.local.AppDatabase
import com.example.offlineapp.data.local.dao.ArticleDao
import com.example.offlineapp.data.remote.api.ArticleApiService
import com.example.offlineapp.data.repository.ArticleRepository
import com.example.offlineapp.data.worker.SyncArticlesWorkerFactory
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
object AppModule {
private const val BASE_URL = "https://jsonplaceholder.typicode.com/" // Usaremos JSONPlaceholder para simular una API
@Provides
@Singleton
fun provideOkHttpClient(): OkHttpClient {
val logging = HttpLoggingInterceptor()
logging.setLevel(HttpLoggingInterceptor.Level.BODY)
return OkHttpClient.Builder()
.addInterceptor(logging)
.build()
}
@Provides
@Singleton
fun provideRetrofit(okHttpClient: OkHttpClient): Retrofit {
return Retrofit.Builder()
.baseUrl(BASE_URL)
.client(okHttpClient)
.addConverterFactory(GsonConverterFactory.create())
.build()
}
@Provides
@Singleton
fun provideArticleApiService(retrofit: Retrofit): ArticleApiService {
return retrofit.create(ArticleApiService::class.java)
}
@Provides
@Singleton
fun provideAppDatabase(@ApplicationContext context: Context): AppDatabase {
return Room.databaseBuilder(
context,
AppDatabase::class.java,
"offline_app_db"
).fallbackToDestructiveMigration() // Solo para desarrollo, en prod se deben manejar migraciones
.build()
}
@Provides
@Singleton
fun provideArticleDao(appDatabase: AppDatabase): ArticleDao {
return appDatabase.articleDao()
}
@Provides
@Singleton
fun provideArticleRepository(
articleDao: ArticleDao,
articleApiService: ArticleApiService,
@ApplicationContext context: Context
): ArticleRepository {
return ArticleRepository(articleDao, articleApiService, context)
}
@Provides
@Singleton
fun provideWorkManager(@ApplicationContext context: Context): WorkManager {
return WorkManager.getInstance(context)
}
// Para Hilt con WorkManager, necesitamos un WorkerFactory
// Esto es manejado automáticamente por @HiltWorker y androidx.hilt:hilt-work:1.1.0
// si se configura correctamente en la App
}
Configurar HiltWorkerFactory para WorkManager:
En tu OfflineFirstApplication.kt, implementa Configuration.Provider para usar HiltWorkerFactory.
package com.example.offlineapp
import android.app.Application
import androidx.hilt.work.HiltWorkerFactory
import androidx.work.Configuration
import dagger.hilt.android.HiltAndroidApp
import timber.log.Timber
import javax.inject.Inject
@HiltAndroidApp
class OfflineFirstApplication : Application(), Configuration.Provider {
@Inject
lateinit var workerFactory: HiltWorkerFactory
override fun getWorkManagerConfiguration(): Configuration {
return Configuration.Builder()
.setWorkerFactory(workerFactory)
.build()
}
override fun onCreate() {
super.onCreate()
if (BuildConfig.DEBUG) {
Timber.plant(Timber.DebugTree())
}
}
}
Paso 9: Actividad Principal
Finalmente, nuestra MainActivity donde se mostrará la interfaz de usuario.
package com.example.offlineapp
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.ui.Modifier
import com.example.offlineapp.presentation.ui.screens.ArticleListScreen
import com.example.offlineapp.presentation.ui.theme.OfflineAppTheme
import dagger.hilt.android.AndroidEntryPoint
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
OfflineAppTheme {
Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
ArticleListScreen()
}
}
}
}
}
✅ Probando la Solución Offline
Para probar esta solución, sigue estos pasos:
- Ejecuta la aplicación con conexión: La primera vez, la aplicación debería mostrar un indicador de carga y luego los artículos se poblarán desde la API remota y se guardarán en Room. Puedes verificar los logs de Timber para ver la actividad del Worker.
- Desactiva la conexión a internet: Pon tu dispositivo o emulador en modo avión o desactiva el Wi-Fi/datos móviles.
- Reinicia la aplicación: Cierra la aplicación completamente (puedes deslizarla desde el recientes o forzar cierre) y vuelve a abrirla. Deberías ver los artículos cargados instantáneamente desde la base de datos Room, incluso sin conexión. El WorkManager intentará sincronizar en segundo plano, pero al no haber red, fallará o se retrasará hasta que la conectividad se restablezca.
- Restaura la conexión: Vuelve a activar el Wi-Fi/datos móviles. Si la sincronización estaba configurada con
Result.retry(), WorkManager intentará ejecutar elSyncArticlesWorkerde nuevo cuando las restricciones (como la conectividad) se cumplan.
🤝 Consideraciones Adicionales y Mejoras
Esta implementación es un punto de partida. Aquí hay algunas áreas para considerar y mejorar:
- Gestión de conflictos: ¿Qué sucede si el usuario modifica un artículo localmente y ese mismo artículo es modificado en el servidor antes de la sincronización? Necesitarás una estrategia para resolver estos conflictos (ej. última escritura gana, preguntar al usuario).
- Sincronización bidireccional: Si los usuarios también pueden crear o modificar datos localmente que luego deben ser enviados al servidor, necesitarás workers adicionales para manejar estas operaciones de "subida" y lógica para marcar los datos como "pendientes de sincronizar".
- Paginación: Para grandes conjuntos de datos, implementar paginación en la API y en la base de datos local es crucial para el rendimiento.
- Indicadores de sincronización en la UI: Mostrar un pequeño icono o mensaje cuando los datos se están sincronizando puede mejorar la experiencia del usuario.
- Estrategias de
ExistingWorkPolicy: ExploraREPLACE,KEEP, yAPPEND_OR_REPLACEsegún tus necesidades para gestionar trabajos duplicados. - Pruebas: Asegúrate de escribir pruebas unitarias para el repositorio, ViewModel y Worker, y pruebas de instrumentación para la base de datos y la UI.
- Seguridad: Si manejas datos sensibles, considera cifrar la base de datos Room con SQLCipher o soluciones similares.
- Manejo de errores mejorado: Proporcionar mensajes de error más específicos a la UI y tener lógicas de reintento más sofisticadas en los Workers.
- Freshness de datos: Podrías añadir una lógica al repositorio para determinar si los datos locales están "lo suficientemente frescos" o si es necesario un refresco. Esto se puede hacer con marcas de tiempo en las entidades.
// Ejemplo de lógica de frescura en el repositorio
suspend fun shouldFetchFromNetwork(): Boolean {
val articles = articleDao.getAllArticles().first() // Obtener la lista actual de artículos
if (articles.isEmpty()) return true
val oldestArticleTimestamp = articles.minOfOrNull { it.lastSync } ?: 0L
val currentTime = System.currentTimeMillis()
val cacheExpiryTime = 5 * 60 * 1000 // 5 minutos
return (currentTime - oldestArticleTimestamp) > cacheExpiryTime
}
// Luego en refreshArticles:
// if (force || shouldFetchFromNetwork()) {
// // Encolar WorkManager
// }
¿Qué pasa si el WorkManager falla repetidamente?
Si un `Worker` retorna `Result.retry()`, WorkManager lo reintentará según una estrategia de backoff configurada. Si retorna `Result.failure()`, no se reintentará. Es importante manejar los errores de forma adecuada para evitar bucles infinitos o fallos persistentes. Puedes establecer un número máximo de reintentos para un `Worker`.¿Es necesario usar Hilt con Room y WorkManager?
No es estrictamente necesario, pero Hilt (o cualquier otra librería de inyección de dependencias) simplifica enormemente la gestión de dependencias, especialmente en aplicaciones más grandes. Sin Hilt, tendrías que pasar instancias de DAO, servicios de API y WorkManager manualmente o a través de un Service Locator, lo que puede ser más propenso a errores y menos mantenible.Conclusión ✨
La creación de aplicaciones offline-first es una capacidad esencial en el desarrollo móvil moderno. Al combinar la persistencia local de Room con la programación robusta de tareas en segundo plano de WorkManager, hemos construido una base sólida para una aplicación Android que ofrece una experiencia de usuario fluida y confiable, sin importar el estado de la conexión a internet.
Este tutorial te ha proporcionado los conocimientos y el código para empezar tu propio proyecto offline. Recuerda que la clave está en una buena arquitectura de repositorio, que actúe como la "única fuente de verdad", y en la gestión inteligente de la sincronización. ¡Ahora estás listo para construir aplicaciones Android verdaderamente resilientes!
Tutoriales relacionados
- Navegación Avanzada en Android: Jetpack Navigation Component y Deep Linksintermediate25 min
- ¡Adiós al Caos! Implementando un Patrón MVI Robusto en tu App Android con Kotlin Flowsintermediate15 min
- ¡Poder y Flexibilidad! Construyendo Componentes Reutilizables en Android con Jetpack Compose y Modificadores Personalizadosintermediate25 min
- Almacenamiento Seguro de Datos en Android: SharedPreferences Cifradas y Jetpack DataStoreintermediate20 min
- ¡Despide la Fragmentación! Construyendo una Arquitectura Modular Robusta en Android con Dynamic Feature Modulesintermediate20 min
Comentarios (0)
Aún no hay comentarios. ¡Sé el primero!