今回は 2022/10 に Beta 公開を迎えノリに乗っている? Kotlin Multiplatform Mobile (KMM) についての記事を書いてみようと思います。
自分は普段は Android 開発をメインでしており、メインPCも Windows のため正直なところ Mac も iPhone もあまり好きではないのですが、社内では Flutter 案件が増えてくるなどマルチプラットフォーム開発の波が押し寄せてきました。
そんな情勢の中、KMM Beta公開後のタイミングでちょうど仕事に余裕が出来たため、iOS のお勉強もしつつKMMをまじめに調査してみることになりました。
はじめに
KMMはその名の通り Kotlin を利用したマルチプラットフォーム開発に関する技術です。
本記事ではいわゆるKMMの概要説明や開発チュートリアル的な内容は記載しません。
いわゆる概要説明などについては検索いただければ先達の記事が色々見つかると思うのでそちらを参照ください。
2022/11末の調査時点ではKotlin Multiplatform MobileのおさらいとABEMAでのマルチプラットフォーム対応という記事が一番分かりやすくまとまっていました。
本記事の内容は「KMMを自分が会社で実際に使う上で気になったこと」を中心に調べた結果がメインとなります。
ビルドの仕組み、流れについて
ある程度ビルドの仕組みを知っていないと後々変な罠にハマらないか心配なのでまずそこを調べました。
内部実装の細かい部分は置いておいて、ビルド設定として見えるレベルについて記載します。
Android は通常の開発と大きく変わるところは少ないので主に iOS についての説明になります。
ひとまず Android Studio に Kotlin Multiplatform Mobile plugin をインストールします。
このプラグインはFAQを見ると主に以下のようなことが書かれています。
- Android Studio から直接 iOS ターゲットでアプリを起動出来る。
- macOS でのみ。
- 新しいマルチプラットフォームモジュールを作成、追加出来る。
- Android 上での作業であればプラグインがなくてもどのOS上でも作業が可能。
つまりWindowsユーザーならインストールは必須ではないようですが、New Project に以下のような項目が追加されたりはするのでひとまず入れた方が良いと思います。
ここで Kotlin Multiplatform App を選択してプロジェクトを作成すると以下のような構成でプロジェクトが生成されます。
Kotlin Multiplatform Library でプロジェクトを作成した場合も、androidApp と iosApp ディレクトリがない以外はほぼ同じになります。
androidApp と iosApp の中身はKMMを利用しない通常の Android/iOS 開発時のアプリケーションモジュールディレクトリと大差ないはずです。
shared の中がライブラリモジュールとなります。
Project ビューの表示を Android に切り替えると以下のようになります。
ビルド実行時の流れを簡単に説明すると以下のようになります。
- shared の中が各OSのネイティブライブラリとしてビルドされる。
- androidApp または iosApp は shared のビルド結果であるライブラリを参照し、通常の Android/iOS アプリ同様の形でビルドされる。
Android のビルド時は androidApp/build.gradle.kts の implementation(project(":shared"))
という記載を見れば分かる通り、通常の Android 開発時と特に変わりません。
iOS のビルド時は shared は framework としてビルドされます。
iosApp ディレクトリを Xcode で開くと Run Script に以下の記述があり、これを見ると何をしているのか分かりやすいです。
Gradle の embedAndSignAppleFrameworkForXcode タスクで shared を framework としてビルドし、あとはその framework を参照した形で通常通りの iOS 開発となります。
iOS (Kotlin/Native) のビルドは内部で色々難しいことをしているわけですが、shared をビルドすると framework が出来てあとはいつも通り、と言うのは分かりやすいですね。
この辺りまで調べた段階でKMM開発による一番の懸念点は払拭できました。
ちなみに、Kotlin Multiplatform App でプロジェクトを作成した場合は iOS framework distribution として CocoaPods Dependency Manager か Regular framework しか選べないのですが、Kotlin Multiplatform Library の場合は XCFramework も選択出来ます。
shared のソースコードについて
commonMain ディレクトリに全OS共通のソースコード、androidMain や iosMain ディレクトリに Android/iOS などの各プラットフォーム固有のコードが入っています。
ソースコードは iOS 固有のコードも含めてすべて Kotlin になります。
Android/iOS 固有のAPI以外にも Kotlin Standard Library などにも各プラットフォームごとに使えないAPIがあるので注意が必要です。
例えば上記の String クラスは Common、JVM、JS、Native で利用可能です。
※Android 固有のコードでは JVM、iOS 固有のコードでは Native の記載があるAPIが使用可能です。
iOS のコードを Kotlin で記述する際、iOS の System Framework はデフォルトでインポートされて Kotlin 上から利用出来るようになります。
Kotlin の概念が Swift/Objective-C にどのようにマッピングされるかは Interoperability with Swift/Objective-C のページに書いてあります。
shared のビルドターゲットについて
Project ビューを Android で表示した際の shared の中の commonXXX, kotlin, iosXXX は shared/build.gradle.kts の以下の記述に対応しています。
kotlin {
android()
listOf(
iosX64(),
iosArm64(),
iosSimulatorArm64()
).forEach {
it.binaries.framework {
baseName = "shared"
}
}
sourceSets {
val commonMain by getting
val commonTest by getting {
dependencies {
implementation(kotlin("test"))
}
}
val androidMain by getting
val androidTest by getting
val iosX64Main by getting
val iosArm64Main by getting
val iosSimulatorArm64Main by getting
val iosMain by creating {
dependsOn(commonMain)
iosX64Main.dependsOn(this)
iosArm64Main.dependsOn(this)
iosSimulatorArm64Main.dependsOn(this)
}
val iosX64Test by getting
val iosArm64Test by getting
val iosSimulatorArm64Test by getting
val iosTest by creating {
dependsOn(commonTest)
iosX64Test.dependsOn(this)
iosArm64Test.dependsOn(this)
iosSimulatorArm64Test.dependsOn(this)
}
}
}
android()
や iosX64()
を実行すると各ビルドターゲットが生成されます。
sourceSets 内の by getting
はパッと見で何しているのか分かりにくいですが、移譲プロパティを使用してプロパティ名を元にしてビルドターゲットの情報を取得しています。
試しに iosX64()
を消して Sync してみると存在しない名称のビルドターゲット情報を取得しようとして val iosX64Main by getting
の行でエラーになります。
続いて iosX64XXX に関連する箇所を追加で消して Sync してみると iosX64Main などが Project ビューから消えます。
また、各ターゲットごとに使用する(出来る)外部ライブラリは変わるため、sourceSets の中で各ターゲットごとに dependencies の記述を書くことになります。
上記の設定は iOS framework distribution を Regular framework にした場合のもので、XCFramework にした場合は以下のようになります。
+ val xcf = XCFramework()
listOf(
iosX64(),
iosArm64(),
iosSimulatorArm64()
).forEach {
it.binaries.framework {
baseName = "shared"
+ xcf.add(this)
}
}
UI実装の共通化
マルチプラットフォーム開発を行う上で一番気になるポイントはやはり「共通化可能なソースコード割合」だと思われます。
結論として現時点ではという但し書きが付きますが Android/iOS のUIコードの共通化は現状出来ない、と思った方が良さそうです。
現状の一般的なアプリ開発案件では、この点がUIコードも共通化出来る Flutter などに劣る面かと思われます。
一応現時点でもUIを共通化するためのサードパーティーライブラリはあるようですが、個人的な感想にはなりますがまだ実際の現場で使いたいと思えるものはありません。
今後については、前述のABEMAの記事内でも触れられていますが JetBrains の Compose Multiplatform がUIコード共通化の本命になりそうだと思っています。
現時点では iOS は非対応ですが、今後の対応に向けて調査はしているようで、この記事に貼られている動画の13分過ぎでも紹介されている通り Skiko を利用した iOS のUI描画サンプルが既にあるようです。
ただ、現時点では Skiko を使うと確定しているわけでもなく、まだα版にすら至っていないためまともに使えるようになるのは2025年とかでしょうか・・・?
なので現時点ではUIコードの共通化が重要なアプリの場合は Flutter などの方が実装効率が良さそうです。
ただし、後述しますがUI共通化以外の点に関しては Flutter と比べてKMMにメリットを感じている点も多いため、現状でもアプリの内容やプロジェクト状況によってはKMMを使う価値はありそうです。
マルチプラットフォームコードの記述
common で各OS共通コードを書けるとは言っても既存ライブラリなどの資産を利用できないと再実装が必要になり逆に大変になる可能性があります。
現状利用可能なライブラリは以下のサイトにまとめられています。
- https://github.com/terrakok/kmm-awesome
- https://github.com/AAkira/Kotlin-Multiplatform-Libraries
- https://libs.kmp.icerock.dev/
コルーチンは一通り使えて、現状だと通信は Ktor、IOは Okio、DBは SQLDelight、DIは Koin という感じでしょうか。
通信に関しては inspired by Retrofit な Ktor クライアントを利用した Ktorfit なるものも開発されているようなので、この辺りを使う場合は Android 開発者は既存ライブラリからの移行もしやすそうではあります。
Kotlin Standard Library も新バージョンが出る度に徐々に拡充されてきていますし、Androidの公式ブログ記事の通り Jetpack Multiplatform Libraries も実験中で、まずは Collections と DataStore をお試し中とのことなのでこの辺りは今後に期待したいところです。
ただし、Date型などもプラットフォーム固有コードでしか利用できないため kotlinx-datetime を使う必要があったり、あれ?commonって〇〇クラス使えないの?ということは結構頻繁にあります。
試しに社内で作ったコルーチンベースのファイルダウンロード用Androidライブラリ(WorkManager未使用、長時間ダウンロードはフォアグラウンドサービスで対応)を common にコピペしてみたところ、使用出来なかった機能は以下のような部分でした。
- Dispatchers.IO
- File
- IOException
- tutorial 記載の内容を見ると RuntimeException に書き換えています。細かい例外処理を必要としなければ問題なさそうですが、JVMのAPIが throw したものを common で個別に catch したい場合は例外クラスを再定義する必要があるなど若干面倒な感じ。
- その他 iOS のエラー周りについては Errors and exceptions を参照。
- InputStream
- このファイルダウンロードライブラリをマルチプラットフォームコードに移植する上で一番面倒そうなのがここでした。汎用性をそこまで考えなければ read などの使用しているメソッドを持つインタフェースを common に作って JVM や Native 側の InputStream 的オブジェクトをラップすればいい感じでしょうか。
- Closeable
- issue
- デフォルトで use が使えないのがちょっと不便ですね。
通信処理部分ももちろんコードが真っ赤だったのですが、元々通信クライアントを差し替えられるようにインタフェースを実装していたため、その部分だけプラットフォーム固有コードに移動したり Ktor に差し替える分にはそんなに手間はかからなさそうな感じでした。
Service 部分は完全に Android 専用コードなので丸々 androidMain にお引越しする形になりそうです。
このライブラリに関してはパッと見で7~8割くらいのコードが共通化できそうな感じでした。
ファイルダウンロード処理の場合、iOS のバックグラウンド処理制限対応が別途必要だったりまだ考えることはありますが、現状でもアプリが必要とする機能次第では十分に利用価値がありそうです。
作ろうとしているアプリにKMMが向いているかは、公式サイトの単一コード化に向いている機能の記載や導入事例などが参考になります。
プラットフォーム固有コードの記述
プラットフォーム固有コードに関しては Android に関しては特に語ることがなく、iOS に関しても前述の Interoperability with Swift/Objective-C 辺りの注意点があるくらいなので、ここでは Flutter と比較してみたいと思います。
結論だけ先に書いておくと、この点に関しては KMM 側にメリットしか感じませんでした。
- KMM は All Kotlin で書けるが、Flutter は platform channels を利用して Dart、Kotlin/Java、Swift/Objective-C を個別に使用する必要がある。
- 最近は Dart 上から dart:ffi で Swift/Objective-C のAPIを直接呼べるようにもなってきているようですが Kotlin/Java のAPI呼び出しは変わらず無理ですし、import や呼び出しに関する実装コストもKMMに比べて高そう&Kotlin/Native の new memory manager のようなものもまだ整備されていないようなのでメモリやマルチスレッド周りの相互運用性の面でもデメリットが多そうです。
- Flutter の platform channels はメッセージの送受信を介した非同期処理しか対応しておらず、StandardMessageCodec が対応している型以外は使いづらいのも難点。
- Pigeon パッケージを使うと typesafe メッセージを送れる、という記載もあるがパッケージの説明文には現状 experimental という言葉が頻出している。
- KMM は common とプラットフォーム固有コード間で同期的なやり取りも可能。同期的なやり取りをしたい場合は速度面でもメリットがあるはず。
- (共通コード部分の話だが) Dart は基本的にシングルスレッドであり、バックグラウンドスレッドで処理したい場合は Isolate を使用した若干コストの高いコードを書く必要がある。
- 一応 compute というものもあるが常に使いやすい機能というわけでもない。
- KMM では現状でもコルーチンのほとんどの機能が common で利用可能であり、プラットフォーム固有コードからも suspend fun を直接利用できる。(Swift から async で呼ぶのはまだ experimental だったり使い勝手面で多少制限はあります)
以前は Kotlin/Native のメモリ管理周りで色々問題があったようですが、new memory manager がリリースされてからは面倒な事情はほぼ解消されたようです。
今後のロードマップ
記事を書き始めた時に Kotlin roadmap のページを見たら Next update November 2022 とあったのでここに載せようかと思っていたのですが、残念ながら記事公開日までに更新されませんでした。
最後に
というわけで、KMMについて Beta 時点で個人的に気になったことを調べた結果のまとめでした。
大雑把な感想としては「UI共通化まで出来るようになれば既存のマルチプラットフォーム技術より断然良さそう」という印象です。
現状だと作るアプリ次第、という感じでしょうか。
iOS 対応がいつになるか分かりませんが、Compose Multiplatform の今後に期待したいです。