【KMP入門】カウンターアプリを作ってみる
今回は Kotlin Multiplatform (KMP) で簡単なカウンターアプリを作成します。
KMPの共通コードで値を管理し、値が変化したらアプリの画面に反映させます。
まずはサンプルコードを削除
まずは前回の記事で作成したサンプルプロジェクトをすっきりさせます。
この記事から読んだ方向けのセットアップ
プロジェクトの作成
KMPで新規プロジェクトを作るには Kotlin Multiplatform Wizard を使用します。
今回はiOSの「Do not share UI (use only SwiftUI)」を選択します。他の設定は初期状態のままで構いません。
「Share UI (with Compose Multiplatform UI framework)」の方はCMPと呼ばれるもので、簡潔に言うとKotlin版Flutterなのですが、こちらはまた別の記事でご説明します。
Fleetのダウンロード
次に、KMP開発元の公式エディターであるFleetをダウンロードします。
ダウンロードが完了したらFleetを起動し、「Open...」から先ほど作成したプロジェクトを開きます。
以下の画面になったら準備完了です。
以下のファイルとディレクトリーを削除します。
- composeApp/src/commonMain をディレクトリーごと
- shared/src/androidMainをディレクトリーごと
- shared/src/iosMainをディレクトリーごと
- shared/src/commonMain/kotlin/org/example/prject/Greeting.kt
- shared/src/commonMain/kotlin/org/example/prject/Platform.kt
次に、App.kt
とCounterView.swift
をすっきりさせます。
package org.example.project
import androidx.compose.material.*
import androidx.compose.runtime.*
@Composable
fun App() {
MaterialTheme {
}
}
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... で新規ファイルが作成できます。
これで真っ白なプロジェクトが作成完了です。アプリを実行してみると真っ白な画面だけが表示されます。
共通コードを作成
KMPは初期状態だと共通コード側からAndroid、iOSに値の変化を伝える手段がないので、外部ライブラリー(と言ってもKotlin公式のですが)を追加します。
shared/build.gradle.kts
の中にput your Multiplatform dependencies here
というコメントがあるので、そこにライブラリーを追加します。
sourceSets {
commonMain.dependencies {
- // put your Multiplatform dependencies here
+ implementation(libs.kotlinx.coroutines.core)
}
}
もしGradleエラーになる場合
一旦以下の書き方にしてインポートさせた後、上記のlibs.kotlinx.coroutines.core
に置き換えてください。
sourceSets {
commonMain.dependencies {
- // put your Multiplatform dependencies here
+ implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:latest.release")
}
}
次に、counter.kt
にCounter
クラスを実装します。
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 でカウンターを実装
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
に少しだけ手を加えます。
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
- App()
+ App(Counter())
}
}
}
@Preview
@Composable
fun AppAndroidPreview() {
- App()
+ App(Counter())
}
これでAndroid版カウンターアプリの完成です。
SwiftUI でカウンターを実装
言語の違いのせいかSwiftにはKotlinのようにcollectAsState()
が無いため、代わりにcollect
を使用するのですが、引数がKotlinx_coroutines_coreFlowCollector
というクラスになっています。
そのため、まずはこれを継承したCollector
というクラスを定義します。
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 とやっていることはほぼ同じです。
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()
}
}
Jetpack Compose と違い、SwiftUIは一行で書けるお手軽な書き方がないので少し面倒ですが、今回作ったCollector
を使えば簡単なアプリなら十分かなと思います。
extension
を使う等でもう少し短く出来そうなので、良い感じの書き方が分かればまた記事にしたいと思います。
おわりに
今回のように小規模なアプリだと利点が分かりづらいと思いますが、コード量が増えたり、通信処理が必要になったりすると恩恵が大きくなっていきます。
ちなみに今回は共通コード内の値の更新をネイティブ側に反映させるところまでやりましたが、実際はここまでする必要はなく、計算処理(Business Logic)やクラス等の定義(Domain)の共通化だけでも十分便利かなと思います。
余談ですがmixi2にKMPのコミュニティーを作りました。
私も絶賛勉強中なので、しばらくは記事やKMP関係のイベント情報を投稿するだけになりそうですが、もし良かったら是非(^_^;)
参考文献
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
Discussion