個人開発アプリに Swift Package Manager を導入してみた

この記事はand factory.inc Advent Calendar 2022 18日目の記事です。
昨日は @y-okudera さんの [Flutterで連続性のある複雑なアニメーションを実装する](https://qiita.com/y-okudera/items/d81715827d01c3509eac) でした。

# はじめに
今回は個人開発しているアプリに Swift Package Manager (以降SPMと記載) を導入した際の記事になります。
これまでは XcodeGen + Carthage を用いており、DataStore層, Domain層, Presentation層の3層を Embedded Framework 化したプロジェクト構成でした。
これらを全て SPM で置き換える事が出来るのでは？と思い移植を試みました。

# 開発環境
- MacBook Pro（14インチ、2021） Apple M1 Max
- Xcode14.1(14B47b)
- Swift version 5.7.1

# XcodeGen, Embedded Framework からの脱却
まずは XcodeGen と Embedded Framework 化をやめて、SPM を用いてマルチモジュール管理するよう対応してみました。

## project.yml の削除
<details><summary>Diff</summary>

```diff
-name: RoyaleApp
-
-options:
-  bundleIdPrefix: nakandakari.toru.RoyaleApp
-  deploymentTarget:
-    iOS: 13.0
-  developmentLanguage: en
-  xcodeVersion: "14.0"
-
-settings:
-  base:
-    MARKETING_VERSION: 1.2.0
-    CURRENT_PROJECT_VERSION: 1
-    DEVELOPMENT_TEAM: 8CBGKNYH9U
-    DEBUG_INFORMATION_FORMAT: "dwarf-with-dsym"
-    CODE_SIGN_STYLE: Manual
-    SWIFT_VERSION: 5.0
...略
```
</details>

## .gitignore の更新
<details><summary>Diff</summary>

```diff
XcodeProj を管理するようにする
-# xcodegen
-*.xcodeproj
-*.xcworkspace
```
</details>

## Package の追加

1. Targets から Embedded Framework を削除する
![スクリーンショット 2022-12-13 19.54.32.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/457899/786659c2-2de4-1e06-998c-92ca1e170473.png)

2. Packageを置く任意のディレクトリ(Packages)を作成し、`File->New->Packages` から Package を作成
![Packages作成①](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/457899/769c5af6-1bf5-c6d3-1358-f51549695d7b.png)
![Packages作成②](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/457899/3895ad0c-2b14-d3ad-ee30-8995592e9338.png)
![Packages作成③](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/457899/d04b620a-7a41-304f-bb43-4a57e005fd44.png)

3. Sources 以下に元の DataStore 層のソースコードを移動させる
![Sources以下にソースコードを移動](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/457899/1a7bdcd8-bbad-2cc0-1e9f-04f09be83140.png)

4. Package.swift にパッケージ構成を書く
![スクリーンショット 2022-12-13 20.14.12.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/457899/88e54202-a95d-7054-0c78-0222c8ad1219.png)

あとは他の Embedded Framework も同様の作業をして置き換え作業していけば OK :ok_hand:

<details><summary>DataStore/Package.swift</summary>

```swift
// swift-tools-version: 5.7
// The swift-tools-version declares the minimum version of Swift required to build this package.

import PackageDescription

let package = Package(
    name: "DataStore",
    platforms: [
        .iOS(.v14)
    ],
    products: [
        // Products define the executables and libraries a package produces, and make them visible to other packages.
        .library(
            name: "DataStore",
            targets: ["DataStore"]
        )
    ],
    dependencies: [
        // Dependencies declare other packages that this package depends on.
        // .package(url: /* package url */, from: "1.0.0"),
        .package(url: "https://github.com/Alamofire/Alamofire", from: "5.6.2"),
        .package(url: "https://github.com/realm/realm-swift", from: "10.30.0")
    ],
    targets: [
        // Targets are the basic building blocks of a package. A target can define a module or a test suite.
        // Targets can depend on other targets in this package, and on products in packages this package depends on.
        .target(
            name: "DataStore",
            dependencies: [
                .product(name: "Alamofire", package: "Alamofire"),
                .product(name: "Realm", package: "realm-swift"),
                .product(name: "RealmSwift", package: "realm-swift")
            ]),
        .testTarget(
            name: "DataStoreTests",
            dependencies: ["DataStore"])
    ]
)
```
</details>

<details><summary>Domain/Package.swift</summary>

```swift
// swift-tools-version: 5.7
// The swift-tools-version declares the minimum version of Swift required to build this package.

import PackageDescription

let package = Package(
    name: "Domain",
    platforms: [
        .iOS(.v14)
    ],
    products: [
        // Products define the executables and libraries a package produces, and make them visible to other packages.
        .library(
            name: "Domain",
            targets: ["Domain"])
    ],
    dependencies: [
        // Dependencies declare other packages that this package depends on.
        // .package(url: /* package url */, from: "1.0.0"),
        .package(name: "DataStore", path: "../DataStore")
    ],
    targets: [
        // Targets are the basic building blocks of a package. A target can define a module or a test suite.
        // Targets can depend on other targets in this package, and on products in packages this package depends on.
        .target(
            name: "Domain",
            dependencies: [
                .product(name: "DataStore", package: "DataStore")
            ]),
        .testTarget(
            name: "DomainTests",
            dependencies: ["Domain"])
    ]
)
```
</details>

<details><summary>Presentation/Package.swift</summary>

```swift
// swift-tools-version: 5.7
// The swift-tools-version declares the minimum version of Swift required to build this package.

import PackageDescription

let package = Package(
    name: "Presentation",
    platforms: [
        .iOS(.v14)
    ],
    products: [
        // Products define the executables and libraries a package produces, and make them visible to other packages.
        .library(
            name: "Presentation",
            targets: ["Presentation"])
    ],
    dependencies: [
        // Dependencies declare other packages that this package depends on.
        .package(name: "Analytics", path: "../Analytics"),
        .package(name: "Domain", path: "../Domain"),
        .package(url: "https://github.com/danielgindi/Charts", from: "4.1.0"),
        .package(url: "https://github.com/marcosgriselli/SwipeableTabBarController", from: "3.4.2"),
        .package(url: "https://github.com/onevcat/Kingfisher", from: "7.3.2"),
        .package(url: "https://github.com/googleads/swift-package-manager-google-mobile-ads", from: "9.11.0")
    ],
    targets: [
        // Targets are the basic building blocks of a package. A target can define a module or a test suite.
        // Targets can depend on other targets in this package, and on products in packages this package depends on.
        .target(
            name: "Presentation",
            dependencies: [
                .product(name: "Analytics", package: "Analytics"),
                .product(name: "Domain", package: "Domain"),
                .product(name: "Charts", package: "Charts"),
                .product(name: "Kingfisher", package: "Kingfisher"),
                .product(name: "SwipeableTabBarController", package: "SwipeableTabBarController"),
                .product(name: "GoogleMobileAds", package: "swift-package-manager-google-mobile-ads")
            ]),
        .testTarget(
            name: "PresentationTests",
            dependencies: ["Presentation"])
    ]
)
```
</details>

最終的なプロジェクト構成はコチラ
![スクリーンショット 2022-12-13 20.36.48.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/457899/af3ac10d-9c32-9023-daa1-bdd3ef0ba267.png)

# Carthage からの脱却

Package.swift にてライブラリの依存関係を記載しているので基本的には以下ファイル郡を削除するだけで OK :ok_hand:
- Cartfile
- Cartfile.resolved
- Carthage/Checkouts/

あとは Makefile を修正したり Carthage 専用のスクリプトを削除したりしました。

# ハマったポイント

## Sources 直下のディレクトリは1つのディレクトリ構成にする

以下のようなディレクトリ構成にすると `Cannot find XXX in scope` のエラーが出てしまいソースコードを読み込んでくれません。

![スクリーンショット 2022-12-17 9.55.42.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/457899/d707a56b-71d6-1e95-0297-12bf4b0b4942.png)


原因としては Package の Sources 以下はディレクトリごとに別ターゲット扱いになるためです。
これを解決する方法として2種類の方法があります。

**①Package.swiftにそれぞれのターゲットの依存関係を記載する**

```swift:Package.swift
略...
targets: [
        // Targets are the basic building blocks of a package. A target can define a module or a test suite.
        // Targets can depend on other targets in this package, and on products in packages this package depends on.
        .target(
            name: "Test",
            dependencies: ["Test2"]), // Test1 のターゲットで Test2 のターゲットを扱うために追加
        // Test2をターゲットとして認識させるために追加
        .target(
            name: "Test2",
            dependencies: []),
        .testTarget(
            name: "TestTests",
            dependencies: ["Test"]),
    ]
```

こうすることで Test1 側で Test2 をインポート出来るようになるので無事エラーが解消されます
```swift:Test.swift
import Test2 // Test2 がインポートできる

public struct Test {
    public private(set) var text = "Hello, World!"

    public init() {
        let test2 = Test2()
        print("$$$ test2.number = \(test2.hogeNumber)")
        print("$$$ test2.string = \(test2.fugaString)")
    }
}
```

<br>

**②Sources 直下を1つのディレクトリとして置く**

![スクリーンショット 2022-12-17 10.09.07.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/457899/c0e8368f-9301-0720-3888-993b3b762b6c.png)

上記のように Sources 直下は `Test` ディレクトリだけにしてその `Test` ディレクトリ以下に `Test2` ディレクトリを配置する。
このディレクトリ構成にすると Package.swift などを変更せずに Test2 の内容を読み込むことができます。

個人的には①より②の解決方法が良いと思っていて、理由としては①のように依存関係を書くならそもそも Package 自体分けるべきなのかなと思ったからです。(Testパッケージ内ではなくTest2のパッケージを作ってそこの Sources 以下に Test2 のコードを配置して Test の Package.swift に Test2 を依存するように書く)

ただ正直まだこの辺の理解をちゃんと出来ている訳ではないので詳しい方がいましたらコメントでご指摘頂けると幸いです。

https://developer.apple.com/documentation/xcode/creating-a-standalone-swift-package-with-xcode

## Realm の依存関係の記述ミス

Realm は1つのパッケージで `Realm` と `RealmSwift` の2つのプロダクトを含んでいるためこの2つの依存関係を書かないと上手く読み込んでくれません。

```swift:Realm/Package.swift
...略
let package = Package(
    name: "Realm",
    platforms: [
        .macOS(.v10_10),
        .iOS(.v11),
        .tvOS(.v9),
        .watchOS(.v2)
    ],
    products: [
        .library(
            name: "Realm",
            targets: ["Realm"]), // Realm
        .library(
            name: "RealmSwift",
            targets: ["Realm", "RealmSwift"]), // RealmSwift
    ],
...略
```

そのため以下のように依存関係を記述しないといけないです。
```swift:DataStore/Package.swift
    ...略
    targets: [
        // Targets are the basic building blocks of a package. A target can define a module or a test suite.
        // Targets can depend on other targets in this package, and on products in packages this package depends on.
        .target(
            name: "DataStore",
            dependencies: [
                .product(name: "Alamofire", package: "Alamofire"),
                .product(name: "Realm", package: "realm-swift"),     // Realm
                .product(name: "RealmSwift", package: "realm-swift") // ReamSwift
            ]),
        .testTarget(
            name: "DataStoreTests",
            dependencies: ["DataStore"])
    ]
    ...略
```

## 起動時に storyboard や xib 周りでクラッシュ

ビルドエラーを解消し、ようやくシミュレータで動作確認しようとすると起動時に以下のクラッシュが発生

`Terminating app due to uncaught exception 'NSInvalidArgumentException', reason: 'Could not find a storyboard named 'RootViewController' in bundle NSBundle`

![スクリーンショット 2022-12-17 10.46.05.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/457899/302bb56b-d99a-1b5d-575a-356ba5996ec8.png)

Embedded Framework の時は storyboard や xib の `Inherit Module From Target` にチェックを付ける必要があったのですが、パッケージ管理にした場合は不要となるので全てのチェックを外す必要があります。(なぜ不要になるかはイマイチ理解出来ていない...)

![スクリーンショット 2022-12-17 11.49.27.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/457899/9407a423-ba8b-f04f-16b7-af3189290d0b.png)


そこまで数は多くなかったものの1つずつ手動で設定を外していくのは面倒だったので以下のコマンドで一括でチェックを外すよう対応

```shell
# storyboard や xib の
$ git grep -l 'customModuleProvider="target"' | xargs sed -i '' -e 's/customModuleProvider="target"//g'
```

# おわりに
SPMが登場してから大分日は経ちますが、まだまだキャッチアップ出来ていない部分も多々あるので今後も引き続き活用して理解を深めていければと思っています。

最後まで読んでいただきありがとうございます。
明日の [Advent Calendar](https://qiita.com/advent-calendar/2022/andfactory) の記事もお楽しみに:santa: