0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

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

Posted at

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

本記事の特徴
  • UI は Compose Multiplatform を使用する
    Compose Multiplatform を使用しない Kotlin Multipltform プロジェクトであってもFirebase Realtime Database へのアクセスするじっsは参考になると思います
  • Kotlin Multiplatform 対応ライブラリ Firebase Kotlin SDK を使って Realtime Database にアクセスする

KMP プロジェクトを作成

KMP のチュートリアル Create your Kotlin Multiplatform app を参考に KMP プロジェクトを作る。この時 Share UI のオプションにチェックをつける。チェックをつけると Compose Multiplatform 対応のプロジェクトとなる。

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

Firebase Console を起動し、「プロジェクトを作成する」をクリックする。

適当なプロジェクト名を付ける。

Google アナリティクスは無効にしておく。(有効にしても良いがサンプルプロジェクトになので余計な機能を付加しないようする)

メニューから「Realtime Database」を選択し、右のペインから「データベースを作成」をクリックすると Firebase プロジェクト内で Realtime Database が有効になる。

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

Firebase プロジェクトに Android アプリを追加するため、以下のAndroid のアイコンをクリックする。

ウィザードを進める。

ウィザードの途中で生成する google-services.json は composeApp ディレクトリ直下に配置する。

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

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

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

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 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 を開いて以下のように編集する。

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 のパッケージ名で更新。

Firebase プロジェクトに iOS アプリを追加するため、以下の iOS のアイコンをクリックする。

ウィザードを進める。

ウィザードの途中で生成する GoogleService-Info.plist は iosApp ディレクトリ直下に配置する。

以下のダイアログが表示されるので「Finish」ボタンをクリックする。

Xcode のメニュー「Add packages…」を選択する。

検索欄に https://github.com/firebase/firebase-ios-sdk を入力し、ヒットしたライブラリを追加する。

Firebase Database を選択し、None から iosApp 変更して、「Add Package」ボタンをクリックする。

firebase-ios-sdk への依存は以下で確認できる。

iosApp/iOSApp.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 を使って Realtime Database にアクセスする。composeApp モジュールの build.gradle.kts を以下のように編集。

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 を生成し、その GUID を Realtime Database に登録する。GUID には残高(円)が紐づけられ、Realtime Database の残高(円)が更新されるとアプリの残高表示が自動更新されるようにしたい。

アプリ初回起動時

データベース更新時

アプリ再起動時

この時点で完成時のデモ動画を載せておく。

Realtime Database

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

今回は Realtime Database を実験的に使うだけなのでアクセスルールを制限なし状態にしておく。

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 roadGuidUseCase = RoadGuidUseCase(guidRepository,createGuidUseCase)

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

    fun setupDatabase() {
        viewModelScope.launch {
            val guid = roadGuidUseCase()
            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/RoadGuidUseCase.kt
class RoadGuidUseCase(
    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 ライブラリを使って Android、iOS それぞれのストレージに GUID を保存する。DataStore ライブラリのセットアップ方法は公式アプリの 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 ver 2.1.0 のターゲット JVM は Java 17 であるのに対し、Create your Kotlin Multiplatform app で生成した 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 を以下のように修正すれば良いでしょう。

     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
     }
 }
0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?