musicLineアプリ開発日記

作曲を誰でも楽しく簡単に♪

SwiftPackageでマルチモジュール開発

今回はマルチモジュール開発の話。
最近の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で定義した方がスマートかもしれません。
いい方法があればコメントください。。