Compose Multiplatform in Action: Using Room for Cross-Platform Database Development & Troubleshooting
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
目錄
- Compose Multiplatform 實戰:放輕鬆點,初探CMP
- Compose Multiplatform 實戰:初戰,安裝CMP環境吧
- Compose Multiplatform 實戰:續戰,用Wizard創建CMP專案
- Compose Multiplatform 實戰:在Android、iOS模擬器上跑CMP專案
- Compose Multiplatform 實戰:CMP的專案結構理解與編譯配置
- Compose Multiplatform 實戰:CMP中跨平台Android、iOS程式碼的進入點
- Compose Multiplatform 實戰:在CMP的Compose中用Material Design3 Theme
- Compose Multiplatform 實戰:CMP用Compose實作跨平台畫面
- Compose Multiplatform 實戰:使用 expect 和 actual 實現跨平台程式碼
- Compose Multiplatform 實戰:CMP中實作Compose Navigation頁面切換
- Compose Multiplatform 實戰:CMP中透過StateFlow來管理UI狀態
- Compose Multiplatform 實戰:CMP中實作NavigationBar底部欄
- Compose Multiplatform 實戰:CMP中使用koin來依賴注入Dependency Injection
- Compose Multiplatform 實戰:CMP實作跨平台資料庫SqlDelight
- Compose Multiplatform 實戰:CMP中使用ROOM開發跨平台資料庫 & 疑難雜症
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
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
- Step 1. Import
Room
into your project
Add the following to yourlibs.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
tosourceSets.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
- Add
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() {}
}
- 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)
}
//in ~/androidMain
actual val platformModule: Module = module {
single<RoomDatabase.Builder<AppDatabase>> {
getAppDatabase(get())
}
}
//in ~/iosMain
actual val platformModule: Module = module {
single { getAppDatabase() }
}
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
)