iOS アプリのマルチモジュール開発とインターフェースモジュール

この記事は はてなエンジニア Advent Calendar 2023 の 13 日目です。

昨日は シンプルで使いやすいマイクロHTTPフレームワーク『Cask』を紹介するよ - Lambdaカクテル でした。 Scala にもこういう入りやすそうなフレームワークがあるんですね。easy で fun なツールをどんどん使っていきたいし、人生も easy で fun になってほしい。

今日は iOS アプリについて書きます。

概要

アプリケーションを継続的に開発していくことで、コードベースもどんどん大きくなっていきます。とくに iOS アプリではコードベースが大きくなることにより、ビルド時間が長くなってしまい開発効率が悪くなってしまうことがあります。

機能とともにアプリのサイズが増えるのは自然なことなので、その問題への対応としてアプリ自体のサイズを小さくする...というのはなかなか難しいです。しかし、アプリを細かいモジュールに分けて、小さい単位で開発していくことはできます。モジュール化によりビルドの範囲がアプリ全体からモジュールに小さくなることで、高速にビルド・テスト・プレビューなどが行えるため、より効率的にアプリ開発をしていくことができます。

この記事では、

  • アプリのマルチモジュール化
  • インターフェースモジュールの導入

によってテストやプレビューが高速化でき、効率よくアプリ開発が進められる様子をサンプルアプリを開発しながら見ていこうと思います。

マルチモジュール開発については自分も試行錯誤しているところであり、この記事には勘違いや改善点が含まれているかもしれません。コメントや twitter で教えていただけるととても助かります。

この記事のための検証は Xcode 15.1 で行っています。

サンプル : カウンターアプリ

サンプルとして、以下のようなボタンを押してカウントをインクリメントするだけの単純なアプリを考えます。

アプリの名前は SampleApp とします。 Xcode を開いてプロジェクトを作成し、カウンターの画面を CounterScreen として実装します。

// SampleApp.swift

import SwiftUI

@main
struct SampleApp: App {
    var body: some Scene {
        WindowGroup {
            CounterScreen()
        }
    }
}
// CounterScreen.swift

import Combine
import SwiftUI

final class CounterViewModel: ObservableObject {
    @Published private(set) var count: Int = 0

    func increment() {
        count += 1
    }
}

struct CounterScreen: View {
    @StateObject private var viewModel: CounterViewModel = .init()

    init() {}

    var body: some View {
        VStack {
            Text("\(viewModel.count)")
                .monospacedDigit()

            Button("+") {
                viewModel.increment()
            }
        }
        .font(.largeTitle)
    }
}

Feature モジュールを切り分ける

この時点ではコードはすべて Xcode プロジェクトに入っている状態です。これをモジュールに分割していくことにします。 iOS アプリにおいては、例えば pointfreeco/isowords で行われているように、画面ごとあるいは関連する画面のまとまりごとに Feature モジュールを切る方法がよくとられていると思います。

カウンターアプリには画面が1つしかありませんが、そのカウンター画面を Feature モジュールに切り分けることにします。モジュール分割の実現方法として、今回は swift package を作成してその中に CounterFeature モジュールを切り、 Xcode プロジェクトから依存することにします。 swift package は SampleAppPackage という名前で作成し、 Package.swift は以下のようにします。

// swift-tools-version: 5.9
// The swift-tools-version declares the minimum version of Swift required to build this package.

import PackageDescription

let package = Package(
    name: "SampleAppPackage",
    platforms: [.iOS(.v17)],
    products: [
        .library(name: "CounterFeature", targets: ["CounterFeature"]),
    ],
    targets: [
        .target(name: "CounterFeature"),
        .testTarget(name: "CounterFeatureTests", dependencies: ["CounterFeature"]),
    ]
)

SampleAppPackageXcode プロジェクトに追加し、 SampleApp ターゲットの Framework, Libraries and Embedded Content にて CounterFeature をリンクします。

