KMP開発体験を改善するためにモノレポ化した話
Photo by Viktor Forgacs on Unsplash
ウォンテッドリーでMobile Tech Leadを務めている久保出と申します。
本記事では、Wantedly Visitアプリの開発において、Kotlin Multiplatform(KMP)を含む3つのリポジトリを1つのモノレポに統合した事例について、その背景から実装、そして得られた知見までを実体験を交えてお伝えします。
目次
はじめに
背景:3リポジトリ構成の課題
従来の構成
直面していた課題
解決策:モノレポ化
新しい構成
得られた改善
移行の実装
移行の本質:200行程度の変更
iOS統合の詳細
Android統合の詳細
リポジトリ統合:merge.shの活用
ハマりポイント
gradlewがXcode上でハングする
OSS licenses pluginの問題
CI/CD移行
iOS(Bitrise)
Android/Shared(CircleCI)
移行後に発生した課題
Xcodeのインラインエラーが表示されない
コード補完とシンボル検索が動作しない
CIのコスト超過
CIサービス連携の認証失敗
スケジュール
まとめ
移行のポイント
得られた効果
メンバーからのフィードバック
トレードオフ
さいごに
参考リンク
はじめに
弊社では2020年からKotlin Multiplatformを導入し、iOS/Androidアプリ間でビジネスロジックを共通化しています。この取り組みについては、以前の記事「WantedlyにおけるKotlin Multiplatformの導入と課題」でも紹介しました。
KMPの導入により多くのメリットを享受してきましたが、運用を続ける中で新たな課題も見えてきました。本記事では、その課題を解決するために行ったモノレポ化について詳しく解説します。
背景:3リポジトリ構成の課題
従来の構成
これまでのWantedly Visitアプリは、以下の3つのリポジトリで構成されていました。
- visit-app-shared:Kotlin Multiplatformによる共通コード
- visit-ios:iOSアプリケーション
- app-android:Androidアプリケーション
KMPのコードはGitHub Packagesを経由して、SwiftPM(iOS向け)とMaven(Android向け)でそれぞれのアプリに配布していました。
直面していた課題
この構成では、以下のような課題が顕在化していました。私たちのチームはプラットフォームごとに分かれたチームではなく1つのモバイルチームであり、iOS/Android/KMPを隔てなく開発する必要があります。しかし、リポジトリが3つに分かれていることで、本来不要なコストが発生していました。
1. リードタイムの長さ
KMPに変更を加えると、GitHub Packagesへのpublish、iOS/Android側でのCIの実行、そして反映まで、最低でも20分程度の待ち時間が発生していました。ちょっとした修正でも、このサイクルを回す必要があり、開発効率を大きく損なっていました。
2. 破壊的変更の検知が困難
KMP側でAPIを変更しても、iOS/Androidのリポジトリでビルドが走るまで問題に気づけませんでした。リポジトリが分かれているため、変更の影響範囲を事前に把握することが難しかったのです。
3. iOS開発体験の低下
バイナリ配布という形態上、Xcode上でKMPのコードにジャンプしたり、ブレークポイントを設置したりすることができませんでした。これにより、デバッグ効率が著しく低下していました。
4. 将来的な懸念
今後チームが複数のSquadに分かれると、KMPの配布タイミングが衝突したり、KMP担当者が固定化したりする可能性がありました。
解決策:モノレポ化
新しい構成
これらの課題を解決するため、3つのリポジトリを1つのvisit-appリポジトリに統合しました。
visit-app/
├── shared/ # KMP(旧 visit-app-shared)
├── iosApp/ # iOS(旧 visit-ios)
└── androidApp/ # Android(旧 app-android)KMPはGitHub Packagesを経由せず、ローカルで直接参照する形に変更しました。
得られた改善
以下のような改善効果があり、直面していた課題を大きく解決することができました。
- KMP変更の反映: 20分以上 → 即座
- 破壊的変更の検知: 別リポジトリでCIが走るまで不明 → 同一PRのCIで即座に検知
- iOS/Androidの変更: 別々のPRで対処 → 1つのPRでアトミックに変更可能
- GitHub Packages認証: 必要(.netrc設定) → 不要
移行の実装
ここからは、具体的にどのように移行を行ったかを説明します。
移行の本質:200行程度の変更
実は、モノレポ化の本質的な変更は驚くほど少なく、200行程度の変更で実現できました。
主な変更内容は以下の通りです。
iOS側の変更
- SwiftPM経由でのKMP依存を削除
- Xcode Schemeのpre-actionsでgradlewを実行するように変更
Android側の変更
settings.gradle.ktsにincludeBuild("../shared")を追加
共通の変更
.netrc設定の削除(GitHub Packages認証が不要に)
iOS統合の詳細
iOS側では、KMPの公式ドキュメント「Multiplatform SPM Local Integration」に記載されている方法を採用しました。
Xcode Schemeのpre-actionsで、ビルド前にgradlewを実行してKMPのframeworkを生成します。
#!/bin/bash -e
# Compile Kotlin Multiplatform Framework for Xcode
# This script is used in XcodeGen scheme preActions
# Install JDK through SDKMAN!
cd "$SRCROOT/.."
source "$HOME/.sdkman/bin/sdkman-init.sh"
sdk env install
cd "shared/"
# Close Stdin to avoid hang when running on console
./gradlew :visit-app-shared:embedAndSignAppleFrameworkForXcode < /dev/null
# Makes code completion and symbol search work
# https://slack-chats.kotlinlang.org/t/16647904/hello-we-re-facing-an-issue-where-code-completion-and-symbol
DERIVED_DATA_DIR=$(echo "${TARGET_BUILD_DIR}" | awk -F'/Build/' '{print $1}')
INDEXER_DATA_DIR="${DERIVED_DATA_DIR}/Index.noindex/Build/Products/Debug-$PLATFORM_NAME"
mkdir -p "$INDEXER_DATA_DIR"
cp -R "visit-app-shared/build/xcode-frameworks/$CONFIGURATION/$SDK_NAME/"* "${INDEXER_DATA_DIR}"この設定と今までのPackage.swiftへのバイナリ参照を削除するだけで、iOSのビルド時にKMPがビルドされるようになりました。
Android統合の詳細
Android側は、Gradle Composite Buildを使用しました。Composite Buildは、複数のGradleプロジェクトを1つのビルドとして扱う仕組みで、includeBuildを使って別プロジェクトの成果物をローカル参照できます。
// settings.gradle.kts
includeBuild("../shared") {
name = "visit-app-shared-build"
}この設定により、sharedモジュールの変更が即座にAndroidアプリに反映されるようになりました。
リポジトリ統合:merge.shの活用
3つのリポジトリを統合する際、履歴を保持したままマージすることを重視しました。git blameやログを追跡できなくなると、将来的なトラブルシューティングに支障が出るためです。
この要件を満たすため、merge.shというスクリプトを作成しました。このスクリプトはGeminiに作成を依頼し、要件を伝えながら調整を行いました。以下は実際にGeminiに送ったプロンプトです。
あなたはgitや周辺ツールに詳しいシニアエンジニアとなって、以下の指示に従います。
## 背景・現状
現在のディレクトリには、次の3つのサブディレクトリが存在します。
* app-android/ (default branch: develop)
* visit-ios/ (default branch: develop)
* visit-app-shared/ (default branch: master)
これらはそれぞれ独立したGitリポジトリとして管理されています。
各リポジトリの全履歴(commit履歴)を保ったまま、新しい統合ディレクトリを構築したいです。
目的・ゴール
新たに visit-app/ ディレクトリを作成し、3つのリポジトリを以下のようにサブディレクトリとして統合したいです。
* app-android/ → visit-app/androidApp/
* visit-ios/ → visit-app/iosApp/
* visit-app-shared/ → visit-app/shared/
各サブディレクトリに元リポジトリの履歴が完全に残る形にしたいです。
## 要望事項
* 統合対応に最適なGitコマンドやツール(例:git-filter-repo)を利用した、具体的な1つのシェルスクリプト(shファイル)を作成してください。
* スクリプトはgit-filter-repo公式ドキュメント(git-filter-repo.html)の推奨に従った内容としてください。
* visit-app/ のdefault branchはdevelopにしてください。
* visit-app/ 及びそのサブディレクトリがすでに存在する場合もあります。削除して作り直さず、各サブディレクトリのgitの差分を反映するようにしてください。
* 履歴の統合後、最終的なvisit-app/ディレクトリが以下の構造になることを保証してください。
visit-app/
├── androidApp/ # app-androidの内容(履歴付き)
├── iosApp/ # visit-iosの内容(履歴付き)
└── shared/ # visit-app-sharedの内容(履歴付き)
* 実行前後の注意点や検証ポイント(例:git-filter-repoのインストール確認、統合後の履歴確認など)があれば付記してください。
* 不確実なことがあれば質問し返して質を向上させてください。
あなたはgitや周辺ツールに詳しいシニアエンジニアとなって、以下の指示に従います。
## 背景・現状
現在のディレクトリには、次の3つのサブディレクトリが存在します。
* app-android/ (default branch: develop)
* visit-ios/ (default branch: develop)
* visit-app-shared/ (default branch: master)
これらはそれぞれ独立したGitリポジトリとして管理されています。
各リポジトリの全履歴(commit履歴)を保ったまま、新しい統合ディレクトリを構築したいです。
目的・ゴール
新たに visit-app/ ディレクトリを作成し、3つのリポジトリを以下のようにサブディレクトリとして統合したいです。
* app-android/ → visit-app/androidApp/
* visit-ios/ → visit-app/iosApp/
* visit-app-shared/ → visit-app/shared/
各サブディレクトリに元リポジトリの履歴が完全に残る形にしたいです。
## 要望事項
* 統合対応に最適なGitコマンドやツール(例:git-filter-repo)を利用した、具体的な1つのシェルスクリプト(shファイル)を作成してください。
* スクリプトはgit-filter-repo公式ドキュメント(git-filter-repo.html)の推奨に従った内容としてください。
* visit-app/ のdefault branchはdevelopにしてください。
* visit-app/ 及びそのサブディレクトリがすでに存在する場合もあります。削除して作り直さず、各サブディレクトリのgitの差分を反映するようにしてください。
* 履歴の統合後、最終的なvisit-app/ディレクトリが以下の構造になることを保証してください。
visit-app/
├── androidApp/ # app-androidの内容(履歴付き)
├── iosApp/ # visit-iosの内容(履歴付き)
└── shared/ # visit-app-sharedの内容(履歴付き)
* 実行前後の注意点や検証ポイント(例:git-filter-repoのインストール確認、統合後の履歴確認など)があれば付記してください。
* 不確実なことがあれば質問し返して質を向上させてください。
このプロンプトに対してGeminiが生成したスクリプトをベースに、実際のリポジトリ構成に合わせて調整を行いました。
# merge.sh の主要な処理
# 1. git-filter-repoで各リポジトリの履歴をサブディレクトリに移動
git-filter-repo --to-subdirectory-filter androidApp
git-filter-repo --to-subdirectory-filter iosApp
git-filter-repo --to-subdirectory-filter shared
# 2. --allow-unrelated-historiesでマージ
git merge --allow-unrelated-historiesこのスクリプトには、以下の工夫を入れています。
- 冪等性:何度実行しても同じ結果になる
- 差分マージ:既にマージ済みの履歴はスキップ
- エラーハンドリング:作業ツリーのクリーンチェック等
特に途中でも差分を取り込める設計にしたことで、ビッグバン移行を避けることができました。旧リポジトリでの開発を止めることなく、段階的に移行を進められました。
ハマりポイント
移行作業中に遭遇した技術的な問題を紹介します。
gradlewがXcode上でハングする
Xcode pre-actionsでgradlewを実行すると、プロセスがハングしてビルドが進まなくなる問題に遭遇しました。
調査の結果、Gradleがstdinを読み取ろうとして待機状態になることが原因でした。解決策は単純で、< /dev/nullでstdinを閉じることで回避できます。
# NG: ハングする
./gradlew :visit-app-shared:embedAndSignAppleFrameworkForXcode
# OK: stdinを閉じる
./gradlew :visit-app-shared:embedAndSignAppleFrameworkForXcode < /dev/nullこの問題はgradle/gradle#15941で報告されています。AIに聞くより、Web検索したほうが早く解決できた例でした。
OSS licenses pluginの問題
Android側でoss-licenses-pluginを使用していたのですが、モノレポ構成だと正常に動作しませんでした。google/play-services-plugins#299で報告されている問題と同様の現象です。
今回は、oss-licenses-pluginを廃止し、別のライブラリへの移行を検討することで対応しました。
CI/CD移行
モノレポ化に伴い、CI/CDの設定も見直しました。
iOS(Bitrise)
BitriseのFile change triggerを活用し、^(iosApp|shared)\/にマッチするファイルが変更された時だけワークフローを実行するようにしました。これにより、Androidのみの変更時にiOSのビルドが走ることを防いでいます。
Android/Shared(CircleCI)
CircleCIでは、path-filtering orbを活用してディレクトリ単位でジョブの実行を制御しています。
移行後に発生した課題
移行完了後、本番運用を始めてから発覚した課題です。プロダクトへの影響はありませんでしたが、開発体験やCI/CDの運用に関わる問題でした。
Xcodeのインラインエラーが表示されない
KMPのコードでエラーが発生した際、Xcode上でインラインエラーが表示されない問題が発生しました。ビルドエラー自体は検知できますが、エラー箇所へのジャンプができず、デバッグ効率が低下していました。
この問題は、Xcodeメニューの Xcode > Settings > General または ⌘, > General から Show live issues を無効にすることで回避可能とStackOverflowで紹介されています。
コード補完とシンボル検索が動作しない
モノレポ化後、Xcode上でKMPのコード補完やシンボル検索が動作しない問題が発生しました。
この問題は、ビルド時にframeworkをXcodeのIndexerが参照するディレクトリにコピーすることで解決しました。iOS統合の詳細で紹介したスクリプトの後半部分がこの対応に該当します。
CIのコスト超過
Bitriseの現状プランでは、毎月のビルド数に上限があるのですが、モノレポ化後にこの上限を超過してしまいました。さらに、ビルドマシンのスペックを上げていたため、クレジット消費が増加していました。
対策として、不要なビルドトリガーを削減し、ビルド数を低減させました。
CIサービス連携の認証失敗
従来のワークフローでは、GitHub Actionsを手動で起動し、CircleCI/Bitriseをトリガーするフローがありました。移行に伴い、認証方法をどちらもGitHub Appsに変更した結果、権限不足になりトリガーできない状態になりました。
現在は、直接CircleCI/Bitrise上から手動でトリガーする運用に変更しています。
スケジュール
移行は約2ヶ月で完了しました。
- 11月上旬: 検証開始、merge.sh作成、ローカルビルド成功
- 11月中旬〜下旬: iOS/Android統合、CI/CD移行検証
- 12月中旬: チームへのアナウンス、移行実施
- 12月下旬〜1月上旬: リリースワークフロー検証、コスト調整
通常の開発と並行して実施し、旧リポジトリでの開発を止めることなく移行を完了できました。
まとめ
本記事では、KMPを含む3リポジトリをモノレポ化した事例を紹介しました。
移行のポイント
- 本質的な変更は少ない: 200行程度の変更
- 既存の仕組みを活用: AndroidのComposite Build、KMP公式のローカル統合方法
- 段階的な移行: merge.shの冪等性により、ビッグバン移行を回避
得られた効果
- KMP変更が即座にiOS/Androidに反映
- 破壊的変更を同一PRのCIで検知可能
- iOS/Androidの変更をアトミックに実行可能
メンバーからのフィードバック
移行後、チームメンバーから以下のようなフィードバックがありました。
ポジティブなフィードバック
- KMPの変更がアトミックになったことで、破壊的変更に気づきやすく、対処しやすくなった
- CI/CDの待ち時間が減り、開発パフォーマンスが向上した
ネガティブなフィードバック
- KMPの変更に対してiOS/Android双方に変更が必要になる場合があり、本来修正したい範囲が広がってしまう
後者については、全体を通してみると破壊的変更の対処が早まっただけであり、利点と捉えています。従来は別リポジトリで後からビルドエラーとして発覚していた問題が、同一PR内で即座に検知されるようになったということです。
トレードオフ
一方で、以下のようなトレードオフも存在します。
- リポジトリの肥大化
- CI/CDのインフラコスト増加
- iOSビルド時の依存ツール増加(SDKMAN、JDK等)
さいごに
モノレポ化を振り返ると、今回直面した課題はいずれもコンウェイの法則(組織のコミュニケーション構造がシステム設計に反映される)に関連していたと考えています。私たちはプラットフォームごとに分かれたチームではなく1つのモバイルチームであり、iOS/Android/KMPを横断して開発しています。チームが1つであるならば、リポジトリも1つであるべきだったのです。
モノレポ化は銀の弾丸ではありませんが、KMPを活用したクロスプラットフォーム開発において、開発効率を大きく改善する選択肢の一つです。同様の課題を抱えている方の参考になれば幸いです。