xcodeprojで実現するミニマルなSwift Package中心のプロジェクト構成
はじめに
pointfreeco/isowords のような、ルートに Swift Package を構築したプロジェクト構成が、現在の iOS 界隈ではデファクトスタンダードとして採用されつつあります。
このようなプロジェクト構成を本記事では、 @d_date さんの資料に則り、Swift Package centered project (Swift Package 中心のプロジェクト構成)と呼ぶことにさせていただきます 🙇
なお、下記資料は本記事の前提知識となるため、もし読んでいない方はお読みください 🙏
Swift Package 中心のプロジェクト構成の特徴とメリット
従来のデフォルト構成と比べ、このような構成がなぜ採用されはじめているのかを今一度整理してみます。
-
project.pbxproj
ファイルに変更が加わりにくく、チーム開発における Git のコンフリクトを最小限に抑えられる - Swift Package 下のファイル実体がそのままプロジェクト上に反映されるため、手動で Xcode プロジェクトへのファイル追加やディレクトリ構成のソートを行う手間や煩雑さが省ける
- ルートディレクトリに置かれたコード
Package.swift
を見るだけで構成や依存関係が一目でわかる - ライブラリを簡単に構築できるため、マルチモジュール構成を構築しやすく、インクリメンタルな開発におけるコンパイル時間やテスト実行時間などの DX も良い
-
xcworkspace
で構築するため開発環境ごとに Xcode プロジェクトを複数作る構成も可能- Firebase 用の plist など、開発環境によって異なるリソースをコピーの際に rename されるようにする Build Phase を用意するなどの手間がなくなる
その一方で・・・
このようなたくさんのメリットがあり、上 3 つはかなりエッセンシャルな一方、下 2 つに関しては、若干悩ましいところがあると自分は考えます。
- 新規構築時などのマルチモジュールの切り方の最適解が導き出せていない場合
- Feature で切るのか、Architecture Layer で切るのか、その粒度をどうするかなど
- 開発環境ごとにプロジェクトを分けてワークスペースで管理すると、自由度が高い反面多少複雑化したり、Configuration と Macro による出しわけに比べ、全てに同じ処理を適切に実装できているかの担保が手動のチェックになりやすい
ということで、最低限、上 3 つの「pbxproj
のコンフリクト回避」、「自動ディレクトリ反映」、「ルートの Package.swift
によるコードでの依存管理」を享受できれば、初期プロジェクト構築作業としては十分と考えており、マルチモジュール化やワークスペース化に関してはあくまでオプションとして利用できるよう、今回は最小限の変更方法かつ xcodeproj
のまま Swift Package 中心の構成を適用するという方法についてまとめてみます。
ミニマル構成の構築手順
大きくは下記の流れに沿って構築していきます。
- 新規 Xcode プロジェクトを作成する
- 今回の構成用のルートディレクトリを作る
- ルートディレクトリで Swift Package を作成する
- 1 で作成した Xcode プロジェクトのディレクトリをルートディレクトリに配置する
- Xcode プロジェクトのディレクトリ内に空定義の
Package.swift
を生成する - Xcode プロジェクトにルートディレクトリのパッケージを追加する
- Xcode プロジェクト内にある実装ファイルとテストファイルをパッケージ側に移動する
- Main Target でビルドが通るようにする
- Test Target を整理してテストが実行できるようにする
意外と手順が多いかつ複雑なため、それぞれの手順を詳細に説明します。
1. 新規 Xcode プロジェクトを作成する
まずは、通常と同じように新規に Xcode プロジェクトを App テンプレートを用いて作成します。
Tests も必要な場合は Include するのを忘れずにしましょう。
のちほど、このプロジェクトのディレクトリはルートディレクトリのサブディレクトリとして配置されるため、Source Control: Create Git repository on my Mac
のチェックは外しておきましょう。
2. 今回の構成用のルートディレクトリを作る
つづいて、今回のプロジェクト用のルートディレクトリを作成します。
このディレクトリ名が Xcode プロジェクト上に取り込まれる Package 名になるため、プロジェクトと同じ名前やわかりやすい名前(Core
などでもよいでしょう)にしましょう。(なお、pbxproj
ファイルの中を直接いじることであとから rename も可能です)
このルートディレクトリは git 管理するべきですので、必要に応じて git init
や git add remote origin
などのセットアップも行ってください。
# 名前は任意
% mkdir -p Core
3. ルートディレクトリで Swift Package を作成する
つづいて、作成したディレクトリ内に Swift Package を作成します。-name
オプションで名前を指定することも可能ですが、省略するとルートディレクトリ名と同じになります。今回は、まだどのようにモジュールを分けていくかが定かではないため、Core
というパッケージを構成することにします。
% cd Core
% swift package init --name Core
Creating library package: Core
Creating Package.swift
Creating .gitignore
Creating Sources/
Creating Sources/Core/Core.swift
Creating Tests/
Creating Tests/CoreTests/
Creating Tests/CoreTests/CoreTests.swift
4. 1 で作成した Xcode プロジェクトのディレクトリをルートディレクトリに配置する
Xcode プロジェクトのディレクトリを App
というサブディレクトリとしてルートディレクトリに移動します
# Xcodeプロジェクトのディレクトリ名をAppに変更
% mv <path/to/xcode-project> App
# Appディレクトリをルートディレクトリに移動
% mv App Core/
# おそらくルートディレクトリはこんな感じになるでしょう
# (この例ではXcodeプロジェクト名はMinimalSwiftPackageCenteredProjectSample)
% tree .
.
├── App
│ ├── MinimalSwiftPackageCenteredProjectSample
│ │ ├── Assets.xcassets
│ │ │ ├── AccentColor.colorset
│ │ │ │ └── Contents.json
│ │ │ ├── AppIcon.appiconset
│ │ │ │ └── Contents.json
│ │ │ └── Contents.json
│ │ ├── ContentView.swift
│ │ ├── MinimalSwiftPackageCenteredProjectSample.entitlements
│ │ ├── MinimalSwiftPackageCenteredProjectSampleApp.swift
│ │ └── Preview Content
│ │ └── Preview Assets.xcassets
│ │ └── Contents.json
│ ├── MinimalSwiftPackageCenteredProjectSample.xcodeproj
│ │ ├── project.pbxproj
│ │ ├── project.xcworkspace
│ │ │ ├── contents.xcworkspacedata
│ │ │ ├── xcshareddata
│ │ │ │ ├── IDEWorkspaceChecks.plist
│ │ │ │ └── swiftpm
│ │ │ │ └── configuration
│ │ │ └── xcuserdata
│ │ │ └── <user>.xcuserdatad
│ │ │ └── UserInterfaceState.xcuserstate
│ │ └── xcuserdata
│ │ └── <user>.xcuserdatad
│ │ └── xcschemes
│ │ └── xcschememanagement.plist
│ ├── MinimalSwiftPackageCenteredProjectSampleTests
│ │ └── MinimalSwiftPackageCenteredProjectSampleTests.swift
│ └── MinimalSwiftPackageCenteredProjectSampleUITests
│ ├── MinimalSwiftPackageCenteredProjectSampleUITests.swift
│ └── MinimalSwiftPackageCenteredProjectSampleUITestsLaunchTests.swift
├── Package.swift
├── Sources
│ └── Core
│ └── Core.swift
└── Tests
└── CoreTests
└── CoreTests.swift
24 directories, 18 files
5. Xcode プロジェクトのディレクトリ内に空定義の Package.swift を生成する
下記のような空定義の Package.swift
を App
ディレクトリに作成します。
これによって、ルートディレクトリをパッケージとして Xcode プロジェクトに追加した際に冗長なディレクトリを Xcode の Project Navigator 上から見えなくすることができます。
// swift-tools-version: 5.10
// The swift-tools-version declares the minimum version of Swift required to build this package.
import PackageDescription
// Xcode Project 上で表示されなくするための Workaround
let package = Package(
products: [],
targets: []
)
% vim App/Package.swift
6. Xcode プロジェクトにルートディレクトリのパッケージを追加する
さて、ここまできたら Xcode を立ち上げます。
サブディレクトリに配置した xcodeproj
ファイルを開きましょう。
% open App/<xcode-project>.xcodeproj
この状態では Xcode の Project Navigator は Before のような構成になっているはずです。
ここで、ルートディレクトリをそのまま Project Navigator 上で既存のプロジェクトの中にドラッグ&ドロップで突っ込みます。
すると、After のようにプロジェクト内にパッケージが内包された形になるかと思います。
Before | After |
---|---|
7. Xcode プロジェクト内にある実装ファイルとテストファイルをパッケージ側に移動する
プロジェクト配下で管理しているファイルをパッケージ側に逃していきます。
まず自動生成された Sources
と Tests
の中にある Swift ファイルは不要なので削除します。
% rm Sources/Core/*.swift Tests/CoreTests/*.swift
つぎに、ContentView.swift
は UI の実装ファイルですので、これを Core
パッケージ下に移動します。
Project Navigator 上でドラッグ&ドロップしていきます。
さらに、テストファイルも Core
パッケージ下に移動します。XCTest の Swift ファイルはそのまま CoreTests
パッケージに入れ、XCUITest の方は@main から起動されるため、そのままにします。
こんな感じになるはずです。
8. Main Target でビルドが通るようにする
この状態ではまだビルドが通りませんので、これを通るようにしていきます。
試しにビルドしてみるとおそらく下記のエラーが@main の App 構造体を拡張定義している swift ファイル上で検出されるはずです。
これは、ContentView.swift
を Package 側に移動したため、アクセスができなくなったことによるエラーです。
これを解決するために、まずはメインのターゲットを開き、Frameworks, Libraries, and Embedded Content
より Core
ライブラリを Add します。
Core
ライブラリの import を追加します。
+ import Core
import SwiftUI
@main
struct MinimalSwiftPackageCenteredProjectSampleApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
}
}
次に、もう一度ビルドすると ContentView.swift
がエラーのオンパレードになると思います。
このファイルは SwiftUI を使っていますが、Core
の Package.swift
側で platforms
の指定がないため、API の利用可能なバージョンの制約によって出てしまっているエラーなので、定義の API が実装されている Ver 以上となるように platforms
の定義を加えます。(自身のプロジェクトの Deployment Target と揃えても良いでしょう)
// swift-tools-version: 5.10
// The swift-tools-version declares the minimum version of Swift required to build this package.
import PackageDescription
let package = Package(
name: "Core",
+ platforms: [.iOS(.v15)],
products: [
// Products define the executables and libraries a package produces, making them visible to other packages.
.library(
name: "Core",
targets: ["Core"]),
],
targets: [
// Targets are the basic building blocks of a package, defining a module or a test suite.
// Targets can depend on other targets in this package and products from dependencies.
.target(
name: "Core"),
.testTarget(
name: "CoreTests",
dependencies: ["Core"]),
.testTarget(
name: "CoreUITests",
dependencies: ["Core"]),
]
)
これでもまだビルドエラーが消えません。
それは ContentView.swift
がパッケージ側に移動したことによって、ファイルアクセス可視性の問題で見えなくなってしまったことによります。
次のように public
修飾子をつけてライブラリ外からもアクセス可能な状態にしましょう。
import SwiftUI
+ public struct ContentView: View {
+ public var body: some View {
VStack {
Image(systemName: "globe")
.imageScale(.large)
.foregroundStyle(.tint)
Text("Hello, world!")
}
.padding()
}
+
+ public init() {}
}
#Preview {
ContentView()
}
これでメインのビルドは通るようになりました 🎉
9. Test Target を整理してテストが実行できるようにする
最後に、テストのビルドおよび実行を通していきます。
この時点で、初期に作成された XCUITest 用のディレクトリは不要なので Project Navigator 上から削除しましょう。(UITests の方は消さないように)
次に、Target も不要なのでこちらも削除します。
左ペインの左から 6 つ目の Test Navigator を開くと赤くエラーになっていることがわかります。
赤い設定を選択し-ボタンで削除すると、xctestplan をカスタムで作成するか聞かれるので Save を選択し、任意の場所に保存します。(App ディレクトリの中とかで良いでしょう)
今度は test plan の作成(+ボタン)を押して、Core
ライブラリ内の testTarget を追加しましょう。
これでテストが全て実行されて通るようになりました 🎉
さいごに
今後の改修時は暫定的に Core
ライブラリの中に実装を生やしていき、CoreTests
ライブラリの中でユニットテストを実装するという形になるでしょう。
依存追加をしたくなったら、Package.swift
に追記するだけです。
モジュールを切り離したくなったときも Package.swift
を修正すれば OK です。
今回は紹介しませんでしたが、Assets などの Resource 系のファイルに関しても今後必要になったタイミングで各モジュール側に設定していく必要があります。
とはいえ、最初の一歩としてやっておくと開発体験としてはだいぶ向上するはずなので、ぜひ試してみてはどうでしょうか?
なお、今回の手順の成果物を GitHub にあげておきましたので、気になる方はご覧ください。(それなりの作業量ではあるので template として利用し rename することで時間の削減にもなるかと思います)
以上です!お読みいただきありがとうございました 🙇
Discussion