サーバー側が保持するデータを 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
@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}円")
}
}
}
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()
}
}
}
}
}
}
ユースケース
import パッケージ名.getGuid
class CreateGuidUseCase {
operator fun invoke(): String {
val guid = getGuid()
return guid.id
}
}
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
}
}
}
}
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)
}
}
}
class ObserveBalanceUseCase(
private val realtimeDatabaseRepository: RealtimeDatabaseRepository
) {
operator fun invoke(id: String): Flow<DataSnapshot> {
return realtimeDatabaseRepository.readBalance(id)
}
}
リポジトリ
Realtime Firebase へのアクセスは RealtimeDatabaseRepository
で行う。
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
}
}
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)
}
}
import kotlinx.serialization.Serializable
@Serializable
data class User(val balance: Int)
shared ロジック
createDataStore
Kotlin Multiplatform 対応の DataStore ライブラリを使って Android、iOS それぞれのストレージに GUID を保存する。DataStore ライブラリのセットアップ方法は公式アプリの build.gradle.kts を参照されたい。
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>
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 }
)
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 を生成する。
data class Guid(val id: String)
expect fun getGuid(): Guid
import java.util.UUID
actual fun getGuid(): Guid {
return Guid(UUID.randomUUID().toString())
}
import platform.Foundation.NSUUID
actual fun getGuid(): Guid {
return Guid(NSUUID().UUIDString())
}
PlatformContext
createDataStore
の Android 側は Context
を必要とするので Context
が格納できる PlatformContext
という shared コードを作る。 iOS 側は Context
的なものが不要なので iOS の PlatformContext
にはプロパティが存在しない。
expect class PlatformContext() {
}
import android.content.Context
actual class PlatformContext actual constructor() {
var context: Context = RtdbApplication.getApplicationInstance().applicationContext
}
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
}
}