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
で管理していますが、いくつかの課題があります。
- マージコンフリクトが頻発する : Xcodeは設定を変更すると、
project.pbxproj
ファイルを自動的に更新します。このファイルは構造化されていないテキスト形式のため、複数人が同時に編集するとGit上でコンフリクトが頻繁に発生します。 - 実質的に意味のない差分が生成される : XcodeのGUI操作によって、実質的な変更がなくても多くの差分が生じ、履歴が煩雑になります。
- 自動化が困難 : 多くの設定がGUI依存であるため、CI/CDやスクリプトによるビルド自動化が困難です。
- レビューし難い :
project.pbxproj
は可読性が低く、変更内容のレビューがしづらくなります。 - 拡張性に限界がある : チーム規模が大きくなると、複数ターゲットやビルド設定の管理が煩雑になります。
*.xcodeproj
ディレクトリは、Androidプロジェクトで言うところのGradleと.idea
ディレクトリを合わせたようなもので、ローカルのXcode設定とiOSアプリのビルド設定を分離できません。
Googleトレンドでも「xcode conflict」の検索数が「xcode dev」に比べて多く、開発時の衝突が多いことがうかがえます。
Tuistとは
3.Swift言語でXcodeのProjectsとworkspacesを生成とXcodeのターミナルツールを組み合わせてビルドも出来るツールです。
主な機能は
- モジュール化サポート
- 環境独立的ビルド(チーム開発指向)
- 自動化サポート
- Swift Package Managerサポート
です。
他のXcodeのビルドツールとしてはSwift Package Manager、XcodeGen、Bazelがあります。
- Swift Package Manager : Gradleの依存性管理機能(
dependencies
ブロック)だけを提供します。 - XcodeGen : ツールの設定値チェックが足りないため人的ミスが発生しやすいです。
- Bazel : 大規模プロジェクトを対象にするため使い方が複雑で、中小規模のプロジェクトにはオーバースペックです。
Tuistを導入する
4.以下はMigrate an Xcode projectを基本に、最新情報を反映した手順です。
4-1. Tuistをインストールする
brew update
brew upgrade tuist
brew install tuist
Homebrew以外のインストール方法はマニュアルをご参照ください。
4-2. Tuist設定ファイルを追加する
Tuist.swift
、Project.swift
、Tuist/Package.swift
の3つのファイル(Manifestファイル)を追加します。
KmpWithSwift/
├── Tuist.swift
├── Tuist/
│ └── Package.swift
├── Project.swift
├── androidApp/
│ └── ...
├── iosApp/
│ └── ...
└── shared/
└── ...
Target
)を追加する
4-3. 設定にモジュール(Project.swift
が主な設定ファイルで、他のファイルはMigrate an Xcode projectのサンプルをそのまま利用しても問題ありません。
Tuist.swift
4-3-1. import ProjectDescription
let tuist = Tuist(project: .tuist())
Project.swift
4-3-2. ターゲットの設定と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"
)
]
)
]
)
Tuist/Package.swift
4-3-3. // 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プロジェクトを削除する
./iosApp/iosApp.xcodeproj
を削除します。./.gitignore
に*.xcodeproj
を追加します。
4-5. 確認
Tuistの設定が正しくできたか確認します。
- TuistのManifestファイルをXcodeで開けることを確認します。
# KmpWithSwiftディレクトリで tuist edit
- TuistでXcodeプロジェクトを生成します。
# KmpWithSwiftディレクトリで tuist generate
- Xcodeが開いたらアプリを実行します。
- アプリが正常に起動すれば完了です。
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. 手順
./iosApp/shared
モジュールからXCFrameworkをビルドする。App
ターゲット(./iosApp/iosApp
ディレクトリー)のスクリプトを更新する。
:iosApp:shared
モジュール追加
5-2-1. ./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.swift
のscripts
の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つの大きな課題があります。
- 共通のXCFrameworkがフィーチャーモジュール(
:iosApp:shared
)の数の分ビルドされてしまいます。 - フィーチャーモジュールのターゲット設定やビルドオプションによっては、アプリが複数バージョンの
: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"
)
]
)
]
)
KmpCore
でshared
を露出する
6-2. 単純にKmpCore
ターゲットがshared
へ依存性を持つだけではFeatureA
とFeatureB
から:shared
のコードへアクセスができません。
FeatureA
とFeatureB
から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でプロジェクトをビルドしたらFeatureA
とFeatureB
がimport 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. 結論
- Xcodeの
*.xcodeproj
は自動化とチーム開発に適していません。 - Xcodeプロジェクトの
*.xcodeproj
の代替としてTuistの使用を推奨します。 - KMP共通モジュールのXCFrameworkの生成をXcodeプロジェクトのターゲットでラップすることで、フィーチャーモジュール化が容易になります。
8. 参考
- Kotlin Multiplatform : Kotlin言語でクロスプラットフォーム開発ができる色んなツールを提供するKotlin公式プロジェクト。
- Gradle : Android、Javaプロジェクトのde-factoビルドツール。
- Tuist : Xcodeプロジェクトのビルドツール。
- Swift : AppleがObjective-Cの代わりに開発したOOP言語。
- Xcode : Appleプラットフォーム用のIDE。
- Xcode / Projects and workspaces
- Swift Package Manager : Swift言語の公式依存性管理ツール。
- XcodeGen : YAMLとJSONでXcode Projectを生成するツール。
- Bazel : Googleが開発したビルドツール。大規模Monorepoを対象にする。
- Monorepo Explained : 複数のSWを1つのレポジトリーで管理する仕組み。
- Google Trends :
xcode conflict
,xcode merge
,xcode dev
: Xcodeの開発全般的な検索に比べてXcodeのコンフリクトの検索の割合が高い。 - What is
project.pbxproj
in Xcode - Project configuration / Projects / Project settings : Android Studio, IntelliJ IDEAの
.idea
ディレクトリーの説明。 - Migrate an Xcode project : マニュアルで既存のXcodeプロジェクトをTuistプロジェクト化する手順。
- Homebrew : macOSのシステムパッケージ管理ツール。
- Install Tuist
- Xcode / Bundles and frameworks / Creating a static framework
- Swift logo : 下段から公式ロゴをダウンロード出来ます。
- Kotlin logo
- Gradle logo
- Tuist logo
- KINTO かんたん申し込み : Androidアプリ
- KINTO かんたん申し込み : iOSアプリ
- Choi Garamoi
関連記事 | Related Posts

