MoTLab -GO Inc. Engineering Blog-MoTLab -GO Inc. Engineering Blog-

RIBs アーキテクチャにおける木構造の再構築について

iOSRIBs
May 27, 2022

タクシーアプリ「GO」の iOS アプリを開発している今入です。本記事では RIBs アーキテクチャにおける「Workflow」の概念とその活用方法について具体的な実装例を交えて紹介します。


はじめに

タクシーアプリ「GO」の iOS アプリでは、RIBs アーキテクチャ が採用されています。RIBs は Router / Interactor / Builder を 1 つのコンポーネント ( 以下 RIB と表現する ) とし、複数の RIB が木構造を成すことでアプリのビジネスロジックが動作する機構となっています。本記事ではこの木構造を「RIBs tree」と表現します。

基本的にはユーザによる操作を起点にその木構造は変化していくのですが、特定の RIB 配下の木構造を指定した形に再構築したいというニーズがあります。

代表的な例では URLScheme による遷移が挙げられます。URLScheme 経由でアプリが開かれた場合、しかるべき処理を行い指定された画面を開かなければなりません。RIBs アーキテクチャを採用している場合、RIBs tree を再構築することでそういった処理を実現することができます。その再構築を行うための仕組みが RIBs フレームワークの中に内包されており、「Workflow」と呼ばれています。

以下に、Workflow の基本的な実装方法とその仕組みについて紹介します。

本記事にて想定する RIBs tree

An image from Notion

本記事では、上記のような RIBs tree の構成で成り立っているアプリを考えることにします。

AppRoot を起点に、ログイン状態とログアウト状態で大きく 2 つに枝分かれしています。ログイン状態の場合は主要機能を束ねる MainFeatures が存在し、3 つの機能の切り替えを行います。

今回は Reservation 配下で管理されている ScheduledReservation までの RIBs tree の再構築を考えます。URLScheme 経由でアプリを起動し、指定された予約の詳細画面に遷移するといったシーンが想定されます。

Workflow による RIBs tree の再構築

どのような手順で RIBs tree を再構築していけばよいのでしょうか。Workflow を使ってそれを実現するためには以下の手順を踏む必要があります。

  1. 既存のルーティングをどのように辿ればよいかを明確にする
  2. 経由する RIB の Interactor を外部から操作できるようにする
  3. 次の RIB へ遷移するまでに待機が必要な処理を検討する
  4. Workflow を実行して RIBs tree を再構築する

Workflow を新しく作成する際に、上記の順番で検討していけばスムーズに実装を進めることができると思います。詳細は以下をご参考ください。

1.既存のルーティングをどのように辿ればよいかを明確にする

An image from Notion

RIBs tree の再構築の手順を図示すると上記のようになります。① から ⑤ まで順番に RIB を繋ぎ込んでいくことで、「予約された情報」を表示させることができます。

ここで重要な点は、目的の RIB までの経路がすべて繋がっているということです。URLScheme 経由での Workflow の利用の場合、AppRoot から繋ぎこみの処理を実行する必要があります。起点となる RIB (今回の場合は AppRoot )から目的の RIB( ScheduledReservation )までが、途切れることなくいくつかの RIB を中継することで繋がっていることを確認しましょう。

これは、Workflow の特徴でもありますが、既存の RIBs tree で表現されているルーティングを辿ることで RIB の再構築が実現されます。裏を返せば、既存の RIBs tree では辿ることができないルーティングは Workflow では表現できません。新たに RIB 同士の依存を作成することでその道を作る必要があります。

2.経由する RIB の Interactor を外部から操作できるようにする

目的の RIB までのルーティングに問題がないことを確認したら、次は ActionableItem の作成を行いましょう。

Workflow 内で、中継する RIB の Interactor を操作する必要があるため、「ActionableItem」という Protocol を作成し Interactor を抽象化させます。こういった目的のために Interactor の抽象化を担う Protocol の総称として「ActionableItem」という名前が RIBs フレームワーク内で定義されています。

protocol AppRootActionableItem: AnyObject { 
    func waitForLoggedIn() -> Observable<(LoggedInActionableItem, ())>
}

ActionableItem は、起点となる RIB を含む中継 RIB すべてで必要となります。

今回のケースだと、AppRootActionableItem, LoggedInActionableItem, MainFeaturesActionableItem, ReservationActionableItem の 4 つを作成しましょう。繰り返しになりますが、ActionableItem は Interactor を抽象化したものなので、その実体は Interactor を指します。つまり、LoggedInActionableItem の実体は LoggedInInteractorとなります。

ActionableItem を作成したら、それを対象の Interactor に準拠させましょう。

// MARK: - AppRootActionableItem
extension AppRootInteractor: AppRootActionableItem {
    func waitForLoggedIn() -> Observable<(LoggedInActionableItem, ())> {
        loggedInActionableItemSubject.map { ($0, ()) }
    }
}

