needleでモジュール間の画面遷移を実現した話 REALITY Advent Calendar #17
概要
REALITY アドベントカレンダーの17日目の記事です。前日は『ゲームパッド対応してみた』でした。
今年は注射を3回も打つ年でした。注射は嫌いなかむいです。
Injection(注入)する仕組みはワクチン同様、モジュール間の依存問題の解決にも役立ちます。マルチモジュール化において重要となる別モジュールへのDIの仕組みについて紹介します。
なおこの記事はneedleを使ったDI改善の導入に続く第二弾です。前回の記事をご覧になったことが無い方はこちらも合わせてどうぞ。
機能単位でモジュールを切り分ける構成の課題
REALITY iOSは機能単位でモジュールを切り分ける構成を取っています。機能ごとにミニアプリを作成しプレビューサイクルを高速化することを目的としているためです。
このポイントは機能モジュール同士は依存し合わないルールとしていることです。依存すると循環参照が起こり、依存関係が壊れてしまうのでそれを避けるためです。
しかし、画面遷移の際に別モジュールにある画面に遷移したいというケースは発生します。例えばある画面上にあるバナーを経由し別画面に遷移する場合、バナーの遷移先が必ずしも遷移元の画面が属しているモジュールと同じとは限りません。
そこでneedleのDIの仕組みを活用しつつ、別モジュールの画面に遷移する仕組みについてトライしてみました。
uber/needleとuber/RIBs
参考にさせて頂いたのはHimotokiなどのOSSを開発されているいけしょーさん(@ikesyo)の登壇スライドです。マルチモジュール構想を想定した画面遷移の仕組みにneedleを採用しており、我々の課題感とマッチした内容だったため参考にしました。
ここでポイントとなるのがneedleと同じくUberが運用しているOSSのRIBsです。RIBsはクロスプラットフォーム開発向けのアーキテクチャフレームワークなのですが、こちらに登場するBuilderという画面遷移の仕組みを流用します。
BuilderはDIシステムのDependencyインターフェースを活用し画面遷移を実現するためのもので、needleと設計思想的にも相性の良いものでした。ただしRIBsの他のアーキテクチャは使わず、画面遷移に必要なこちらのインターフェースのみ拝借しモジュール間の画面遷移を実現させるのがミソです。
実装例
RIBsのBuilderインターフェースを全てのモジュールで利用できる共通モジュールに用意します。
import NeedleFoundation
public protocol Buildable: AnyObject {}
open class Builder<Dependency>: Buildable {
public let dependency: Dependency
public init(dependency: Dependency) {
self.dependency = dependency
}
}
このBuildableインターフェースを必要とする箇所で呼び出し、実際の画面インスタンスの型を指定しないようにします。このインターフェースも共通モジュールで用意します。
// Buildableに準拠した専用Buildableを用意
public protocol GachaBuildable: Buildable {
// 返り値はUIViewController
func makeViewController() -> UIViewController
}
public protocol Gacha {
func gachaBuilder() -> GachaBuildable
}
このBuilderクラスを利用するのは、別モジュールの画面インスタンスを必要とする画面のDIコンテナです。GachaBuildableを通して依存を必要としている箇所で画面インスタンスを取得するようにします。
// ガチャ画面のDIコンテナ
public class GachaComponent: Component<GachaDependency>, Gacha {
public func gachaBuilder() -> GachaBuildable {
GachaBuilder(dependency: dependency)
}
}
class GachaBuilder: Builder<GachaDependency>, GachaBuildable {
func makeViewController() -> UIViewController {
let viewModel = GachaViewModel()
let view = GachaView(viewModel: viewModel)
return GachaHostingController(rootView: view)
}
}
// 依存を必要としている画面のDIコンテナ
public protocol MediaListDependency: Dependency {
var gachaBuilder: GachaBuildable { get }
}
public class MediaListComponent: Component<MediaListDependency>, MediaListBuildable {
public func makeViewController() -> UIViewController {
let useCase = DefaultMediaListUseCase(repository: DefaultMediaRepository())
let viewModel = MediaListViewModel(useCase: useCase)
let view = MediaListView(viewModel: viewModel)
return MediaListHostingController(rootView: view, gachaBuilder: dependency.gachaBuilder)
}
}
最後に実際に画面インスタンスを取得する箇所について。gachaBuilder.makeViewController()で返り値に指定していた型はUIViewControllerのため、このクラスで実体の型を把握している必要はなく、結果import Gachaといった依存(実体の型を知る術)が不要となります。
private func transitionToGacha() {
let vc = gachaBuilder.makeViewController()
present(vc, animated: true, completion: nil)
}
導入までの流れ
前回同様サンプルアプリにこの仕組みを導入し、プロダクト導入前にチームメンバーに伝搬するところから始めました。メンバーに伝搬し得られたFeedbackは主に以下の様なものがありました。
SwiftUIについてはneedleが対応済みのためサンプルアプリ上でSwiftUI.ViewなUIを使ったUIHostingControllerを用意してみました。makeViewController()から返す値をSwiftUI.Viewにする関数についても議論がありましたが、直近ではUIKitを使った画面遷移が主となる画面が多いため優先度を下げています。
現在はサンプルアプリにFeedbackを反映し、プロダクト導入前の調整を行なっている最中です。今後はgit管理しているDIのドキュメントを更新し、導入後は別モジュールへの画面インスタンスのDIが実現できるため、マルチモジュール化を更に促進していく予定です。
最後に
needleを使ったモジュール間の画面遷移の仕組みについて書いてみました。
今年はマルチモジュール化について積極的に取り組み出した年でしたが、来年はその拡充を図りつつ、引き続きユーザーに楽しんでもらえる新規機能開発にも取り組んでいきたいと思います。
次回はizmさんの『REALITY VRで全身を動かしてみた話』です。お楽しみに!