KINTO Tech Blog
KMP (Kotlin Multiplatform)

KMP Project with Tuist - KMPのiOSビルドシステム構築

Cover Image for KMP Project with Tuist - KMPのiOSビルドシステム構築

0. はじめに

Androidアプリ「KINTO かんたん申し込み」の開発を担当しているChoi Garamoiです。

このアプリではKMPを導入し、一部のビジネスロジックをiOSアプリと共有しています。

今回はよりスムーズなKMPプロジェクtの開発のためTuistを試した結果をまとめました。

1. 概要

Android Studioでは、KMP Applicationプロジェクトは新規プロジェクトウィザードを使って作成します。作成後は以下のようなMonorepoになります:

KmpProject/
├── androidApp/          # Android専用モジュール(Kotlin/Android)
├── iosApp/              # iOS専用モジュール(Swift)
└── shared/              # 共通ビジネスロジック(Kotlin/Multiplatform)

AndroidのGradleと同様に、iOSでもモジュール化とチーム開発に適したビルド環境が重要です。
本記事では、それをTuistを使って構築する方法を紹介します。

2. Xcodeプロジェクトの問題

Xcodeはアプリのビルドに必要な情報を*.xcodeprojで管理していますが、いくつかの課題があります。

  1. マージコンフリクトが頻発する : Xcodeは設定を変更すると、project.pbxprojファイルを自動的に更新します。このファイルは構造化されていないテキスト形式のため、複数人が同時に編集するとGit上でコンフリクトが頻繁に発生します。
  2. 実質的に意味のない差分が生成される : XcodeのGUI操作によって、実質的な変更がなくても多くの差分が生じ、履歴が煩雑になります。
  3. 自動化が困難 : 多くの設定がGUI依存であるため、CI/CDやスクリプトによるビルド自動化が困難です。
  4. レビューし難い : project.pbxprojは可読性が低く、変更内容のレビューがしづらくなります。
  5. 拡張性に限界がある : チーム規模が大きくなると、複数ターゲットやビルド設定の管理が煩雑になります。

*.xcodeprojディレクトリは、Androidプロジェクトで言うところのGradle.ideaディレクトリを合わせたようなもので、ローカルのXcode設定とiOSアプリのビルド設定を分離できません。

Googleトレンドでも「xcode conflict」の検索数が「xcode dev」に比べて多く、開発時の衝突が多いことがうかがえます。

3. Tuistとは

Swift言語でXcodeのProjectsとworkspacesを生成とXcodeのターミナルツールを組み合わせてビルドも出来るツールです。

主な機能は

  1. モジュール化サポート
  2. 環境独立的ビルド(チーム開発指向)
  3. 自動化サポート
  4. Swift Package Managerサポート

です。

他のXcodeのビルドツールとしてはSwift Package ManagerXcodeGenBazelがあります。

  • Swift Package Manager : Gradleの依存性管理機能(dependenciesブロック)だけを提供します。
  • XcodeGen : ツールの設定値チェックが足りないため人的ミスが発生しやすいです。
  • Bazel : 大規模プロジェクトを対象にするため使い方が複雑で、中小規模のプロジェクトにはオーバースペックです。

4. Tuistを導入する

以下はMigrate an Xcode projectを基本に、最新情報を反映した手順です。

4-1. Tuistをインストールする

brew update
brew upgrade tuist
brew install tuist

Homebrew以外のインストール方法はマニュアルをご参照ください。

4-2. Tuist設定ファイルを追加する

Tuist.swiftProject.swiftTuist/Package.swiftの3つのファイル(Manifestファイル)を追加します。

KmpWithSwift/
├── Tuist.swift
├── Tuist/
│   └── Package.swift
├── Project.swift
├── androidApp/
│   └── ...
├── iosApp/
│   └── ...
└── shared/
    └── ...

4-3. 設定にモジュール(Target)を追加する

