📁

xcodeprojで実現するミニマルなSwift Package中心のプロジェクト構成

2024/07/21に公開

はじめに

pointfreeco/isowords のような、ルートに Swift Package を構築したプロジェクト構成が、現在の iOS 界隈ではデファクトスタンダードとして採用されつつあります。

このようなプロジェクト構成を本記事では、 @d_date さんの資料に則り、Swift Package centered project (Swift Package 中心のプロジェクト構成)と呼ぶことにさせていただきます 🙇
なお、下記資料は本記事の前提知識となるため、もし読んでいない方はお読みください 🙏

https://speakerdeck.com/d_date/swift-package-centered-project-build-and-practice

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 中心の構成を適用するという方法についてまとめてみます。

ミニマル構成の構築手順

大きくは下記の流れに沿って構築していきます。

  1. 新規 Xcode プロジェクトを作成する
  2. 今回の構成用のルートディレクトリを作る
  3. ルートディレクトリで Swift Package を作成する
  4. 1 で作成した Xcode プロジェクトのディレクトリをルートディレクトリに配置する
  5. Xcode プロジェクトのディレクトリ内に空定義の Package.swift を生成する
  6. Xcode プロジェクトにルートディレクトリのパッケージを追加する
  7. Xcode プロジェクト内にある実装ファイルとテストファイルをパッケージ側に移動する
  8. Main Target でビルドが通るようにする
  9. Test Target を整理してテストが実行できるようにする

意外と手順が多いかつ複雑なため、それぞれの手順を詳細に説明します。

1. 新規 Xcode プロジェクトを作成する

まずは、通常と同じように新規に Xcode プロジェクトを App テンプレートを用いて作成します。
Tests も必要な場合は Include するのを忘れずにしましょう。

のちほど、このプロジェクトのディレクトリはルートディレクトリのサブディレクトリとして配置されるため、Source Control: Create Git repository on my Mac のチェックは外しておきましょう。

2. 今回の構成用のルートディレクトリを作る

つづいて、今回のプロジェクト用のルートディレクトリを作成します。
このディレクトリ名が Xcode プロジェクト上に取り込まれる Package 名になるため、プロジェクトと同じ名前やわかりやすい名前(Core などでもよいでしょう)にしましょう。(なお、pbxproj ファイルの中を直接いじることであとから rename も可能です)
このルートディレクトリは git 管理するべきですので、必要に応じて git initgit 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.swiftApp ディレクトリに作成します。
これによって、ルートディレクトリをパッケージとして 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 プロジェクト内にある実装ファイルとテストファイルをパッケージ側に移動する

プロジェクト配下で管理しているファイルをパッケージ側に逃していきます。
まず自動生成された SourcesTests の中にある 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 を使っていますが、CorePackage.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 することで時間の削減にもなるかと思います)

https://github.com/ruwatana/minimal-swift-package-centered-project-sample

以上です!お読みいただきありがとうございました 🙇

Discussion