Etiqueta: Repository Pattern

  • Unidad 3 — Tema 2: Offline-first con Room: caché local y Repository pattern

    Unidad 3 — Tema 2: Offline-first con Room: caché local y Repository pattern

    En el artículo anterior de la serie (Unidad 3 — Tema 1), logramos conectar con éxito nuestra infraestructura a internet consumiendo una API real de noticias con Ktor Client. Pero si cierras la app y la abres sin conexión, no verás nada. Hoy lo arreglamos.

    El patrón que vamos a implementar se llama offline-first: la app siempre muestra primero lo que tiene en la base de datos local, luego intenta refrescar desde la red, y si falla, el usuario sigue viendo los datos cacheados. Al final del artículo tendrás un NewsRepository que combina Ktor y Room, y que emite noticias como un Flow reactivo.

    Lo que vamos a construir

    1. La base de datos Room: entidad, DAO y database en commonMain.
    2. Los builders nativos: cómo inicializar Room en Android e iOS (rutas diferentes).
    3. El Repository: combina red y caché con una estrategia offline-first.
    4. La fachada NewsSdk ampliada: expone el acceso offline-first a androidApp sin filtrar clases internas.

    1. Añade Room y KSP

    Room Multiplatform usa KSP para generar código en tiempo de compilación. Añade esto a gradle/libs.versions.toml:

    [versions]
    ksp  = "2.3.8"
    room = "2.8.4"
    sqlite = "2.6.2"
    
    [libraries]
    room-runtime  = { module = "androidx.room:room-runtime",  version.ref = "room" }
    room-compiler = { module = "androidx.room:room-compiler", version.ref = "room" }
    sqlite-bundled = { module = "androidx.sqlite:sqlite-bundled", version.ref = "sqlite" }
    
    [plugins]
    ksp  = { id = "com.google.devtools.ksp", version.ref = "ksp"  }
    room = { id = "androidx.room",           version.ref = "room" }

    En shared/build.gradle.kts, aplica los plugins y configura KSP para cada target:

    plugins {
        alias(libs.plugins.kotlinMultiplatform)
        alias(libs.plugins.kotlinxSerialization)
        alias(libs.plugins.ksp)
        alias(libs.plugins.room)
    }
    
    room {
        schemaDirectory("$projectDir/schemas")
    }
    
    kotlin {
        sourceSets {
            commonMain.dependencies {
                // ... dependencias de Ktor del tema anterior ...
                implementation(libs.room.runtime)
                implementation(libs.sqlite.bundled)
            }
        }
    }
    
    dependencies {
        add("kspCommonMainMetadata", libs.room.compiler)
        add("kspAndroid",            libs.room.compiler)
        add("kspIosArm64",           libs.room.compiler)
        add("kspIosSimulatorArm64", libs.room.compiler)
        add("kspIosX64",             libs.room.compiler)
    }

    2. Define la entidad, el DAO y la base de datos

    package dev.asiles.newsexplorer.data.local
    
    import androidx.room.*
    import kotlinx.coroutines.flow.Flow
    
    @Entity(tableName = "articles")
    data class ArticleEntity(
        @PrimaryKey val url: String,
        val title: String,
        val description: String,
        val imageUrl: String,
        val source: String,
        val publishedAt: String,
        val cachedAt: Long = System.currentTimeMillis()
    )
    
    @Dao
    interface ArticleDao {
        @Query("SELECT * FROM articles ORDER BY publishedAt DESC")
        fun getAll(): Flow<List<ArticleEntity>>
    
        @Insert(onConflict = OnConflictStrategy.REPLACE)
        suspend fun insertAll(articles: List<ArticleEntity>)
    
        @Query("DELETE FROM articles")
        suspend fun clearAll()
    }
    
    @Database(entities = [ArticleEntity::class], version = 1)
    @ConstructedBy(NewsDatabaseConstructor::class)
    abstract class NewsDatabase : RoomDatabase() {
        abstract fun articleDao(): ArticleDao
    }
    
    @Suppress("KotlinNoActualForExpect")
    expect object NewsDatabaseConstructor : RoomDatabaseConstructor<NewsDatabase> {
        override fun initialize(): NewsDatabase
    }

    Usamos la URL como @PrimaryKey: si la API devuelve el mismo artículo dos veces, REPLACE lo actualiza en lugar de duplicarlo. El expect object NewsDatabaseConstructor delega la construcción del archivo .db a cada plataforma; KSP genera los actual automáticamente.

    3. Inicializa la base de datos en cada plataforma

    package dev.asiles.newsexplorer.data.local
    
    import androidx.room.RoomDatabase
    
    interface DatabaseFactory {
        fun create(): RoomDatabase.Builder<NewsDatabase>
    }
    package dev.asiles.newsexplorer.data.local
    
    import android.content.Context
    import androidx.room.Room
    import androidx.room.RoomDatabase
    import androidx.sqlite.driver.bundled.BundledSQLiteDriver
    
    class AndroidDatabaseFactory(private val context: Context) : DatabaseFactory {
        override fun create(): RoomDatabase.Builder<NewsDatabase> {
            val dbFile = context.getDatabasePath("news_explorer.db")
            return Room.databaseBuilder<NewsDatabase>(
                context = context,
                name = dbFile.absolutePath
            ).setDriver(BundledSQLiteDriver())
        }
    }
    package dev.asiles.newsexplorer.data.local
    
    import androidx.room.Room
    import androidx.room.RoomDatabase
    import androidx.sqlite.driver.bundled.BundledSQLiteDriver
    import platform.Foundation.NSHomeDirectory
    
    class IosDatabaseFactory : DatabaseFactory {
        override fun create(): RoomDatabase.Builder<NewsDatabase> {
            val dbPath = "${NSHomeDirectory()}/Documents/news_explorer.db"
            return Room.databaseBuilder<NewsDatabase>(name = dbPath)
                .setDriver(BundledSQLiteDriver())
        }
    }

    En Room Multiplatform no basta con crear el builder: hay que fijar explícitamente BundledSQLiteDriver en ambos targets. Ese driver usa la implementación SQLite empaquetada por AndroidX y es la configuración recomendada para que el mismo esquema funcione de forma consistente en Android e iOS.

    4. Crea el modelo de dominio

    package dev.asiles.newsexplorer.domain.model
    
    data class Article(
        val url: String,
        val title: String,
        val description: String,
        val imageUrl: String,
        val source: String,
        val publishedAt: String
    )

    5. Implementa el Repository offline-first

    package dev.asiles.newsexplorer.data
    
    import dev.asiles.newsexplorer.data.local.ArticleDao
    import dev.asiles.newsexplorer.data.local.ArticleEntity
    import dev.asiles.newsexplorer.data.remote.NewsApiService
    import dev.asiles.newsexplorer.data.remote.model.ArticleDto
    import dev.asiles.newsexplorer.domain.model.Article
    import kotlinx.coroutines.flow.Flow
    import kotlinx.coroutines.flow.map
    
    class NewsRepository(
        private val apiService: NewsApiService,
        private val dao: ArticleDao
    ) {
        // La UI siempre observa este Flow. Cada vez que la BD cambia, emite automáticamente.
        val articles: Flow<List<Article>> = dao.getAll().map { entities ->
            entities.map { it.toDomain() }
        }
    
        // Llamar para refrescar desde la red. Si falla, los datos cacheados siguen visibles.
        suspend fun refresh() {
            val response = apiService.getTopHeadlines()
            val entities = response.articles
                .filter { it.title != "[Removed]" }
                .map { it.toEntity() }
            dao.clearAll()
            dao.insertAll(entities)
        }
    }
    
    private fun ArticleEntity.toDomain() = Article(
        url = url,
        title = title, 
        description = description,
        imageUrl = imageUrl,
        source = source,
        publishedAt = publishedAt
    )
    
    private fun ArticleDto.toEntity() = ArticleEntity(
        url = url,
        title = title,
        description = description.orEmpty(),
        imageUrl = imageUrl.orEmpty(),
        source = source.name,
        publishedAt = publishedAt
    )

    articles es un Flow que siempre lee de Room. Cuando llamas a refresh(), Room detecta el cambio y emite automáticamente la nueva lista. La UI no sabe si los datos vienen de la red o del disco.

    6. Amplía la fachada NewsSdk

    Recuerda la regla del proyecto: androidApp no puede importar clases de librerías de terceros directamente. Room y Ktor viven en shared. Para que MainActivity pueda usar el Repository sin tocar ninguna de esas clases, ampliamos la clase NewsSdk que creamos en el Tema 1:

    package dev.asiles.newsexplorer
    
    import dev.asiles.newsexplorer.data.NewsRepository
    import dev.asiles.newsexplorer.data.local.DatabaseFactory
    import dev.asiles.newsexplorer.data.remote.NewsApiService
    import dev.asiles.newsexplorer.data.remote.createHttpClient
    import dev.asiles.newsexplorer.domain.model.Article
    import kotlinx.coroutines.flow.Flow
    
    // Fachada pública del módulo compartido.
    // androidApp solo conoce esta clase; Room, Ktor y el Repository quedan encapsulados.
    class NewsSdk(apiKey: String, databaseFactory: DatabaseFactory) {
    
        private val repository = NewsRepository(
            apiService = NewsApiService(createHttpClient(apiKey)),
            dao = databaseFactory.create().build().articleDao()
        )
    
        // Flow de artículos cacheados — emite cada vez que Room actualiza la BD
        val articles: Flow<List<Article>> = repository.articles
    
        // Descarga noticias frescas y actualiza la caché
        suspend fun refresh() = repository.refresh()
    }

    DatabaseFactory es la única abstracción que sí puede cruzar la frontera hacia androidApp: es una interfaz definida en commonMain, sin dependencias de terceros. AndroidDatabaseFactory implementa esa interfaz en androidMain, donde Room sí es accesible.

    7. Pruébalo desde Android

    Ahora MainActivity solo importa clases del módulo compartido:

    import dev.asiles.newsexplorer.NewsSdk
    import dev.asiles.newsexplorer.data.local.AndroidDatabaseFactory
    import kotlinx.coroutines.CoroutineScope
    import kotlinx.coroutines.Dispatchers
    import kotlinx.coroutines.launch
    
    class MainActivity : ComponentActivity() {
        override fun onCreate(savedInstanceState: Bundle?) {
            super.onCreate(savedInstanceState)
    
            val sdk = NewsSdk(
                apiKey = "TU_API_KEY",
                databaseFactory = AndroidDatabaseFactory(this)
            )
    
            CoroutineScope(Dispatchers.IO).launch {
                sdk.articles.collect { articles ->
                    articles.forEach { println("📰 ${it.title}") }
                }
            }
            CoroutineScope(Dispatchers.IO).launch {
                sdk.refresh()
            }
        }
    }

    Ninguna clase de Ktor ni de Room aparece en androidApp. AndroidDatabaseFactory implementa DatabaseFactory, que es una interfaz de commonMain: el compilador sí la ve desde androidApp. Ejecuta la app, verifica que aparecen noticias en Logcat, luego activa el modo avión y vuelve a ejecutar: las noticias cacheadas seguirán ahí.

    8. Enfoque senior: Room vs SQLDelight en KMP

    Room Multiplatform es la opción más cómoda si tu equipo viene del mundo Android, pero hay escenarios donde merece evaluar SQLDelight:

    • Usa Room si el equipo es principalmente Android y quieres la menor curva de aprendizaje. KSP genera los constructores actual automáticamente.
    • Considera SQLDelight si necesitas control total sobre SQL en compilación (SQLDelight valida las queries y genera tipos estrictos), o si tu proyecto incluye targets Desktop o Web.
    • Migraciones: guarda siempre el directorio schemas/ en control de versiones. Las migraciones hay que aplicarlas en ambos targets.
    • El patrón clearAll + insertAll es válido para listas sin estado local. Si el usuario puede marcar favoritos o leer artículos, necesitas una estrategia de merge basada en IDs únicos remotos.

    Para profundizar

    Conclusión

    El patrón offline-first no es solo una mejora de UX: es una decisión arquitectónica que define cómo fluye la verdad en tu aplicación. Al hacer de Room la única fuente de verdad y tratar la red como un mecanismo de actualización, el código se vuelve predecible: la UI siempre muestra lo que hay en la BD, y la BD siempre refleja lo más reciente disponible.

    Lo que falta es estructura: la inicialización manual en MainActivity escala mal. En el próximo artículo, Unidad 3 — Tema 3: ViewModel + Koin + Compose: la app funciona de principio a fin, conectaremos todas las piezas con Koin, moveremos la lógica a un ViewModel compartido en commonMain y mostraremos las noticias en una pantalla Compose Multiplatform real. La app estará completa de principio a fin.