KMPでRoom

RoomはAndroidアプリ開発で利用するJetpackライブラリの一つです。
SQLiteのデータベースの構築や、データの挿入、取得などを実装する際に利用します。

[Room](https://developer.android.com/jetpack/androidx/releases/room?hl=ja)のバージョンが2.7.0になり、[Kotlin Multiplatform(KMP)](https://www.jetbrains.com/ja-jp/kotlin-multiplatform/)で利用可能になりました。
この記事ではKMPかつCompose MultiplatformでのRoomの利用方法を記載しています。(一般的なRoomの利用方法は記載していません)

# 導入
KMPのプロジェクトにRoomを導入します。

## libs.version.toml
各種ライブラリを追記します。
```toml:libs.versions.toml
[versions]
# ...
kotlin = "2.1.20"
ksp = "2.1.20-2.0.0"
androidx-room = "2.7.0"
sqlite-bundled = "2.5.0"

[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" }
androidx-sqlite-bundled = { group = "androidx.sqlite", name = "sqlite-bundled", version.ref = "sqlite-bundled" }

[plugins]
# ...
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
room = { id = "androidx.room", version.ref = "androidx-room" }
```

* KMPに対応しているRoomのバージョンは2.7.0以上(2.7.0は2025年4月9日にリリースされています)
* KotlinとKspのバージョンを統一(統一しないとビルド時にエラーとなります)

## Gradleファイル
KMPの共通モジュールの`build.gradle.kts`を修正し、各種ライブラリの導入とセットアップを行います。
```kotlin:build.gradle.kts(:composeApp)
plugins {
    // ...
    alias(libs.plugins.ksp)
    alias(libs.plugins.room)
}

kotlin {
    // ...
    sourceSets {
        // ...
        commonMain.dependencies {
            // ...
            implementation(libs.androidx.room.runtime)
            implementation(libs.androidx.sqlite.bundled)
        }
    }
}
// ...
dependencies {
    // ...
    add("kspCommonMainMetadata", libs.androidx.room.compiler)
    add("kspAndroid", libs.androidx.room.compiler)
    add("kspIosSimulatorArm64", libs.androidx.room.compiler)
    add("kspIosX64", libs.androidx.room.compiler)
    add("kspIosArm64", libs.androidx.room.compiler)
}

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

```
`dependencies`で`room-compiler`を導入する方法が、Androidのみのプロジェクトの場合と異なります。
Androidのみのプロジェクトの場合、
```kotlin:build.gradle.kts
dependencies {
    ksp(libs.androidx.room.compiler)
}
```
このように導入しますが、KMPのプロジェクトの場合にこの方法で導入すると、iOSでビルド時にエラーとなります。

# Database、Entity、Daoの実装
AndroidとiOSでRoomを用いた実装を共通化するため、共通モジュールに実装します。
特筆する部分がないためサンプルコードだけ記載しておきます。

## Database
```kotlin:AppDatabas.kt
import androidx.room.ConstructedBy
import androidx.room.Database
import androidx.room.RoomDatabase
import androidx.room.RoomDatabaseConstructor
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.IO

@Database(entities = [PersonEntity::class], version = 1)
@ConstructedBy(AppDatabaseConstructor::class)
abstract class AppDatabase : RoomDatabase() {
    abstract fun getPersonDao(): PersonDao
}

@Suppress("NO_ACTUAL_FOR_EXPECT")
expect object AppDatabaseConstructor : RoomDatabaseConstructor<AppDatabase> {
    override fun initialize(): AppDatabase
}

fun getRoomDatabase(builder: RoomDatabase.Builder<AppDatabase>): AppDatabase {
    return builder
        .addMigrations()
        .fallbackToDestructiveMigrationOnDowngrade(true)
        .setDriver(BundledSQLiteDriver())
        .setQueryCoroutineContext(Dispatchers.IO)
        .build()
}
```

## Entity
```kotlin:PersonEntity.kt
import androidx.room.Entity
import androidx.room.PrimaryKey

@Entity
data class PersonEntity(
    @PrimaryKey(autoGenerate = true) val id: Int = 0,
    val name: String
)
```

## Dao
```kotlin:PersonDao.kt
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.Query

@Dao
interface PersonDao {
    @Insert
    suspend fun insert(person: PersonEntity)

    @Query("SELECT * FROM PersonEntity")
    suspend fun getAll(): List<PersonEntity>
}
```

# Database Builderの実装
Android、iOSでRoomを利用するために、それぞれのOS固有のシステムを利用したDatabase Builderを実装する必要があります。

## 共通モジュール
Compose MultiPlatformではUIもAndroidとiOSで共通となります。
よって共通モジュールは、AndroidモジュールもしくはiOSモジュールで作成したDatabase Builderインスタンスを取得する必要があるため、`expect` `actual`パターンを利用して実装します。
まずは共通モジュールに`expect fun getDatabaseBuilder(): RoomDatabase.Builder<AppDatabase>`を追加します。
```kotlin:Platform.kt
import androidx.room.RoomDatabase

// ...

expect object AppContext

expect fun getDatabaseBuilder(): RoomDatabase.Builder<AppDatabase>
```
AndroidではDatabase Builder作成時に、`Context`が必要となるため`expect object AppContext`も追加しています。

## Androidモジュール
Androidモジュールに`actual object AppContext`と`actual fun getDatabaseBuilder(): RoomDatabase.Builder<AppDatabase>`を実装します。
```kotlin:Platform.android.kt
import android.content.Context
import androidx.room.Room
import androidx.room.RoomDatabase
import java.lang.ref.WeakReference

// ...

actual object AppContext {
    private var value: WeakReference<Context?>? = null
    fun set(context: Context) {
        value = WeakReference(context)
    }

    internal fun get(): Context {
        return value?.get() ?: throw RuntimeException("Context Error")
    }
}

actual fun getDatabaseBuilder(): RoomDatabase.Builder<AppDatabase> {
    val appContext = AppContext.get().applicationContext
    val dbFile = appContext.getDatabasePath("app_room.db")
    return Room.databaseBuilder<AppDatabase>(
        context = appContext,
        name = dbFile.absolutePath
    )
}
```
Androidアプリ実行時に、最初に`Context`をsetしておく必要があるため、`MainActivity.kt`を以下のように修正します。
```kotlin:MainActivity.kt
class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        // 以下を記載
        AppContext.apply { set(applicationContext) }
        setContent {
            App()
        }
    }
}
```

## iOSモジュール
iOSモジュールに`actual fun getDatabaseBuilder(): RoomDatabase.Builder<AppDatabase>`を実装します。
```kotlin:Platform.ios.kt
import androidx.room.Room
import androidx.room.RoomDatabase
import kotlinx.cinterop.ExperimentalForeignApi
import platform.Foundation.NSDocumentDirectory
import platform.Foundation.NSFileManager
import platform.Foundation.NSUserDomainMask

// ...

actual object AppContext

actual fun getDatabaseBuilder(): RoomDatabase.Builder<AppDatabase> {
    val dbFilePath = documentDirectory() + "/app_room.db"
    return Room.databaseBuilder<AppDatabase>(
        name = dbFilePath,
    )
}

@OptIn(ExperimentalForeignApi::class)
private fun documentDirectory(): String {
    val documentDirectory = NSFileManager.defaultManager.URLForDirectory(
        directory = NSDocumentDirectory,
        inDomain = NSUserDomainMask,
        appropriateForURL = null,
        create = false,
        error = null,
    )
    return requireNotNull(documentDirectory?.path)
}
```

iOSではDatabase Builder作成時に、`Context`が必要ではないため、`actual object AppContext`は形だけ実装します。

# UIとViewModelの実装
あとは`ViewModel`でデータを取得する処理とデータを挿入する処理を作成しUIに反映するだけとなります。サンプルコードは以下となります。

## ViewModel
```kotlin:AppViewModel.kt
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch

class AppViewModel : ViewModel() {
    private val personDao = getRoomDatabase(getDatabaseBuilder()).getPersonDao()
    private val _personList = MutableStateFlow(emptyList<PersonEntity>())
    val personList: StateFlow<List<PersonEntity>> = _personList.asStateFlow()

    init {
        viewModelScope.launch {
            _personList.value = personDao.getAll()
        }
    }

    fun insertPerson(name: String) {
        val person = PersonEntity(name = name)
        viewModelScope.launch {
            personDao.insert(person)
            val currentPersonList = _personList.value.toMutableList()
            currentPersonList.add(person)
            _personList.value = currentPersonList
        }
    }
}
```

## Composable
```kotlin:App.kt
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.Button
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.material.TextField
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import org.jetbrains.compose.ui.tooling.preview.Preview

@Composable
@Preview
fun App() {
    MaterialTheme {
        val viewModel = AppViewModel()
        val personList by viewModel.personList.collectAsState()
        var name by remember { mutableStateOf("") }

        Column(
            modifier = Modifier
                .fillMaxWidth()
                .padding(16.dp),
            horizontalAlignment = Alignment.CenterHorizontally,
            verticalArrangement = Arrangement.spacedBy(16.dp),
        ) {
            Row(
                verticalAlignment = Alignment.CenterVertically,
                horizontalArrangement = Arrangement.spacedBy(12.dp)
            ) {
                TextField(
                    value = name,
                    onValueChange = { name = it },
                    label = { Text("name") }
                )
                Button(
                    onClick = { viewModel.insertPerson(name = name) },
                    content = { Text("Click!") },
                )
            }

            LazyColumn(
                modifier = Modifier
                    .fillMaxWidth()
                    .padding(horizontal = 16.dp),
                horizontalAlignment = Alignment.Start,
            ) {
                items(items = personList) { person ->
                    Row {
                        Text("id : ${person.id} / ")
                        Text("name : ${person.name}")
                    }
                }
            }
        }
    }
}
```