こんにちは。STORES でiOSエンジニアをしている榎本 (@enomotok_ )です。
STORES では、 KMP/CMP を用いて Android, iOS のマルチプラットフォームアプリ開発を行なっています。 *1
本記事では、Kotlin Multiplatform(KMP)と Compose Multiplatform (CMP) を用いたアプリ開発において、iOS と Android 間で共通化が難しい処理をどのように設計・実装しているか、私たちのチームの事例を紹介します。
Kotlin の共通実装だけではカバーできない領域をどのように解決するか
KMP/CMP を活用することで、Kotlin で記述した共通コードを iOS と Android の両プラットフォームで利用できる点は、大きな利点の一つです。ただし実際の開発現場では、すべての処理を共通コードで完結させるのが難しいケースがほとんどです。たとえば、デバイスのネイティブ API を直接操作したい場合や、OS 毎に作成されたサードパーティ製のライブラリを活用したい場合などが該当します。
こうしたプラットフォーム固有の要件に対応するために、私たちのチームでは主に二つのアプローチを検討しました。一つは expect
/ actual
を用いて Kotlin でプラットフォームごとの実装を用意する方法、もう一つは expect / actual
の代わりに、 iOS 専用の処理を Swift で実装するという方法です。
expect/actual のデメリット
KMP/CMP では、プラットフォーム固有の処理を Kotlin コードで記述するための仕組みとして、expect
/ actual
というキーワードが用意されています。これは、共通モジュール(commonMain
)でインターフェース(expect
)を宣言し、各プラットフォームのモジュール(iosMain
や androidMain
など)でその実装(actual
)を与える、という構造です。
これは KMP における標準的な拡張の仕組みであり、公式で推奨されている手法です。しかし、実際の開発においては、特に iOS 側の実装をする場合において、いくつかの課題が存在します。 ひとつ目の課題は、iOS の標準ライブラリである UIKit や Foundation を Kotlin で操作しなければならない点です。Kotlin/Native 経由でこれらの API を扱う場合、型や記法が Swift と異なり、iOS エンジニアにとっては直感的ではないコードを書くことになります。
もうひとつの課題は、Swift Package Manager(SPM)で提供されているライブラリを利用したい場合です。2025年6月現在、KMP のコードから SPM 経由で導入した Swift で書かれたライブラリを直接参照することはできません。 cinterop を用いるワークアラウンドがあるようですが、筆者の調べた限りでは簡単に実現する方法は見つかりませんでした。 Swift の構文や型システムの多くは Objective-C にブリッジできないため、Kotlin から扱うには独自のラッパーや @objc
属性の付与など、大幅な手間が必要になります。このような理由から、私たちは SPM を用いた Swift 製ライブラリを Kotlin 側から直接利用する構成は、導入・保守のコストが高く、現実的ではないと判断しました。
Swift を積極的に活用する
私たちのチームは、iOS と Android でそれぞれ2名ずつのエンジニアが在籍しています。 iOS に精通したメンバーがいるため、プラットフォーム固有の処理については、無理に Kotlin で完結させようとせず、必要に応じて Swift を使って実装する方針を採用しました。
このアプローチには、いくつかのメリットがあります。まず、Swift で書かれたライブラリをそのまま活用できるという点です。 expect/actual 経由で SPM から導入した Swift ライブラリを扱おうとすると、Kotlin/Native の cinterop を用いる必要があり、煩雑な設定が求められる可能性があることは前述のとおりです。これに比べ、Swift 側で直接ライブラリを利用すれば、よりシンプルかつ堅実に機能を取り込むことができます。 また、CMP に対応したサードパーティ製ライブラリの選択肢は現時点ではまだ限られており、機能や品質面で採用に慎重にならざるを得ないケースもあります。その点、Swift 実装であれば成熟したエコシステムを活用できるため、ライブラリ選定にかかるコストを抑えることができます。
そしてなにより、プラットフォームごとの責務を明確に切り分けることで、チーム内での分業がしやすくなります。Kotlin の共通実装と Swift のネイティブ実装とで明確な境界を引くことで、互いの専門性を活かしながら、見通しの良いコードベースを維持しやすくなります。
KotlinとSwiftの連携:DIを前提に設計する
Swift で iOS 側の処理を実装するにあたり、そのインスタンスを Kotlin 側の共通コードに橋渡しするために、依存性注入(DI)の仕組みを活用しました。
使用したのは Koin です。もともと Android での利用が中心ですが、私たちは iOS でも Koin を用いて依存性を注入できる仕組みを導入しました。
1. Kotlin側にinterfaceを定義する
commonMain 配下に interface
を定義します
CrashlyticsCore.kt
interface CrashlyticsCore { fun log (message: String ) // ... }
2. Swiftでそのinterfaceを実装する
iosApp 配下で Swift で interface
を実装します
CrashlyticsCoreImpl.swift
import Foundation import sharedUi import FirebaseCore import FirebaseCrashlytics final class CrashlyticsCoreImpl: CoreCrashlyticsCore { private var crashlytics = Crashlytics.crashlytics ( ) func log (message: String ) { crashlytics.log (message ) } // ... }
3. Swift側のエントリーポイントでKoinに登録
iOSApp.swift
import SwiftUI import sharedUi import FirebaseCore @main struct iOSApp: SwiftUI.App { init() { FirebaseApp.configure() InitKoinKt.doInitKoin(config: nil, iosNativeModule: iosNativeModule) sharedUi.App.companion.initialize( crashlyticsCore: CrashlyticsCoreImpl(), remoteConfigCore: RemoteConfigCoreImpl() ) } var body: some Scene { WindowGroup { ContentView() } } } var iosNativeModule: Koin_coreModule = MakeNativeModuleKt.makeNativeModule( crashlyticsCore: { _ in return CrashlyticsCoreImpl() }, remoteConfigCore: { _ in return RemoteConfigCoreImpl() } )
InitKoin.kt
fun initKoin( config: KoinAppDeclaration? = null, iosNativeModule: Module? = null, ) { val modules = mutableListOf( // ... Kotlinで実装されたKoinモジュール ) iosNativeModule?.let { modules.add(it) } KoinDispatcher.start( modules = modules, config = config, ) }
Swift実装を選んでよかったと感じた点
実際にプロジェクトで Swift 実装を取り入れてみて、その選択が正しかったと感じる場面は多々ありました。特に大きかったのは、Swift 製ライブラリをそのまま利用できる点です。SPMとの連携が非常にスムーズで、ライブラリの導入やアップデートが容易になりました。
また、実装の責務を明確に分離できるようになったことで、コードベース全体の見通しが良くなりました。Kotlin による共通処理と Swift による iOS 固有の処理とが明確に切り分けられているため、チーム内での分業がやりやすくなりました。
さらに、依存性注入(DI)を前提とした設計を導入したことにより、Swift 側の実装と Kotlin 側の共通コードとの接点が意図的に設計されており、責務の境界が分かりやすく保たれています。
まとめ
Kotlin で全てを完結させることは一見理想的に思えるかもしれませんが、実際のアプリ開発ではそれが常に最適解であるとは限りません。特に私たちのように、iOS に精通したメンバーがチームにいる場合は、Swift を前提とした設計のほうが自然で柔軟性の高い選択になることもあります。
KMP/CMP を採用するうえでは、どこまでを共通化し、どこからをプラットフォームごとに切り分けるか。その線引きをチーム構成や専門性、プロダクトの特性に応じて適切に判断することが、健全なアーキテクチャを築くうえで非常に重要だと考えています。
KMP/CMP における、より良い実装の知見をお持ちの方がいたら、是非ともこの記事へのフィードバックをお願いします。最後までお読みいただきありがとうございます。
*1:プロダクトについては過去の記事で詳しく紹介しています: https://product.st.inc/entry/2024/12/26/100000
*2:このサンプルコードで protocol にプレフィックス Core がついているのは、 core モジュール配下の commonMain に CrashlyticsCore を定義しているからです