Compose Multiplatform 實戰:CMP中使用ROOM開發跨平台資料庫 & 疑難雜症
Compose Multiplatform (簡稱CMP)
在 CMP
專案中
如何實現跨平台的資料庫操作呢?
昨天我們提到了SqlDelight
這是CMP初期提出並針對本地資料庫
做的跨雙平台解決方案
也就在近期2024/05
左右
Room
也開始提供CMP跨平台的解決方案
另外,Android Developer
官方也在網站上放上KMP使用的文章
本文將介紹如何在跨平台Android
& iOS
環境中
使用 Room
進行資料庫操作
目錄
- 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開發跨平台資料庫 & 疑難雜症
注意1
. Room版本2.7.0-alpha01
之後才支援KMM。
注意2
.
Room
在CMP
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
一開始遇到[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
- 步驟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/metadata
到sourceSets.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() {}
}
- 前面我們一樣需要 建立所有目標平台的
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)
}
//in ~/androidMain
actual val platformModule: Module = module {
single<RoomDatabase.Builder<AppDatabase>> {
getAppDatabase(get())
}
}
//in ~/iosMain
actual val platformModule: Module = module {
single { getAppDatabase() }
}
Room 的核心概念是透過面向物件的方式操作本地資料庫
透過實作 RoomDatabase
、DAO
(資料存取物件)、和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
)