Room en Kotlin Multiplatform

Room es la librería oficial de Android para bases de datos, y ahora también se puede utilizar en proyectos de Kotlin Multiplatform.
Desde ahora, la misma base de datos que creas para el target de Android, estará disponible en el resto.
El problema es que la configuración, al menos a día de hoy que nos encontramos en versiones alpha, es un poco tediosa.
Así que aquí te explico el paso a paso para que no pierdas tiempo.
Configuración de KSP
Para usar el compilador de Room, hace falta configurar el plugin de KSP, que se encargará de generar el código a partir de las anotaciones de Room.
Pero configurarlo para Room y Kotlin Multiplatform a día de hoy requiere de un poco de trabajo.
En el futuro será más sencillo, cuando Room soporte completamente KSP2, pero ahora hacen falta varios pasos:
Primero configuramos la dependencia en el libs.versions.toml
:
[versions]
ksp = "2.0.0-1.0.21"
[plugins]
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
Después activamos el plugin en el build.gradle
del módulo donde vayamos a usar Room:
plugins {
...
alias(libs.plugins.ksp)
}
A partir de aquí, toda esta configuración no será necesaria con KSP2:
Para que luego podamos usar una función que se genera específicamente para iOS, tenemos que poder acceder al código generado por KSP como si fuese parte de nuestro código.
sourceSets.commonMain {
kotlin.srcDir("build/generated/ksp/metadata")
}
Además, para que la generación de código ocurra en el orden adecuado, hay que indicar que todas las tareas que compilan Kotlin dependan de kspCommonMainKotlinMetada
(después veremos dónde se usa esto):
tasks.withType<org.jetbrains.kotlin.gradle.dsl.KotlinCompile<*>>().configureEach {
if (name != "kspCommonMainKotlinMetadata") {
dependsOn("kspCommonMainKotlinMetadata")
}
}
También en gradle.properties
hay que añadir un flag para sortear un bug que hay en KSP
#Room
kotlin.native.disableCompilerDaemon=true
Configuración de Room
Para trabajar con Room, necesitamos añadir tres librerías y un plugin:
-
Runtime de Room: el que nos permite escribir código en nuestros proyectos con Room
-
Compiler de Room: el que genera el código necesario a partir de anotaciones
-
SqLite Bundled: Permite trabajar con SqLite aunque el sistema no lo permita por defecto. Nos ayuda a que Room se pueda usar en cualquier entorno que no sea Android.
[versions]
androidx-room = "2.7.0-alpha03"
androidx-sqliteBundled = "2.5.0-alpha03"
[libraries]
androidx-room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "androidx-room" }
androidx-room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "androidx-room" }
androidx-sqliteBundled = { group = "androidx.sqlite", name = "sqlite-bundled", version.ref = "androidx-sqliteBundled" }
[plugins]
androidxRoom = { id = "androidx.room", version.ref = "androidx-room" }
Añadimos el plugin a los build.gradle
, y la configuración del schemaDirectory
de Room, de otra forma no compilará:
- En el
build.gradle
del móduloapp
:
`
plugins {
...
alias(libs.plugins.ksp)
alias(libs.plugins.androidxRoom)
}
...
room {
schemaDirectory("$projectDir/schemas")
}
Ahora añadimos las dependencias. Las de room-runtime
y sqlite-bundled
van en las dependencias de commonMain
:
commonMain.dependencies {
...
implementation(libs.androidx.room.runtime)
implementation(libs.androidx.sqlite.bundled)
}
Mientras que la de room-compiler
tiene que ir fuera del todo, ya que afecta todos los targets. Con KSP2 será tan fácil como esto:
dependencies {
ksp(libs.androidx.room.compiler)
}
Actualmente hay que definir cada uno de los targets en los que se va a ejecutar KSP. Nos es suficiente con common
:
dependencies {
add("kspCommonMainMetadata", libs.androidx.room.compiler)
}
Creación de la base de datos
Funciona exactamente igual que con el Room clásico:
La Entity
@Entity
data class Movie(
@PrimaryKey(autoGenerate = true)
val id: Int,
val title: String,
val overview: String,
val releaseDate: String,
val poster: String,
val backdrop: String?,
val originalTitle: String,
val originalLanguage: String,
val popularity: Double,
val voteAverage: Double,
)
El DAO
@Dao
interface MoviesDao {
@Query("SELECT * FROM Movie")
fun fetchPopularMovies(): Flow<List<Movie>>
@Query("SELECT * FROM Movie WHERE id = :id")
fun findMovieById(id: Int): Flow<Movie?>
@Query("SELECT COUNT(id) FROM Movie")
suspend fun countMovies(): Int
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun save(movies: List<Movie>)
}
La Database
const val DATABASE_NAME = "movies.db"
@Database(entities = [Movie::class], version = 1, exportSchema = false)
abstract class MoviesDatabase : RoomDatabase() {
abstract fun moviesDao(): MoviesDao
}
Crear la instancia de la base de datos
Esta parte hay que hacerla específica de cada una de las plataformas. La inicialización del Builder depende de la plataforma correspondiente, y hay que hacerlo en su propio sourceSet
.
Para Android
En androidMain
, crea la función:
fun getDatabaseBuilder(ctx: Context): RoomDatabase.Builder<MoviesDatabase> {
val appContext = ctx.applicationContext
val dbFile = appContext.getDatabasePath(DATABASE_NAME)
return Room.databaseBuilder<MoviesDatabase>(
context = appContext,
name = dbFile.absolutePath
)
}
Puede ser que en Android obtengas el error:
Class ‘MoviesDatabase_Impl’ is not abstract and does not implement abstract base class member ‘clearAllTables’
Si esto te ocurre, necesitas hacer un pequeño “hack” para que la base de datos contenga esta función:
Añade la interfaz:
@Database(entities = [Movie::class], version = 1, exportSchema = false)
abstract class MoviesDatabase : RoomDatabase(), DB {
abstract fun moviesDao(): MoviesDao
override fun clearAllTables() { }
}
Y haz que la base de datos la implemente, añadiendo la implementación vacía:
@Database(entities = [Movie::class], version = 1, exportSchema = false)
abstract class MoviesDatabase : RoomDatabase(), DB {
abstract fun moviesDao(): MoviesDao
override fun clearAllTables() { }
}
Entiendo que esto es un bug, que acabarán solucionando en próximas versiones
Para iOS
En iosMain
, usamos la función:
fun getDatabaseBuilder(): RoomDatabase.Builder<MoviesDatabase> {
val dbFilePath = NSHomeDirectory() + "/$DATABASE_NAME"
return Room.databaseBuilder<MoviesDatabase>(
name = dbFilePath,
factory = { MoviesDatabase::class.instantiateImpl() }
).setDriver(BundledSQLiteDriver())
}
Aquí es donde entra en juego que podamos acceder al código generado por KSP, porque la función instantiateImpl()
será generada durante la compilación.
Por eso, es probable que hasta que no compiles por primera vez no puedas utilizarla. A veces puede que incluso el IDE siga mostrando error aunque al ejecutar funcione.
Para Desktop (JVM)
Si tuvieras target para Desktop, se haría:
fun getDatabaseBuilder(): RoomDatabase.Builder<AppDatabase> {
val dbFile = File(System.getProperty("java.io.tmpdir"), DATABASE_NAME)
return Room.databaseBuilder<AppDatabase>(
name = dbFile.absolutePath,
)
}
Instancia de la base de datos
Para crear la instancia, en commonMain
puedes crear una función así:
fun getRoomDatabase(
builder: RoomDatabase.Builder<MoviesDatabase>
): MoviesDatabase {
return builder
.setDriver(BundledSQLiteDriver())
.build()
}
Esta añade el driver de SqLite por defecto. En Android funcionará sin llamar a la función setDriver()
, pero en iOS y otros targets no.
Provisión de las dependencias
Lo ideal sería usar algún inyector de dependencias. Te recomiendo Koin para Kotlin Multiplatform.
Pero si no lo tienes, la forma más sencilla es pasarle la dependencia al composable App
que suelen generar los proyectos en Compose Multiplatform.
Si las UIs son nativas, cada UI tendrá su punto de entrada, y ahí podrías hacerlo.
@Composable
@Preview
fun App(moviesDao: MoviesDao) {
// Tu código de Compose
}
En Android, en la MainActivity
, dentro de la función setContent { }
, necesitaríamos este código:
setContent {
val db = getRoomDatabase(
getDatabaseBuilder(ctx = LocalView.current.context)
)
App(db.moviesDao())
}
En iOS, el composable App
se llama en la definición del MainViewController
:
fun MainViewController() = ComposeUIViewController {
val database: MoviesDatabase = getRoomDatabase(getDatabaseBuilder())
App(database.moviesDao())
}
Ahora ya puedes ejecutar tu App, y comprobar que todo funciona.
Simplifica el uso de Coroutines con Flows
Funciones de extensión en Kotlin