🙆

Swift Package Managerを利用してアプリ間でコード共用する実験

に公開

Swift Package Manager(以下SPM)を利用することで、iOSアプリ内のモジュールをパッケージとして外部に公開することができます。
またローカルパッケージを作成することもでき、これは外部に非公開で、内部で参照できるパッケージです。
更にSPMは柔軟性があり、ローカルパッケージと通常のモジュールは併用可能なので、巨大なアプリであっても、全モジュールをSPM対応する必要はありません。

これによって、マルチモジュール構成のアプリAの1モジュールだけをパッケージとして公開して、アプリBから使わせることが可能です!
公開するモジュールが依存しているモジュールがいくつかあったのですが、それはローカルパッケージとして定義して、
外部からは隠蔽して、内部依存として閉じます。

これ、実験してみて、できるはできたんですが、課題も多かったので結局採用しませんでした。
この記事でどう実験したかと気づいた課題をまとめます。

SPMのマニフェストファイル(Package.swift)の記法

SPMのドキュメントはサンプルコードレベルしかなくて、応用したいときにどうすれば良いのかわからず、トライアンドエラーで進めました。
公式ドキュメントに記法を説明しているものがあったので、これが一番重宝しました。

https://docs.swift.org/package-manager/

ローカルパッケージの設定方法

まず公開したい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の仕様というわけでもなさそうです。

https://github.com/tuist/tuist/issues/6256#issuecomment-2145237392

対処法ですが、一つのパッケージにインポートを限定して、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