タクシーアプリ「GO」のiOSアプリを開発している黒田です。 今回はRIBsアーキテクチャにNeedleを導入検討してみた話をご紹介します。
弊社は、2021年度下期より、エンジニアのスキルアップを促進するための取り組みとしてEngineer Challenge Week 施策が導入されました。詳しくは、「取締役エンジニアとして考える新しい個人と組織の成長の形 - Engineer Challenge Week」を是非読んでみてください。
私は、以前から気になっていたNeedleというDIライブラリの導入にチャレンジしてみました。
NeedleはUber社が開発したSwift製の依存性注入(DI)フレームワークです。 階層的な DI 構造(DIツリー)を構築し、コードを自動生成することでコンパイル時の安全性を保証します。
一方、タクシーアプリ「GO」の iOS アプリでは、RIBs アーキテクチャが採用されています。 RIBsアーキテクチャでは「RIB」と呼ばれるコンポーネントを組み合わせることで、木構造(RIBsツリー)でアプリを構築します。
どちらもComponentクラスとDependencyプロトコルを用いて木構造の親子間で依存関係を構築しており、また同じUber社のフレームワークということもあり、RIBsとNeedleの親和性は高そうです。
またNeedleを利用することで、RIBsの親子間の依存関係を自動で解決し、コードの見通しがよくなることが期待できます。
以下は、Rootコンポーネントをルートとした場合のDIツリーもしくはRIBsツリーを図で表したものです。 Rootの子がLoggedIn、LoggedOutであり、LoggedInの子がTicTacToe、OffGameであることを表しています。
Needleには、NeedleコードジェネレータとNeedleFoundationフレームワークの 2 つのパーツがあり、DIシステムとしてNeedleを使用するためには両方をプロジェクトに統合する必要があります。
Needleコードジェネレータは、開発者が書いたDIのコードを解析してSwiftのソースコードを生成するコマンドラインユーティリティです。
開発者が書いたComponent間の親子関係を解析し、木構造を形成します。Component同士が親子関係を結ぶには、どのComponentがどのComponentをインスタンス化しているかで判断します。 具体例を下記に示します。
class RootComponent: Component<LoggedInDependency> {
var loggedOutComponent: LoggedOutComponent {
return LoggedOutComponent(parent: self)
}
func loggedInComponent(player1Name: String, player2Name: String) -> LoggedInComponent {
return LoggedInComponent(parent: self, player1Name: player1Name, player2Name: player2Name)
}
}
Needleコードジェネレータは上記のSwiftコードを解析し、RootComponentがLoggedOutComponentおよびLoggedInComponentの親であることを推論しています。
依存関係が解決できない場合はビルドに失敗し、該当箇所の依存関係を説明するエラーを返します。
これにより、機能開発時のフィードバックとイテレーションのサイクルを迅速に行えます。
NeedleFoundationにはRIBsと同様にComponentクラスとDependencyプロトコルがあります。 NeedleではRIBsと同様に、親Componentから受け取る必要がある依存を子のDependencyプロトコルに定義し、そのプロトコルを子のComponentに準拠させます。
例)
protocol OffGameDependency: NeedleFoundation.Dependency {
// 親Componentから受け取る依存
var player1Name: String { get }
var player2Name: String { get }
}
final class OffGameComponent: NeedleFoundation.Component<OffGameDependency> {
fileprivate var player1Name: String {
return dependency.player1Name
}
fileprivate var player2Name: String {
return dependency.player2Name
}
}
Needleの導入は2つのステップで行います。
それではRIBsアーキテクチャアプリにNeedleを導入してみたいと思います。 今回はRIBsの公式チュートリアルアプリ「TicTocToe」をベースにしていきます。
ソースコードはこちらにあります。また、チュートリアルの解説はこちらをご参照ください。
インストールはCarthageもしくはHomebrewで行います。(CarthageおよびHomebrew自体の設定については省略します)
github "https://github.com/uber/needle.git" ~> VERSION_OF_NEEDLE
$ brew install needle
今回はHomebrewを使用しました。 インストールが終わったら、次にアプリケーションの実行ターゲットの「Build Phases」セクションに「Run Script」フェーズを追加します(Compile Sourcesの前)。 Run Scriptの内容は以下の通りです。
Shell: /bin/sh
# Type a script or drag a script file from your workspace to insert its path.
# 以下の部分はAppleシリコンのMacで必要になります。
if [ $(uname -m) = "arm64" ]; then
export PATH="/opt/homebrew/bin:/opt/homebrew/sbin:${PATH+:$PATH}";
fi
export SOURCEKIT_LOGGING=0 && needle generate $SRCROOT/TicTacToe/NeedleGenerated.swift TicTacToe
一度ビルドします。 ビルドが成功すると、指定したディレクトリにNeedleGenerated.swiftというファイル名が生成されているので、これをXcodeのプロジェクトにドラッグ&ドロップで追加します。 NeedleGenerated.swiftの中身を見るとregisterProviderFactoriesメソッドが記載されています。これはアプリケーション起動時に最初に呼び出す必要があるメソッドになります。 registerProviderFactories()をAppDelegateに追加します。
@UIApplicationMain
public class AppDelegate: UIResponder, UIApplicationDelegate {
/// The window.
public var window: UIWindow?
public func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplicationLaunchOptionsKey: Any]?) -> Bool {
// ↓追記
registerProviderFactories()
let window = UIWindow(frame: UIScreen.main.bounds)
self.window = window
・・・
}
これでNeedleコードジェネレータのセットアップは完了です。
次にNeedleFoundationフレームワークを追加します。 インストールにはCarthageもしくはSwift Package Manager(SPM)を使用します。
今回はSPMを使用しました。 これでNeedleを使う準備が整いました。
導入するにあたり、RIBsアーキテクチャのどの箇所を修正するかを簡単に説明します。 一つ一つのRIBは以下の図のような構成となっています。
(https://github.com/uber/RIBs/wiki より)
RIBsアーキテクチャでは上図のComponentの部分で、親子間の依存関係を扱っています。 具体的には、依存を親RIBから受け取る必要があるものを定義するDependencyプロトコル、そのRIB内で使うものを定義するComponentクラスで扱います。 Needleと違い、親RIBと子RIBの依存解決には子RIBのDependencyを親RIBのComponentに準拠させることで、親RIBが子RIBの依存を持つことをコンパイラによって保証します。
今回利用するRIBsの構造は以下の通りです。
それではNeedleをRIBsアーキテクチャに組み込んでみます。 今回は要点となる部分を抜粋していきます。
サンプルコードはRootがRIBsツリーのルートとなっています。 今回はRootBuilderの初期化に必要なAppComponentがDIツリーのルートのComponentになります。DIツリーのルートは親Componentを持たないため、BootstrapComponentという特別なクラスを利用します。ルートのComponentはBootstrapComponentを継承することで、依存関係のプロトコル(Dependency)を指定する必要がなくなります。 下記の通り修正します。
修正前
import RIBs
class AppComponent: Component<EmptyDependency>, RootDependency {
init() {
super.init(dependency: EmptyComponent())
}
}
修正後
import NeedleFoundation
class AppComponent: BootstrapComponent {
var rootComponent: RootComponent {
RootComponent(parent: self)
}
}
DependencyおよびComponentはRIBsアーキテクチャでも定義されているため、名前が衝突しないようにNeedleFoundationをインポートし名前空間を追記します。 (もしくはDependencyおよびComponentを、別ファイルを作成しそこに移動させても良いです)
修正前
protocol RootDependency: Dependency {
}
final class RootComponent: Component<RootDependency> {
/// Root component code...
}
修正後
import NeedleFoundation
protocol RootDependency: NeedleFoundation.Dependency {
}
final class RootComponent: NeedleFoundation.Component<RootDependency> {
/// Root component code...
}
Needleコードジェネレータの説明にあった通り、ComponentとComponentの親子関係を結びます。
final class RootComponent: NeedleFoundation.Component<RootDependency> {
/// Root component code...
// 子コンポーネント
var loggedOutComponent: LoggedOutComponent { LoggedOutComponent(parent: self) }
func loggedInComponent(player1Name: String, player2Name: String) -> LoggedInComponent {
return LoggedInComponent(parent: self, player1Name: player1Name, player2Name: player2Name)
}
}
これにより以下のような、RIBsにおける依存解決のロジックは不要になります。
protocol LoggedInDependencyOffGame: Dependency {
}
extension LoggedInComponent: OffGameDependency {
var scoreStream: ScoreStream {
return mutableScoreStream
}
}
上記のscoreStreamは、LoggedInComponent本体で宣言すればOffGameComponentにデータを渡すことができます。
RIBsではComponentのインスタンスをBuilder内で生成していましたが、Needleでは3.で記載の通り親Componentで生成します。 そのため、外部からComponentを渡せるSimpleComponentizedBuilderもしくはComponentizedBuilderクラスを利用します。(ソースコードはこちら)
SimpleComponentizedBuilderの場合
修正前
final class RootBuilder: Builder<RootDependency>, RootBuildable {
override init(dependency: RootDependency) {
super.init(dependency: dependency)
}
func build() -> LaunchRouting {
let viewController = RootViewController()
let component = RootComponent(dependency: dependency,
rootViewController: viewController)
let interactor = RootInteractor(presenter: viewController)
let loggedOutBuilder = LoggedOutBuilder(dependency: component)
let loggedInBuilder = LoggedInBuilder(dependency: component)
return RootRouter(interactor: interactor,
viewController: viewController,
loggedOutBuilder: loggedOutBuilder,
loggedInBuilder: loggedInBuilder)
}
}
修正後
final class RootBuilder: SimpleComponentizedBuilder<RootComponent, LaunchRouting>, RootBuildable {
override func build(with component: RootComponent) -> LaunchRouting {
// ViewControllerはComponentで生成する
let viewController = component.rootViewController
let interactor = RootInteractor(presenter: viewController)
let loggedOutBuilder = LoggedOutBuilder { component.loggedOutComponent }
let loggedInBuilder = LoggedInBuilder { component.loggedInComponent }
return RootRouter(interactor: interactor,
viewController: viewController,
loggedOutBuilder: loggedOutBuilder,
loggedInBuilder: loggedInBuilder)
}
}
呼び出し元
let appComponent = AppComponent()
let rootBuilder = RootBuilder { appComponent.rootComponent }
let launchRouter = rootBuilder.build()
ComponentizedBuilderの場合
修正前
final class LoggedInBuilder: Builder<LoggedInDependency>, LoggedInBuildable {
override init(dependency: LoggedInDependency) {
super.init(dependency: dependency)
}
func build(withListener listener: LoggedInListener, player1Name: String, player2Name: String) -> LoggedInRouting {
let component = LoggedInComponent(dependency: dependency,
player1Name: player1Name,
player2Name: player2Name)
let interactor = LoggedInInteractor(mutableScoreStream: component.mutableScoreStream)
interactor.listener = listener
let offGameBuilder = OffGameBuilder(dependency: component)
let ticTacToeBuilder = TicTacToeBuilder(dependency: component)
return LoggedInRouter(interactor: interactor,
viewController: component.loggedInViewController,
offGameBuilder: offGameBuilder,
ticTacToeBuilder: ticTacToeBuilder)
}
}
修正後
final class LoggedInBuilder: ComponentizedBuilder<LoggedInComponent, LoggedInRouting, LoggedInListener, (String, String)>, LoggedInBuildable {
func build(withListener listener: LoggedInListener, player1Name: String, player2Name: String) -> LoggedInRouting {
build(withDynamicBuildDependency: listener, dynamicComponentDependency: (player1Name, player2Name))
}
override func build(with component: LoggedInComponent, _ listener: LoggedInListener) -> LoggedInRouting {
let interactor = LoggedInInteractor(mutableScoreStream: component.mutableScoreStream)
interactor.listener = listener
let offGameBuilder = OffGameBuilder { component.offGameComponent }
let ticTacToeBuilder = TicTacToeBuilder { component.ticTacToeComponent }
return LoggedInRouter(interactor: interactor,
viewController: component.loggedInViewController,
offGameBuilder: offGameBuilder,
ticTacToeBuilder: ticTacToeBuilder)
}
}
呼び出し元(ロジックの変更はなし)
let loggedIn = loggedInBuilder.build(withListener: interactor, player1Name: player1Name, player2Name: player2Name)
attachChild(loggedIn)
大きな修正点としては上記4点になります。 これを全てのRIBに対して行います。 修正はRIBsツリーのルートから順に行うことで少しずつ進めることができます。 (ツリーの末端からおこなってしまうと、親のRIBの修正が必要になり、全ての修正が終わるまでエラーが解決できません)
今回はRIBsアーキテクチャにNeedleを適用できるかどうかの検討を行いました。 所感は以下の通りです。 ・Needleを使うことで依存関係のロジックをシンプルに記述できる ・依存解決ができない場合ビルドエラーになるので実装・修正のサイクルが早くなる ・階層が深くなると、先祖のどこで定義されたものかを追うのが難しくなる(これはNeedleGenerated.swiftの中身を見れば解決しそう) ・IDEのコードジャンプで依存関係を辿るのが難しくなる
今後はこの調査を元にタクシーアプリ「GO」に導入できるかどうかについて検討を進めてみたいと思います。
興味のある方は 採用ページ も見ていただけると嬉しいです。
Twitter @mot_techtalk のフォローもよろしくお願いします!