1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

KMPのiOSプロジェクト管理にTuistを使う

Posted at

Tuist

普段Androidをやってるので、KMPをいじる時のiOSのプロジェクト管理むずー って思いつつ色々漁ってたのですが、XcodeGenっぽくプロジェクトファイルを吐き出してくれつつ, SwiftPMっぽくSwiftファイルで構造を管理できるTuistいいのではとなった。

experimentalですが https://kotlinlang.org/docs/native-swift-export.html を使うならそれがいいかもしれないが(試してない)

基本

KMP(Do not Share UI)のデフォルトの構成を移し替えつつTuistの理解を深めると..

├── composeApp
│   ├── build
│   ├── build.gradle.kts
│   └── src
├── iosApp
│   ├── Configuration
│   ├── iosApp
│   └── iosApp.xcodeproj
└── shared
    ├── build
    ├── build.gradle.kts
    └── src

Tuistの設定ファイルを追加

├── Project.swift
├── Tuist
│   └── Package.swift
└── Tuist.swift
  • Tuist.swift: プロジェクト単位の Tuist設定
  • Package.swift: 依存関係を定義するためのマニフェスト
  • Project.swift: プロジェクトを定義するためのマニフェスト

中身

// Tuist.swift
import ProjectDescription
let tuist = Tuist()

// Project.swift
import ProjectDescription
let project = Project(
    name: "MyApp-Tuist",
    targets: [
        /** Targets will go here **/
    ]
)

// Tuist/Package.swift
// swift-tools-version: 5.9
import PackageDescription
#if TUIST
import ProjectDescription

let packageSettings = PackageSettings(
    // Customize the product types for specific package product
    // Default is .staticFramework
    // productTypes: ["Alamofire": .framework,]
    productTypes: [:]
)
#endif
let package = Package(
    name: "MyApp",
    dependencies: [
        // Add your own dependencies here:
        // .package(url: "https://github.com/Alamofire/Alamofire", from: "5.0.0"),
        // You can read more about dependencies here: https://docs.tuist.io/documentation/tuist/dependencies
    ]
)

既存の.xcconfigのマイグレート

これでKMPのiosAppのconfigがtuistで使うconfigファイルに吐き出される。

mkdir -p xcconfigs/
tuist migration settings-to-xcconfig -p MyApp.xcodeproj -x xcconfigs/MyApp-Project.xcconfig

Tuistからの参照設定
Project.swiftに設定を追加する

import ProjectDescription

let project = Project(
    name: "MyApp",
+    settings: .settings(configurations: [
+        .debug(name: "Debug", xcconfig: "./xcconfigs/MyApp-Project.xcconfig"), 
+        .release(name: "Release", xcconfig: "./xcconfigs/MyApp-Project.xcconfig"), 
+    ]),
    targets: [
        /** Targets will go here **/
    ]
)

KMPのFramework生成タスクをつけたターゲットの作成

基本的にはサンプル通りで、embedAndSignAppleFrameworkForXcodeだけ設定している。

import ProjectDescription

let project = Project(
    name: "MyApp-Tuist",
    settings: .settings(configurations: [
        .debug(name: "Debug", xcconfig: "./xcconfigs/iosApp-project.xcconfig"),
        .release(name: "Release", xcconfig: "./xcconfigs/iosApp-project.xcconfig"),
    ]),
    targets: [
        .target(
            name: "iosApp",
            destinations: .iOS,
            product: .app,
            bundleId: "MyApp-Tuist",
            infoPlist: .extendingDefault(
                with: [
                    "UILaunchScreen": [
                        "UIColorName": "",
                        "UIImageName": "",
                    ],
                ]
            ),
            sources: ["iosApp/iosApp/**"],
            resources: ["iosApp/iosApp/**"],
            scripts: [
                .pre(
                    script: """
                            cd "$SRCROOT"
                            ./gradlew :shared:embedAndSignAppleFrameworkForXcode
                            """,
                    name: "Compile Kotlin Framework"
                )
            ]
        ),
    ]
)

tuist generateでプロジェクトを作成してビルド

動くはず。もともとのxcode.proj と info.plist は消して良い。
最終的にはこうなる。

├── composeApp
│   └── build.gradle.kts
├── Derived // Tuist生成
│   ├── InfoPlists
│   └── Sources
├── iosApp
│   ├── iosApp
├── MyApp-Tuist.xcodeproj // Tuist生成
│   ├── project.pbxproj
│   ├── project.xcworkspace
│   ├── xcshareddata
│   └── xcuserdata
├── MyApp-Tuist.xcworkspace // Tuist生成
│   ├── contents.xcworkspacedata
│   ├── xcshareddata
│   └── xcuserdata
├── Project.swift
├── shared
│   └── build.gradle.kts
├── Tuist
│   └── Package.swift
├── Tuist.swift
└── xcconfigs
    └── iosApp-project.xcconfig

Tuist管理への移行ステップのおおよそがなんとなく掴める

いかにして KMPでTuistを使うと良さそうか

前提は一緒で UIシェアはしないKMPプロジェクト

