Compose Multiplatform 實戰:CMP中使用ROOM開發跨平台資料庫 & 疑難雜症

前言

Compose Multiplatform (簡稱CMP)

CMP 專案中
如何實現跨平台的資料庫操作呢?
昨天我們提到了SqlDelight
這是CMP初期提出並針對本地資料庫做的跨雙平台解決方案

也就在近期2024/05左右
Room 也開始提供CMP跨平台的解決方案
另外,Android Developer官方也在網站上放上KMP使用的文章

本文將介紹如何在跨平台Android & iOS環境中
使用 Room 進行資料庫操作

前期配置

注意1. Room版本2.7.0-alpha01之後才支援KMM。

注意2. RoomCMP or KMP中的Build.gradle.kts配置可能需要搭配ksp
ksp導入時可能會因為kotlin版本不同
而出現ksp版本太低或提示需更新版本 並且無法Build過
這時候可以去官方github找跟Kotlin能搭配的版本
可參考這:ksp releases

注意3. 使用kotlin搭配ksp會檢查ksp版本跟kotlin相容性
當使用kotlin 2.0.0 時,gradle sync時顯示版本太低或不相容時
會出現 Cannot change attributes of configuration ':composeApp:debugFrameworkIosX64' after it has been locked for mutation
[KSP2] Annotation value is missing in nested annotations

CMP配置Room時可能遇到的問題

一開始遇到[KSP2] Annotation value is missing in nested annotations
後來在網上搜尋研究後發現可以
gradle.property中加入ksp.useKSP2=true可以解決這個問題

上面解決了一個問題後
雖然可以gradle sync
但在用ksp配置Room又會遇到問題
例如配置ksp(libs.androidx.room.compiler)
會出現[ksp] [MissingType]: xxxxx 'data.xxx.xxxDao' references a type that is not present

後來我發現
問題的原因是官方文件上的配置主要針對 Kotlin 1.9.0 版本
然而,Kotlin 升級到 2.0.0 之後
KSP 的搭配方式有所調整
有些網友也有遇到
以下是我找到的幾個相關 Issue 回報
有興趣的話可以看看:
Issue 1
Issue 2
Issue 3

有人建議將 Kotlin 版本降到與 KSP 相同的版本來解決問題
但因為現在使用官方 Wizard 生成的 CMP 預設已經是 Kotlin 2.0.0
所以我還是選擇秉持著「用新不用舊」的原則 XD。

如果想在 Kotlin 2.0.0 上成功搭建 Room
可能需要使用一些暫時的解決方案
在官方修復這個問題之前
可以參考以下配置方法
來讓 Room 正常運作在 Kotlin 2.0.0

下方我將開始分享如何配置2.0.0上使用Room

CMP上使用`kotlin 2.0.0`實作ROOM
  • 步驟1. 在專案中導入 Room
    需在libs.version.toml 文件中添加:
[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" }

並在gradle的 commonMain中加入

commonMain.dependencies {
    implementation(libs.androidx.room.runtime)
    implementation(libs.sqlite.bundled)
}
  • 步驟2. 調整build.gradle.kts,主要有下面幾點
    • 加入build/generated/ksp/metadatasourceSets.commonMain
    • 用add方法導入ksp:add("kspCommonMainMetadata", libs.androidx.room.compiler)
    • 最外層加入tasks.withType
    • 加入room schemas配置
    • 加入plugins配置
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")
    }
}
  • 步驟3. 使用workaround實現RoomDatabas

這個是現階段的workaround
如果你要用kotlin 2.0.0版本搭配Room就得先做
因為現在與ksp的兼容性需等待官方修復

主要是建立Room時候會繼承Room的RoomDatabase
正常來說編譯完成後會幫你產生AppDataBase的實作
不過目前版本缺少clearAllTables
所以這邊手動先自己加入
當暫時的解

// 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() {}
}
實際開發Room
  • 前面我們一樣需要 建立所有目標平台的RoomDatabase.Builder
// 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搭配koin
//in ~/androidMain

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

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

Room 的核心概念是透過面向物件的方式操作本地資料庫

透過實作 RoomDatabaseDAO(資料存取物件)、和Entity(實體類別)
可以方便地對資料庫進行操作

AppDatabase:實作RoomDatabase的類別,其中帶入Room的annotation @Database
裡面可以針對entity做宣告,以及做版本遷移…等

dao : 建立一個interface,並搭配些許的SQL cmd 讓操作DB可以使用物件導向方式

entity: 主要是把建立table變成物件導向的方式
這邊使用@Entity annotation去宣告其為Room的entity
加入到RoomDatabase()後進行編譯
就會在你的DB產生對應的table

  • 實作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() {}
}
  • 建立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?)

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

You might also enjoy