Compose Multiplatform in Action: Using Room for Cross-Platform Database Development & Troubleshooting

Introduction

Compose Multiplatform (CMP)

In a CMP project
how can we implement cross-platform database operations?
Yesterday we talked about SqlDelight
which was an early cross-platform solution for local databases in CMP

Recently, around May 2024
Room also started offering a cross-platform solution for CMP
Additionally, the Android Developer official site has published articles on using KMP

This article will introduce how to perform database operations
using Room in a cross-platform Android & iOS environment

Initial Configuration

Note 1. Room version 2.7.0-alpha01 and later supports KMM.

Note 2. Room in CMP or KMP with Build.gradle.kts configuration may need to be paired with ksp
When importing ksp, you might encounter issues like ksp version too low or need to update version and be unable to build
In this case, you can find compatible versions for Kotlin on the official GitHub
Reference: ksp releases

Note 3. Using kotlin with ksp checks for ksp version and kotlin compatibility
When using kotlin 2.0.0, if gradle sync shows version is too low or incompatible
you might see errors like Cannot change attributes of configuration ':composeApp:debugFrameworkIosX64' after it has been locked for mutation
or [KSP2] Annotation value is missing in nested annotations

Potential Issues When Configuring Room in CMP

Initially encountered [KSP2] Annotation value is missing in nested annotations
After searching online, I found that
adding ksp.useKSP2=true to gradle.property can solve this problem

After solving one problem
although gradle sync works
configuring Room with ksp might lead to other issues
For example, after configuring ksp(libs.androidx.room.compiler)
you might see [ksp] [MissingType]: xxxxx 'data.xxx.xxxDao' references a type that is not present

Later I discovered
the issue is that the official documentation is mainly for Kotlin 1.9.0
However, after Kotlin upgraded to 2.0.0
the way it works with KSP changed
Some users have encountered similar issues
Here are some related Issue reports I found
if you’re interested:
Issue 1
Issue 2
Issue 3

Some suggest downgrading Kotlin to the same version as KSP to solve the problem
But since the official Wizard generated CMP projects now default to Kotlin 2.0.0
I chose to stick with the principle of “use new, not old” XD.

If you want to successfully set up Room on Kotlin 2.0.0
you might need some temporary solutions
Before the official fix is released
you can refer to the configuration methods below
to make Room work properly on Kotlin 2.0.0

Below I’ll share how to configure Room on Kotlin 2.0.0

Implementing ROOM with `kotlin 2.0.0` in CMP
  • Step 1. Import Room into your project
    Add the following to your libs.version.toml file:
[versions]
kotlin = "2.0.0"
ksp = "2.0.0-1.0.21"
sqlite = "2.5.0-SNAPSHOT"
androidx-room = "2.7.0-alpha01"

[libraries]
androidx-room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "androidx-room" }
androidx-room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "androidx-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 = "androidx-room" }

Then add to commonMain in gradle

commonMain.dependencies {
    implementation(libs.androidx.room.runtime)
    implementation(libs.sqlite.bundled)
}
  • Step 2. Adjust build.gradle.kts, focusing on these points
    • Add build/generated/ksp/metadata to sourceSets.commonMain
    • Import ksp using the add method: add("kspCommonMainMetadata", libs.androidx.room.compiler)
    • Add tasks.withType to the outermost layer
    • Add room schemas configuration
    • Add plugins configuration
plugins {
    alias(libs.plugins.ksp)
    alias(libs.plugins.room)
}

kotlin {
    sourceSets.commonMain {
        kotlin.srcDir("build/generated/ksp/metadata")
    }
    ...
}

room {
    schemaDirectory("$projectDir/schemas")
}

dependencies {
    add("kspCommonMainMetadata", libs.androidx.room.compiler)
}

tasks.withType<org.jetbrains.kotlin.gradle.dsl.KotlinCompile<*>>().configureEach {
    if (name != "kspCommonMainKotlinMetadata" ) {
        dependsOn("kspCommonMainKotlinMetadata")
    }
}
  • Step 3. Implement RoomDatabase using a workaround

This is a current workaround
Necessary if you want to use Kotlin 2.0.0 with Room
As we await official fixes for compatibility with ksp

The main issue is that when creating Room, you extend Room’s RoomDatabase
Normally after compilation, it generates an implementation of AppDataBase
However, the current version is missing clearAllTables
So we manually add it here
As a temporary solution

// in ~/commonMain/db/AppDataBase.kt