Project.swiftが主な設定ファイルで、他のファイルはMigrate an Xcode projectのサンプルをそのまま利用しても問題ありません。

4-3-1. Tuist.swift

import ProjectDescription

let tuist = Tuist(project: .tuist())

4-3-2. Project.swift

ターゲットの設定とKMP共通モジュールのビルドスクリプトを追加する。

  • infoPlist : 全体画面の設定。
  • scripts : KMP共通モジュールをビルドするコマンド。
import ProjectDescription

let project = Project(
    name: "KmpWithSwift",
    targets: [
        .target(
            name: "App",
            destinations: .iOS,
            product: .app,
            bundleId: "ktc.garamoi.choi.kmp.with.tuist.App",
            infoPlist: .extendingDefault(
                with: [
                    "UILaunchScreen": [
                        "UIColorName": "",
                        "UIImageName": "",
                    ],
                ]
            ),
            sources: ["iosApp/iosApp/**"],
            resources: ["iosApp/iosApp/**"],
            scripts: [
                .pre(
                    script: """
                    cd "$SRCROOT"
                    ./gradlew :shared:embedAndSignAppleFrameworkForXcode
                    """,
                    name: "Build KMP"
                )
            ]
        )
    ]
)

4-3-3. Tuist/Package.swift

// swift-tools-version: 6.0
import PackageDescription

#if TUIST
    import struct ProjectDescription.PackageSettings

    let packageSettings = PackageSettings(
        productTypes: [:]
    )
#endif

let package = Package(
    name: "App",
    dependencies: [
    ]
)

4-4. 古いXcodeプロジェクトを削除する

  1. ./iosApp/iosApp.xcodeprojを削除します。
  2. ./.gitignore*.xcodeprojを追加します。

4-5. 確認

Tuistの設定が正しくできたか確認します。

  1. TuistのManifestファイルをXcodeで開けることを確認します。
    # KmpWithSwiftディレクトリで
    tuist edit
    
  2. TuistでXcodeプロジェクトを生成します。
    # KmpWithSwiftディレクトリで
    tuist generate
    
  3. Xcodeが開いたらアプリを実行します。
  4. アプリが正常に起動すれば完了です。

5. 共通機能からiOS設定を分離する

共通モジュールの./shared/build.gradle.ktsは、共通のビジネスロジックのビルド設定とiOS専用のXCFrameworkのビルドの責任範囲が適切に分離されていません。

kotlin {
    // ...

    listOf(
        iosX64(),
        iosArm64(),
        iosSimulatorArm64()
    ).forEach {
        it.binaries.framework {
            baseName = "shared"
            isStatic = true
        }
    }

    // ...
}

5-1. 対応

下記のiOSのXCFramework設定を:sharedから分離してiosへ移動すると、より自然な形で設定でき、マルチモジュール化も楽になります。

it.binaries.framework {
    baseName = "shared"
    isStatic = true
}

5-2. 手順

  1. ./iosApp/sharedモジュールからXCFrameworkをビルドする。
  2. Appターゲット(./iosApp/iosAppディレクトリー)のスクリプトを更新する。

5-2-1. :iosApp:sharedモジュール追加

./iosApp/shared/build.gradle.ktsファイルを追加し、./settings.gradle.ktsにモジュールを追加します。
Androidの設定は不要です。

// ./iosApp/shared/build.gradle.kts
plugins {
    alias(libs.plugins.kotlin.multiplatform)
}

kotlin {
    listOf(
        iosX64(),
        iosArm64(),
        iosSimulatorArm64()
    ).forEach {
        it.binaries.framework {
            baseName = "shared"
            isStatic = true

            export(projects.shared)
        }
    }

    sourceSets {
        commonMain.dependencies {
            api(projects.shared)
        }
    }
}
// ./settings.gradle.kts
// ... 省略 ...
rootProject.name = "KmpProject"
include(
    ":androidApp",
    ":iosApp:shared",
    ":shared"
)

