【KMP最初の一歩】プロジェクト作成、CI構築、Xcodeへインポートする手順
こんにちは、Takahiroです。プログリットではKMPを採用してプラットフォーム間のロジック共通化に取り組んでいます。今回はKMPのライブラリプロジェクト作成、CI構築、Xcodeへインポートするまでの手順をご紹介します。これからKMPを導入したい方々へのご参考になれば幸いです。
対象読者
まだKMPの環境を構築したことがない人
これからやってみたいが、どこから手をつけたら良いか分からない人
この記事で書かないこと
なぜこの環境にしたのか?
移行コストを最小限に抑えるため、弊社現行の環境を尊重しています。
他のサービスや環境との優位性の比較は本記事では行いません。
KMPで解決する課題や目的
本記事は内容をなるべくシンプルにする為に『How』にフォーカスします。
ざっくりだけご紹介しますと、特にサーバーAPIをリクエストする部分(Repositoryなど)はAndroid / iOSで同じものを作り込んでいるので、その部分の重複排除は目的の1つとなります。
0. 今回の環境
Xcode 16&既に運用中のコードがあるものとします
CIはBitriseを利用します
KMPライブラリ用のGithub repositoryは新規作成します
既存アプリのrepositoryからgit submoduleとしてKMPソースセットを参照します
Xcode上でビルドをする都度、KMPもビルドしてxcframeworkを生成します
1. Kotlin Multiplatform Library を作成する
まずはプロジェクトを作成します。
XCFrameworkを選択する理由
実は公式のリファレンスを見ているとRegular Frameworkを前提にしている様に見えます。( `embedAndSignAppleFrameworkForXcode` を実行すると .framework が生成される)
ただ、弊社においてはLocal SPMにてマルチモジュール構成にしており、.framework だとモジュール側のソースから参照ができない問題が発生しそうだと考えました。この点が決め手となり XCFramework を採用しています。(歴史的にも後発であるという点も決め手の1つになりました)
Library作成完了!
フォルダ構成は以下のようになります。
2. CIの為に簡単なロジックとUnit testを書く
機能を開発する前に、簡単なロジックでCI環境を構築することをお勧めします。色々な機能が乗った後に、最後にCI環境を構築すると必要な環境設定や依存関係の考慮を一気に対処することとなり、作業が難しくなる懸念があるためです。
以下、実装サンプルとしてLoggerのコードをご紹介します。前述のフォルダ構成と合わせてご覧ください。
要件
commonMainの開発をし易くするためLoggerを自作する
Androidの文化に合わせ、Log.xの形式で学習コスト無く使える
2-1. commonMain
ピュアKotlinで実装する共通コードの格納場所です。プラットフォーム固有の実装の抽象化はcommonMainの中で『expect fun』を記載し、androidMain / iosMain にて 『actual fun』を実装します。
class HelloKmp {
suspend fun greeting() {
Log.d(message = "hello, kmp!")
}
}
interface Logger {
fun d(tag: String = "", message: String)
fun i(tag: String = "", message: String)
fun w(tag: String = "", message: String)
fun e(tag: String = "", message: String)
}
expect fun getLogger(): Logger
開発者向けエンドポイント(Log.x)
val Log: Logger = if (KmpConfig.actualPlatform) {
getLogger()
} else {
EmptyLogger()
}
private class EmptyLogger: Logger {
override fun d(tag: String, message: String) {
// NOP
}
override fun i(tag: String, message: String) {
// NOP
}
override fun w(tag: String, message: String) {
// NOP
}
override fun e(tag: String, message: String) {
// NOP
}
}
ここでわざわざEmptyLoggerを噛ませているのは、commonMainのUnit testの失敗を防ぐためです。実行の際、環境(android or iOS)を選択するのですが、actualのコードが呼ばれてしまうとクラッシュしてしまいます。
KmpConfigが必要かどうかは状況によって異なると思います。弊社につきましては、この他にも設定群を引数で渡す予定があるため作成しました。
object KmpConfig {
internal var actualPlatform: Boolean = false
/**
* 実行環境(iOS / Android)で利用する場合に呼び出してください
*/
fun setup() {
actualPlatform = true
}
}
2-2. commonTest
commonMainのユニットテストの格納場所です。
class HelloKmpTest {
@Test
fun `kmp greets us`() = runTest {
// Arrange
val model = HelloKmp()
// Act
model.greeting()
// Assert: NOP
}
}
2-3. androidMain
具象プラットフォーム(Android)向け実装のフォルダです。
class AndroidLogger : Logger {
override fun d(tag: String, message: String) {
android.util.Log.d(tag, message)
}
override fun i(tag: String, message: String) {
android.util.Log.i(tag, message)
}
override fun w(tag: String, message: String) {
android.util.Log.w(tag, message)
}
override fun e(tag: String, message: String) {
android.util.Log.e(tag, message)
}
}
actual fun getLogger(): Logger = AndroidLogger()
2-4. androidUnitTest
Android向けテストコードの格納場所です。
class AndroidLoggerTest {
private val scenario = "AndroidLogger integrate"
private val message = "hello!"
private lateinit var Log: Logger
@BeforeTest
fun setUp() {
Log = AndroidLogger()
}
/**
* NOTE:
* android.util.Log は androidUnitTest では動作しない。
* (java.lang.RuntimeException: Method x in android.util.Log not mocked.)
*
* 従って、目的を果たせればいいので呼び出したメソッド名をStackTraceから取得するという荒技を取ります。
*/
private fun getExceptionOccurredMethodName(testingBlock: () -> Unit): String {
return try {
testingBlock()
""
} catch (e: Exception) {
e.stackTrace[0].methodName
}
}
@Test
fun `Log d will succeed and will not crash`() {
// Act
val actual = getExceptionOccurredMethodName {
Log.d(tag = scenario, message = message)
}
// Assert
assertEquals("d", actual)
}
...(略)
ここはコメントの通り、少々荒技を使いました。今の目的はCI構築なので、テストコードの品質に拘るよりは、まずは動く状態を目指します。
2-5. iosMain
具象プラットフォーム(iOS)向け実装のフォルダです。
class IOSLogger : Logger {
override fun d(tag: String, message: String) {
log(tagPrefix = "DEBUG", tag = tag, message = message)
}
override fun i(tag: String, message: String) {
log(tagPrefix = "INFO", tag = tag, message = message)
}
override fun w(tag: String, message: String) {
log(tagPrefix = "WARN", tag = tag, message = message)
}
override fun e(tag: String, message: String) {
log(tagPrefix = "ERROR", tag = tag, message = message)
}
private fun log(tagPrefix: String, tag: String, message: String) {
NSLog("$tagPrefix: [$tag] $message")
}
}
actual fun getLogger(): Logger = IOSLogger()
2-6. iosTest
iOS向けテストコードの格納場所です。
class IOSLoggerTest {
private val senario = "IOSLogger integrate"
private val message = "hello!"
private lateinit var Log: Logger
@BeforeTest
fun setUp() {
Log = IOSLogger()
}
@Test
fun `Log d will succeed and will not crash`() {
// Act
Log.d(tag = senario, message = message)
}
...(略)
2-7. Gradle
kotlin {
sourceSets {
commonMain.dependencies {
//put your multiplatform dependencies here
}
commonTest.dependencies {
implementation(libs.kotlin.test)
implementation(libs.kotlinx.coroutines.test)
}
}
}
3. KMP単独用のBitriseの設定
ポイントは以下です。
Java version = 21
commonMain & androidMain のテスト = testDebugUnitTest task
iosMain のテスト = iosSimulatorArm64Test task
実際のConfiguration YAMLのイメージを添付します(このままコピペしても動作しないのでご注意ください)
---
format_version: '13'
default_step_lib_source: https://github.com/bitrise-io/bitrise-steplib.git
project_type: android
workflows:
_git-clone:
steps:
- activate-ssh-key@4: {}
- git-clone@8: {}
_run_tests:
steps:
- gradle-unit-test@1:
inputs:
- unit_test_task: iosSimulatorArm64Test
title: iosSimulatorArm64Test (Gradle Unit Test)
- gradle-unit-test@1:
inputs:
- unit_test_task: testDebugUnitTest
title: testDebugUnitTest [commonTest, androidUnitTest] (Gradle Unit Test)
before_run: []
_set_java_version:
steps:
- set-java-version@1:
inputs:
- set_java_version: '21'
_setup_env:
steps:
- install-missing-android-tools@3: {}
before_run: []
run_tests:
before_run:
- _set_java_version
- _git-clone
- _setup_env
- _run_tests
steps:
- slack@4:
// 必要に応じたメッセージを送信する
4. Xcode へ KMPを取り込む
最終的には以下の様なフォルダ構成になります。KMPから生成したxcframeworkはdebug / release向けがあるので、後述のスクリプトにて適切なLibを kmp-build へ移動してXcodeビルドします。
4-1. git submoduleによりKMPソースセットを読み込む
本題ではないので手順は簡単に記載します。
% git submodule add git@github.com:hogehoge/kmp-my-hoge-lib.git
SPMで配信することも可能ですが、その場でKotlin側を修正しながらデバッグできる環境を目指したいのでこの構成となっています。
4-2. Xcodeのビルド時にKMPのビルドもトリガーする
Xcode > Scheme > Build > Pre-action にKMPからxcframeworkを生成するスクリプトを記述します。
"$SRCROOT/Script/kmp-build.sh"
このように、shファイルを切り出しておいて実行すると、コード管理しやすいです。Schemeに直接記述すると、プルリクの差分で改行などのインデントが崩れた状態になるためです。
4-3. kmp-build.shの中身(xcframework生成の手順)
概要としては、以下の様なステップを実行します。
Java環境を設定する(CI環境 と Local環境の分岐)
KMPをビルドする
生成したxcframeworkをコピーで所定の場所に移動する
XcodeのCONFIGURATIONを参照し、debug / release の xcframework を切り替えているのがポイントです。
# Build shared_myHogeLib.xcframework & cp kmp-build
set -e
# Check if running on Bitrise
if [ "$BITRISE_IO" == "true" ]; then
echo "Running on Bitrise"
else
echo "Running locally"
export JAVA_HOME=/Applications/Android\ Studio.app/Contents/jbr/Contents/Home
fi
if [ "${CONFIGURATION}" == "Release" ]; then
echo "CONFIGURATION=Release"
IS_RELEASE_BUILD=true
else
echo "CONFIGURATION=Debug"
IS_RELEASE_BUILD=false
fi
# コピー元の親ディレクトリ
KMP_BIN_DIR="$SRCROOT/kmp-hoge-lib/myHogeLib/build"
KMP_FW_ROOT_DIR="$KMP_BIN_DIR/XCFrameworks"
# コピー先のディレクトリ
DEST_ROOT_DIR="$SRCROOT/kmp-build"
if [ "$IS_RELEASE_BUILD" == true ]; then
BUILD_CONFIG="release"
else
BUILD_CONFIG="debug"
fi
FRAMEWORK_DIR="SharedHogeLib.xcframework"
# start execute
cd "$SRCROOT"
echo "*** clean up $KMP_BIN_DIR"
rm -rf "$KMP_BIN_DIR"
echo "*** clean up $DEST_ROOT_DIR"
rm -rf "$DEST_ROOT_DIR"
mkdir -p "$DEST_ROOT_DIR"
# Build framework
echo "*** start build: $FRAMEWORK_DIR"
cd "$SRCROOT/kmp-hoge-lib"
if [ "$IS_RELEASE_BUILD" == true ]; then
./gradlew :shared:assembleSharedHogeLibReleaseXCFramework
else
./gradlew :shared:assembleSharedHogeLibDebugXCFramework
fi
if [ -d "$KMP_FW_ROOT_DIR" ]; then
echo "*** $FRAMEWORK_DIR build successful!"
else
echo "*** $FRAMEWORK_DIR build failed..."
exit 1
fi
cd "$SRCROOT"
cp -r "${KMP_FW_ROOT_DIR}/${BUILD_CONFIG}/$FRAMEWORK_DIR" "$DEST_ROOT_DIR"
4-4. KMPライブラリの依存関係をXcodeに設定する
ここまで設定したら、一度、ビルドします。その後、『Xcode > TARGETS > General > Framework, Libraries, and Embedded Content』にて、kmp-build フォルダの .xcodeproj の参照を追加します。
毎回KMPビルドするメリデメ
ビルド結果をRemote SPMとして配信する構成もあり得たのですが、今回はXcodeのビルドをトリガーに毎回KMPビルドをする形にしました。メリデメがあるので、現状に合わせて選定するのが良いと思います。
メリット
KMP側の変更をすぐに反映できる
(その場でデバッグ、その場で修正)
デメリット
KMPビルドのオーバーヘッドが増える
5. Swift から KMP のロジックを呼び出す!
場所はどこでも良いです。試しに呼びだしてみましょう!
class HogeAppClass {
func xxxxx() {
KmpConfig.shared.setup()
Task {
do {
try await HelloKmp().greeting()
} catch {
print(error)
}
}
}
}
hello, kmp!
6. Xcode の Bitrise workflowを修正する
こちらは簡単で、ビルド前に java version = 21 を設定するだけです。
暗黙的でも動作はするかも知れませんが、安定化させるために明示するのがお勧めです。
おわりに
本記事ではKMPのプロジェクト作成に関する手順をざっくり記載しました。もし具体の取り組みを聞きたい方や、弊社にご興味を持った方は、ぜひお気軽にカジュアル面談でお会いしましょう!