@Database(entities = [VocabularyEntity::class], version = 1)
abstract class AppDatabase : RoomDatabase(), DB {
    abstract fun vocabularyDao(): VocabularyDao

    override fun clearAllTables() {
        super.clearAllTables()
    }
}

// FIXME: Added a hack to resolve below issue:
// Class 'AppDatabase_Impl' is not abstract and does not implement abstract base class member 'clearAllTables'.
interface DB {
    fun clearAllTables() {}
}
Actual Room Development
  • As before, we need to create a RoomDatabase.Builder for all target platforms
// in ~/androidMain
fun getAppDatabase(context: Context): RoomDatabase.Builder<AppDatabase> {
    val dbFile = context.getDatabasePath("app.db")
    return Room.databaseBuilder<AppDatabase>(
        context = context.applicationContext,
        name = dbFile.absolutePath
    )
        .fallbackToDestructiveMigrationOnDowngrade(true)
        .setDriver(BundledSQLiteDriver())
        .setQueryCoroutineContext(Dispatchers.IO)
}

// in ~/iOSMain
fun getAppDatabase(): RoomDatabase.Builder<AppDatabase> {
    val dbFile = NSHomeDirectory() + "/app.db"
    return Room.databaseBuilder<AppDatabase>(
        name = dbFile,
        factory = { AppDatabase::class.instantiateImpl() }
    )
        .fallbackToDestructiveMigrationOnDowngrade(true)
        .setDriver(BundledSQLiteDriver()) // Very important
        .setQueryCoroutineContext(Dispatchers.IO)
}
RoomDatabase.Builder with Koin
//in ~/androidMain

actual val platformModule: Module = module {
    single<RoomDatabase.Builder<AppDatabase>> {
        getAppDatabase(get())
    }
}

//in ~/iosMain
actual val platformModule: Module = module {
    single { getAppDatabase() }
}
Implementing ROOM in commonMain

The core concept of Room is to operate the local database in an object-oriented way

By implementing RoomDatabase, DAO (Data Access Object), and Entity classes
you can easily operate on the database

AppDatabase: A class implementing RoomDatabase with the Room annotation @Database
where you can declare entities and handle version migrations, etc.

dao: Create an interface, paired with SQL commands to enable object-oriented database operations

entity: Mainly turns table creation into an object-oriented approach
Using the @Entity annotation to declare it as a Room entity
After adding it to RoomDatabase() and compiling
it will generate the corresponding table in your DB

  • Implementing AppDatabase
@Database(entities = [VocabularyEntity::class], version = 1)
abstract class AppDatabase : RoomDatabase(), DB {
    abstract fun vocabularyDao(): VocabularyDao

    override fun clearAllTables() {
        super.clearAllTables()
    }

}

fun getRoomDatabase(
    builder: RoomDatabase.Builder<AppDatabase>,
    migrationsProvider: MigrationsProvider
): AppDatabase {
    return builder
        .addMigrations(*migrationsProvider.ALL_MIGRATIONS.toTypedArray())
        .build()
}

// FIXME: Added a hack to resolve below issue:
// Class 'AppDatabase_Impl' is not abstract and does not implement abstract base class member 'clearAllTables'.
interface DB {
    fun clearAllTables() {}
}
  • Creating a Dao interface
@Dao
interface VocabularyDao {
    @Insert(onConflict = OnConflictStrategy.REPLACE)
    suspend fun insert(vocabulary: VocabularyEntity)

    @Query("SELECT * FROM VocabularyEntity")
    suspend fun getAllVocabulary(): List<VocabularyEntity>

    @Query("UPDATE VocabularyEntity SET name = :name WHERE id = :id")
    suspend fun updateName(id: Int, name: String)

    @Query("DELETE FROM VocabularyEntity WHERE id = :id")
    suspend fun delete(id: Int)

    @Query("UPDATE VocabularyEntity SET description = :description WHERE id = :id")
    suspend fun updateDescription(id: Int, description: String)

    @Query("SELECT * FROM VocabularyEntity WHERE id = :id")
    suspend fun getVocabularyById(id: Int): VocabularyEntity?

    @Query("UPDATE VocabularyEntity SET name = :name, description = :description WHERE id = :id")
    suspend fun updateNameAndDescription(id: Int, name: String, description: String?)

}
  • Creating an Entity
@Entity
data class VocabularyEntity(
    @PrimaryKey(autoGenerate = true) val id: Int = 0,
    val name: String,
    val description: String? = null
)

You might also enjoy