./shared/build.gradle.ktsからXCFramework設定を削除します。

// ./shared/build.gradle.kts
plugins {
    alias(libs.plugins.kotlinMultiplatform)
    alias(libs.plugins.androidLibrary)
}

kotlin {
    // ... 省略 ...

    iosX64()
    iosArm64()
    iosSimulatorArm64()

    // ... 省略 ...
}
// ... 省略 ...

5-2-2. Tuistターゲット更新

Project.swiftscriptsのGradleコマンドを更新します。

scriptsのモジュールを:sharedから追加した:iosApp:sharedへ変更します(./gradlew :shared:embedAndSignAppleFrameworkForXcode ➡️ ./gradlew :iosApp:shared:embedAndSignAppleFrameworkForXcode)。

// ./Project.swift
import ProjectDescription

let project = Project(
    name: "KmpWithSwift",
    targets: [
        .target(
            name: "App",
            destinations: .iOS,
            product: .app,
            bundleId: "ktc.garamoi.choi.kmp.with.tuist.App",
            infoPlist: .extendingDefault(
                with: [
                    "UILaunchScreen": [
                        "UIColorName": "",
                        "UIImageName": "",
                    ],
                ]
            ),
            sources: ["iosApp/iosApp/**"],
            resources: ["iosApp/iosApp/**"],
            scripts: [
                .pre(
                    script: """
                    cd "$SRCROOT"
                    ./gradlew :iosApp:shared:embedAndSignAppleFrameworkForXcode
                    """,
                    name: "Build KMP"
                )
            ]
        )
    ]
)

6. マルチモジュール化

アプリが成長するとフィーチャーモジュール化が必要になります。

しかし各フィーチャーが:iosApp:sharedが必要な場合、Tuist設定は下記のようになります。

// ./Project.swift
import ProjectDescription

let project = Project(
    name: "KmpWithSwift",
    targets: [
        .target(
            name: "App",
            destinations: .iOS,
            product: .app,
            bundleId: "ktc.garamoi.choi.kmp.with.tuist.App",
            infoPlist: .extendingDefault(
                with: [
                    "UILaunchScreen": [
                        "UIColorName": "",
                        "UIImageName": "",
                    ],
                ]
            ),
            sources: ["iosApp/iosApp/**"],
            resources: ["iosApp/iosApp/**"],
            dependencies: [
                .target("FeatureA"),
                .target("FeatureB")
            ]
        ),
        .target(
            name: "FeatureA",
            destinations: .iOS,
            product: .framework,
            bundleId: "ktc.garamoi.choi.kmp.with.tuist.FeatureA",
            infoPlist: .default,
            sources: ["iosApp/FeatureA/**"],
            resources: ["iosApp/FeatureA/**"],
            scripts: [
                .pre(
                    script: """
                    cd "$SRCROOT"
                    ./gradlew :iosApp:shared:embedAndSignAppleFrameworkForXcode
                    """,
                    name: "Build KMP"
                )
            ]
        ),
        .target(
            name: "FeatureB",
            destinations: .iOS,
            product: .framework,
            bundleId: "ktc.garamoi.choi.kmp.with.tuist.FeatureB",
            infoPlist: .default,
            sources: ["iosApp/FeatureB/**"],
            resources: ["iosApp/FeatureB/**"],
            scripts: [
                .pre(
                    script: """
                    cd "$SRCROOT"
                    ./gradlew :iosApp:shared:embedAndSignAppleFrameworkForXcode
                    """,
                    name: "Build KMP"
                )
            ]
        )
    ]
)

この設定では下記の2つの大きな課題があります。

  1. 共通のXCFrameworkがフィーチャーモジュール(:iosApp:shared)の数の分ビルドされてしまいます。
  2. フィーチャーモジュールのターゲット設定やビルドオプションによっては、アプリが複数バージョンの:iosApp:sharedを利用してしまう恐れが有ります。