CounterScreen.swiftXcode プロジェクトから CounterFeature の中に移動します。 CounterScreenXcode プロジェクト内、すなわち CounterFeature 外の SampleApp.swift から利用できるようにする必要があるため、アクセスレベルを public にしておきます。

// CounterFeature/CounterScreen.swift

- struct CounterScreen: View {
+ public struct CounterScreen: View {
    @StateObject private var viewModel: CounterViewModel = .init()

-   init() {}
+   public init() {}

-   var body: some View {
+   public var body: some View {
        // ...
    }
}

SampleApp.swift の方では CounterScreen を利用するために CounterFeature を import します。

import SwiftUI
+ import CounterFeature

// ...

以上で、カウンター画面を Feature モジュールに切り分けることができました。

テストとプレビューを追加する

アプリ全体を毎回ビルドしなくても開発が進められるようにするための道具として、テストとプレビューを追加します。

すでにテスト用に CounterFeatureTests ターゲットがあるので、そこにカウンターのロジックを確認する以下のようなテストを追加しておきましょう。

// CounterFeatureTests/CounterViewModelTests.swift

import XCTest
@testable import CounterFeature

final class CounterViewModelTests: XCTestCase {
    func testIncrement() {
        let viewModel: CounterViewModel = .init()

        XCTAssertEqual(viewModel.count, 0)
        viewModel.increment()
        XCTAssertEqual(viewModel.count, 1)
    }
}

SampleApp スキーマのテストプランに CounterFeatureTests を追加することで、テストが実行できるようになります。

続いて、プレビューも追加します。

// CounterFeature/CounterScreen.swift

// ...

+ #Preview {
+     CounterScreen()
+ }

プレビューにより、コードの変更がアプリの画面にどのように反映されるかを明示的なビルドなしで確認することができます。タップなどのインタラクションも有効になっているので、以下のように試行錯誤しつつ UI を開発していくことができます。

この時点で、プロジェクト構成は以下のようになっています。

ビルドの範囲をモジュールに狭める

実は、現状だとテストやプレビュー時にアプリ全体のビルドが走ってしまっているという問題があります。

例えば、プレビュー時に走るビルドのタイムラインを見てみると以下のようになっています。 CounterScreen のプレビューをしたいだけなのに、関係のない SampleApp.swift もビルドされてしまっていることがわかります。

アプリの規模が小さいうちはアプリ全体をビルドしても大した問題にならないのですが、アプリが成長していくにつれてビルド時間がどんどん長くなっていくことが予想されます。一度プレビューが表示されてしまえばその後は差分ビルドが効くので初回ビルドと比べて速くなりますが、プレビューにおいてビルドするものが増えていくとそもそもプレビューの動作自体が不安定になることがあるのと、 iOS 開発においては様々な原因でビルドをクリーンして初回ビルドからやり直さなければならないことがあるため、ビルド範囲を狭められるとうれしいです。

CounterFeature のテストやプレビューを実行するためには、アプリ全体ではなく CounterFeature モジュールのビルドをすれば十分なので、そのような方式に変更していきます。Xcode の上部のスキーマから New Scheme... をクリックして、CounterFeature スキーマを追加します(設定によっては自動的に追加されています)。また、 SampleApp のテストプランと同様に、 CounterFeature のテストプランにも CounterFeature を追加しておきます。

CounterFeature スキーマをアクティブにした状態で再度プレビューを表示してみると、ビルドタイムラインは以下のようになりました。ビルドの対象から SampleApp.swift が消え、プレビューに必要な CounterScreen.swift のビルドのみが実行されるようになっていることがわかります。

テストに関しても同様のメリットがあります。 CounterFeature スキーマをアクティブにして cmd+U を押してテストを実行することにより、 CounterViewModelTests のテストのために走るビルドの範囲が CounterFeature モジュールのみになっており、ビルド時間の削減ができています。

カウンター画面に変更した際にその振る舞いや UI の変化を確認するため、アプリを実行したり、プレビューを表示したり、テストを実行したりすると思いますが、モジュール化されていないアプリではいずれにしてもアプリ全体をビルドする必要があります。しかし、上記でモジュール化したサンプルアプリでは、 CounterFeature の動作確認が CounterFeature のみをビルドするテストやプレビューで行えます。 これにより、アプリ全体が大きくなっていっても、 Feature モジュールはテストやプレビューから高速なフィードバックを受けつつ開発をしていくことができます。スキーマを切り替える手間があったり、テストプランをモジュールごとに作らないといけなかったりなどのデメリットもあるため、このやり方が常に最高!というわけではないのですが、ある程度以上の大きさのアプリではメリットの方が大きい場合が多いと思っています。

ライブラリに依存する

ほとんどのアプリでは開発が進むうちに何らかのサードパーティライブラリに依存することになります。ここからは、ライブラリ依存がある場合のマルチモジュール開発について考えていきます。今回は、よくある例として Firebase への依存を追加することにしましょう。開発者として、アプリのカウンターをユーザがちゃんとインクリメントしてくれているかを知りたいので、カウントが 10 になったらその旨を Firebase Analytics に送信することにします。

Package.swift から Firebase の SDK を依存として追加します。

// Package.swift

let package = Package(
    // ...
+   dependencies: [
+       .package(url: "https://github.com/firebase/firebase-ios-sdk.git", exact: "10.19.0"),
+   ],
    targets: [
        .target(
            name: "CounterFeature",
+           dependencies: [.product(name: "FirebaseAnalytics", package: "firebase-ios-sdk")]
        ),
        .testTarget(name: "CounterFeatureTests", dependencies: ["CounterFeature"]),
    ]
)

実際に Firebase Analytics を使うためには Xcode プロジェクトや Firebase コンソールからいくつかの設定をする必要がありますが、この記事の本題には関係がないのでここではすべてがよしなに設定されているとして CounterFeature にて必要な差分のみ示します。

import Combine
import SwiftUI
+ import FirebaseAnalytics

final class CounterViewModel: ObservableObject {
    @Published private(set) var count: Int = 0
    
    func increment() {
        count += 1
+       if count == 10 {
+           Analytics.logEvent("count10", parameters: nil)
+       }
    }
}

public struct CounterScreen: View {
    // ...
}

これで、カウントが 10 に達したときにイベントを送れるようになりました。

しかし、現状の実装には問題点もあります。

1つ目の問題は、 CounterViewModel の中に Firebase にイベントを送るコードが直書きされているため、イベントが送られることをテストする手段がないことです。このままだとイベントが送信の確認のためにアプリを実行して Firebase コンソールを見にいく必要がありますが、手間がかかるし、確認を忘れてイベントが送られなくなってしまうデグレが発生するリスクもあります。

2つ目の問題は、依存の追加により CounterFeature をビルドするために Firebase のビルドが必要になり、プレビューやテストが遅く、不安定になってしまっていることです。 CounterScreen のプレビューを表示したときのビルドタイムラインを見ると、以下のように Firebase がビルドされてしまっていることがわかります。

イベント送信の実装に Firebase を利用しているということはアプリの動作や UI に関係ないので、本来テストやプレビューのために Firebase をビルドする必要はないはずです。せっかくモジュール分割をしてビルド範囲を狭めたので、依存を追加するたびにビルドする対象が増えてしまうのはできれば避けたいです。

これから、上記2つの問題に対処していきます。

依存をモジュールに切り出す

まずは、イベント送信をユニットテストできるようにします。現状テストができない原因は CounterViewModel に直接 Firebase のコードを書いていることなので、イベント送信の実装を抜き出して CounterViewModel に DI することにします。

そのために、イベント送信のロジックをまとめた AnalyticsService というモジュールを新たに切ることにします。 Package.swift を以下のように変更します。

// Package.swift

let package = Package(
    // ...
    targets: [
        .target(
            name: "CounterFeature",
-           dependencies: [.product(name: "FirebaseAnalytics", package: "firebase-ios-sdk")]
+           dependencies: ["Analytics"]
        ),
        .testTarget(name: "CounterFeatureTests", dependencies: ["CounterFeature"]),
+       .target(
+           name: "Analytics",
+           dependencies: [.product(name: "FirebaseAnalytics", package: "firebase-ios-sdk")]
+       ),
    ]
)

Firebase のロジックをまとめる Analytics モジュールを追加し、 CounterFeature からは Firebase ではなく Analytics に依存するようにしています。

Analytics モジュールの実装をしていきます。まず、イベント送信を抽象化したプロトコルとして、 AnalyticsService を切ります。その具体的な実装として、アプリ実行時に利用される実際に Firebase へのイベント送信を行う AnalyticsServiceLive と、ユニットテストにて実行されたイベント送信を検証するための AnalyticsServiceMock を作ります。

// Analytics/AnalyticsService.swift

import FirebaseAnalytics

public protocol AnalyticsService {
    func log(event: String)
}

public final class AnalyticsServiceLive: AnalyticsService {
    public init() {}

    public func log(event: String) {
        Analytics.logEvent(event, parameters: nil)
    }
}

public final class AnalyticsServiceMock: AnalyticsService {
    public var loggedEvents: [String] = []

    public init() {}

    public func log(event: String) {
        loggedEvents.append(event)
    }
}

CounterFeature 側では、 Firebase を直接触っていた箇所を AnalyticsService を利用するように書き換えます。

// CounterFeature/CounterScreen.swift

import Combine
import SwiftUI
- import FirebaseAnalytics
+ import Analytics

final class CounterViewModel: ObservableObject {
    // ...

+   private let analyticsService: any AnalyticsService
+
+   init(analyticsService: any AnalyticsService) {
+       self.analyticsService = analyticsService
+   }

    func increment() {
        count += 1
        if count == 10 {
-           Analytics.logEvent("count10", parameters: nil)
+           analyticsService.log(event: "count10")
        }
    }
}

public struct CounterScreen: View {
-   @StateObject private var viewModel: CounterViewModel = .init()
+   @StateObject private var viewModel: CounterViewModel = .init(analyticsService: AnalyticsServiceLive())

    // ...
}

以上の変更をもとに、 CounterViewModel のイベント送信ロジックのテストを書いていきます。

// CounterFeatureTests/CounterViewModelTests.swift

+ import Analytics
import XCTest
@testable import CounterFeature


final class CounterViewModelTests: XCTestCase {
    func testIncrement() {
-       let viewModel: CounterViewModel = .init()
+       let viewModel: CounterViewModel = .init(analyticsService: AnalyticsServiceMock())

        // ...
    }

+   func testCount10Event() {
+       let analyticsService = AnalyticsServiceMock()
+       let viewModel: CounterViewModel = .init(analyticsService: analyticsService)
+
+       for _ in 0..<9 {
+           viewModel.increment()
+       }
+       XCTAssertEqual(analyticsService.loggedEvents, [])
+       viewModel.increment()
+       XCTAssertEqual(analyticsService.loggedEvents, ["count10"])
+   }
}

AnalyticsServiceAnalyticsServiceMock に差し替えることで、適切なタイミングで "count10" イベントが送信されることが確認できるようになりました。

Feature モジュールが Firebase のような具体的なライブラリを直接操作するのではなく、ライブラリをラップしたモジュールを介して操作するようにすることで、アプリ内のロジックのテストを書くことが可能になります。現状だと、 Analytics モジュールを切らなくても CounterFeature 内に AnalyticsService / AnalyticsServiceLive / AnalyticsServiceMock を実装してしまうことで同じことが実現可能ですが、これから他の Feature モジュールからもイベント送信をしたくなることを考えると、モジュールを切る方がよさそうなことがわかります。

インターフェースモジュールを導入する

Analytics モジュールを切ったことにより、 CounterFeature はイベント送信の実装が Firebase によって行われていることを知らなくてよくなり、イベント送信ロジックのテストが可能になりました。しかし、 CounterFeature のプレビューを表示しようとすると、相変わらず Firebase をビルドしてしまっています。

これは、以下の図のように CounterFeature モジュールが Analytics モジュールを介して間接的に Firebase に依存しているためです。間接的であっても依存関係がある限りは、 CounterFeature のビルドのために Firebase のビルドが必要になってしまいます。

graph LR
    classDef default font-family:monospace;
    CounterFeature --> Analytics
    Analytics --> Firebase

ロジック上は CounterFeature が Firebase を知らなくてよい状態になっているので、モジュールの依存関係においても CounterFeature と Firebase を切り離すことができます。そのために、 AnalyticsService モジュールを、インターフェースモジュールと実装モジュールに分割します。

CounterFeature のテストやプレビューを行う上で必要なのは、 AnalyticsService プロトコルAnalyticsServiceMock クラスのみで、それらはいずれも Firebase に依存していません。このことを利用して、 Analytics モジュールに AnalyticsServiceAnalyticsServiceMock のみを残して、 AnalyticsServiceLiveAnalyticsLive というモジュールに分けることにします。

// Package.swift

let package = Package(
    // ...
    targets: [
        // ...
        .target(
            name: "Analytics",
-           dependencies: [.product(name: "FirebaseAnalytics", package: "firebase-ios-sdk")]
        ),
+       .target(
+           name: "AnalyticsLive",
+           dependencies: [
+               "Analytics",
+               .product(name: "FirebaseAnalytics", package: "firebase-ios-sdk"),
+           ]
+       ),
    ]
)
// Analytics/AnalyticsService.swift

- import FirebaseAnalytics

public protocol AnalyticsService {
    func log(event: String)
}

- public final class AnalyticsServiceLive: AnalyticsService {
-     public init() {}
- 
-     public func log(event: String) {
-         FirebaseAnalytics.Analytics.logEvent(event, parameters: nil)
-     }
- }

// ...
// AnalyticsLive/AnalyticsServiceLive.swfit

import Analytics
import FirebaseAnalytics

public final class AnalyticsServiceLive: AnalyticsService {
    public init() {}

    public func log(event: String) {
        FirebaseAnalytics.Analytics.logEvent(event, parameters: nil)
    }
}

以上の作業により、 CounterFeature モジュールから AnalyticsServiceLive が見えなくなったため、 CounterScreen にて初期化することができなくなります。そのため、 AnalyticsService を外部から DI してもらうことにします。

public struct CounterScreen: View {
-   @StateObject private var viewModel: CounterViewModel = .init(analyticsService: AnalyticsServiceLive())
+   @StateObject private var viewModel: CounterViewModel

-   public init() {}
+   public init(analyticsService: any AnalyticsService) {
+       self._viewModel = .init(wrappedValue: .init(analyticsService: analyticsService))
+   }

    public var body: some View {
        // ...
    }
}

#Preview {
-   CounterScreen()
+   CounterScreen(analyticsService: AnalyticsServiceMock())
}

