見出し画像

【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 を作成する

まずはプロジェクトを作成します。

Kotlin Multiplatform Library を選択します
Package Nameなど必要事項を記入します
XCFrameworkを選択します

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ビルドします。

kmp-build にビルドの度にコピーしてくる

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生成の手順)

概要としては、以下の様なステップを実行します。

  1. Java環境を設定する(CI環境 と Local環境の分岐)

  2. KMPをビルドする

  3. 生成した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のプロジェクト作成に関する手順をざっくり記載しました。もし具体の取り組みを聞きたい方や、弊社にご興味を持った方は、ぜひお気軽にカジュアル面談でお会いしましょう!

カジュアル面談はこちら

Open Positions


いいなと思ったら応援しよう!