Swift Package Managerを利用してアプリ間でコード共用する実験
Swift Package Manager(以下SPM)を利用することで、iOSアプリ内のモジュールをパッケージとして外部に公開することができます。
またローカルパッケージを作成することもでき、これは外部に非公開で、内部で参照できるパッケージです。
更にSPMは柔軟性があり、ローカルパッケージと通常のモジュールは併用可能なので、巨大なアプリであっても、全モジュールをSPM対応する必要はありません。
これによって、マルチモジュール構成のアプリAの1モジュールだけをパッケージとして公開して、アプリBから使わせることが可能です!
公開するモジュールが依存しているモジュールがいくつかあったのですが、それはローカルパッケージとして定義して、
外部からは隠蔽して、内部依存として閉じます。

これ、実験してみて、できるはできたんですが、課題も多かったので結局採用しませんでした。
この記事でどう実験したかと気づいた課題をまとめます。
SPMのマニフェストファイル(Package.swift)の記法
SPMのドキュメントはサンプルコードレベルしかなくて、応用したいときにどうすれば良いのかわからず、トライアンドエラーで進めました。
公式ドキュメントに記法を説明しているものがあったので、これが一番重宝しました。
ローカルパッケージの設定方法
まず公開したいFeatureモジュールが依存しているモジュールをローカルパッケージ化しました。
依存しているモジュールが依存しているモジュールも対象になるので、数はそれなりに多くなります。
一部を抜粋します。
let package = Package(
name: "LocalPackages",
defaultLocalization: "ja",
platforms: [.iOS(.v17)],
products: [
.library(name: "Assets", targets: ["Assets"]),
.library(name: "Models", targets: ["Models"]),
],
targets: [
.target(
name: "Assets"
),
.target(
name: "Models",
dependencies: [
"Assets",
]
),
.testTarget(
name: "ModelsTests",
dependencies: [
"Models",
],
resources: [
.process("Resources"),
]
),
]
)
ディレクトリ構成は以下で設定しました。
SPMはpathの指定が効くので、ある程度柔軟にできる仕様にはなっているんですが、
デフォルトで期待しているディレクトリ構成があって、そこから外れると予期せぬエラーが出ることがあるので、可能な限りデフォルトに従う方が良いと思いました。
my-app/
├── my-app.xcodeproj
└── LocalPackages/
├── Package.swift
├── Sources/
│ ├── Assets/
│ │ └── Resources/
│ └── Models/
└── Tests/
└── ModelsTests/
└── Resources/
Sourcesに通常ターゲット、Testsにテストターゲットを入れると、pathを指定しなくても認識してくれます。
またResourcesというディレクトリに入っているファイルは、swiftファイルじゃないものも読み込みます。
ルートディレクトリで公開パッケージを指定
外部ライブラリからパッケージを読ませるためには、ルートにマニフェストファイルを置きます。
import PackageDescription
let package = Package(
name: "my-app",
defaultLocalization: "ja",
platforms: [.iOS(.v17)],
products: [
.library(name: "FeatureA", targets: ["FeatureA"]),
],
dependencies: [
.package(path: "LocalPackages"),
],
targets: [
.target(
name: "FeatureA",
dependencies: [
.product(name: "Assets", package: "LocalPackages"),
.product(name: "Models", package: "LocalPackages"),
// …
]
)
]
)
ローカルパッケージへの依存はpathを使って記述します。
ディレクトリ構成は下記になります。
my-app/
├── Package.swift
├── Sources/
│ └── FeatureA/
├── my-app.xcodeproj
└── LocalPackages/
├── Package.swift
├── Sources/
│ ├── Assets/
│ │ └── Resources/
│ └── Models/
└── Tests/
└── ModelsTests/
└── Resources/
ここまでやると、外部アプリからモジュールが読み込めるようになります。
.package(url: "https://github.com/my-name/my-app.git", from: "1.0.0"),
// ローカルでとりあえず試す場合
.package(path: "/Users/my-name/Dev/my-app"),
.package(url: "https://github.com/my-name/my-app.git", .branch("branch-name")),
なおブランチ名指定は便利そうだったんですが、deprecated扱いなのと
error: package 'my-app' is required using a revision-based requirement and it depends on local package 'localpackages', which is not supported
というエラーになって、使えませんでした。
課題1: Firebaseの依存関係
外部利用には成功したものの、課題がありました。
外部パッケージのインポートも書いてたんですが、その中でFirebaseのリンクエラーが発生しました。