ここまでの変更により、モジュール間の依存関係が以下のようになりました。

graph LR
    classDef default font-family:monospace;
    CounterFeature --> Analytics
    AnalyticsLive --> Firebase

CounterFeature と Firebase の間には間接的な依存関係もなくなっているため、 CounterFeature のテストやプレビューのために Firebase のビルドが走らなくなっています。

Analytics のようなインターフェースモジュールはその名の通りインターフェースのみを持ち実装を含みません。そのためアプリの機能が増えていっても大きくなったりライブラリに依存したりすることがなく、ビルドが軽いままでいられるという特徴を持っています。 Feature モジュールからインターフェースモジュールにのみ依存することで、アプリ全体のサイズが大きくなっても、テストやプレビューは軽いまま Feature モジュールの開発を効率よく続けていくことができます。

注意点として、すべてのライブラリに対してインターフェースモジュールを切ることができるわけではありません。例えば、 pointfreeco/swift-composable-architectureReactiveX/RxSwift など Feature モジュールのロジックを実行すること自体に必要なライブラリには直接的に依存せざるを得ないでしょう。 また、インターフェースモジュールを切ることができる場合でも必ず切ったほうがよいというわけでもありません。利用したいライブラリのビルドが軽かったり、特定の Feature モジュールからしか使われないような場合には、インターフェースモジュールを切るメリットが相対的に小さくなるため、ライブラリに直接依存してしまった方がアプリ全体の開発効率が高苦なるかもしれません。新しくモジュールを切るということはそれ自体がコストなので、メリットとデメリットのバランスを見てどうするか決めていくのがよいと思っています。

