🔢

【KMP入門】カウンターアプリを作ってみる

2025/01/24に公開
1

今回は Kotlin Multiplatform (KMP) で簡単なカウンターアプリを作成します。

KMPの共通コードで値を管理し、値が変化したらアプリの画面に反映させます。

まずはサンプルコードを削除

まずは前回の記事で作成したサンプルプロジェクトをすっきりさせます。

この記事から読んだ方向けのセットアップ

プロジェクトの作成

KMPで新規プロジェクトを作るには Kotlin Multiplatform Wizard を使用します。
https://kmp.jetbrains.com

Kotlin Multiplatform Wizard の選択画面

今回はiOSの「Do not share UI (use only SwiftUI)」を選択します。他の設定は初期状態のままで構いません。

Share UI (with Compose Multiplatform UI framework)」の方はCMPと呼ばれるもので、簡潔に言うとKotlin版Flutterなのですが、こちらはまた別の記事でご説明します。

Fleetのダウンロード

次に、KMP開発元の公式エディターであるFleetをダウンロードします。
https://www.jetbrains.com/ja-jp/fleet/

ダウンロードが完了したらFleetを起動し、「Open...」から先ほど作成したプロジェクトを開きます。

Fleetの初期起動画面

以下の画面になったら準備完了です。
Fleetでサンプルプロジェクトを開いた時の画面

以下のファイルとディレクトリーを削除します。

  1. composeApp/src/commonMain をディレクトリーごと
  2. shared/src/androidMainをディレクトリーごと
  3. shared/src/iosMainをディレクトリーごと
  4. shared/src/commonMain/kotlin/org/example/prject/Greeting.kt
  5. shared/src/commonMain/kotlin/org/example/prject/Platform.kt

次に、App.ktCounterView.swiftをすっきりさせます。

App.kt
package org.example.project

import androidx.compose.material.*
import androidx.compose.runtime.*

@Composable
fun App() {
    MaterialTheme {
    }
}
ContentView.swift
import SwiftUI
import Shared

struct ContentView: View {
    var body: some View {
        VStack {
        }
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}

最後にKMPの共有コードが0個だとビルドに失敗するのでCounter.ktを作成しておきます。
右クリック→New File... で新規ファイルが作成できます。

shared/src/commonMain/kotlin/org/example/project で右クリックした様子

New File...を選択後、Counter.ktと入力

これで真っ白なプロジェクトが作成完了です。アプリを実行してみると真っ白な画面だけが表示されます。

共通コードを作成

KMPは初期状態だと共通コード側からAndroid、iOSに値の変化を伝える手段がないので、外部ライブラリー(と言ってもKotlin公式のですが)を追加します。

shared/build.gradle.ktsの中にput your Multiplatform dependencies hereというコメントがあるので、そこにライブラリーを追加します。

build.gradle.kts
sourceSets {
    commonMain.dependencies {
-       // put your Multiplatform dependencies here
+      implementation(libs.kotlinx.coroutines.core)
    }
}
もしGradleエラーになる場合

一旦以下の書き方にしてインポートさせた後、上記のlibs.kotlinx.coroutines.coreに置き換えてください。

build.gradle.kts
sourceSets {
    commonMain.dependencies {
-       // put your Multiplatform dependencies here
+      implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:latest.release")
    }
}

次に、counter.ktCounterクラスを実装します。

counter.kt
package org.example.project

import kotlinx.coroutines.flow.MutableStateFlow

class Counter {
    val count = MutableStateFlow(0)
    fun increment() {
        count.value += 1
    }