この問題を解決するために、:iosApp:sharedをTuistターゲットでラップします。

6-1. Wrappingターゲット追加

フィーチャーモジュールが直接にGradleの:sharedモジュールを使わず、Xcodeのターゲットを共有するようにKmpCoreターゲットを追加します。

ソースコードはiosApp/shared/**:iosApp:sharedと同じですが、KMPで生成するネームスペースとカプセル化してWrappingターゲットを使うようにKmpCoreにします。
この対応により、KMP共通コードの情報を持っているターゲットはKmpCoreのみになります。

// ./Project.swift
import ProjectDescription

let project = Project(
    name: "KmpWithSwift",
    targets: [
        .target(
            name: "App",
            destinations: .iOS,
            product: .app,
            bundleId: "ktc.garamoi.choi.kmp.with.tuist.App",
            infoPlist: .extendingDefault(
                with: [
                    "UILaunchScreen": [
                        "UIColorName": "",
                        "UIImageName": "",
                    ],
                ]
            ),
            sources: ["iosApp/iosApp/**"],
            resources: ["iosApp/iosApp/**"],
            dependencies: [
                .target("FeatureA"),
                .target("FeatureB")
            ]
        ),
        .target(
            name: "FeatureA",
            destinations: .iOS,
            product: .framework,
            bundleId: "ktc.garamoi.choi.kmp.with.tuist.FeatureA",
            infoPlist: .default,
            sources: ["iosApp/FeatureA/**"],
            resources: ["iosApp/FeatureA/**"],
            dependencies: [.target(name: "KmpCore")]
        ),
        .target(
            name: "FeatureB",
            destinations: .iOS,
            product: .framework,
            bundleId: "ktc.garamoi.choi.kmp.with.tuist.FeatureB",
            infoPlist: .default,
            sources: ["iosApp/FeatureB/**"],
            resources: ["iosApp/FeatureB/**"],
            dependencies: [.target(name: "KmpCore")]
        ),
        .target(
            name: "KmpCore",
            destinations: .iOS,
            product: .framework,
            bundleId: "ktc.garamoi.choi.kmp.with.tuist.KmpCore",
            infoPlist: .default,
            sources: ["iosApp/shared/**"],
            resources: ["iosApp/shared/**"],
            scripts: [
                .pre(
                    script: """
                    cd "$SRCROOT"
                    ./gradlew :iosApp:shared:embedAndSignAppleFrameworkForXcode
                    """,
                    name: "Build KMP"
                )
            ]
        )
    ]
)

6-2. KmpCoresharedを露出する

単純にKmpCoreターゲットがsharedへ依存性を持つだけではFeatureAFeatureBから:sharedのコードへアクセスができません。

FeatureAFeatureBからKmpCoreを経由してKMP共通コード(Gradleの:sharedモジュール)へアクセスできるように追加設定が必要です。

まずはKmpCoreターゲットにsettings設定を追加します。

// ./Project.swift
import ProjectDescription

let project = Project(
    name: "KmpWithSwift",
    targets: [
        // ... 省略 ...
        .target(
            name: "KmpCore",
            destinations: .iOS,
            product: .framework,
            bundleId: "ktc.garamoi.choi.kmp.with.tuist.KmpCore",
            infoPlist: .default,
            sources: ["iosApp/shared/**"],
            resources: ["iosApp/shared/**"],
            scripts: [
                .pre(
                    script: """
                    cd "$SRCROOT"
                    ./gradlew :iosApp:shared:embedAndSignAppleFrameworkForXcode
                    """,
                    name: "Build KMP"
                )
            ],
            settings: .settings(base: [
                "FRAMEWORK_SEARCH_PATHS": "iosApp/shared/build/xcode-frameworks/**",
                "OTHER_LDFLAGS": "-framework shared"
            ])
        )
    ]
)

KmpCoreターゲットがsharedネームスペースをKmpCoreネームスペースで露出するするように下記のSwiftファイルを追加します。

// ./iosApp/shared/KmpCore.swift
@_exported import shared

6-3. 確認

XcodeでプロジェクトをビルドしたらFeatureAFeatureBimport KmpCoreしたら:sharedへアクセス出来ます。

例えば:sharedモジュールにSomeModel(shared/src/commonMain/kotlin/ktc/garamoi/choi/kmp/with/tuist/SomeModel.kt)のクラスがある場合FeatureAから下記のようにアクセスできます。

import Foundation
import KmpCore

public class SomeFeatureAClass {
    let model: SomeModel

    // ...
}

もしコンパイルエラーが発生する場合は、ビルド順やキャッシュの影響で一度目のビルドが不安定になることがあります。その場合はクリーンビルド又は複数回のビルドを試すことで解決できます。

7. 結論

  1. Xcodeの*.xcodeprojは自動化とチーム開発に適していません。
  2. Xcodeプロジェクトの*.xcodeprojの代替としてTuistの使用を推奨します。
  3. KMP共通モジュールのXCFrameworkの生成をXcodeプロジェクトのターゲットでラップすることで、フィーチャーモジュール化が容易になります。

8. 参考

  1. Kotlin Multiplatform : Kotlin言語でクロスプラットフォーム開発ができる色んなツールを提供するKotlin公式プロジェクト。
  2. Gradle : Android、Javaプロジェクトのde-factoビルドツール。
  3. Tuist : Xcodeプロジェクトのビルドツール。
  4. Swift : AppleがObjective-Cの代わりに開発したOOP言語。
  5. Xcode : Appleプラットフォーム用のIDE。
  6. Xcode / Projects and workspaces
  7. Swift Package Manager : Swift言語の公式依存性管理ツール。
  8. XcodeGen : YAMLとJSONでXcode Projectを生成するツール。
  9. Bazel : Googleが開発したビルドツール。大規模Monorepoを対象にする。
  10. Monorepo Explained : 複数のSWを1つのレポジトリーで管理する仕組み。
  11. Google Trends : xcode conflict, xcode merge, xcode dev : Xcodeの開発全般的な検索に比べてXcodeのコンフリクトの検索の割合が高い。
  12. What is project.pbxproj in Xcode
  13. Project configuration / Projects / Project settings : Android Studio, IntelliJ IDEAの.ideaディレクトリーの説明。
  14. Migrate an Xcode project : マニュアルで既存のXcodeプロジェクトをTuistプロジェクト化する手順。
  15. Homebrew : macOSのシステムパッケージ管理ツール。
  16. Install Tuist
  17. Xcode / Bundles and frameworks / Creating a static framework
  18. Swift logo : 下段から公式ロゴをダウンロード出来ます。
  19. Kotlin logo
  20. Gradle logo
  21. Tuist logo
  22. KINTO かんたん申し込み : Androidアプリ
  23. KINTO かんたん申し込み : iOSアプリ
  24. Choi Garamoi
Facebook

関連記事 | Related Posts

We are hiring!

【フロントエンドエンジニア】新車サブスク開発G/大阪・福岡

新車サブスク開発グループについてTOYOTAのクルマのサブスクリプションサービスである『 KINTO ONE 』のWebサイトの開発、運用をしています。​業務内容トヨタグループの金融、モビリティサービスの内製開発組織である同社にて、自社サービスである、TOYOTAのクルマのサブスクリプションサービス『KINTO ONE』のWebサイトの開発、運用を行っていただきます。

【フロントエンドエンジニア(リードクラス)】プロジェクト推進G/東京

配属グループについて▶新サービス開発部 プロジェクト推進グループ 中古車サブスク開発チームTOYOTAのクルマのサブスクリプションサービスである『 KINTO ONE 中古車 』のWebサイトの開発、運用を中心に、その他サービスの開発、運用も行っています。

イベント情報

Mobility Night #3 - マップビジュアライゼーション -