依存の実体を DI する

この時点で CounterFeature 単体ではビルドできますが、アプリ全体ではビルドが通らない状態になっています。 CounterScreenAnalyticsService が渡されていないためです。

CounterFeature と Firebase に依存関係はなくなりましたが、アプリの実行中はもちろん実際に Firebase に向けてイベントを送りたいです。このような要件は一般に、よりアプリケーションの main に近い側のモジュールから依存の実体を Feature モジュールに DI することにより実現できます。今回のカウンターアプリにおいては、 SampleApp から CounterFeatureAnalyticsServiceLive を渡してあげます。

そのために AnalyticsLive をライブラリとして書き出して、 SampleApp にリンクします。

// Package.swift

let package = Package(
    // ...
    products: [
        .library(name: "CounterFeature", targets: ["CounterFeature"]),
+       .library(name: "AnalyticsLive", targets: ["AnalyticsLive"]),
    ],
    // ...
}

その上で、 SampleApp を以下のように変更することでアプリのビルドが通り、 Firebase にイベントを送れるようになります。

// SampleApp.swift

import SwiftUI
+ import AnalyticsLive
import CounterFeature
import FirebaseCore
import FirebaseAnalytics

// ...

@main
struct SampleApp: App {
    // ...
    
    var body: some Scene {
        WindowGroup {
-           CounterScreen()
+           CounterScreen(analyticsService: AnalyticsServiceLive())
        }
    }
}

