【KMP】Kotlin Multiplatform プロジェクトで Firebase Realtime Database にアクセスする

サーバー側が保持するデータを PUSH 型（Web API は PULL 型）でアプリと連携できる機能を持つクラウドサービスを選定するにあたり、候補として挙がった Firebase Realtime Database を Kotlin Multiplatform で試行し、その過程を記事にまとめた。

##### 本記事の特徴

* UI は [Compose Multiplatform](https://www.jetbrains.com/ja-jp/compose-multiplatform/) を使用する
Compose Multiplatform を使用しない Kotlin Multipltform プロジェクトであってもFirebase Realtime Database へのアクセスする箇所は参考になると思います
* Kotlin Multiplatform 対応ライブラリ [Firebase Kotlin SDK](https://github.com/GitLiveApp/firebase-kotlin-sdk/tree/master) を使って Realtime Database にアクセスする

## KMP プロジェクトを作成

KMP のチュートリアル [Create your Kotlin Multiplatform app](https://www.jetbrains.com/help/kotlin-multiplatform-dev/multiplatform-create-first-app.html#create-the-project-from-a-template) を参考に KMP プロジェクトを作る。この時 `Share UI` のオプションにチェックをつける。チェックをつけると [Compose Multiplatform](https://www.jetbrains.com/ja-jp/compose-multiplatform/) 対応のプロジェクトとなる。

## Firebase プロジェクトを作成する

[Firebase Console](https://console.firebase.google.com/) を起動し、「プロジェクトを作成する」をクリックする。
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3494458/6c6d11e3-3356-8556-bf79-d84847c66e58.png" alt="" width="200">

適当なプロジェクト名を付ける。
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3494458/8e8cb3d4-1742-c825-ede7-29d1ea34db3b.png" alt="" width="300">

Google アナリティクスは無効にしておく。(有効にしても良いがサンプルプロジェクトになので余計な機能を付加しないようする)
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3494458/5cef4a35-7cfc-c758-9400-a1cd01d3c144.png" alt="" width="300">

メニューから「Realtime Database」を選択し、右のペインから「データベースを作成」をクリックすると Firebase プロジェクト内で Realtime Database が有効になる。
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3494458/951d0086-65b3-cfe7-8925-c6629f3a4f7f.png" alt="" width="500">

## Android(=KMP) プロジェクトに Firebase を追加する

Firebase プロジェクトに Android アプリを追加するため、以下のAndroid のアイコンをクリックする。
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3494458/a98ea485-6a47-0393-ba1f-e9e65043898a.png" alt="" width="500">

ウィザードを進める。
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3494458/ff55041a-06f6-9dc5-2641-c67ab970535b.png" alt="" width="300">

ウィザードの途中で生成する google-services.json は composeApp ディレクトリ直下に配置する。
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3494458/5cb8ccd9-4158-3cc6-e3df-b166e75f9bc2.png" alt="" width="200">

ルートプロジェクトの build.gradle.kts を開いて以下のように編集。

``` gradle
plugins {
    .....
    id("com.google.gms.google-services").version("4.4.2").apply(false)  // Add
}
```

composeApp モジュールの build.gradle.kts を開いて以下のように編集。

``` gradle
plugins {
    .....
    id("com.google.gms.google-services") // Add
}

kotlin {
    .....
    sourceSets {
    
        androidMain.dependencies {
            .....
            implementation(platform("com.google.firebase:firebase-bom:33.7.0")) // Add
            implementation(project(":shared"))
        }
    }
}
```

AndroidManifest.xml を開いて以下のように編集する。

``` xml
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">

    <uses-permission android:name="android.permission.INTERNET"/> <!-- Add -->
    <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/> <!-- Add -->

    <application
        .....
```

MainActivity を開いて以下のように編集する。

``` kotlin
class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        Firebase.initialize(this) // Add
        setContent {
            App()
        }
    }
}
```

## iOS プロジェクトに Firebase を追加する

iOS の Bundle ID を Android のパッケージ名と同じするため、Config.xconfig を開き、`Bundle_ID` を Android のパッケージ名で更新。
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3494458/f3fe2907-464e-feec-5f82-6bdd4fd31c1b.png" alt="" width="400">

Firebase プロジェクトに iOS アプリを追加するため、以下の iOS のアイコンをクリックする。
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3494458/a98ea485-6a47-0393-ba1f-e9e65043898a.png" alt="" width="500">

ウィザードを進める。
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3494458/fbe053b6-7660-cf4c-d452-519c32d15f61.png" alt="" width="300">

ウィザードの途中で生成する GoogleService-Info.plist は iosApp ディレクトリ直下に配置する。
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3494458/fe2288c1-ec69-6416-91b4-97d8dad81b14.png" alt="" width="200">

以下のダイアログが表示されるので「Finish」ボタンをクリックする。
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3494458/e3b7fd18-412f-e7e0-1d28-712e2a4b7c19.png" alt="" width="300">

Xcode のメニュー「Add packages…」を選択する。
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3494458/b983fe8a-e7e6-d934-93a7-e01b305e9a77.png" alt="" width="300">

検索欄に `https://github.com/firebase/firebase-ios-sdk` を入力し、ヒットしたライブラリを追加する。 
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3494458/2fa4b626-d11e-44b8-6df6-b27a9e7e9c85.png" alt="" width="300">

Firebase Database を選択し、None から iosApp 変更して、「Add Package」ボタンをクリックする。
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3494458/903f79a4-96e2-76ac-ebe6-e9d11d90f382.png" alt="" width="300">

firebase-ios-sdk への依存は以下で確認できる。
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3494458/6df09d5a-9101-06d0-67b7-6bcb71b083ef.png" alt="" width="300">

iosApp/iOSApp.swift を開いて以下のように編集する。

``` swift
import SwiftUI
import Firebase // Add

@main
struct iOSApp: App {
    init(){
      FirebaseApp.configure() // Add
    }
    
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
    }
}
```


## composeApp モジュールのセットアップ

※ Compose Multiplatform を採用していない場合は shared モジュールのセットアップ

Kotlin Multiplatform 対応ライブラリ [Firebase Kotlin SDK](https://github.com/GitLiveApp/firebase-kotlin-sdk/tree/master) を使って Realtime Database にアクセスする。composeApp モジュールの build.gradle.kts を以下のように編集。

``` gradle
plugins {
    kotlin("multiplatform")
    .....
    kotlin("plugin.serialization") version "2.1.0" // Add ("multiplatform" プラグインと同じバージョンにする)
}

kotlin{
    .....
    sourceSets {
        // .....
        commonMain.dependencies {
            // .....
            implementation("dev.gitlive:firebase-firestore:2.1.0") // Add
            implementation("dev.gitlive:firebase-common:2.1.0")// Add
            implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3") // Add
        }
    }
    .....
}
```

一旦ここまでで Android、iOS 両方のビルドを実行してエラーが発生しないかを確認すると良い。

## 実装

#### アプリの仕様

アプリをインストールした端末ごとに [GUID](https://ja.wikipedia.org/wiki/GUID) を生成し、その GUID を Realtime Database に登録する。GUID には残高(円)が紐づけられ、Realtime Database の残高(円)が更新されるとアプリの残高表示が自動更新されるようにしたい。

アプリ初回起動時
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3494458/69bcfe24-9a1d-79e2-ff5e-5c2bb9593578.png" alt="" width="400">

データベース更新時 
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3494458/007ab01e-83f2-2040-7410-e97ccda64f88.png" alt="" width="400">

アプリ再起動時
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3494458/3c88e8a7-fcfb-e160-e069-2445e235c9d9.png" alt="" width="400">

この時点で完成時のデモ動画を載せておく。
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3494458/8cc9eb51-4025-4096-32c5-4b19ba1767eb.gif" alt="" width="400">

#### Realtime Database 

データベースはデフォルトのものを使用する。
アプリ初回起動時にアプリから Realtime Database へデータが書き込まれると以下のようになる。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3494458/d8510221-d99f-856f-79f4-e98223cb6b45.png" alt="" width="600">

今回は Realtime Database を実験的に使うだけなのでアクセスルールを制限なし状態にしておく。
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3494458/cd052b6b-e52e-0170-2247-f3af1efeb7bc.png" alt="" width="600">


#### UI

``` composeApp/src/commonMain/kotlin/パッケージ名/App.kt
@Composable
@Preview
fun App() {
    MaterialTheme {
        val viewModel: AppViewModel = viewModel()

        val balanceState by viewModel.balance.collectAsStateWithLifecycle()

        LaunchedEffect(Unit) {
            viewModel.setupDatabase()
        }

        Column(Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally) {
            Text("残高 ${balanceState}円")
        }
    }
}
```

``` composeApp/src/commonMain/kotlin/パッケージ名/AppViewModel.kt
class AppViewModel : ViewModel() {

    private val guidRepository = GuidRepository(createDataStore(PlatformContext()))
    private val databaseRepository = RealtimeDatabaseRepository()

    private val createGuidUseCase = CreateGuidUseCase()
    private val observeBalanceUseCase = ObserveBalanceUseCase(databaseRepository)
    private val registerDatabaseEventUseCase = RegisterDatabaseEventUseCase(databaseRepository)
    private val loadGuidUseCase = LoadGuidUseCase(guidRepository,createGuidUseCase)

    private val _balance = MutableStateFlow("")
    val balance = _balance.asStateFlow()

    fun setupDatabase() {
        viewModelScope.launch {
            val guid = loadGuidUseCase()
            registerDatabaseEventUseCase(guid) {
                observeBalanceUseCase(it).collect { dataSnapshot ->
                    val value = dataSnapshot.value
                    if (value is Long) {
                        _balance.value = value.toString()
                    }
                }
            }
        }
    }
}
```

#### ユースケース

``` composeApp/src/commonMain/kotlin/パッケージ名/usecase/CreateGuidUseCase.kt

import パッケージ名.getGuid

class CreateGuidUseCase {
    operator fun invoke(): String {
        val guid = getGuid()
        return guid.id
    }
}
```

``` composeApp/src/commonMain/kotlin/パッケージ名/usecase/LoadGuidUseCase.kt
class LoadGuidUseCase(
    private val guidRepository: GuidRepository,
    private val createGuidUseCase: CreateGuidUseCase
) {
    suspend operator fun invoke(): String {
        return guidRepository.load().let { guid ->
            if (guid == "") {
                val newGuid = createGuidUseCase()
                guidRepository.save(newGuid)
                newGuid
            } else {
                guid
            }
        }
    }
}
```

``` composeApp/src/commonMain/kotlin/パッケージ名/usecase/RegisterDatabaseEventUseCase.kt
import パッケージ名.repository.User

class RegisterDatabaseEventUseCase(
    private val realtimeDatabaseRepository: RealtimeDatabaseRepository
) {
    suspend operator fun invoke(id: String, onCompleted: suspend (String) -> Unit) {
        realtimeDatabaseRepository.readUser(id).collect { dataSnapshot ->
            if (dataSnapshot.value == null) {
                realtimeDatabaseRepository.writeUser(id, User(balance = 0))
            }
            onCompleted(id)
        }
    }
}
```

``` composeApp/src/commonMain/kotlin/パッケージ名/usecase/ObserveBalanceUseCase.kt
class ObserveBalanceUseCase(
    private val realtimeDatabaseRepository: RealtimeDatabaseRepository
) {
    operator fun invoke(id: String): Flow<DataSnapshot> {
        return realtimeDatabaseRepository.readBalance(id)
    }
}
```

#### リポジトリ

Realtime Firebase へのアクセスは `RealtimeDatabaseRepository` で行う。

```composeApp/src/commonMain/kotlin/パッケージ名/repository/GuidRepository.kt
class GuidRepository(
    private val dataStore: DataStore<Preferences>
) {

    private val guidKey = stringPreferencesKey("guid")

    suspend fun save(guid: String) {
        dataStore.edit {
            it[guidKey] = guid
        }
    }

    suspend fun load(): String {
        val preferences = dataStore.data.first()
        val guid = preferences[guidKey] ?: ""
        return guid
    }
}
```

``` composeApp/src/commonMain/kotlin/パッケージ名/repository/RealtimeDatabaseRepository.kt
import dev.gitlive.firebase.Firebase
import dev.gitlive.firebase.database.DataSnapshot
import dev.gitlive.firebase.database.database
import kotlinx.coroutines.flow.Flow

class RealtimeDatabaseRepository {

    fun readUser(id: String): Flow<DataSnapshot> {
        val database = Firebase.database
        val ref = database.reference("users/$id")
        return ref.valueEvents
    }

    fun readBalance(id: String): Flow<DataSnapshot> {
        val database = Firebase.database
        val ref = database.reference("users/$id/balance")
        return ref.valueEvents
    }

    suspend fun writeUser(id: String, user: User) {
        val database = Firebase.database
        val ref = database.reference("users")
        val childRef = ref.child(id)
        childRef.setValue(user)
    }
}
```

``` composeApp/src/commonMain/kotlin/パッケージ名/repository/User.kt 
import kotlinx.serialization.Serializable

@Serializable
data class User(val balance: Int)
```

#### shared ロジック

##### createDataStore

Kotlin Multiplatform 対応の [DataStore](https://developer.android.com/kotlin/multiplatform/datastore) ライブラリを使って Android、iOS それぞれのストレージに GUID を保存する。DataStore ライブラリのセットアップ方法は公式アプリの [build.gradle.kts](https://github.com/android/kotlin-multiplatform-samples/blob/main/Fruitties/shared/build.gradle.kts) を参照されたい。

``` composeApp/src/commonMain/kotlin/パッケージ名/DataStore.common.kt
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
import androidx.datastore.preferences.core.Preferences
import kotlinx.atomicfu.locks.SynchronizedObject
import kotlinx.atomicfu.locks.synchronized
import okio.Path.Companion.toPath

private lateinit var dataStore: DataStore<Preferences>

private val lock = SynchronizedObject()

fun getDataStore(producePath: () -> String): DataStore<Preferences> =
    synchronized(lock) {
        if (::dataStore.isInitialized) {
            dataStore
        } else {
            PreferenceDataStoreFactory.createWithPath(produceFile = { producePath().toPath() })
                .also { dataStore = it }
        }
    }

internal const val dataStoreFileName = "rtdb.preferences_pb"

expect fun createDataStore(platformContext: PlatformContext): DataStore<Preferences>
```

``` composeApp/src/androidMain/kotlin/パッケージ名/DataStore.android.kt
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences

actual fun createDataStore(platformContext: PlatformContext): DataStore<Preferences> = getDataStore(
    producePath = { platformContext.context.filesDir.resolve(dataStoreFileName).absolutePath }
)
```

``` composeApp/src/iosMain/kotlin/パッケージ名/DataStore.ios.kt
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import platform.Foundation.NSDocumentDirectory
import platform.Foundation.NSFileManager
import platform.Foundation.NSURL
import platform.Foundation.NSUserDomainMask

@OptIn(kotlinx.cinterop.ExperimentalForeignApi::class)
actual fun createDataStore(platformContext: PlatformContext): DataStore<Preferences> = getDataStore(
    producePath = {
        val documentDirectory: NSURL? = NSFileManager.defaultManager.URLForDirectory(
            directory = NSDocumentDirectory,
            inDomain = NSUserDomainMask,
            appropriateForURL = null,
            create = false,
            error = null,
        )
        requireNotNull(documentDirectory).path + "/$dataStoreFileName"
    }
)
```

##### getGuid

Android、iOS それぞれで GUID を生成する。

``` composeApp/src/commonMain/kotlin/パッケージ名/Guid.common.kt
data class Guid(val id: String)

expect fun getGuid(): Guid
```
 
``` composeApp/src/commonMain/kotlin/パッケージ名/Guid.android.kt
import java.util.UUID

actual fun getGuid(): Guid {
    return Guid(UUID.randomUUID().toString())
}
```

``` composeApp/src/iosMain/kotlin/パッケージ名/Guid.ios.kt
import platform.Foundation.NSUUID

actual fun getGuid(): Guid {
    return Guid(NSUUID().UUIDString())
}
```

##### PlatformContext

`createDataStore` の Android 側は `Context` を必要とするので `Context` が格納できる `PlatformContext` という shared コードを作る。 iOS 側は `Context` 的なものが不要なので iOS の `PlatformContext` にはプロパティが存在しない。

``` composeApp/src/commonMain/kotlin/パッケージ名/PlatformContext.common.kt
expect class PlatformContext() {
}
```

``` composeApp/src/commonMain/kotlin/パッケージ名/PlatformContext.android.kt
import android.content.Context

actual class PlatformContext actual constructor() {
    var context: Context = RtdbApplication.getApplicationInstance().applicationContext
}
```

``` composeApp/src/iosMain/kotlin/パッケージ名/PlatformContext.ios.kt
actual class PlatformContext actual constructor() {
}
```

#### 補足

[Firebase Kotlin SDK](https://github.com/GitLiveApp/firebase-kotlin-sdk/tree/master) ver 2.1.0 のターゲット JVM は Java 17 であるのに対し、[Create your Kotlin Multiplatform app](https://www.jetbrains.com/help/kotlin-multiplatform-dev/multiplatform-create-first-app.html#create-the-project-from-a-template) で生成した Android プロジェクトのターゲット JVM が Java 11 の場合 (2024/01/18 時点では Java 11)、

```
Cannot inline bytecode built with JVM target 17 into bytecode that is being built with JVM target 11oper '-jvm-target' option.
```

というコンパイルエラーが発生する可能性があります。このエラーに対処するには、composeApp モジュールの build.gradle.kts を以下のように修正すれば良いでしょう。


``` diff
     androidTarget {
         @OptIn(ExperimentalKotlinGradlePluginApi::class)
         compilerOptions {
-            jvmTarget.set(JvmTarget.JVM_11)
+            jvmTarget.set(JvmTarget.JVM_17)
         }
     }
         }
     }
     .....
     compileOptions {
-        sourceCompatibility = JavaVersion.VERSION_11
-        targetCompatibility = JavaVersion.VERSION_11
+        sourceCompatibility = JavaVersion.VERSION_17
+        targetCompatibility = JavaVersion.VERSION_17
     }
 }