やりたいこと

KMP内のiOSプロジェクトとKMPのKotlin側のソースどちらもマルチモジュールにしたい。
KMPではXCFrameworkを吐き出してiOSに連携する性質上、マルチモジュール構成はUmbrellaを使った構成が今のところよいと思われる。
Umbrella: ざっくりFrameworkを吐き出すモジュールは一つにするみたいなイメージ( このあたりに詳しく書いてある)

こういう制約だと以下がやりたくなる。

  • KMP側では、iOSに公開するXCFrameworkを生成するモジュールを一つする
  • 吐き出したXCFrameworkをリンクするiOS側のパッケージを一つ用意して、iOSの各パッケージでは必要に応じてそこに依存する
  • この二つをいい感じに同じ場所で管理したい

構成の概略

  • Androidアプリケーション: android-app
  • iOSアプリケーション: ios-app
  • kmp-libraries : feature-a, feature-b, data-a, data-b... といった KMPLibrary群
  • kmp-umbrella: KMPからiOSに向けのフレームワークを生成するUmbrellaモジュール
  • KMPFramework: iOS側でXCFrameworkをリンクするswiftPackage

図にするとこうで

ファイル構造的には以下イメージ

├── android-app
├── ios
│   ├── feature-a
│   ├── feature-b
│   ├── kmpFramework
│   └── kmp-umbrella
└── kmp-libraries
    ├── data-a
    ├── feature-a
    └── feature-b

やりかた(抜粋)

まずkmp-umbrellaを既存のSharedで動かす

とりあえず新しいKMPプロジェクト用意してapp系のリネームをしてモジュールを作る

settings.gradle.kt
...
include(":android-app")
include(":shared") // あとで消す
include(":kmp-libraries:feature-a")
include(":kmp-libraries:feature-b")
include(":kmp-libraries:data-a")
include(":kmp-libraries:data-b")
include(":ios:kmp-umbrella")

sharedのiOS向けターゲットを以下に変更

shared/build.gradle.kt
iosArm64()
iosSimulatorArm64()

kmp-umbrellaでフレームワークを公開する

build.gradle.kt
plugins {
    alias(libs.plugins.kotlinMultiplatform)
}

kotlin {
    listOf(
        iosArm64(),
        iosSimulatorArm64()
    ).forEach { iosTarget ->
        iosTarget.binaries.framework {
            baseName = "Shared"
            isStatic = true
            export(projects.shared)
        }
    }

    sourceSets {
        commonMain.dependencies {
            api(projects.shared)
        }
    }
}

commonMain/src/kotlinにファイルをダミーで作らないとiOS側でビルドできない
https://slack-chats.kotlinlang.org/t/522648/i-m-trying-to-compile-multiple-kmm-modules-into-a-single-ios

XCodeのBuild Phaseにある「Compile Kotlin Framework」のタスクを以下に変更

cd "$SRCROOT/.."
./gradlew :ios:kmp-umbrella:embedAndSignAppleFrameworkForXcode

ここまでで、既存のSharedがkmp-umbrellaから生成されるフレームワークとしてlinkされるようになる

Tuistに移行する

前述どおりxcconfigのマイグレートまで進めつつ
Package.swiftは以下のように設定。

import ProjectDescription

let bundleId = "bundle.id.example"
let project = Project(
    name: "ios-app",
    settings: .settings(configurations: [
        .debug(name: "Debug", xcconfig: "./xcconfigs/ios-app.xcconfig"),
        .release(name: "Release", xcconfig: "./xcconfigs/ios-app.xcconfig"),
    ]),
    targets: [
        .target(
            name: "ios-app",
            destinations: .iOS,
            product: .app,
            bundleId: "\(bundleId)",
            infoPlist: .extendingDefault(
                with: [
                    "UILaunchScreen": [
                        "UIColorName": "",
                        "UIImageName": "",
                    ],
                ]
            ),
            sources: ["ios/ios-app/**"],
            resources: ["ios/ios-app/**"],
            dependencies: [
                .target(name: "KmpFramework"),
            ]
        ),
        .target(
            name: "KmpFramework",
            destinations: .iOS,
            product: .framework,
            bundleId: "\(bundleId).kmp.framework",
            sources: ["ios/KMPFramework/**"],
            scripts: [
                .pre(
                    script: """
                            cd "$SRCROOT"
                            ./gradlew :ios:kmp-umbrella:embedAndSignAppleFrameworkForXcode
                            """,
                    name: "Compile Kotlin Framework"
                )
            ],
            settings: .settings(base: [
                "FRAMEWORK_SEARCH_PATHS": "ios/kmp-umbrella/build/xcode-frameworks/**",
                "OTHER_LDFLAGS": "-framework shared" // フレームワークの名称と合わせる
            ])
        )
    ]
)

KMPFrameworkからiOSの他のモジュール向けにexport

// KMPFramework/KMPFramework.swift
@_exported import Shared

iOSの使う側でimport

import KMPFramework

Tuist generateからビルド。

iOSもマルチモジュールにする

