KMP Project with Tuist: KMP iOS Build System Construction

0. Introduction
My name is Choi Garamoi, and I am responsible for developing the Android app "KINTO Kantan Moushikomi."
This app uses KMP and shares some business logic with iOS app.
This time, I've summarized the results of using Tuist to enable smoother development of a KMP project.
1. Overview
In Android Studio, the KMP Application project is created using the New Project Wizard. Once it is created, the Monorepo will look like this:
KmpProject/
├── androidApp/ # Android専用モジュール(Kotlin/Android)
├── iosApp/ # iOS専用モジュール(Swift)
└── shared/ # 共通ビジネスロジック(Kotlin/Multiplatform)
Similar to Gradle for Android, a build environment suitable for modularization and team development is also important in iOS. This article will show you how to build one using Tuist.
2. Challenges in Xcode Project
Xcode manages the information required to build an app in *.xcodeproj
, but there are some challenges.
- Merge conflicts occur frequently: When settings are changed, Xcode automatically updates the
project.pbxproj
file. This file is in an unstructured text format, which often leads to Git conflicts when multiple people edit it simultaneously. - Practically meaningless diffs are generated: Xcode's GUI operations can generate many diffs without any real changes, cluttering the history.
- Difficult to automate: Many settings are GUI dependent, making it difficult to automate builds using CI/CD or scripts.
- Difficult to review:
project.pbxproj
is hard to read, making it difficult to review changes. - Scalability is limited: As the team size grows, managing multiple targets and build settings can become cumbersome.
The *.xcodeproj
directory is like a combination of Gradle and .idea
directories in an Android project, so it is not possible to separate local Xcode settings from iOS app build settings.
Google Trends also shows that there are more searches for "xcode conflict" than for "xcode dev," indicating that there are more conflicts during development.
Tuist?
3. What isIt is a tool that can generate Xcode projects and workspaces in the Swift language and can also build in combination with Xcode terminal tools.
Main features are as follows:
- Modularization support
- Environment-independent builds (team development oriented)
- Automation support
- Swift Package Manager support
Other Xcode build tools include Swift Package Manager, XcodeGen, and Bazel.
- Swift Package Manager : It only provides the dependency management functionality of Gradle (the
dependencies
block). - XcodeGen : Insufficient checking of tool settings makes it prone to human error.
- Bazel : It is complex to use because it is intended for large-scale projects and is overengineered for small and medium-sized projects.
Tuist
4. IntroducingThe following steps are based on Migrate an Xcode project and reflect the latest information:
4-1. Install Tuist
brew update
brew upgrade tuist
brew install tuist
For installation methods other than Homebrew, refer to the Manual.
4-2. Add Tuist Config files
Add three files (Manifest files): Tuist.swift
, Project.swift
, and Tuist/Package.swift
.
KmpWithSwift/
├── Tuist.swift
├── Tuist/
│ └── Package.swift
├── Project.swift
├── androidApp/
│ └── ...
├── iosApp/
│ └── ...
└── shared/
└── ...
Target
) to the Settings
4-3. Add a Module (Project.swift
is the main config file, and you can use the sample from Migrate an Xcode project as is for the other files.
Tuist.swift
4-3-1. import ProjectDescription
let tuist = Tuist(project: .tuist())
Project.swift
4-3-2. Add target settings and build scripts for KMP common modules.
infoPlist
: Whole screen setup.scripts
: Command to build the KMP common module.
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. Delete the Old Xcode Project
- Delete
./iosApp/iosApp.xcodeproj
. - Add
*.xcodeproj
to./.gitignore
.
4-5. Check
Check whether Tuist has been set up correctly.
- Verify that the Tuist Manifest file can be opened in Xcode.
# KmpWithSwiftディレクトリで tuist edit
- Generate an Xcode project with Tuist.
# KmpWithSwiftディレクトリで tuist generate
- Once Xcode is open, run the app.
- If the app starts normally, the process is complete.
5. Separate iOS Settings from Common Features
The common module ./shared/build.gradle.kts
does not properly separate the scope of responsibility for the common business logic build settings and the iOS-specific XCFramework build.
kotlin {
// ...
listOf(
iosX64(),
iosArm64(),
iosSimulatorArm64()
).forEach {
it.binaries.framework {
baseName = "shared"
isStatic = true
}
}
// ...
}
5-1. Response
If you separate the iOS XCFramework settings below from :shared
and move them to ios
, you can set them more effortlessly and enhance modularity.
it.binaries.framework {
baseName = "shared"
isStatic = true
}
5-2. Procedure
- Build XCFramework from the
./iosApp/shared
module. - Update the scripts in the
App
target (./iosApp/iosApp
directory).
:iosApp:shared
Module
5-2-1. Add the Add the ./iosApp/shared/build.gradle.kts
file and add the module to ./settings.gradle.kts
. No Android settings are required.
// ./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"
)
Delete the XCFramework configuration from ./shared/build.gradle.kts
.
// ./shared/build.gradle.kts
plugins {
alias(libs.plugins.kotlinMultiplatform)
alias(libs.plugins.androidLibrary)
}
kotlin {
// ... 省略 ...
iosX64()
iosArm64()
iosSimulatorArm64()
// ... 省略 ...
}
// ... 省略 ...
5-2-2. Tuist Target Update
Update the Gradle command in scripts
of Project.swift
.
Change the module in scripts
from :shared
to the added :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. Multimodularization
As your app scales, modularizing features becomes essential.
But if each feature requires :iosApp:shared
, the Tuist config becomes as follows:
// ./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"
)
]
)
]
)
This configuration has the following two major issues:
- A common XCFramework will be built for the number of feature modules (
:IOSApp:shared
). - Depending on your feature module target settings and build options, there is a risk that the app may use multiple versions of
:iosApp:shared
.
To solve this problem, wrap :iosApp:shared
with a Tuist target.
6-1. Add Wrapping Target
Add the KmpCore
target so that feature modules share Xcode targets instead of directly using the Gradle :shared
module.
The source code is in iosApp/shared/**
, which is the same as :iosApp:shared
, but uses KmpCore
as a wrapping target to utilize the namespace generated by KMP and to encapsulate it. As a result, the only target that holds information about the KMP common code is 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"
)
]
)
]
)
shared
via KmpCore
6-2. Expose Simply the KmpCore
target dependency on shared
does not allow access from FeatureA
and FeatureB
to :shared
code.
Additional configuration is required to allow FeatureA
and FeatureB
to access the KMP common code (Gradle's :shared
module) via KmpCore
.
First, add the settings
configuration to the KmpCore
target.
// ./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"
])
)
]
)
Add the following Swift file so that the KmpCore
target exposes the shared
namespace in the KmpCore
namespace.
// ./iosApp/shared/KmpCore.swift
@_exported import shared
6-3. Check
After building the project in Xcode, FeatureA
and FeatureB
can access :shared
via import KmpCore
.
For example, if the :shared
module has a SomeModel
(shared/src/commonMain/kotlin/ktc/garamoi/choi/kmp/with/tuist/SomeModel.kt
) class, it can be accessed from FeatureA
as follows.
import Foundation
import KmpCore
public class SomeFeatureAClass {
let model: SomeModel
// ...
}
If a compilation error occurs, the initial build may become unstable due to build order or caching issues. In that case, you can resolve the issue by performing a clean build or building multiple times.
7. Conclusion
- Xcode's
*.xcodeproj
is not suitable for automation and team development. - I recommend using Tuist as an alternative to
*.xcodeproj
for Xcode projects. - By wrapping the KMP common module's XCFramework generation in an Xcode project target, feature modularization becomes easier.
8. References
- Kotlin Multiplatform : The official Kotlin project that provides various tools for cross-platform development using the Kotlin language.
- Gradle : A de-facto build tool for Android and Java projects.
- Tuist : Build tools for Xcode projects.
- Swift : An OOP language developed by Apple to replace Objective-C.
- Xcode : An IDE for Apple platforms.
- Xcode / Projects and workspaces
- Swift Package Manager : The official dependency management tool for the Swift language.
- XcodeGen : A tool to generate Xcode Project in YAML and JSON.
- Bazel : A build tool developed by Google. Targets large-scale Monorepo.
- Monorepo Explained : A system for managing multiple software in a single repository.
- Google Trends :
xcode conflict
,xcode merge
,xcode dev
: The proportion of Xcode conflict searches is higher when compared to general Xcode development searches. - What is
project.pbxproj
in Xcode - Project configuration / Projects / Project settings : Explanation of the
.idea
directory in Android Studio and IntelliJ IDEA. - Migrate an Xcode project : Manual steps to turn an existing Xcode project into a Tuist project.
- Homebrew : A system package management tool for macOS.
- Install Tuist
- Xcode / Bundles and frameworks / Creating a static framework
- Swift logo : The official logos can be downloaded below.
- Kotlin logo
- Gradle logo
- Tuist logo
- KINTO Kantan Moushikomi : Android App
- KINTO Kantan Moushikomi : iOS App
- Choi Garamoi
関連記事 | Related Posts

KMP Project with Tuist: KMP iOS Build System Construction

既存のアプリへのKMPの適用:チームの経験と成果

Kotlin Multiplatform Mobile (KMM)を使ったモバイルアプリ開発

Applying KMP to an Existing App: Our Team’s Experience and Achievements

Kotlin Multiplatform Mobile(KMM)およびCompose Multiplatformを使用したモバイルアプリケーションの開発

Kotlin Multiplatform (KMP)でのテストのすべて
We are hiring!
【ソフトウェアエンジニア(リーダークラス)】共通サービス開発G/東京・大阪
共通サービス開発グループについてWebサービスやモバイルアプリの開発において、必要となる共通機能=会員プラットフォームや決済プラットフォームなどの企画・開発を手がけるグループです。KINTOの名前が付くサービスやKINTOに関わりのあるサービスを同一のユーザーアカウントに対して提供し、より良いユーザー体験を実現できるよう、様々な共通機能や顧客基盤を構築していくことを目的としています。
【福岡拠点立ち上げ】オープンポジション(エンジニア)
やっていること国内サービスでは、トヨタのクルマのサブスクリプションサービスである『 KINTO ONE 』を中心に、移動のよろこびを提供する『 モビリティーマーケット 』、MaaSサービスの『 my route(マイルート) 』など、トヨタグループが展開する各種サービスの開発・運営を担っています。