Room en Kotlin Multiplatform

A
Antonio Leiva
6 min lectura

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ódulo app:
    `
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.

Recomendado para ti