KMMでサンプル作ってみた
はじめに
KMMはKotlin Multiplatform Mobileといって、KotlinでiOSとAndroidの共通なビジネスロジックを書いて時間と労力を節約しようというものです。(公式より)
最近は、Flutterのコミュニティがかなり活発で人気があるように感じますが、KMMはいい意味でも悪い意味でもiOSとAndroidのUI部分を別々に記述できます。Flutterでは自分で工夫しないとiOSでもマテリアルデザインっぽいUIになってしまってiOSユーザからは少し見慣れないUIになりがちかなと思います。私もFlutterを触っていくつか趣味程度に個人アプリを開発してきましたが、Flutter開発に使うDartに比べてKotlinのほうが個人的に書きやすくて機能が豊富だったりサポートが手厚い感じがしていいなと思いました 。最近色々な豊富な機能が追加されてきているCoroutinesを使えるのもかなり大きいと思います。
KMMではそんなKotlinを使って今まで冗長になっていたiOSとAndroidのビジネスロジックのコードを共通化することで開発に必要なリソースの削減が可能になります。
作ったものはこちらにあります。
この記事では自分がKMMを触って得た知見をざっくりまとめていこうと思います。
この記事を読んでKMM触ったことない人がざっくりKMMでアプリを作るときどんな感じなのかイメージを掴んでもらえたらいいなと思います。
内容
モジュール・パッケージ構成
Android StudioでKMMのpluginがあってそれを入れるとNew ProjectするときにKMMの雛形ができたProjectを作ることができます。今回自分もそれを使いました。
そうすると、大きく androidApp
、iosApp
、shared
の3つのモジュールが作られます。
このshared
モジュールの中にiOSとAndroidの共通コードを記述していくのですが、この中は大きくandroidMain
、commonMain
、iosMain
という3つのpackageに分けられています。
主にcommonMain
の中に共通のロジックを書いていきます。自分がつくったサンプルではcommonMain
の中のdata
パッケージの中にDataSourceやそれにアクセスするRepositoryなどのロジックを入れました。これらのコードをAndroidはもちろん、iOSでも参照して使うことでプラットフォームごとに別々に同じようなコードを書くことを防いで開発リソースを削減するイメージです。
他のandroidMain
、iosMain
は共通コードの中でもプラットフォームに依存していて別々に記述しないといけないものをKotlinで記述していきます。その際、まずcommonMain
にそれぞれのプラットホームで実装する必要があるものをInterfaceのようにexpect class
として記述しておきます。そのあとにandroidMain
、iosMain
に同じクラス名でactual class
として実装します。KMMでNew Projectした段階ではPlatform.kt
がそれにあたって、デバイスの名前やOSの名前を取得しています。
ここで気をつけないといけないのが、それぞれのプラットフォームごとの実装クラスを書くときのクラスファイルを配置するpackage階層を`commonMain`と合わせないといけないことです。
たとえば、commonMain/hoge
の直下にPlatform.kt
をexpect class
として作った場合、androidMain
とiosMain
においてもandridMain/hoge/Platform.kt
、iosMain/hoge/Platform.kt
となるようにactual class
のファイルを配置しないとエラーが起こり続けます。
これを使う場面としては、後述するローカルDBに接続するために必要なDriverの作成を各プラットフォームごとに実装するときなどに使います。具体的にはDatabaseDriverFactory.kt
に当たります。
ちなみにこのcommonMain
モジュールのbuild.gradle.kts
を見てみると
import org.jetbrains.kotlin.gradle.plugin.mpp.KotlinNativeTarget import dependencies.Dep plugins { kotlin("multiplatform") kotlin("plugin.serialization") id("com.android.library") id("com.squareup.sqldelight") } kotlin { android() ios { binaries { framework { baseName = "shared" } } } sourceSets { val commonMain by getting { dependencies { implementation(Dep.Kotlin.coroutines) implementation(Dep.Kotlin.serialization) implementation(Dep.Ktor.client) implementation(Dep.Ktor.serialization) implementation(Dep.SqlDelight.runtime) implementation(Dep.SqlDelight.coroutinesExtensions) implementation(Dep.Kodein.kodeinDi) } } val commonTest by getting { dependencies { implementation(kotlin("test-common")) implementation(kotlin("test-annotations-common")) } } val androidMain by getting { dependencies { implementation(Dep.AndroidX.design) implementation(Dep.Ktor.clientAndroid) implementation(Dep.SqlDelight.androidDriver) } } val iosMain by getting { dependencies { implementation(Dep.Ktor.clientIos) implementation(Dep.SqlDelight.nativeDriver) } }
こんな感じになっていて、commonMain
、androidMain
、iosMain
ごとに依存関係を書く感じになってます。
どこまで共通化させられるのか
私のサンプルではMVVMな感じで作ったのですが、ViewとViewMode以外を共通化させてます。頑張ったらViewModelまで共通化させられるのか私のレベルではわかりませんが、ViewModelで参照しているUseCaseまで共通化させています。ViewModelでは宣言したメンバー変数たちをViewでObserveする必要があってそれぞれのプラットフォームでObserveできるオブジェクトにする必要があるため、それぞれのプラットフォームごとにViewModel以降作ったほうがいいかなと思います。ライフサイクルの兼ね合いもありますね。
iOSでもKotlin Coroutines Flowが使える
Flowはマルチプラットフォームに対応するようにも作られているようで、マルチプラットフォーム用のCoroutinesのバージョンはKotlin1.4.20現在で1.4.1-native-mtが最新のようです。 Concurrency and coroutines—Kotlin Multiplatform Mobile Docs
そのままでもiOSでFlowで流れてきた値を受け取れるようですが、使いにくいようなので私のつくったサンプルではCFlowというFlowのラッパーを作って使いました。
これはM3 Tech BlogのKotlin Multiplatform Mobileを使ってBrainf*ckエディタアプリを作るで紹介されていて自分も真似しました。そちらのブログでも紹介されているのですが、JetBrainsが開発したkotlinconf-appのFlowUtils.ktでも使われているやり方みたいです。
Androidでは普通にCoroutineScopeの中でcollect
を使って受け取ってiOSでは下のように受け取ることができます。watchは先程のFlowのラッパーで実装したメソッドです。completionHandler
でエラーハンドリングもできるようです。
searchActressUseCase.searchActress(searchedKeyWord: searchedKeyWord, completionHandler: { response, error in response?.watch { [weak self] response in if let actresses = response?.result.actress { self?.actresses = actresses } } })
DataSource - Remote
サーバのAPIを叩くとき、素のAndroidアプリを開発するときはRetrofitとかを脳死で使うと思いますが、KMMではKtorを使うのが今の所のデファクトスタンダードみたいです。
こんな感じでクライアントを作って使います。
private val dmmApiClient = HttpClient { install(JsonFeature) { val json = Json { ignoreUnknownKeys = true isLenient = true } serializer = KotlinxSerializer(json) } }
実際にGETする処理はこんな感じです。
dmmApiClient.get { url(Constants.DMM_ENDPOINT) parameter("api_id", Constants.API_ID) parameter("affiliate_id", Constants.AFFILIATE_ID) parameter("keyword", searchedKeyWord) }
このコードでは、https://api.dmm.com/affiliate/v3/ActressSearch?api_id=API_ID&affiliate_id=AFFILIATE_ID&keyword=searchedKeyWord
というようなエンドポイントを叩いています。
あまり調べてもクエリパラメータを混ぜる方法がわからなかったので残しておこうと思います。
ちなみにそれっぽいこういう公式のドキュメントが出てきましたが僕には解読できませんでした。
DataSource - Local
Androidアプリ開発でいうとRoomに当たる部分です。サンプルアプリでは現在のデファクトスタンダードそうなSQLDelightというライブラリを使っています。 cashapp.github.io
ここで、DBにアクセスするためのSqlDriverを生成するコードが各プラットフォームに依存しているため、commonMain
にexpect class
を書いてandroidMain
とiosMain
でactual class
として実装します。
これはあまり難しいことはないです。公式のドキュメントも充実してます。
Flowにも対応しています。
DI
KMMのDIではマルチプラットフォーム対応しているKodein-DIというライブラリがいいみたいです。 kodein.org
DIというとAndroidアプリエンジニアだとDaggerとかを想像して超難しいイメージがあるかもしれません。自分がそうです。しかしこのライブラリのDIはそれにくらべてとても簡単でわかりやすかったです。私自身あまり使ったことがないのですがKoinを使ったDIみたいな雰囲気を感じました。 これについても M3 Tech Blogの方でわかりやすく解説されています。
おわり
iOSアプリ開発者目線でKMMがどのような受け取られ方をしているかあまりわからないのですが、KMMを使った開発がプロダクト開発でも取り入れられて開発リソースの削減から余ったリソースで今までできなかったことに手を出せるような世界になったらいいなと思ってます。
KMMが今後開発者界隈でどのように受け取られてどうなっていくか楽しみです。