Swift Package Manager を利用したマルチモジュール構成の画面遷移実装
こんにちは!ニフティライフスタイルでモバイルアプリの開発をしているt-toshasegawです。
iOSアプリ「ニフティ不動産 賃貸版」では、Swift Package Manager(SPM)を利用したマルチモジュール構成でアプリ開発をしています(紹介記事もご覧ください)。
この記事では、SPM を利用したマルチモジュール構成において課題となる画面遷移について、ニフティ不動産アプリでの実装方法をご紹介します。
目次
SPM を利用したマルチモジュール構成の課題
SPM を利用したマルチモジュール構成において、課題となるのが画面遷移です。
循環参照を避けるため、モジュール同士は依存関係を持たず、独立している必要があります。例えば、FeatureA と FeatureB というモジュールがあるとします。この場合、FeatureA と FeatureB はお互いの存在を認識していない状態です。この状態でFeatureA から FeatureB に画面遷移を行いたい場合、FeatureA に import FeatureB
はできないため、画面遷移が実現できません。
この問題を解決するため、iOSアプリ「ニフティ不動産 賃貸版」ではCookpad様のブログを参考に画面遷移を実現しました。今回はその実装方法について紹介します。
コード生成を用いたiOSアプリマルチモジュール化のための依存解決 – クックパッド開発者ブログ
実装
iOSアプリ「ニフティ不動産 賃貸版」は VIPER アーキテクチャを採用しており、以下のようなパッケージ構成になっています。
VIPER アーキテクチャについては以下の記事がとても参考になります。
VIPERアーキテクチャ まとめ
App
- アプリ本体
- アプリのライフサイクルや初期設定を管理
Feature
- アプリ内の特定の機能や画面を実装するためのモジュール
- 独立した機能ごとに分けられている
Core
- アプリ全体で共通して使用されるビジネスロジックを管理
- API通信や UserDefaults への保存など
依存関係は App -> Feature -> Core となっています。
今回は Feature パッケージに、FeatureA モジュールと FeatureB モジュールを作成し、FeatureA から FeatureB への画面遷移を実装します。
実装に必要な要素は以下の通りです。
- ViewDescriptor:モジュール間で遷移するために必要な情報の定義
- Environment:モジュールの依存関係を取り出すための DI コンテナ
- AppEnvironment:ViewDescriptor をもとに、Feature の Router へアクセスして ViewController を解決する役割
これらを順に作成した後、FeatureA の Router から FeatureB の画面へ遷移するための処理を記述します。
ViewDescriptor の作成
まずは ViewDescriptor を作成します。 ViewDescriptor は他のモジュールの実装を取り出すために使用します。
ViewDescriptor は以下の要件に基づいて作成します。
- どこからでもアクセスできる Core パッケージに作成
- 1画面につき1つの ViewDescriptor を作成
- TypedDescriptor に準拠
- 次の画面に渡す情報がある場合は必要なパラメータを定義
public protocol TypedDescriptor {
associatedtype Output
}
public extension ViewDescriptor {
struct FeatureADescriptor: TypedDescriptor {
public typealias Output = UIViewController
}
struct FeatureBDescriptor: TypedDescriptor {
public typealias Output = UIViewController
public let text: String
}
}
Environment の作成
次に Environment を作成します。 Environment は各モジュールの依存関係を取り出すための DI コンテナです。
Environment は以下の要件に基づいて作成します。
- どこからでもアクセスできる Core パッケージに作成
- 画面生成に必要な
resolve()
を定義
public protocol Environment {
func resolve<Descriptor: TypedDescriptor>(_ descriptor: Descriptor) -> Descriptor.Output
}
AppEnvironment の作成
続いて AppEnvironment を作成します。 AppEnvironment は Environment に準拠した具体的な実装を注入する役割を持ちます。
AppEnvironment は以下の要件に基づいて作成します。
- App(アプリ本体)に定義
- Environment に準拠
resolve()
内で、定義した全ての ViewDescriptor の画面を生成
assembleModules()
は Router に定義された画面生成メソッドです。引数でその画面に対応する ViewDescriptor と Environment を受け取ります。
final class AppEnvironment: Environment {
func resolve<Descriptor: TypedDescriptor>(_ descriptor: Descriptor) -> Descriptor.Output {
switch descriptor {
case let descriptor as ViewDescriptor.FeatureADescriptor:
FeatureARouter.assembleModules(
with: descriptor,
environment: self
) as! Descriptor.Output
case let descriptor as ViewDescriptor.FeatureBDescriptor:
FeatureBRouter.assembleModules(
with: descriptor,
environment: self
) as! Descriptor.Output
default:
fatalError("descriptor cases are not covered")
}
}
}
画面遷移
最後に画面遷移の実装です。以下は FeatureA から FeatureB に画面遷移する実装です。
resolve()
から返却される型は TypedDescriptor の Output に定義した型なので、 FeatureBViewController から UIViewController へとアップキャストされます。アップキャストされたことで FeatureA では UIViewController として扱われるため、 FeatureB に依存せずに画面遷移が可能になります。
また、全ての画面を UIViewController として扱えるので、 Objective-C で作成されていて App からパッケージへ切り出せない画面であっても、 ViewDescriptor さえ作成することができれば Feature パッケージから画面遷移が可能になるのも大きなメリットです。
// FeatureA モジュール
final class FeatureARouter {
private unowned let viewController: UIViewController
private let environment: any Environment
// AppEnvironment から呼ばれる
static func assembleModules(
with descriptor: ViewDescriptor.FeatureADescriptor,
environment: some Environment
) -> UIViewController {
let view = FeatureAViewController()
let router = FeatureARouter(
viewController: view,
environment: environment
)
// 割愛するが presenter もここで生成
return view
}
}
extension FeatureARouter {
// 画面遷移時に呼ばれる
func navigate() {
// 画面遷移したい画面の ViewDescriptor を生成
let descriptor = ViewDescriptor.FeatureBDescriptor(text: "hoge")
// resolve() で画面を生成
let view = environment.resolve(descriptor)
viewController.present(view, animated: true)
}
}
AppEnvironment の自動生成
AppEnvironment は定義した全ての ViewDescriptor を網羅していないと resolve()
で fatalError が呼ばれクラッシュしてしまうというデメリットがあります。
そこで実装漏れを回避するために Sourcery を導入しています。Sourcery はボイラープレートを自動生成するためのライブラリです。 ニフティ不動産では定義している全ての ViewDescriptor を網羅して AppEnvironment を生成する Build Script を実行し、実装漏れを防ぐ運用をしています。
// AppEnvironment を自動生成するためのテンプレート
final class AppEnvironment: Environment {
func resolve<Descriptor: TypedDescriptor>(_ descriptor: Descriptor) -> Descriptor.Output {
switch descriptor {
{% for type in types.implementing.TypedDescriptor|struct %}
case let descriptor as {{ type.name }}:
{{ type.localName | replace:"Descriptor","Router" }}.assembleModules(
with: descriptor,
environment: self
) as! Descriptor.Output
{% endfor %}
default:
fatalError("descriptor cases are not covered")
}
}
}
UseCase の DI を Environment で実施
Environment は、resolve()
による画面生成だけでなく、ビジネスロジック の DI も担当しています。ニフティ不動産ではビジネスロジッククラスのプロトコルを UseCase とし、その実装クラスを Interactor と呼んでいるため、以下では UseCaseと Interactor を用いて説明します。
Environment に UseCase を定義し、 AppEnvironment を UseCase の DI コンテナとして使用します。 Router の assembleModules()
から Presenter に Environment を渡すことで、 Presenter から UseCase を呼び出すことが可能になります。
画面遷移と同様に、レガシーな Interactor であっても UseCase を Core パッケージに移動すれば、 Feature パッケージから呼び出すことができます。
また、ユニットテストの際には MockEnvironment を作成し、全ての依存を差し替えることが可能です。
// Core
public protocol Environment {
var hogeInteractor: any HogeUseCase { get }
}
// App
final class AppEnvironment: Environment {
var hogeInteractor: any HogeUseCase {
HogeInteractor()
}
}
// ユニットテストで使用
final class MockEnvironment: Environment {
lazy var hogeInteractor: any HogeUseCase = MockHogeInteractor()
}
ニフティ不動産では、UseCase だけでなく、行動ログの計測も Environment を通じて DI を行っています。この計測の実装はアプリリリース当初からのレガシーなものですが、Environment を活用することで、App 以外からの呼び出しやユニットテストが可能になりました。
最後に
ViewDescriptor, Environment を活用することで VIPER アーキテクチャで異なるモジュール間での画面遷移が実現できました。また以前までは DI に swift-dependencies を使用していましたが、 @Dependency
による呼び出しやユニットテスト時の依存の差し替えも不要になりコードの可読性が向上したと感じています。
iOSアプリ「ニフティ不動産 賃貸版」はまだまだレガシーなコードが残っていますが、今回紹介した手法を通じて少しずつ App から切り離し、ビルド時間の高速化を目指しています。
この記事がマルチモジュール構成の運用をしている方々にとって、何らかの参考になれば幸いです。最後までお読みいただき、ありがとうございました。
掲載内容は、記事執筆時点の情報をもとにしています。