ターゲットにFeatureを追加 & appから依存

Package.swift
.target(
    name: "ios-app",
    destinations: .iOS,
    product: .app,
    bundleId: "\(bundleId)",
    infoPlist: .extendingDefault(
        with: [
            "UILaunchScreen": [
                "UIColorName": "",
                "UIImageName": "",
            ],
        ]
    ),
    sources: ["ios/ios-app/**"],
    resources: ["ios/ios-app/**"],
    dependencies: [
        .target(name: "Feature-a"),
    ]
),
.target(
    name: "Feature-a",
    destinations: .iOS,
    product: .framework,
    bundleId: "\(bundleId).feature.a",
    sources: ["ios/Feature-a/**"],
    dependencies: [
        .target(name: "KMPFramework")
    ]
),
...

kmp-umbrellaで kmp側の別モジュールをexport

kmp-umbrella/build.gradle.kt
plugins {
    alias(libs.plugins.kotlinMultiplatform)
}

kotlin {
    listOf(
        iosArm64(),
        iosSimulatorArm64()
    ).forEach { iosTarget ->
        iosTarget.binaries.framework {
            baseName = "Shared"
            isStatic = true
            export(projects.shared)
            export(projects.kmpLibraries.dataA) // これ
        }
    }

    sourceSets {
        commonMain.dependencies {
            api(projects.shared)
            api(projects.kmpLibraries.dataA) // これ
        }
    }
}

Feature-Aから参照。必要なexportをふやせば、iOS側でUmbrella単位で全て参照できる様になる。

// ContentView(ios-app)
import SwiftUI
import Feature_a

struct ContentView: View {
    var body: some View {
        FeatureAView()
    }
}


// Feature-a
import SwiftUI
import KMPFramework

public struct FeatureAView: View {
    public init() {}

    public var body: some View {
        VStack(spacing: 20) {
            Text("Feature A")
                .font(.largeTitle)
                .fontWeight(.bold)
            Text("kmp Data-a: \(DataARepository())")
                .font(.caption)
                .foregroundColor(.blue)
        }
    }
}

Option Tuistファイルをios配下に移動する

お好みだが、若干全体が整理できる。

Package.swiftのパスを調整して、Tuist周りを全てiosに移動する。

import ProjectDescription

let bundleId = "$(PRODUCT_BUNDLE_IDENTIFIER)"
let project = Project(
    name: "ios-app",
    settings: .settings(configurations: [
        .debug(name: "Debug", xcconfig: "./xcconfigs/ios-app.xcconfig"),
        .release(name: "Release", xcconfig: "./xcconfigs/ios-app.xcconfig"),
    ]),
    targets: [
        .target(
            name: "ios-app",
            destinations: .iOS,
            product: .app,
            bundleId: "\(bundleId)",
            infoPlist: .extendingDefault(
                with: [
                    "UILaunchScreen": [
                        "UIColorName": "",
                        "UIImageName": "",
                    ],
                ]
            ),
            sources: ["ios-app/**"],
            resources: ["ios-app/**"],
            dependencies: [
                .target(name: "Feature-a"),
            ]
        ),
        .target(
            name: "KMPFramework",
            destinations: .iOS,
            product: .framework,
            bundleId: "\(bundleId).kmp.framework",
            sources: ["KMPFramework/**"],
            scripts: [
                .pre(
                    script: """
                            cd "$SRCROOT/.."
                            ./gradlew :ios:kmp-umbrella:embedAndSignAppleFrameworkForXcode
                            """,
                    name: "Compile Kotlin Framework"
                )
            ],
            settings: .settings(base: [
                "FRAMEWORK_SEARCH_PATHS": "kmp-umbrella/build/xcode-frameworks/**",
                "OTHER_LDFLAGS": "-framework shared" // フレームワークの名称と合わせる
            ])
        ),
        .target(
            name: "Feature-a",
            destinations: .iOS,
            product: .framework,
            bundleId: "\(bundleId).feature.a",
            sources: ["Feature-a/**"],
            dependencies: [
                .target(name: "KMPFramework")
            ]
        ),
        .target(
            name: "Feature-b",
            destinations: .iOS,
            product: .framework,
            bundleId: "\(bundleId).feature.b",
            sources: ["Feature-b/**"]
        )
    ]
)

全体感

├── android-app
│   ├── build.gradle.kts
│   └── src
├── ios
│   ├── Configuration
│   ├── Derived
│   ├── Feature-a
│   ├── Feature-b
│   ├── ios-app
│   ├── ios-app.xcodeproj
│   ├── ios-app.xcworkspace
│   ├── kmp-umbrella
│   ├── KMPFramework
│   ├── Project.swift
│   ├── Tuist
│   ├── Tuist.swift
│   └── xcconfigs
├── kmp-libraries
│   ├── data-a
│   ├── data-b
│   ├── feature-a
│   └── feature-b
├── settings.gradle.kts

android only なモジュールが増えるなら androidディレクトリを作ってまとめても。

終わり

気が向けばtemplate作りたいけど多分向かない。

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?