MoTLab -Mobility Technologies Engineering Blog-MoTLab -Mobility Technologies Engineering Blog-

RIBsアーキテクチャのアプリにNeedleを導入検討してみた話

iOSRIBs
November 30, 2022

タクシーアプリ「GO」のiOSアプリを開発している黒田です。 今回はRIBsアーキテクチャにNeedleを導入検討してみた話をご紹介します。


はじめに

弊社は、2021年度下期より、エンジニアのスキルアップを促進するための取り組みとしてEngineer Challenge Week 施策が導入されました。詳しくは、「取締役エンジニアとして考える新しい個人と組織の成長の形 - Engineer Challenge Week」を是非読んでみてください。

私は、以前から気になっていたNeedleというDIライブラリの導入にチャレンジしてみました。

Needleとは

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であることを表しています。

An image from Notion

Needleには、NeedleコードジェネレータNeedleFoundationフレームワークの 2 つのパーツがあり、DIシステムとしてNeedleを使用するためには両方をプロジェクトに統合する必要があります。

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の親であることを推論しています。

💡
上記のlogggedOutComponentは静的依存性、loggedInComponentについては動的依存性と呼ばれるものです。動的依存性とは実行時に取得される依存性のことです。 この例のplayer1Name、player2Nameを静的依存性で扱った場合、Root RIB の作成時にplayer1Name、player2Nameの値を決定できない(このサンプルではLoggedOutの画面でプレイヤー名を入力する処理があります)ため、オプショナルにする必要があります。オプショナルな値を処理することにした場合、LoggedIn RIBはnil 値の処理を実装する必要があり、コードがより複雑になってしまいます。

依存関係が解決できない場合はビルドに失敗し、該当箇所の依存関係を説明するエラーを返します。

An image from Notion

これにより、機能開発時のフィードバックとイテレーションのサイクルを迅速に行えます。

NeedleFoundationフレームワーク

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の導入

Needleの導入は2つのステップで行います。

  1. NeedleコードジェネレータをSwiftプロジェクトに統合
  2. NeedleFoundationフレームワークのAPIに従って、アプリケーションのDIコードを記述

それではRIBsアーキテクチャアプリにNeedleを導入してみたいと思います。 今回はRIBsの公式チュートリアルアプリ「TicTocToe」をベースにしていきます。

ソースコードはこちらにあります。また、チュートリアルの解説はこちらをご参照ください。

Needleコードジェネレータの追加

インストールはCarthageもしくはHomebrewで行います。(CarthageおよびHomebrew自体の設定については省略します)

  • Carthageの場合
github "https://github.com/uber/needle.git" ~> VERSION_OF_NEEDLE
  • Homebrewの場合
$ 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フレームワークの追加

次にNeedleFoundationフレームワークを追加します。 インストールにはCarthageもしくはSwift Package Manager(SPM)を使用します。

  • Carthageの場合
    • Needleコードジェネレータと同様です。
  • SPMの場合

今回はSPMを使用しました。 これでNeedleを使う準備が整いました。

RIBsアーキテクチャへの導入

概要

導入するにあたり、RIBsアーキテクチャのどの箇所を修正するかを簡単に説明します。 一つ一つのRIBは以下の図のような構成となっています。

An image from Notion

https://github.com/uber/RIBs/wiki より)

RIBsアーキテクチャでは上図のComponentの部分で、親子間の依存関係を扱っています。 具体的には、依存を親RIBから受け取る必要があるものを定義するDependencyプロトコル、そのRIB内で使うものを定義するComponentクラスで扱います。 Needleと違い、親RIBと子RIBの依存解決には子RIBのDependencyを親RIBのComponentに準拠させることで、親RIBが子RIBの依存を持つことをコンパイラによって保証します。

RIBsの構成

今回利用するRIBsの構造は以下の通りです。

An image from Notion

それではNeedleをRIBsアーキテクチャに組み込んでみます。 今回は要点となる部分を抜粋していきます。

1. ルートとなるComponentの修正

サンプルコードは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)
    }
}

2. ComponentおよびDependencyをNeedleFoundationのものに置き換える

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...
}

3. 親コンポーネントで子コンポーネントのインスタンス化を行う

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にデータを渡すことができます。

4. BuilderをComponentizedBuilderに準拠させる

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()

💡
上記では、呼び出し元でbuild(with component: RootComponent)メソッドではなく、引数のないbuild()メソッドを呼び出しています。ComponentizedBuilder.swiftを見ると、build(with component: Component) メソッドを直接呼び出してはいけない旨が記載されており、また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)

💡
こちらもSimpleComponentizedBuilderの場合と同様、ComponentizedBuilder.swiftの中で、build(withDynamicBuildDependency: listener, dynamicComponentDependency: (player1Name, player2Name))から内部的にbuild(with component: LoggedInComponent, _ listener: LoggedInListener)メソッドが呼ばれています。

大きな修正点としては上記4点になります。 これを全てのRIBに対して行います。 修正はRIBsツリーのルートから順に行うことで少しずつ進めることができます。 (ツリーの末端からおこなってしまうと、親のRIBの修正が必要になり、全ての修正が終わるまでエラーが解決できません)

まとめ

今回はRIBsアーキテクチャにNeedleを適用できるかどうかの検討を行いました。 所感は以下の通りです。 ・Needleを使うことで依存関係のロジックをシンプルに記述できる ・依存解決ができない場合ビルドエラーになるので実装・修正のサイクルが早くなる ・階層が深くなると、先祖のどこで定義されたものかを追うのが難しくなる(これはNeedleGenerated.swiftの中身を見れば解決しそう) ・IDEのコードジャンプで依存関係を辿るのが難しくなる

今後はこの調査を元にタクシーアプリ「GO」に導入できるかどうかについて検討を進めてみたいと思います。


We're Hiring!

📢
Mobility Technologies ではともに働くエンジニアを募集しています。

興味のある方は 採用ページ も見ていただけると嬉しいです。

Twitter @mot_techtalk のフォローもよろしくお願いします!