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. 3. What is Tuist ? It 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. 4. Introducing Tuist The 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/ └── ... 4-3. Add a Module ( Target ) to the Settings Project.swift is the main config file, and you can use the sample from Migrate an Xcode project as is for the other files. 4-3-1. Tuist.swift import ProjectDescription let tuist = Tuist(project: .tuist()) 4-3-2. Project.swift 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" ) ] ) ] ) 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. 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). 5-2-1. Add the :iosApp:shared Module 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. %%{ init: { 'theme': 'neutral' } }%% graph TB App ==> FeatureA App ==> FeatureB FeatureA --> :iosApp:shared FeatureB --> :iosApp:shared 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. %%{ init: { 'theme': 'neutral' } }%% graph TB App ==> FeatureA App ==> FeatureB FeatureA ==> KmpCore FeatureB ==> KmpCore KmpCore --> :iosApp:shared 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" ) ] ) ] ) 6-2. Expose shared via KmpCore 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