今回はマルチモジュール開発の話。
最近のSwiftPackageを使ったマルチモジュール開発について調査しました。
はじめに
開発が大きくなったり複数人で進めていく場合、モジュール分割してないとよくわからない状態でも実装を進められてしまいます。最初はなんとなく動作しているのでうまく実装できているような気になりますが、その状態で進めると依存関係がどんどん複雑になり、不具合が多くなってしまう原因になります。
モジュールを分割することで、
- ある程度の実装方法を強制できる
- モジュール単位での依存関係が把握できる
- 関心のあるモジュールのみ把握すればいい 等
以上のメリットがあり、開発しやすくなります。
musicLineでは、MVVMアーキテクチャを採用しており、モジュールも大体レイヤー単位で分割していました。モジュール分割の手法として、今までは1つのワークスペースに複数のプロジェクトを作成して、プロジェクト設定で依存関係やライブラリの種類(staticLibraryやframework等)を設定して、マルチモジュール開発を実現してました。
しかし、ファイルの追加や変更のたびにproject.pbxproj
が変更されるため、コンフリクトが起きやすいという問題があります。XcodeGenでこの問題は解消されるようですが、プロジェクト生成するファイルの管理にも煩わしさがあります。
他にもアプリサイズが少なくなる等のメリットもあるようで、今の方法からSwiftPackageを使ってモジュール分割する方法へ移行しました。
SwiftPackageでモジュール分割
以下のページ・ソースコードを参考にSwiftPackageでのモジュール分割を行いました。
モジュール分割も細分化し、以下のページを参考に「機能 x レイヤー」で分割することにしました。
Packageのコードを表示
import PackageDescription let package = Package( name: "musicline-ios", defaultLocalization: "en", platforms: [.iOS(.v14)], products: [ .library(name: "AppFeature", targets: ["AppFeature"]) ], dependencies: [ .package(name: "realm-swift", url: "https://github.com/realm/realm-swift.git", from: "10.38.0") ], targets: [ // MARK: 1層.Core .target( name: "CoreModel", path: "Sources/01.Core/CoreModel"), .target( name: "CoreView", path: "Sources/01.Core/CoreView"), // MARK: 2層.Model .target( name: "Common", dependencies: ["CoreModel"], path: "Sources/02.Model/01.Common"), .target( name: "Account", dependencies: ["Common"], path: "Sources/02.Model/02.Account"), .target( name: "Advertisement", dependencies: ["Common"], path: "Sources/02.Model/02.Advertisement"), .target( name: "Domain", dependencies: ["Common", .product(name: "RealmSwift", package: "realm-swift"), .product(name: "Realm", package: "realm-swift")], path: "Sources/02.Model/02.Domain"), .target( name: "Composition", dependencies: ["Domain"], path: "Sources/02.Model/03.Composition"), .target( name: "Community", dependencies: ["Domain"], path: "Sources/02.Model/03.Community"), // MARK: 3層.ViewModel .target( name: "CommonViewModel", dependencies: ["Composition", "Community"], path: "Sources/03.ViewModel/01.Common"), .target( name: "CompositionViewModel", dependencies: ["Composition"], path: "Sources/03.ViewModel/02.Composition"), .target( name: "CommunityViewModel", dependencies: ["Community"], path: "Sources/03.ViewModel/02.Community"), // MARK: 4層.View .target( name: "CommonView", dependencies: ["CommonViewModel", "CompositionViewModel", "CoreView"], path: "Sources/04.View/01.Common", resources: [.process("Assets.xcassets")]), .target( name: "CompositionView", dependencies: ["CommonView", "CompositionViewModel"], path: "Sources/04.View/02.Composition"), .target( name: "CommunityView", dependencies: ["CommonView", "CommunityViewModel"], path: "Sources/04.View/02.Community"), // MARK: 5層.Application .target( name: "AppFeature", dependencies: ["CompositionView","CommunityView"], path: "Sources/05.Application/musicLine") // MARK: TEST ] )
ポイント
フォルダを階層化したい
パッケージ設定で.target
によりターゲットを作成すると、デフォルトはSources
の直下のフォルダを参照します。
musicLineでは、見やすさ的にMVVMの層ごとにフォルダを分割したかったので、path
ラベルでフォルダのパスを設定しました。
例えば、CoreModel
の場合、以下のようにパスを設定します。
.target( name: "CoreModel", path: "Sources/01.Core/CoreModel")
Package.swift
ファイルから相対パスで指定します。
また、ファイルは名前順に並ぶため、並び方を制御したいときは番号をつけました。
モジュールにリソースを含める
モジュールにリソースを含める場合、resources
ラベルでリソースリストを指定します。
リソースは以下の2種類あり、モジュールフォルダからの相対パスを指定して作成します。
- .process
フォルダ階層を除去して、Bundle直下にファイルをコピーする - .copy
フォルダ階層そのままで、フォルダごとコピーする
例えば、Common
モジュールをテストしたい時にdata
フォルダをリソースに追加したい場合、以下のように設定します。
.testTarget( name: "CommonTests", dependencies: ["Common"], resources: [.process("data")] )
また、プロジェクトからモジュールに移行するときに、参照するBundleに注意です。
// プロジェクトの場合 Bundle(for: type(of: self)) // モジュールの場合 Bundle.module
なぜかAssetsからリソースを取得できない
結構長い間ハマってしまいました。
Assetsでリソースを登録しているのに、SwiftUIのColorやImageがリソースから取得できない状態になっていました。Assetsはパッケージ設定にresources
ラベルで指定しなくても、デフォルトでBundleに入るようなので、Assetsの参照切れでもなさそうでした。
原因は異なるBundleを参照しており、Assets内のリソースに指定したリソースが無いため、取得できていませんでした。特にどこのBundleを参照しているかを意識していなかったのですが、デフォルトではメイン(現在実行中のコードを含むBundle)を参照するようで、モジュールのBundleを参照することで解決しました。
// プロジェクトの場合 Color("color_name") // モジュールの場合 Color("color_name", bundle: .module)
リリース時にPreview Contentフォルダを除外したい
プロジェクトみたいにDevelopment Assets
に登録して、リリース時にフォルダを除外するような挙動はできないようで困りました。
exclude
ラベルで一部のフォルダを除外できるようですが、デバッグ時には欲しいファイルなのでこれは使えず。。
最終的にはターゲットを新しく作成して、ライブラリを別にすることでこの問題を回避しました。でもこの方法だと、スキームが無駄に多くなってしまうことが気になるところです。
Previewする時のクラスはファイルに書き出さずに、Viewファイルの中でPreviewProvider
クラスと一緒に#if DEBUG
で定義した方がスマートかもしれません。
いい方法があればコメントください。。