この記事は はてなエンジニア 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"]), ] )
SampleAppPackage
を Xcode プロジェクトに追加し、 SampleApp
ターゲットの Framework, Libraries and Embedded Content
にて CounterFeature
をリンクします。
CounterScreen.swift
を Xcode プロジェクトから CounterFeature
の中に移動します。 CounterScreen
は Xcode プロジェクト内、すなわち 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"]) + } }
AnalyticsService
を AnalyticsServiceMock
に差し替えることで、適切なタイミングで "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
モジュールに AnalyticsService
と AnalyticsServiceMock
のみを残して、 AnalyticsServiceLive
を AnalyticsLive
というモジュールに分けることにします。
// 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-architecture や ReactiveX/RxSwift など Feature モジュールのロジックを実行すること自体に必要なライブラリには直接的に依存せざるを得ないでしょう。 また、インターフェースモジュールを切ることができる場合でも必ず切ったほうがよいというわけでもありません。利用したいライブラリのビルドが軽かったり、特定の Feature モジュールからしか使われないような場合には、インターフェースモジュールを切るメリットが相対的に小さくなるため、ライブラリに直接依存してしまった方がアプリ全体の開発効率が高苦なるかもしれません。新しくモジュールを切るということはそれ自体がコストなので、メリットとデメリットのバランスを見てどうするか決めていくのがよいと思っています。
依存の実体を DI する
この時点で CounterFeature
単体ではビルドできますが、アプリ全体ではビルドが通らない状態になっています。 CounterScreen
に AnalyticsService
が渡されていないためです。
CounterFeature
と Firebase に依存関係はなくなりましたが、アプリの実行中はもちろん実際に Firebase に向けてイベントを送りたいです。このような要件は一般に、よりアプリケーションの main に近い側のモジュールから依存の実体を Feature モジュールに DI することにより実現できます。今回のカウンターアプリにおいては、 SampleApp
から CounterFeature
に AnalyticsServiceLive
を渡してあげます。
そのために 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 モジュールよりも依存が少なくなるため、テストはとても高速に実行できることが多いです。