KINTO Tech Blog
KMP (Kotlin Multiplatform)

KMP Project with Tuist: KMP iOS Build System Construction

Cover Image for 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.

  1. 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.
  2. Practically meaningless diffs are generated: Xcode's GUI operations can generate many diffs without any real changes, cluttering the history.
  3. Difficult to automate: Many settings are GUI dependent, making it difficult to automate builds using CI/CD or scripts.
  4. Difficult to review: project.pbxproj is hard to read, making it difficult to review changes.
  5. 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:

  1. Modularization support
  2. Environment-independent builds (team development oriented)
  3. Automation support
  4. 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

  1. Delete ./iosApp/iosApp.xcodeproj.
  2. Add *.xcodeproj to ./.gitignore.

4-5. Check

Check whether Tuist has been set up correctly.

  1. Verify that the Tuist Manifest file can be opened in Xcode.
    # KmpWithSwiftディレクトリで
    tuist edit
    
  2. Generate an Xcode project with Tuist.
    # KmpWithSwiftディレクトリで
    tuist generate
    
  3. Once Xcode is open, run the app.
  4. 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

  1. Build XCFramework from the ./iosApp/shared module.
  2. 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.

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:

  1. A common XCFramework will be built for the number of feature modules (:IOSApp:shared).
  2. 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"
                )
            ]
        )
    ]
)

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

  1. Xcode's *.xcodeproj is not suitable for automation and team development.
  2. I recommend using Tuist as an alternative to *.xcodeproj for Xcode projects.
  3. By wrapping the KMP common module's XCFramework generation in an Xcode project target, feature modularization becomes easier.

8. References

  1. Kotlin Multiplatform : The official Kotlin project that provides various tools for cross-platform development using the Kotlin language.
  2. Gradle : A de-facto build tool for Android and Java projects.
  3. Tuist : Build tools for Xcode projects.
  4. Swift : An OOP language developed by Apple to replace Objective-C.
  5. Xcode : An IDE for Apple platforms.
  6. Xcode / Projects and workspaces
  7. Swift Package Manager : The official dependency management tool for the Swift language.
  8. XcodeGen : A tool to generate Xcode Project in YAML and JSON.
  9. Bazel : A build tool developed by Google. Targets large-scale Monorepo.
  10. Monorepo Explained : A system for managing multiple software in a single repository.
  11. Google Trends : xcode conflict, xcode merge, xcode dev : The proportion of Xcode conflict searches is higher when compared to general Xcode development searches.
  12. What is project.pbxproj in Xcode
  13. Project configuration / Projects / Project settings : Explanation of the .idea directory in Android Studio and IntelliJ IDEA.
  14. Migrate an Xcode project : Manual steps to turn an existing Xcode project into a Tuist project.
  15. Homebrew : A system package management tool for macOS.
  16. Install Tuist
  17. Xcode / Bundles and frameworks / Creating a static framework
  18. Swift logo : The official logos can be downloaded below.
  19. Kotlin logo
  20. Gradle logo
  21. Tuist logo
  22. KINTO Kantan Moushikomi : Android App
  23. KINTO Kantan Moushikomi : iOS App
  24. Choi Garamoi
Facebook

関連記事 | Related Posts

We are hiring!

【ソフトウェアエンジニア(リーダークラス)】共通サービス開発G/東京・大阪

共通サービス開発グループについてWebサービスやモバイルアプリの開発において、必要となる共通機能=会員プラットフォームや決済プラットフォームなどの企画・開発を手がけるグループです。KINTOの名前が付くサービスやKINTOに関わりのあるサービスを同一のユーザーアカウントに対して提供し、より良いユーザー体験を実現できるよう、様々な共通機能や顧客基盤を構築していくことを目的としています。

【福岡拠点立ち上げ】オープンポジション(エンジニア)

やっていること国内サービスでは、トヨタのクルマのサブスクリプションサービスである『 KINTO ONE 』を中心に、移動のよろこびを提供する『 モビリティーマーケット 』、MaaSサービスの『 my route(マイルート) 』など、トヨタグループが展開する各種サービスの開発・運営を担っています。

イベント情報