詳細はわからないのですが、どうやらFirebaseは複数のSPMでインポートするとリンクエラーが出るようです。
他のライブラリは複数インポートでも問題なかったので、SPMの仕様というわけでもなさそうです。
対処法ですが、一つのパッケージにインポートを限定して、Firebaseを使うパッケージはそれに依存させる、という方法で解消できました。
export.swiftというファイルを下記のように書くと外部に公開できます。
//
// export.swift
@_exported import FirebaseAnalytics
@_exported import FirebaseCrashlytics
@_exported import FirebaseInstallations
@_exported import FirebaseMessaging
@_exported import FirebasePerformance
@_exported import FirebaseRemoteConfig
またexportだけだと読めなくて、ライブラリ指定を.dynamicにする必要もありました。
.library(name: "FirebaseService", type: .dynamic, targets: ["FirebaseService"]),
これでリポジトリ内部のリンクエラーは解消します。
……ただ外部から読み込むタイミングで問題が起きました。
読み込み側のリポジトリでもFirebase読んでたんですが、両者の指定バージョンが合わないとエラーになります。
これも地味にめんどくさいです。
課題2: ライブラリ名が衝突する
アプリA, Bで同名のライブラリが存在しました。
たとえばAssets。
.library(name: "Assets", targets: ["Assets"]),
これだと当然衝突してしまいます。
.library(name: "AppNameAssets", targets: ["Assets"]),
このように記述すると、外部で公開する名前と内部ターゲットとでわけられる……とのことなのですが、
実際に読み込ませてみると、ローカルパッケージの内部ターゲットであっても、ライブラリ名は衝突しました。
したがって、衝突を避けた命名にする必要があります。
.library(name: "AppNameAssets", targets: ["AppNameAssets"]),
これをやるとimport文を全部書き換えないといけず、更にコード中でライブラリ名を指定した箇所も修正となります。
検索して全置換で対応はできますが、修正箇所は多くなります。
課題3: ローカルパッケージの名前空間が隠蔽されない
これが最大の誤算でした。
読み込み先では、公開されたパッケージ内の名前だけの衝突を気にすればいい想定だったのですが、
ローカルパッケージの名前もガンガン衝突しました。
FeatureAを依存パッケージに追加して、importした時点で、FeatureAの依存ローカルパッケージとの名前衝突が発生しました。
この記事のサンプルでは簡略化していますが、実際はもう少し依存しているモジュールがあり、かなりの衝突が発生しました。
なので現実的にやるのであれば、アプリBの中にFeatureAの機能を隠蔽するようなモジュールが必要となります。
ラッパーモジュール?的なものをアプリBの別モジュールから読むこむ際は衝突が避けられそうでした。
まとめ
以上の課題があり、最終的に採用は断念しました。
記事の尺の都合上、書けなかったハマりポイントとして、
- Bundle.moduleを見なきゃいけない
- テストのときはBundle.moduleが見れない
- ビルド環境が壊れる問題
- Resourcesの記載を省略すると#if SWIFT_PACKAGEの中でもBundle.moduleが参照できない
- スナップショットテストのリファレンスの場所が変わる?
- ↑これは原因よくわかっていません
などがあります。
とりあえず似たようなことをやりたい方の参考になれば幸いです。
(了)
Discussion