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系のリネームをしてモジュールを作る
...
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向けターゲットを以下に変更
iosArm64()
iosSimulatorArm64()
kmp-umbrellaでフレームワークを公開する
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から依存
.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
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作りたいけど多分向かない。