When We Put Compose on Top of BottomSheetDialogFragment, Anchoring a Button to the Bottom Proved Harder Than Expected

Jetpack Compose in myroute Android App

First Steps When Migrating From Android View to Jetpack Compose

A Kotlin Engineer’s Journey Building a Web Application with Flutter in Just One Month
![Cover Image for [iOS] From UIKit + Combine to a Tailor-Made SwiftUI Architecture](/assets/common/thumbnail_default_×2.png)
[iOS] From UIKit + Combine to a Tailor-Made SwiftUI Architecture

A Beginner’s Story of Inspiration With Compose Preview
We are hiring!
【フロントエンドエンジニア】新車サブスク開発G/大阪・福岡
新車サブスク開発グループについてTOYOTAのクルマのサブスクリプションサービスである『 KINTO ONE 』のWebサイトの開発、運用をしています。業務内容トヨタグループの金融、モビリティサービスの内製開発組織である同社にて、自社サービスである、TOYOTAのクルマのサブスクリプションサービス『KINTO ONE』のWebサイトの開発、運用を行っていただきます。
【フロントエンドエンジニア(リードクラス)】プロジェクト推進G/東京
配属グループについて▶新サービス開発部 プロジェクト推進グループ 中古車サブスク開発チームTOYOTAのクルマのサブスクリプションサービスである『 KINTO ONE 中古車 』のWebサイトの開発、運用を中心に、その他サービスの開発、運用も行っています。