この記事はand factory.inc Advent Calendar 2022 18日目の記事です。
昨日は @y-okudera さんの Flutterで連続性のある複雑なアニメーションを実装する でした。
はじめに
今回は個人開発しているアプリに 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 の削除
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
...略
.gitignore の更新
Diff
XcodeProj を管理するようにする
-# xcodegen
-*.xcodeproj
-*.xcworkspace
Package の追加
あとは他の Embedded Framework も同様の作業をして置き換え作業していけば OK
DataStore/Package.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"])
]
)
Domain/Package.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"])
]
)
Presentation/Package.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"])
]
)
Carthage からの脱却
Package.swift にてライブラリの依存関係を記載しているので基本的には以下ファイル郡を削除するだけで OK
- Cartfile
- Cartfile.resolved
- Carthage/Checkouts/
あとは Makefile を修正したり Carthage 専用のスクリプトを削除したりしました。
ハマったポイント
Sources 直下のディレクトリは1つのディレクトリ構成にする
以下のようなディレクトリ構成にすると Cannot find XXX in scope
のエラーが出てしまいソースコードを読み込んでくれません。
原因としては Package の Sources 以下はディレクトリごとに別ターゲット扱いになるためです。
これを解決する方法として2種類の方法があります。
①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 をインポート出来るようになるので無事エラーが解消されます
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)")
}
}
②Sources 直下を1つのディレクトリとして置く
上記のように Sources 直下は Test
ディレクトリだけにしてその Test
ディレクトリ以下に Test2
ディレクトリを配置する。
このディレクトリ構成にすると Package.swift などを変更せずに Test2 の内容を読み込むことができます。
個人的には①より②の解決方法が良いと思っていて、理由としては①のように依存関係を書くならそもそも Package 自体分けるべきなのかなと思ったからです。(Testパッケージ内ではなくTest2のパッケージを作ってそこの Sources 以下に Test2 のコードを配置して Test の Package.swift に Test2 を依存するように書く)
ただ正直まだこの辺の理解をちゃんと出来ている訳ではないので詳しい方がいましたらコメントでご指摘頂けると幸いです。
Realm の依存関係の記述ミス
Realm は1つのパッケージで Realm
と RealmSwift
の2つのプロダクトを含んでいるためこの2つの依存関係を書かないと上手く読み込んでくれません。
...略
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
],
...略
そのため以下のように依存関係を記述しないといけないです。
...略
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
Embedded Framework の時は storyboard や xib の Inherit Module From Target
にチェックを付ける必要があったのですが、パッケージ管理にした場合は不要となるので全てのチェックを外す必要があります。(なぜ不要になるかはイマイチ理解出来ていない...)
そこまで数は多くなかったものの1つずつ手動で設定を外していくのは面倒だったので以下のコマンドで一括でチェックを外すよう対応
# storyboard や xib の
$ git grep -l 'customModuleProvider="target"' | xargs sed -i '' -e 's/customModuleProvider="target"//g'
おわりに
SPMが登場してから大分日は経ちますが、まだまだキャッチアップ出来ていない部分も多々あるので今後も引き続き活用して理解を深めていければと思っています。
最後まで読んでいただきありがとうございます。
明日の Advent Calendar の記事もお楽しみに