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


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

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

# まずはサンプルコードを削除
まずは[前回の記事](e943fded39ca1d50746b)で作成したサンプルプロジェクトをすっきりさせます。
<details><summary>この記事から読んだ方向けのセットアップ</summary>
# プロジェクトの作成
KMPで新規プロジェクトを作るには **Kotlin Multiplatform Wizard** を使用します。

https://kmp.jetbrains.com

![p2ui9dxdad.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/679221/e9e6704c-d45c-5ea2-1867-6fd1a2092db4.png)

今回は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...」から先ほど作成したプロジェクトを開きます。

![dryniu9jxm.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/679221/dd37add3-9e84-deb8-d7e6-1a02036430a2.png)


以下の画面になったら準備完了です。
![ucnad6c3fg.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/679221/8558586f-a788-79b2-1168-87958fbf2e78.png)
</details>

以下のファイルとディレクトリーを削除します。
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.kt`と`CounterView.swift`をすっきりさせます。
```kotlin:App.kt
package org.example.project

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

@Composable
fun App() {
    MaterialTheme {
    }
}
```

```swift: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...** で新規ファイルが作成できます。

![9t3hmdsjz3.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/679221/ed7a317e-e60b-6599-279b-9e76e475a308.png)

![cdyp5a4jt2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/679221/16e8156c-4395-9007-725a-9c4736175fde.png)

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

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

`shared/build.gradle.kts`の中に`put your Multiplatform dependencies here`というコメントがあるので、そこにライブラリーを追加します。
```diff_gradle:build.gradle.kts
sourceSets {
    commonMain.dependencies {
-       // put your Multiplatform dependencies here
+      implementation(libs.kotlinx.coroutines.core)
    }
}
```

<details><summary>もしGradleエラーになる場合</summary>

一旦以下の書き方にしてインポートさせた後、上記の`libs.kotlinx.coroutines.core`に置き換えてください。
```diff_gradle:build.gradle.kts
sourceSets {
    commonMain.dependencies {
-       // put your Multiplatform dependencies here
+      implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:latest.release")
    }
}
```

</details>

次に、`counter.kt`に`Counter`クラスを実装します。

```kotlin: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
    }
}
```

:::note
`MutableStateFlow`は`coroutines`のクラスです。大雑把に言うと、Swiftの[Combine](https://developer.apple.com/documentation/combine)の[CurrentValueSubject](https://developer.apple.com/documentation/combine/currentvaluesubject)、React Native（というよりJavascript）の[RxJS](https://rxjs.dev)の[BehaviorSubject](https://rxjs.dev/api/index/class/BehaviorSubject)、Flutterの[StreamController](https://api.flutter.dev/flutter/dart-async/StreamController-class.html)の値を保持する版（要は[rxdart](https://pub.dev/packages/rxdart)の[BehaviorSubject](https://pub.dev/documentation/rxdart/latest/rx/BehaviorSubject-class.html)）に相当します。
:::

<details><summary>コードの保存時に自動フォーマットする</summary>
Fleetの画面右上の六角形ボタンからSettings→Global→Editor→Codeにある「Reformat code on save」をONにすると保存時に自動整形してくれます。
</details>


# Jetpack Compose でカウンターを実装

```kotlin: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`に少しだけ手を加えます。
```diff_kotlin: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版カウンターアプリの完成です。

![6i34ejm7s9.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/679221/3696ddc2-ae26-cdbd-dd96-04d3c030917a.png)

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

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

```swift: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 とやっていることはほぼ同じです。

```swift: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()
    }
}
```

![8p48sk7c2b.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/679221/c681468d-728c-77cf-cd47-d33a834a791c.png)

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](https://github.com/Kotlin/kotlinx.coroutines/issues/2306)
`Collector`クラスを作成する際に参考に致しました。

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

```
Fleet: 1.44.151
Android Studio: 2024.1 (AI-241.18034.62.2411.12071903)
Xcode 16.2
```