Kotlin Multiplatform Mobile(KMM)によるロジックの共通化と、Reduxベースによるマルチプラットフォームアーキテクチャ

こんにちは。ferciを担当しているマネックス ラボの佐藤です。今回は、一年後を見据えてiOSとAndroidのアーキテクチャを考えるとするならば、どうするのが良いのかについて書きたいと思います。 なお、モバイルアプリのアーキテクチャは規模や要件次第であるため唯一の正解はないことと、本記事執筆時点ではStableではなくベータ版であるKotlin Multiplatform Mobile(KMM)についても言及していますので、あくまでご参考にしていただければと思います。

ferciのプロジェクトで起きている問題

ferciは2019年2月に開発をスタートしていることから、当時主流だったアーキテクチャで実装されています。非同期処理にはRxを使い、MVVMを意識した構造になっています。 UIにこだわりたいことと、ライブラリのアップデートに巻き込まれることを避けるために、ReactNativeやFlutterは使わず、iOS版はSwift、Android版はKotlinで実装されています。

しかしその一方で、機能が増えるごとに悩みが出てきました。

  • ロジックの実装を一回で済ませたい
    iOSとAndroidで2回実装するのは2倍近い工数がかかりますし、それぞれに微妙な違いが生じて、メンテナンスが難しくなってきました。 コードそのものだけでなく、開発時に使うスタブデータもそれぞれのリポジトリに入れている都合、差分が生じてきます。スタブデータを業務的に正しい状態で維持し続けるのはとても難しいことで、特定のプロパティの表示を確認するだけのために、スタブデータ全体の整合性が取れていない状態で部分的に変更してしまうこともあり、もはや正しいデータであるということが難しくなっています。

  • MVVMにこだわりたくない
    MVVMはもはやフロントエンドを作っている人であれば誰でも知っているくらい定着しているアーキテクチャだと思います。 その一方で、シンプルな画面ではMVVMだとViewModel層が冗長になり、複雑な画面ではViewModel層だけでは表現できない場合も出てきます。

一方で、大切にしたいこともあります。

  • UIの実装は分けたい
    ferciはあくまでもUIにこだわったプロダクトとしているため、iOSとAndroidそれぞれの固有のUIは、できるだけそれぞれに最適化したデザインで表示したいと考えています。 そのため、UIの実装は共通にしたくない、という事情があります。

  • ネイティブコードを触る機会は残したい
    言語としてはSwiftとKotlin、IDEではXcodeとAndroidStudioとなりますが、ネイティブコードの領域を触る機会は残しておきたいと考えています。 マルチプラットフォーム開発の場合でも問題が起きた時には結局のところネイティブコードを見にいくことになりますし、何かの改善(アプリの機能的にも、開発プロセス的にも)を行う場合には、ネイティブの知見は大切だと思うからです。

まとめると、下記の前提でアーキテクチャを考えたいと思います。

  • ロジックをiOSとAndroidで共通にしたい。
  • MVVMよりも柔軟に機能を分割したい。
  • UIの実装は、プラットフォームごとに行いたい。
  • 可能ならばネイティブの領域を残したい。

これらを加味すると、Kotlin Multiplatform Mobile(KMM)を使ってロジックだけを共通にすることが、1年後、2年後以降にモバイルアプリを開発する場合の有力な候補となるのではと考えました。 また、アーキテクチャはロジックの範囲はReduxベースとすることが良いのではと考えています。

Kotlin Multiplatform Mobile(KMM)によるロジックの共通化

なぜ今KMMなのか

