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
- La base de datos Room: entidad, DAO y database en
commonMain. - Los builders nativos: cómo inicializar Room en Android e iOS (rutas diferentes).
- El Repository: combina red y caché con una estrategia offline-first.
- La fachada
NewsSdkampliada: expone el acceso offline-first aandroidAppsin 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
actualautomá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 + insertAlles 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
- JetBrains — Create a multiplatform app using Ktor and SQLDelight: tutorial oficial que muestra el enfoque SQLDelight como alternativa a Room en KMP.
- Koin — KMP Advanced Patterns: cómo gestionar factorías de base de datos por plataforma con inyección de dependencias.
- Kotlin — Asynchronous Flow: documentación oficial de
Flow, operadores de transformación, manejo de errores y ciclo de vida.
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.

