株式会社 ONE COMPATH でアプリ開発を担当しているハラです。
先日、比較的古い構成の既存アプリにKotlin Multiplatform(KMP)を導入する機会がありました。
試行錯誤しましたが無事に導入することができたので、その際の手順などを紹介いたします。
1. はじめに
ネイティブ実装のアプリ(Android:Java、iOS:Swift)に、アプリのデータ引き継ぎの機能を新規開発する案件がありました。
本アプリではデータをサーバ上に保存していないため、ローカルデータを文字列化・暗号化してQRコードにしてやりとりする仕様で実装することとなりました。
iOS/Android ともに同じロジックでQRコード(URL)化とその復元を行いたく、また開発リソースも自分ひとりであり、今後の効率化も見据えて、Kotlin MultiPlatform (以下、KMP) を利用して本機能を実装し既存アプリに組み込むこととしました。
弊社では、新規アプリ開発にFlutterを利用するケースも多く、本件での選択肢としてもFlutterも検討しました。
しかし、以前別の機能でFlutterを試した際に、ビルド時間の長さやFramework化した際の容量の大きさ、Flutterエンジンの初期化の扱いなどが課題となり導入を見送った経験がありました。
また、今回は既存のQRコード表示・読み取り機能をそのまま活用したかったため、ロジック部分のみを共通化できるKMPが最適だと判断しました。
本記事では、既存のネイティブアプリへのKMP段階的導入の手順と、開発環境の整備について実体験をもとに紹介します。
2. プロジェクト構成とKMP導入準備
既存プロジェクトの前提条件
今回KMPを導入した既存プロジェクトは、特にAndroidにおいて比較的古い構成で開発されていました。
Android側の特徴
- Version Catalogs(libs.versions.toml)を利用していない従来の build.gradle での依存ライブラリ管理
- Gradleバージョンが古い(7.x系)
- Kotlinを利用しておらず、Java中心の実装
- 長期間運用されている中規模アプリ
iOS側の特徴
- Swift実装
- UIはstoryboard利用が多く、SwiftUIは未導入
- 既存のQRコード機能など、活用したいネイティブ実装が存在
このAndroidの状態のように「モダンではないが安定稼働している既存アプリ」へのKMP導入は、公式の手順などに従うだけではなかなかうまくいかず、AIに聞くことも含め試行錯誤してどうにか導入することができました。
本記事ではこの経験を踏まえ、古めの環境への導入手順を整理して紹介いたします。
リポジトリの管理
既存のAndroidアプリとiOSアプリはそれぞれ別のGitリポジトリで管理されています。
今回のKMPロジック部分も新たに独立したリポジトリとして作成し、既存アプリの各リポジトリからは git submodule を利用して利用することとしました。
モノレポ化は既存の開発フローやリポジトリを大幅に変更する必要があり、パッケージ配布では開発時のデバッグが困難になるため、submoduleが最も現実的な選択と判断しました。
2.1 KMPプロジェクトの新規作成
まず、共通ロジック用のKMPプロジェクトを新規作成しました。
今回、アプリ部分は別プロジェクトなので、「Kotlin Multiplatform Library」でプロジェクトを作成しました。
以下はプロジェクト作成時点で利用していたAndroid Studio (Android Studio Meerkat Feature Drop | 2024.3.2) によるものです。
このバージョンではNew Projectからのテンプレートに「Kotlin Multiplatform Library」があったのですが、投稿時点の最新版(Android Studio Narwhal | 2025.1.1 Patch 1)ではその項目がなくなっていました。そちらでの作成方法は後日また書けたらと思います。
Android StudioのNew Projectから「Kotlin Multiplatform Library」を選択し、基本的な構成でプロジェクトを生成します。
これで以下のような構成のプロジェクトが生成されるはずです。
MyApp_KMP_Shared/
├── build.gradle.kts
├── gradle/
│ ├── libs.versions.toml
│ └── wrapper
├── gradle.properties
├── gradlew
├── gradlew.bat
├── local.properties
├── settings.gradle.kts
└── shared/
├── build.gradle.kts
└── src/
├── commonMain/kotlin/
├── androidMain/kotlin/
├── iosMain/kotlin/
└── commonTest/kotlin/
プロジェクト作成後、以下のコマンドでビルドが正常に動作すること・既存テストが動くことを確認します。
./gradlew build
./gradlew :shared:testDebugUnitTest
今回の既存Androidプロジェクトとは異なって Version Catalog 利用となっています。そのため、
- 既存プロジェクト側を Version Catalog に対応させる
- KMPプロジェクト側を Version Catalog を利用しないように対応する
の二択になりますが、既存プロジェクトに依存ライブラリが多いこともあり、前者だとtoml追加分が多くなりそうなため、今回は後者で対応しました。
具体的な対応は後のAndroidへの導入の箇所で述べるので、一旦はこのまま進めます。
src内にはサンプルのkotlinファイルとしてcommonMain以下に Greeting.kt
等、commonTest以下に テストファイルの Test.kt
が実装されていると思います。
先ほどは gradle コマンドでテストを確認しましたが、Android Studio のエディタ上からもテスト実行してみましょう。
commonTest/kotlin 以下の Test.kt ファイルを開くと、テスト実行のボタンが出ていると思うので実行してみます。
実行しようとすると、iOSまたはAndroidの環境を選べるようになっています。
どちらでも成功すれば、ロジック実装の準備は完了です!
共通ロジックの実装へと入る前に、既存プロジェクトへの導入を試しておくのが安心です。
いったん、このサンプルの状態のKMPロジックのプロジェクトは、GitHub等に新規リポジトリを作成してプッシュしておきます。
2.2 既存プロジェクトへのsubmodule追加
既存の各ネイティブプロジェクトのルートにsubmoduleとして追加します。
cd path/to/MyApp-Android
git submodule add https://github.com/yourorg/MyApp_KMP_Shared.git MyApp_KMP_Shared
cd path/to/MyApp-iOS
git submodule add https://github.com/yourorg/MyApp_KMP_Shared.git MyApp_KMP_Shared
サブモジュールを追加すると、既存アプリのgit差分としては以下となっているはずです。
new file: .gitmodules
new file: MyApp_KMP_Shared
サブモジュールである MyApp_KMP_Shared
は、ディレクトリの中身がコミットされているわけではなく、差分を見るとコミットハッシュの情報だけとなっています。
$ git diff --staged
diff --git a/.gitmodules b/.gitmodules
new file mode 100644
index 00000000..91b1c9b3
--- /dev/null
+++ b/.gitmodules
@@ -0,0 +1,3 @@
+[submodule "MyApp_KMP_Shared"]
+ path = MyApp_KMP_Shared
+ url = https://github.com/yourorg/MyApp_KMP_Shared.git
diff --git a/MyApp_KMP_Shared b/MyApp_KMP_Shared
new file mode 160000
index 00000000..85e0ef02
--- /dev/null
+++ b/MyApp_KMP_Shared
@@ -0,0 +1 @@
+Subproject commit xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
2.3 各プラットフォームでの設定
Androidアプリの設定
settings.gradleに以下の設定を追加します。
include ':shared'
project(':shared').projectDir = file('MyApp-KMP-Shared/shared')
app/build.gradleでは通常通りプロジェクトの依存関係を追加します。
dependencies {
//...既存のものは省略
implementation project(':shared')
}
Androidでの課題と対処
先述の通り、既存プロジェクトとKMPプロジェクトでビルド設定の形式が異なっておりました。
- 既存Android: 従来の build.gradle 内でgradle等のバージョン管理
- 新規KMP: Version Catalogs( lib.versions.toml)利用での管理
これに対し、既存プロジェクトへの影響を最小限に抑えるため、KMPプロジェクト側を従来の管理方式に変更することにしました。
また、KMPの要求するGradleバージョンが既存プロジェクトより新しかったため、結果的に既存プロジェクトのGradleバージョンアップも必要になりました。
結果的に、Kotlinバージョンを2.0.0以上にすることで問題なく動作するようになりました。
(この変更により、既存アプリでも変更する箇所が多く発生しましたが、その内容はここでは割愛します。)
buildscript {
ext.kotlin_version = '2.1.21' // 2.0.0以上に
repositories {
google()
mavenCentral()
}
dependencies {
classpath 'com.android.tools.build:gradle:8.10.0' // kotlinバージョンに対応したもの
classpath 'com.google.gms:google-services:4.3.10'
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
}
}
distributionUrl=https\://services.gradle.org/distributions/gradle-8.4-bin.zip // gradleバージョンに対応したもの
KMP側も修正します。
Version Catalog利用ではない従来のバージョン指定方法に変更し、Androidアプリ側と同じバージョンを指定します。
plugins {
id("com.android.library") version "8.10.0" apply false // Androidアプリ側と同じものを指定
id("org.jetbrains.kotlin.multiplatform") version "2.1.21" apply false // Androidアプリ側と同じものを指定
}
plugins {
id("org.jetbrains.kotlin.multiplatform") // 変更
id("com.android.library") // 変更
}
kotlin {
//中略
sourceSets {
commonMain.dependencies {
}
commonTest.dependencies {
implementation("org.jetbrains.kotlin:kotlin-test") //変更
}
}
}
KMPプロジェクト内のファイルを変更したら、KMPプロジェクトのルートに移動して、変更をコミット、プッシュしておきましょう。
Androidのビルド確認
gradle sync が成功したら、既存のJavaファイルにimport文を追加して、ビルドが通ること・KMPのロジックを呼び出せることを確認しておきます。
// 既存のJavaクラスに追加
import com.example.myapp_kmp_shared.Greeting;
public class MainActivity extends AppCompatActivity {
// テスト用のメソッドを追加
private void testKmpImport() {
// KMPクラスのインスタンス化ができることを確認
Greeting greeting = new Greeting();
String message = greeting.greet();
Log.d("KMP-Message", message);
}
}
iOSアプリの設定
iOS側では、XcodeのBuild PhasesにRun Scriptを追加し、ビルド時にKotlin/Nativeのコンパイルが自動実行されるよう設定しました:
(RunScript内での順序を並べ替えて最初のほうに移動しておくこと)
cd MyApp-KMP-Shared
./gradlew :shared:embedAndSignAppleFrameworkForXcode
設定してビルドが成功したら、既存のSwiftファイルにimport文を追加してビルドが通ることを確認しておきましょう。
// 既存のViewController等に追加
import shared
// テスト用のメソッド
func testKmpImport() {
// KMPクラスのインスタンス化ができることを確認
let greeting = Greeting()
let message = greeting.greet()
print(message)
}
// Objective-Cの場合
@import shared;
// テスト用のメソッド
- (void) testKmpImport
{
SharedGreeting *greeting = [[SharedGreeting alloc] init];
NSString *message = [greeting greet];
NSLog(@"%@", message);
}
既存プロジェクトへの影響と注意点
先述の通り、手元のAndroid環境では、KMP導入に合わせてGradleバージョンを更新する必要がありました。
このようなバージョンアップは、既存の設定に予期しない影響を与える可能性があるため、段階的な検証を行うことをお勧めします。
この設定が完了するまでが最も苦労した部分でしたが、一度設定できてしまえば、その後の開発は非常にスムーズでした。
3. KMPプロジェクトの実装
今回実装した共通ロジックは、データの暗号化・復号化とQRコード用URL生成・解析処理です。
詳細な実装内容は本記事の主旨ではないため概要のみ紹介します。
共通ロジックの構成(概略)
// commonMain/kotlin 以下に実装
class QRDataLogic {
fun exportDataToUrls(data: AppData): List<String>
fun restoreDataFromUrls(urls: List<String>): AppData
fun isAppDataUrl(url: String): Boolean
fun processImportUrl(url: String, callback: QrScanCallback): Boolean
}
class EncryptManager {
fun encrypt(data: String): String
fun decrypt(encryptedData: String): String
}
class QrScanManager {
fun processScannedQrUrl(url: String): ScanResult
fun restoreFromScannedQrData(): String?
}
テストの実装
KMPでロジックを分離したことにより、純粋な単体テストが書きやすくなりました。
// commonTest/kotlin 以下に実装
class QRDataLogicTest {
@Test
fun testDataExportAndRestore() {
val logic = QRDataLogic()
val testData = listOf(/* テストデータ */)
val urls = logic.exportDataToUrls(testData)
assertTrue(urls.isNotEmpty())
// ... 他のテスト
}
}
4. ネイティブ側との連携
KMPライブラリの実装が完了し、既存のAndroid/iOSアプリから呼び出す段階では、想定以上にスムーズに統合できました。
Android(Java)からの呼び出し
import com.example.shared_logic.QRDataLogic;
public class DataTransferActivity {
private void exportData() {
List<AppData> dataList = getCurrentData();
QRDataLogic logic = new QRDataLogic();
List<String> urls = logic.exportDataToUrls(dataList);
generateQRCodes(urls); // 既存アプリ内のQRコード生成処理
}
}
iOS(Swift)からの呼び出し
import shared
class DataTransferViewController: UIViewController {
func exportData() {
let dataList = getCurrentData()
let logic = QRDataLogic()
let urls = logic.exportDataToUrls(dataList: dataList)
generateQRCodes(urls: urls) // 既存アプリ内のQRコード生成処理
}
}
コールバック設計
QRコード読み取り処理は元々別の機能としてネイティブアプリに実装してありました。
データ引き継ぎ用のURLかどうかをKMPのロジックにURLを渡して判断し、その場合はデータ復元処理に進む、という処理を追加しました。
複数のQRコードを順次読み取る必要があるため、処理状態に応じた適切なフィードバックをユーザーに提供する必要がありました。この部分では、KMP側でロジック処理を行い、UI表示(ダイアログやメッセージ)はネイティブ側で行うという責任分離を明確にしました。
interface QrScanCallback {
fun onReadSingleQRCode(backupData: QrBackupData)
fun onReadFirstQrCode(totalCount: Int)
fun onScanSuccess(currentCount: Int, totalCount: Int)
fun onScanComplete(backupData: QrBackupData)
fun onReadAlreadyScannedCode(notScannedNumbers: List<Int?>?)
fun onScanError(state: QrScanManager.ScanState)
fun onOtherError()
}
このコールバック設計により、複雑な状態管理はKMP側で行いつつ、各プラットフォーム固有のUI表現(ダイアログのデザインや文言など)はネイティブ側で自由に実装できるようになりました。
環境設定さえ完了してしまえば、その後の開発は非常にスムーズに進めることができました。アプリ側実装中にKMPのほうに手を入れるのもやりやすく、通常のアプリ開発と変わらない感覚で開発を進めることができました。
5. GitHub Actions環境構築
KMPプロジェクトの品質を保つため、GitHub Actionsを使った自動テスト環境を整備しました。今回のKMP実装では、Android/iOS固有の処理は含まれておらず、共通ロジック(commonTest)のテストのみで十分だったため、比較的シンプルな構成で済みました。
5.1 KMPプロジェクトのCI/CD
基本的なワークフローは以下のような構成です:
name: KMP Tests
on:
push:
branches: [ main, develop ]
pull_request:
branches: [ main, develop ]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up JDK 17
uses: actions/setup-java@v4
with:
java-version: '17'
distribution: 'temurin'
- name: Cache Gradle packages
uses: actions/cache@v3
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
- name: Run tests
run: ./gradlew :shared:testDebugUnitTest
プルリクエスト時に自動でテストが実行され、共通ロジックの品質を継続的に担保できるようになりました。
実際には、テスト結果を解析してテストの成功率を表示したり、その結果をPRに投稿する仕組みも導入しましたが、本記事では割愛します。
5.2 アプリ側のCI/CD
KMPをsubmoduleとして導入したことで、既存のAndroid/iOSアプリのCI/CDにも調整が必要になりました。
submoduleの取得
- name: Checkout with submodules
uses: actions/checkout@v4
with:
submodules: recursive
token: ${{ secrets.PERSONAL_ACCESS_TOKEN }}
プライベートリポジトリのsubmoduleにアクセスするため、Personal Access TokenをGitHub Secretsに設定し、「Read and Write access to code」権限を付与する必要がありました。
開発フローの調整
submoduleの内容が更新された場合、親リポジトリ側でもsubmoduleのコミットハッシュを更新する必要があるため、KMPロジック修正時は各プラットフォームのリポジトリでsubmoduleの参照を更新し、動作確認を行ってからリリースするという手順を行う必要があります。
この環境整備により、KMP部分のロジックに対して継続的な品質担保が可能になり、ロジック分離によって純粋な単体テストが書きやすくなったのも大きなメリットでした。
6. 導入効果と課題
6.1 導入効果
開発効率の向上
同じロジックを2回実装する必要がなくなり、仕様変更時の修正漏れリスクも軽減されました。特に、暗号化・復号化や複数QRコードの状態管理といった複雑なロジックを一度の実装で済ませられたのは大きな効果でした。
テスタビリティの向上
予想していなかった副次的効果として、テスタビリティの大幅な向上がありました。従来はUI込みのテストが中心で単体テストが書きにくかった状況から、ロジック部分が分離されたことで純粋な単体テストが書きやすくなりました。これにより、GitHub Actionsでの自動テスト環境も整備でき、継続的な品質担保が可能になりました。
段階的導入のメリット
既存アプリへの段階的導入が可能だったことが最大のメリットでした。UIは既存実装をそのまま活用し、必要な部分のみを共通化することで、開発リスクを最小限に抑えながら効率化を実現できました。
6.2 課題・注意点
環境設定の複雑さ
特にiOS側でのFramework参照設定が最も苦労した部分でした。
当初はローカルでpodspecs作成しcocoapodsで参照する方法や、事前にXCFrameworkをビルドする等も試しましたが、結果的にはXcode内のBuild PhaseのRun Script設定のみで動くこととなり、運用しやすい方法を見つけることができました。
git submodule管理の学習コスト
プライベートリポジトリでのsubmodule利用では、GitHub ActionsでのPersonal Access Token設定や適切な権限付与が必要でした。また、submoduleの更新時には親リポジトリでのコミットハッシュ更新も必要になり、開発フローに若干の変更が生じました。
既存プロジェクトへの副次的影響
KMP導入に伴うGradleバージョンアップが必要となり、それによってAndroidの難読化設定に影響が出ました。リリースビルドでクラッシュが発生し、ProGuard/R8設定の見直しが必要になりました。KMP自体の問題ではありませんが、導入時には既存環境全体への影響を考慮した十分なテスト期間の確保が重要です。
(導入直後にリリースビルドも試しておけば良かったと思っています)
6.3 今後の展望
段階的な共通化拡張
現在は一部のビジネスロジックの共通化に留まっていますが、今後は他のロジックへの展開だけでなく、ViewModel層の共通化や、Compose Multiplatformを活用したUI要素の共通化も検討しています。
プロジェクト全体への展開
新規機能は原則KMPで実装し、既存機能も段階的にKMP化していく方針です。また、組織内の他のクロスプラットフォーム化していないサービスにも、今回のノウハウを活用して導入を進めていきたいと考えています。
Flutter vs KMP の使い分け戦略
組織内にはFlutter経験者も多く、今後の技術選択については慎重な検討が必要です。現在考えている使い分けの方針は以下の通りです:
- 新規アプリ開発: FlutterとKMP+Compose Multiplatformを比較検討
- 既存アプリへの機能追加:
- 既存画面にロジックを追加する場合: KMP一択
- 完全に新規の画面で機能が閉じている場合: Flutterも選択肢に
- ただし、将来的にはCompose Multiplatformに統一する可能性も
理想的には技術スタックを統一したいところですが、既存資産やチームのスキルセット、開発スケジュールを考慮すると、現実的には「状況に応じた最適解」を選択していくことになりそうです。今回のKMP導入で得られた段階的アプローチのノウハウは、今後の技術選択においても重要な判断材料になると考えています。
まとめ
既存のネイティブアプリへのKMP導入は、環境設定がやや難しいという初期コストはあるものの、一度設定が完了すれば非常にスムーズな開発体験を得られました。特に「段階的導入」というアプローチにより、開発リスクを抑えながら効率化を実現できたのは大きな成果でした。
今回の経験を通じて、KMPは既存アプリの古い作りの刷新において非常に有効な選択肢であることを実感しました。完全なリプレイスではなく、必要な部分から段階的に共通化していくアプローチは、多くの開発現場で参考にしていただけるのではないかと思います。