上記の例の中に、ActionableItemSubject というものが登場しています。これは、Interactor を格納するための ReplaySubject 型のオブジェクトを指します。

lazy var loggedInActionableItemSubject = ReplaySubject<LoggedInActionableItem>.create(bufferSize: 1)

では、どのタイミングで loggedInActionableItemSubject に LoggedInInteractor を格納すればよいのでしょうか。そもそも LoggedInInteractor を親の Interactor つまり AppRootInteractor で参照することは可能なのでしょうか。

protocol AppRootRouting: ViewableRouting {
    func routeToLoggedIn() -> LoggedInActionableItem
}

親の Interactor で子の Interactor を参照するには一工夫が必要です。上記のように、親の Router で子 Router を attach したあとに、子 Interactor を自身の Interactor に返すことで実現できます。

// MARK: - Builder
protocol LoggedInBuildable: Buildable {
    func build(withListener listener: LoggedInListener) -> (router: LoggedInRouting, actionableItem: LoggedInActionableItem)
}

つまり、LoggedInBuilder の build メソッドの返り値として、Router だけでなく Interactor を返す必要があります。ここで Interactor を直接返さずに、先ほど準備した ActionableItem として抽象化した形で返すことがポイントです

こうすることで、親 Interactor で抽象化された子 Interactor を参照することができるようになりました。

if let loggedInActionableItem = router?.routeToLoggedIn() {
    loggedInActionableItemSubject.on(.next(loggedInActionableItem))
}

子 Router が attach された直後に ActionableItemSubject に子 Interactor を格納しましょう。

なぜ ReplaySubject を利用するのか

ActionableItem の格納に、PublishSubject ではなく ReplaySubject を利用しています。これは、中継 RIB の Router がすでに attach されている場合でも Workflow がきちんと動作するようにするためです。

たとえば今回の例でいうと、④ までのルーティングが行われている状態で対象の URLScheme による遷移が行われた場合でも、④ → ⑤ の遷移のみ実行できるようにするためです。PublishSubject の場合だと最初の 1 回のみ成功しますが、繰り返し同じ Workflow が実行された場合に動作しなくなります。

ReplaySubject を利用していることで、実装上の注意が必要な箇所があります。格納されている Interactor が deactive 状態になる際に、 ReplaySubject 内から解放してあげなければなりません

// MARK: - LoggedInListener
extension AppRootInteractor {
    func willResignLoggedInActive() {
        loggedInActionableItemSubject = .create(bufferSize: 1)
    }
}

この処理を忘れると、LoggedInInteractor がいつまでもメモリ上に存在してしまい、循環参照によるメモリリークが発生してしまいます。

3.次の RIB へ遷移するまでに待機が必要な処理を検討する

ここで今回のケースにおける Workflow の実装例を紹介します。

final class ScheduledReservationWorkflow: Workflow<AppRootActionableItem> {
    init() {
        super.init()
        self.onStep { appRootActionableItem -> Observable<(LoggedInActionableItem, ())> in // ① → ②
            appRootActionableItem.waitForLoggedIn() // LoggedInInteractor が取得されるまで待機
        }
        .onStep { loggedInActionableItem -> Observable<(MainFeaturesActionableItem, ())> in // ② → ③
            loggedInActionableItem.waitForMainFeatures() // MainFeaturesInteractor が取得されるまで待機
        }
        .onStep { mainFeaturesActionableItem -> Observable<(OrderActionableItem, ())> in // ③ → ④
            Observable<Void>.just(())
                .filter { _ in !mainFeaturesActionableItem.isMaintenance } // メンテナンス中の場合は後続の処理を行わない
                .do { _ in
                    mainFeaturesActionableItem.removeCurrentFeatureAndSwitchToMainFeature(mainFeatureType: .reservation) // MainFeatures配下にすでに構築済みの RIBs を破棄して Reservation に切り替える
                }
                .flatMap { _ in
                    mainFeaturesActionableItem.waitForReservation() // ReservationInteractor が取得されるまで待機
                }
        }
        .onStep { reservationActionableItem -> Observable<(ScheduledReservationActionableItem, ())> in // ④ → ⑤
            reservationActionableItem.showScheduledReservationIfNeeded() // ScheduledReservation の表示処理を命令
        }
        .commit()
    }
}

まず Workflow クラスのサブクラスを定義します。Workflow のジェネリクスには、Workflow を実行する RIB の ActionableItem を指定しましょう。つまり、RIBs tree を再構築する際の起点となる RIB の Interactor に準拠させる ActionableItem が指定されることになります。ここで指定した型は onStep 処理の最初の引数の型に一致します。

Workflow 内の処理は、onStep で ActionableItem を数珠つなぎしていきます。そして最後に commit() を実行して完了です。