アプリ全体の最終的なモジュール間の依存関係は以下です。 CounterFeature モジュールは依存のインターフェースしか知らず、そのインターフェースを満たす依存の実体を SampleApp から渡してもらって使うだけになっています。

graph LR
    classDef default font-family:monospace;
    CounterFeature --> AnalyticsService
    AnalyticsServiceLive --> Firebase
    SampleApp --> AnalyticsServiceLive
    SampleApp --> CounterFeature

まとめ

アプリの成長とともに、アプリ全体のビルド時間が長くなっていくことは避けがたいです。しかし、アプリをモジュール分割したり、インターフェースモジュールを導入したりすることで、テストやプレビューから高速なフィードバックを受けながら Feature モジュールを開発していくことができます。

もちろんこの記事のサンプルアプリは本当にシンプルなものなので、実際のアプリでもこの記事のようにことがうまく運ぶとは限りません。しかし、適切な場面でモジュール化やインターフェースモジュールの導入をすることでアプリの開発体験を向上させることができるのではないかと思います。

記事で触れられなかったモジュール化のメリット

この記事ではモジュール化やインターフェースモジュールのメリットとして Feature モジュールのテストやプレビューの高速化について見てきました。

これに加えて、インターフェースモジュールには例えば how-to-improve-ios-build-times-with-modularization に書かれているようにアプリ全体の差分ビルドを効率化するというメリットもあります。あわせて読んでいただけるとよさそうです。

また、 Feature モジュールに限らずモデル層のコンポーネントについても、モジュール化により開発効率を向上させることができます。例えば、サンプルアプリの AnalyticsServiceLive は現状テストを書くほどのロジックがありませんが、もしロジックが複雑化した場合には AnalyticsServiceLiveTests のようなテストターゲットを追加することでユニットテストが可能です。 AnalyticsServiceLiveTests の実行のためにはアプリ全体ではなく AnalyticsServiceLive をビルドすればよいため、モジュール化によりテストの実行が効率化できます。 AnalyticsServiceLive は Firebase に依存してしまっていますが、一般にモデル層のモジュールは Feature モジュールよりも依存が少なくなるため、テストはとても高速に実行できることが多いです。

参考

www.runway.team

www.pointfree.co

qiita.com