    fun decrement() {
        count.value -= 1
    }
}
コードの保存時に自動フォーマットする

Fleetの画面右上の六角形ボタンからSettings→Global→Editor→Codeにある「Reformat code on save」をONにすると保存時に自動整形してくれます。

Jetpack Compose でカウンターを実装

App.kt
package org.example.project

import androidx.compose.foundation.layout.*
import androidx.compose.material.*
import androidx.compose.runtime.*
import androidx.compose.ui.*
import androidx.compose.ui.unit.dp

@Composable
// KMPの共通コード
fun App(counter: Counter) {
    // KMPの共通コードのカウンターの値と紐づけて、値の更新を画面に反映させる
    val count by counter.count.collectAsState()

    MaterialTheme {
        // 画面中央にテキストやボタンを置かせるためのColumn
        Column(
            modifier = Modifier.fillMaxSize(),
            verticalArrangement = Arrangement.Center,
            horizontalAlignment = Alignment.CenterHorizontally
        ) {
            Text("count: $count")
            // デザイン調節用のスペース
            Spacer(modifier = Modifier.height(16.dp))
            // +ボタンと-ボタン
            Row {
                Button(onClick = { counter.increment() }) {
                    Text("+")
                }
                // デザイン調節用のスペースその2
                Spacer(modifier = Modifier.width(8.dp))
                Button(onClick = { counter.decrement() }) {
                    Text("-")
                }
            }
        }
    }
}

このように、KMP共通コードも Jetpack Compose もKotlinなので、collectAsState()と紐づけた変数を一つ書くだけで簡単に値の変更を画面に反映できます。

Appに引数が増えたのでMainActivity.ktに少しだけ手を加えます。

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

        setContent {
-           App()
+           App(Counter())
        }
    }
}

@Preview
@Composable
fun AppAndroidPreview() {
-   App()
+   App(Counter())
}

これでAndroid版カウンターアプリの完成です。

Jetpack Compose とKMPで作成したカウンターアプリの画面

SwiftUI でカウンターを実装

言語の違いのせいかSwiftにはKotlinのようにcollectAsState()が無いため、代わりにcollectを使用するのですが、引数がKotlinx_coroutines_coreFlowCollectorというクラスになっています。

そのため、まずはこれを継承したCollectorというクラスを定義します。

Collector.swift
import Shared

class Collector<T>: Kotlinx_coroutines_coreFlowCollector {
    // 値が更新された時に呼ばれる
    let callback: (T) -> Void

    init(callback: @escaping (T) -> Void) {
      self.callback = callback
    }

    func emit(value: Any?, completionHandler: @escaping ((any Error)?) -> Void) {
      callback(value as! T)
      // 最後にcompletionHandlerを呼ばないと値の変更が更新されないのでご注意
      completionHandler(nil)
    }
}

次にContentView.swiftを更新します。Jetpack Compose とやっていることはほぼ同じです。

ContentView.swift
import SwiftUI
import Shared

struct ContentView: View {
    // KMPのクラスを変数として持っておく
    private let counter = Counter()
    @State private var count = 0

    var body: some View {
        VStack(spacing: 16) {
            Text("Count: \(count)")
            HStack(spacing: 32) {
                Button(action: {
                    counter.increment()
                }) {
                    Text("+")
                }
                Button(action: {
                    counter.decrement()
                }) {
                    Text("-")
                }
            }
        }
        .padding()
        // ContentViewが表示された時に一度だけ実行される
        .onAppear {
            // KMP側の値の変化を@State付きの変数countに反映させる
            counter.count.collect(collector: Collector<Int> {
                self.count = $0
            }, completionHandler: { _ in })
        }
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}

SwiftUIとKMPで作成したカウンターアプリの画面

Jetpack Compose と違い、SwiftUIは一行で書けるお手軽な書き方がないので少し面倒ですが、今回作ったCollectorを使えば簡単なアプリなら十分かなと思います。

extensionを使う等でもう少し短く出来そうなので、良い感じの書き方が分かればまた記事にしたいと思います。

おわりに

今回のように小規模なアプリだと利点が分かりづらいと思いますが、コード量が増えたり、通信処理が必要になったりすると恩恵が大きくなっていきます。

ちなみに今回は共通コード内の値の更新をネイティブ側に反映させるところまでやりましたが、実際はここまでする必要はなく、計算処理(Business Logic)やクラス等の定義(Domain)の共通化だけでも十分便利かなと思います。

余談ですがmixi2にKMPのコミュニティーを作りました。
私も絶賛勉強中なので、しばらくは記事やKMP関係のイベント情報を投稿するだけになりそうですが、もし良かったら是非(^_^;)
https://mixi.social/communities/8323123c-cd05-46ea-9e5d-4f097763cf22?r=c6fi80fmwy5y

参考文献

Swift's flow.collect function only receives first value
Collectorクラスを作成する際に参考に致しました。

備考

本記事は以下の環境で検証しました

Fleet: 1.44.151
Android Studio: 2024.1 (AI-241.18034.62.2411.12071903)
Xcode 16.2
GitHubで編集を提案
1
合同会社zoome(ズーム)

Discussion