基本的には、再構築したい RIB の Router を上から順番に attach していき Interactor を次の Step に渡していくだけです。しかし、その過程でさまざまな処理を行いたいこともあるはずです。

An image from Notion

Router を次々と attach していくとはいえ、その過程では通信処理であったり様々なビジネスロジックが実行されます。

たとえば、① → ② の過程ではユーザがログイン状態になるまで待機されます。自動的にログイン処理を行わせることも場合によっては可能でしょうし、ユーザが自らアプリを操作してログインを完了させるまで待機させるといったこともできます。RIBs の Workflow では、各 Step が完了するまで( つまり該当の Interactor が返ってくるまで )、後続の処理を実行せずに待機できるという特徴があります

同様に ② → ③ の遷移では、LoggedInInteractor 内での通信処理が完了するまで MainFeaturesRouter は attach されないので、その間は待機状態となります。

③ → ④ の遷移では、Workflow 内に条件式を挿入したり、ActionableItem 経由で MainFeaturesInteractor を操作し、Workflow 実行時のみの特別な処理を動かしています。

このように、既存の処理を再利用しつつ、必要に応じて特別な処理を差し込むことができるので、柔軟に RIBs tree の再構築を行うことができます。

再利用可能な Step

Workflow 内の onStep の処理ですが、部分的に切り出して再利用させることができます。

// 共通の Step を切り出す
let mainFeaturesStep = self.onStep { appRootActionableItem -> Observable<(LoggedInActionableItem, ())> in.
}
.onStep { loggedInActionableItem -> Observable<(MainFeaturesActionableItem, ())> in.
}

let forkedMainFeaturesStep : Step<AppRootActionableItem, MainFeaturesActionableItem, ()>? = mainFeaturesStep.asObservable().fork(self)

switch mainFeatureType { // 途中から再構築する RIB が分岐する
    case .normalDispatch:
        forkedMainFeaturesStep?
        .onStep { mainFeaturesActionableItem, _ -> Observable<(NormalDispatchActionableItem, ())> in
            ...
        }
        .onStep { normalDispatchActionableItem, _ -> Observable<(XXXActionableItem, ())> in
            ...
        }
        .commit()    
    case .reservation:
        forkedMainFeaturesStep?
        .onStep { mainFeaturesActionableItem, _ -> Observable<(ReservationActionableItem, ())> in
            ...
        }
        .onStep { reservationActionableItem, _ -> Observable<(YYYActionableItem, ())> in
            ...
        }
        .commit()
}

onStep の返り値を保持し、fork することで Step を使い回すことができます。

この fork メソッドは、公式の Wiki には記載されておらず Workflow.swiftWorkflow に対する Unit Test 内にあるコードからその利用方法を探る必要があります。

Step 型で呼び出すことができる .asObservable()RIBs の Workflow の Step 独自のメソッドであり、RxSwift の .asObservable() とは似て非なるものなので混乱しないように注意が必要です。

上記の例では、fork() の引数として self を与えましたが、別の Workflow を指定することも可能だと思われます。このあたりのベストプラクティスはまだ調査段階です。

URLScheme で Workflow を利用していると、起点となる RIB から数個先の階層までは同じルーティングを辿ることが多く、類似したコードが散見されました。この fork を利用することでそういったコードを共通化して再利用できるので積極的に使っていきたいと考えています。

4.Workflow を実行して RIBs tree を再構築する

ここまでは Workflow の作成方法について紹介してきました。最後に、その Workflow をどう呼び出すかについて紹介します。

let scheduledReservationWorkflow = ScheduledReservationWorkflow()

scheduledReservationWorkflow
    .subscribe(self)
    .disposeOnDeactivate(interactor: self)

Workflow の呼び出し方は簡単で、起点となる RIB の Interactor 、今回だと AppRootInteractor にて Workflow の初期化を行い、subscribe するだけです。subscribe 時に渡す引数は Workflow のジェネリクスで指定した Interactor に当たるので、基本的には self となるかと思います。

この subscribe() メソッドも RIBs の Workflow 独自に定義されたものなので、RxSwift のそれとは全く別物です。

上記の実行後、Workflow 内の Step 処理が順番に行われ、RIBs tree の再構築が成されていきます。

おわりに

RIBs アーキテクチャにおける Workflow の使い方とその仕組みについて紹介しました。

Workflow の機構を用いて RIBs tree を再構築することの最大のメリットは、既存のルーティング時の処理を再利用できることです。一見複雑そうに見える仕組みですが、非常に利便性が高く RIBs アーキテクチャにおいて必要不可欠な機構だといえるでしょう。

本記事が RIBs アーキテクチャにおける Workflow の理解の手助けになれば幸いです。


We're Hiring!

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

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

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