Kotlin Multiplatform Mobile(KMM)は2022年10月にベータ版に昇格しました。また、2023年末までにはStableに昇格させるスケジュールとなっています。(https://youtrack.jetbrains.com/issue/KT-55513/Promote-Kotlin-Multiplatform-Mobile-to-Stable) スケジュールが遅れることは大いにあり得ますし(ベータ版も当初の予定より半年遅れている)、いずれにしても、1年後くらいにはKMMをプロダクション向けのアプリで使うことができるようになります。 今から1年後というとだいぶ先ですし、今すぐにリリースしたい機能をベータ版であるKMMで実装することはできないためまだ時期は早い気がしますが、触りつつ、KMMをどう使うのかという指針を探っていくとすれば、今がちょうど良い時期なのではと思います。

KMMについて簡単に

KMMは、Kotlinで書かれたコードを、AndroidとiOS両方で実行できるように設計されたSDKです。iOS向けには、KotlinのコードがObjective-Cに変換されます。 プラットフォームに依存する機能についてはそれぞれ専用のコードを記述することで、実装することができます。

AndroidStudioでKotlin Multiplatform Libraryを試す

AndroidStudioのセットアップとビルド環境のセットアップ

AndroidStudioでプラグインを入れることで、簡単にKotlin Multiplatformのプロジェクトを作成することができます。 ドキュメントだけでは読み取れない部分を試すことと、既存のアプリ、とりわけXcodeのプロジェクトに対して部分的に導入できるかどうかを検証してみました。 検証は、ferciのWebAPIに対してHTTPリクエストを行い、レスポンスを受け取り(ここまでをKMMで書く)、その結果をiOS側でログ出力する(ここはXcode側でSwiftで書く)、という流れを最低限の実装で行いました。

WebAPIへのリクエストについては、下記のようなレスポンスを返すWebAPIに対してリクエストを投げ、返ってきたJSONをデシリアライズしてエンティティオブジェクトに変換してみます。銘柄コードをパスにセットしてリクエストすると、銘柄名と株価が返ってくる想定です。authKeyヘッダはカスタムヘッダ追加の方法を試す目的であり、実在するものではありません。

$ curl -X GET "https://<ferciの銘柄情報を返すエンドポイント>/7203" -H 'authKey:012345'
{
    "name": "トヨタ",
    "value": 1890.9
}

参考にしたのは公式のチュートリアル(https://kotlinlang.org/docs/multiplatform-mobile-ktor-sqldelight.html#create-a-multiplatform-project)です。 エンティティの定義からWebAPIへのHTTPリクエスト、SwiftUIでの表示までを紹介しているので、こちらを参考に始めるのが良いと思います。 一方でこのチュートリアルが紹介しているのがKotlin Multiplatform Appであって、すでにXcodeでiOSを開発している場合に部分的にロジックをKotlin Multiplatformで書くことには適していないため、Kotlin Multiplatform Libraryプロジェクトを作成して検証を行いました。AndroidStudio上でKotlin Multiplatform Libraryプロジェクトを作成してビルドし、出力されたXCFrameworkを既存のXcodeプロジェクトにリンクして、iOS側で使う、という流れです。

AndroidStudioへのプラグインの追加やbuild.gradle.ktsのセットアップは上記のチュートリアルを参考に行いましたが、下記の変更を加えて検証を行いました。

  • プロジェクトはKotlin Multiplatform Libraryで作成する。
  • プロジェクトのbuild.gradle.ktsにDependenciesを追加する。
    下記を参考にセットアップしました。ただし、SQLDelightは使わなかったので、省略しました。
    https://kotlinlang.org/docs/multiplatform-mobile-ktor-sqldelight.html#add-dependencies-to-the-multiplatform-library
    また、ビルドエラーを解消するために、ktorのバージョンを2.2.3にする必要がありました。
  • ルートのbuild.gradle.ktsを修正する。
    そのままだと./gradlew buildでエラーが出るため、下記のバージョンの修正が必要でした。
    • kotlin("multiplatform")を、1.8.10にする。
  • multiplatform-swiftpackage(https://github.com/ge-org/multiplatform-swiftpackage)を追加する。
    ビルドしたXCFrameworkをSPMとして扱いたかったので、下記を参考に、settings.gradle.ktsに設定を入れました。
    https://github.com/ge-org/multiplatform-swiftpackage#configuration
  • Kotlin Multiplatform Library側のビルドは、./gradlew createSwiftPackageで行う。
  • Xcodeプロジェクトで、ビルドされたパッケージをリンクする。

以上で、KMMでロジックを書き、ビルドしてSPMパッケージにし、Swiftから呼び出す準備ができました。

エンティティを定義する

KMM側のcommonMain/kotlin配下にファイルを作り、下記のエンティティ定義を作成します。WebAPIからの銘柄情報のレスポンスを模しています。 現在値がDouble型なのは、後述するDecimal型を使えないことに由来します。

@Serializable
data class KabuEntity (
    val name: String?,  // 銘柄名
    val value: Double?,  // 現在値
)
HTTPリクエストを実装する

同じくcommonMain/kotlin配下にファイルを作り、下記のようなHTTPリクエストを行うクライアントクラスを作成します。 importは検証なので*を許容しています。

import io.ktor.client.*
import io.ktor.client.call.*
import io.ktor.client.plugins.contentnegotiation.*
import io.ktor.client.request.*
import io.ktor.serialization.kotlinx.json.*
import kotlinx.serialization.json.Json

class KabuApi {
    private val httpClient = HttpClient {
        install(ContentNegotiation) {
            json(Json {
                ignoreUnknownKeys = true
                useAlternativeNames = false
            })
        }
    }

    suspend fun getKabu(id: String, authKey: String): KabuEntity {
        return httpClient.get("https://<ferciの銘柄情報を返すエンドポイント>/$id"){
            header("authKey", authKey)
        }.body()
    }
}
ビルドする

multiplatform-swiftpackageをセットアップしているので、./gradlew createSwiftPackageコマンドでSPMパッケージの作成までを行うことができます。

Xcodeプロジェクトから使ってみる

Xcodeに移動して、検証用の.swiftファイルを作成し、下記を記述します。

import shared

class KKMTestClass {
    private let kabuApi = KabuApi()

    func requestKabu() {
        kabuApi.getKabu(id: "7203", completionHandler: { kabu, error in
            if let kabu = kabu, let name = kabu.name, let value = kabu.value {
                log?.debug("\(name): \(value)")
            }
            else {
                log?.error(error?.localizedDescription ?? "error")
            }
        })
    }
}

requestKabu()を実行すると、銘柄名と株価がログ出力されます。

import sharedの部分は、KMMのSharedModuleNameで、デフォルトだとsharedとなっているものです。(厳密にはSPMを作るときに指定するパッケージ名です。) このimportを書くだけで、KMMで実装した機能にアクセスできるようになります。

Kotlin Coroutineのsuspend関数は、KMMのコンパイル時にObjective-Cコードが生成される際、completionHandlerで置き換えられます。ただしこのまま使うとお馴染みのネスト地獄に陥るので、async/awaitに置き換えると良いと思います。

ビルド時間について

KMMで上記の簡単な検証コードをビルドし、SPMパッケージを出力するだけでも、2,3分待たされました。また、SPMパッケージが更新された後にXcodeがResolving Package Graph...で固まる時間を考えると、ロジックを修正してUIに反映させるまでに5分くらいは要することになります。チームの体制として、UIを作る人もロジックをどんどん変更するような場合には、ストレスになる可能性があります。一方で、KMMの世界でロジックを実装する人と、XcodeやAndroidStudioでロジックのライブラリを使ってUIの実装を行う人を綺麗に分けられる場合は、許容できるかもしれません。

Decimal型について

Kotlin Multiplatformでは、BigDecimal(java.math.BigDecimal)を使うことができません。Javaのクラスなので、当たり前なのですが。 しかし金融のアプリケーションを作る場合にはWebAPIからのレスポンスに含まれる値をDecimalでデシリアライズすることが必要で、代替手段を考えなければなりません。 結論から書くと、WebAPIからのレスポンスを文字列型で返すようにすれば対応できるが、値型だと難しい、ということになります。

Kotlin Multiplatformで扱えない型や機能については、プラットフォーム依存のコードをandroidMainやiosMainという名前で分けられたフォルダに固有のコードを実装していくことになります。 その際、expect/actualを使って記述することになります。 これはcommonMainでexpectを使い"期待される"インターフェースを記述し、プラットフォーム固有の実装側でactualを使うことで、"実際の実装"を示すことになります。

Decimal型を扱うことを目標に、サンプルを示します。jsonに含まれる値をiOSの場合はNSDecimalNumberで、Androidの場合はBigDecimalでデシリアライズすることを考えます。

commonMain側の定義は下記のようになります。FerciDecimalという型があり、そのシリアライザとしてDecimalSerializerを定義しています。expectなので、具体的な実装はありません。

expect class FerciDecimal

expect object DecimalSerializer : KSerializer<FerciDecimal> {
    override fun deserialize(decoder: Decoder): FerciDecimal
    override fun serialize(
        encoder: Encoder,
        value: FerciDecimal
    )
    override val descriptor: SerialDescriptor
}

iosMain側の実装例は下記のようになります。

actual data class FerciDecimal(val number: NSNumber)

actual object DecimalSerializer : KSerializer<FerciDecimal> {
    actual override fun deserialize(decoder: Decoder): FerciDecimal {
        return FerciDecimal(NSDecimalNumber(decoder.decodeDouble()))
    }

    actual override fun serialize(
        encoder: Encoder,
        value: FerciDecimal
    ) {
        encoder.encodeString(value.number.toString())
    }

    actual override val descriptor: SerialDescriptor
        get() = PrimitiveSerialDescriptor("FerciDecimal", PrimitiveKind.DOUBLE)

}

このFerciDecimalを使ったエンティティ定義は下記のように書き直すことができます。

@Serializable
data class KabuEntity (
    val name: String?,  // 銘柄名
    @Serializable(with = DecimalSerializer::class)
    val value: FerciDecimal?,  // 現在値
)

ひとまずこれで動きはするのですが、問題があります。DecimalSerializer.descriptorで記述している、PrimitiveKind.DOUBLEです。 これはjsonのフィールドに合わせる必要があり、PrimitiveKindの定義を見ると、Decimalはありません。もっとも、プラットフォーム依存の型なので、あるわけはないのですが。 もしサーバーサイドからのレスポンスが文字列なのであればこれをPrimitiveKind.STRINGにすることができるため、誤差なくNSDecimalNumberのインスタンスを作成することができます。 しかし、レスポンスが値型で返ってくるのであれば、今のところは誤差なくDecimalにデシリアライズすることはできません。

KMMを検証してみて

AndroidStudioを使って簡単に始められること、ライブラリにすることで既存のXcodeプロジェクトやAndroidStudioのプロジェクトに追加することもできるので、少しずつ導入することもやりやすいと感じました。 型についてはDecimalの他にもDate型がプラットフォーム依存であるため、ひと工夫必要であると思います。ビルド時間の長さとパッケージの配布の仕方は一工夫する余地があると思います。

Reduxベースのアーキテクチャ

なぜReduxなのか

Reduxの良い点として、データ(State)、データの保存場所(Store)、データを処理するモジュール(Reducer)、データを変更するきっかけ(Action)が分離されていることが挙げられると思います。

MVVMだと、例えば複数の画面に反映させたいデータが出てきた時、EventBusにするのか、シングルトンでマネージャークラスのような実装をするのかなど、その都度データの保存場所を考える必要が出てきます。 また、複数のViewModelでほぼ同じ処理をする場合には、どうモジュール化するのかを考える必要があります。 そういった点において、Reduxは必要な分離が行われつつもモジュールを組み合わせる機能も提供されていて、柔軟に使えるアーキテクチャであるように思えます。

ただし、その一方でReduxは学習コストが高いと言われており、また、モバイルでReduxを使って実装しているような事例をあまりみないようにも思います。

redux-kotlin

Kotlin、とりわけKotlin Multiplatformを意識したReduxの実装がいくつかみられますが、一番メンテナンスされていそうなのがredux-kotlin(https://github.com/reduxkotlin/redux-kotlin)です。 この記事を書いている時点では深い検証までできていないのですが、少なくとも、AndroidStudioでKolitn Multiplatform Libraryプロジェクトを作り、build.gradle.ktsファイルのdependenciesに追加してビルドした限りでは、エラーもなくビルドすることはできました。(KMMの場合、KMM非サポートのライブラリを使っている場合にはビルドできないので、まず"ビルドできるか"を試すようにしています。)

一番使われているであろうreduxjsのリポジトリに比べるとForkやStarの桁がだいぶ違いますが、これから発展していくことに期待したいです。

The Composable Architecture(TCA)

iOS(Swift)で今時のアーキテクチャをどうすべきか?についてWeb上で調べていくと、一番盛り上がりを感じさせるのがTCA(https://github.com/pointfreeco/swift-composable-architecture)だと思います。 README.mdにも書かれていますが、TCAはReduxから影響を受けたアーキテクチャです。Swift言語の表現能力を活かして作られたフレームワークになっていて、SwiftでReduxのようなアーキテクチャで実装をするにはとてもわかりやすく書くことができます。 とりわけEffect型のアイディアが秀逸で、WebAPIへのリクエストのような非同期処理をEffect型で表現していて、そのレスポンスもActionになっている、つまりレスポンスが返ってきた時点でまたReducerでActionが実行され、Stateが変更される点がとてもわかりやすいです。 そしてこのAction型はEnumで定義することができ、レスポンスをassociated valueで定義してActionの値と一緒に取り扱うことができる点も直感的です。 できれば素のReduxではなく、TCAを使いたいところです。

Kotlin版TCA

Swift版TCAに触発されてKotlinでTCAを実装しようというGitHubリポジトリもいくつか見つかります。

まずKotlin Multiplatform Libraryプロジェクトに追加できるか、という点については、kotlin-composable-architectureの方はgradleのバージョンが合わず、うまくいきませんでした。 また、どちらのプロジェクトもKotlinでの実装であって、Kotlin Multiplatformについては言及されていません。仮にgradleのバージョンなどをうまく調整しても、Kotlin Multiplatformで使えないライブラリを使っていると、導入できないということになります。 どちらのリポジトリのREADME.mdでも言及されていることですが、Kotlinの言語機能では表現できない範囲があり(Swiftでのenum、値型、KeyPathsなど)、妥協している点もそれなりにあります。

結局何がいいのか

理想を言えばSwift版TCAがモバイル開発においては(Swifterにとっては)一番綺麗な答えだと思うのですが、ロジック部分をKotlin Multiplatformで共通にしたいので、採用できない、ということになります。 また、Kotlin版の実装ではSwift版に比べて妥協点が多く、かつメンテナンスされているリポジトリがないので、あまり現実的ではないと考えます。 そうすると、redux-kotlinが現実的な選択肢となってきます。TCAに比べて素のReduxに近い実装ですので、今後TCAのようにReduxを改良したライブラリが出てきた場合に、移植することもやりやすいのではと思います。

最後に

以上、1年後のモバイルアプリ開発を見据えた言語の選定とアーキテクチャについて書いてみました。まだまだ検証し切れていないことは多々ある一方で、Flutter以外にもマルチプラットフォーム開発の方法がプロダクションレベルでも可能になってきているように感じています。

また、技術的な検証だけでなく、チームの作り方も工夫が必要であるように思います。これまではサーバーサイドの人がWebAPIを作り、iOSエンジニアとAndroidエンジニアがクライアントアプリを作るという住み分けでしたが、ロジックを誰が作るのか?という問題も起きてきます。おそらく、サーバーサイドの担当者は業務ドメインに詳しいわけであり、そういった意味ではクライアントのロジックもまとめて書いてしまった方が早いように思います。しかしそうするとサーバーサイドの担当者の負荷が増えてしまいますし、それなりにクライアントの事情についても知る必要があります。

考えなければならないことは多々ありますが、ユーザー体験と開発者体験を高めるためにも、引き続き検証していきたいと思います。

佐藤 俊介マネックス・ラボ