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

iOS アプリにおけるアーキテクチャの部分適用について

iOSRIBs
April 21, 2022

タクシーアプリ「GO」の iOS アプリを開発している久利です。キャンプに行きたいけど花粉症が怖い今日この頃。

今回はアーキテクチャ変更の過程で必要になった、アーキテクチャの部分適用についてご紹介します。


背景

タクシーアプリ「GO」の iOS アプリでは MVVM と独自のルーティングを採用していましたが、様々な課題感から RIBs アーキテクチャ(以下 RIBs)へのアーキテクチャ変更を行いました。

アーキテクチャ変更についてはタクシーアプリ『GO』に向けたアーキテクチャ変更や、大規模リファクタリングへの極意で詳しく紹介していのでこちらも参考にしてみてください。

アーキテクチャ変更では、どこからどこまでを RIBs に変更するかは事前に計画しており、普段修正が入ることの多いメインの配車導線をターゲットに変更していました。

それ以外の部分は適宜案件の内容とタイミングで現在も MVVM から RIBs への変更をしていますが、一部の機能は現在でも MVVM のままになっています。

アプリ内に2つのアーキテクチャが混在している状況で、新しい機能を作る際に RIBs の機能と MVVM の機能両方から呼び出したい場面が何回も発生しました。本来であれば、追加する機能の周辺を RIBs に置き換えることが望ましいですが、リファクタリングに使える時間にも限りがあります。

両方から呼び出すことを実現するには、以下の2つの選択肢がありました。

  1. 新しい機能は MVVM で作成し、既存の RIBs や 既存の MVVM から新規の MVVM に繋ぎ込む。
  2. 新しい機能は RIBs で作成し、既存の MVVM や 既存の RIBs から 新規の RIBs へに繋ぎ込む。

1の場合は、どこかのタイミングで、依存している MVVM も合わせて RIBs に置き換える必要が出てくるので、今後のことを考えると圧倒的に2にメリットがあります。

また、RIBs に変更していくことがチームの方針で、新しく MVVM の機能を追加することは負債を生むことになるため、私たちは積極的に2の選択肢を選ぶことにしました。

しかしながら、RIBs では独自の依存解決とルーティング(詳しくは RIBs の依存解決とルーティングで説明)があり、両立するには考慮が必要でした。

その辺りも、どのような実装にしていったかを詳しく解説していきます。

RIBs の依存解決とルーティング

An image from Notion

(https://github.com/uber/RIBs/wiki

RIBs は RIB(Router + Interactor + Builder + View(optional)) を1つコンポーネントとして扱い RIB を組み合わせて機能を作っていきます。特徴の1つとして ViewController のライフサイクルに依存しない独自のライフサイクルを持っており、 RIB の Interactor が active 状態になるとビジネスロジックが開始されます。これにより View を持たないロジックだけの RIB を作ることが可能になります。

RIB 同士は必ず親子関係にあり、 状態をツリー構造(RIBs ツリー)で管理できます。

An image from Notion

GO RIBs

依存解決

依存は親から受け取る必要があるものを定義する Dependency と、その RIB 内で使うものを定義する Component で扱います。親と子の依存解決には、親の Component に 子の Dependency を準拠(Component Extensionの作成)することで、親が子の依存を持つことをコンパイラによって保証されます。

依存解決は、子 RIB の Builder を生成するタイミングで行われます。Builder の init で引数に親 RIB の Componet を渡たすことで依存解決され、子 RIB の Builder を生成できます。

ルーティング

RIB のルーティング処理は Router で行われます。親 RIB の Router で attachChild を呼び、子 RIB の Router を渡すことで RIBs ツリーに追加され、子 RIB の Interactor が active 状態になります。

画面を閉じた際や特定のロジックを持つ RIB の実行を止める場合には detachChild を呼びます。これにより RIBs ツリーから切り離すことができます。内部的には 子 RIB の Router がメモリ上から解放され、Interactor の deactivate() が呼ばれることで Interactor は deactive 状態となります。

適切に detachChild を実行しないと RIB が残った状態になり、意図しないロジックが実行されバグの温床になるので注意が必要です。

MVVM から RIB の繋ぎ込み

今回は MVVM から RIB を繋ぎ込むにあたり、MVVM 側で親 RIB がやっている依存解決とルーティング処理をやる必要がありました。

RIBs は前述の通りツリー構造を取りますが、依存解決では、ツリー構造の大元である一番最初の親 RIB (親がいない RIB) の実装を参考に EmptyDependency と Component を MVVM 側に用意しました。

ルーティングでは、attachChild で行っている処理を直に呼び出すことで 子 RIB を active 状態にする様にしました。逆の detachChild では、 Router.deinit{ } を見ると deactive 状態にする処理が行われているので、子 RIB の Router を開放することで実現しています。

例: MVVM の乗務員キャンセル画面から RIB の問い合わせ画面を開く

//  DriverCancelOverlayViewController.swift

import UIKit
import RIBs

// 乗務員キャンセル画面の Dependency
protocol DriverCancelOverlayDependency: DriverCancelOverlayDependencyInquiry {
}

// 乗務員キャンセル画面の Component
final class DriverCancelOverlayComponent: Component<EmptyDependency>, DriverCancelOverlayDependency {
    let apiClient: APIClient

    init(apiClient: APIClient) {
        self.apiClient = apiClient
        super.init(dependency: EmptyComponent())
    }
}

// 乗務員キャンセル画面
final class DriverCancelOverlayViewController: UIViewController {
    private lazy var inquiryButton = IconTextButton(title: R.string.phrase.crewCancel_enableToMeet_useGoTicket_link_contact()).apply {
        $0.addTarget(self, action: #selector(inquiryButtonDidTap), for: .touchUpInside)
    }

    private let viewModel: DriverCancelOverlayViewModel
    private let component: DriverCancelOverlayComponent
    private var child: InquiryRouting?

    init(carRequest: CarRequest,
         apiClient: APIClient) {
        self.viewModel = DriverCancelOverlayViewModel(carRequest: carRequest)
        self.component = DriverCancelOverlayComponent(apiClient: apiClient)
        super.init(nibName: nil, bundle: nil)
    }

    // 問い合わせ画との繋ぎ込み
    @objc private func inquiryButtonDidTap() {
        // 親子の依存解決
        let inquiryBuilder = InquiryBuilder(dependency: component)
        // Listener として ViewController を渡す
        let inquiry = inquiryBuilder.build(withListener: self)

        // 子 RIB の Router を Property で保持
        child = inquiry
        // 問い合わせ RIB の activate
        inquiry.interactable.activate()
        inquiry.load()

        let navigationController = UINavigationController(rootViewController: inquiry.viewControllable.uiviewController)
        present(navigationController, animated: true)
    }
}

// MARK: - InquiryListener
// 子 RIB -> 親 RIB のコミュニケーション
extension DriverCancelOverlayViewController: InquiryListener {
    // 問い合わせ画面を閉じる処理
    func removeInquiry() {
        dismiss(animated: true)
        // 問い合わせ RIB の Router を解放
        child = nil
    }
    // 問い合わせ画面を detach だけをする処理
    func deactivateInquiry() {
        // 問い合わせ RIB の Router を解放
        child = nil
    }
}
  • 親子の依存を解決する Component Extension
//  DriverCancelOverlayComponent+Inquiry.swift

import RIBs

protocol DriverCancelOverlayDependencyInquiry: Dependency { }

// 問い合わせ RIB の Dependency を 乗務員キャンセル RIB の Component で準拠
extension DriverCancelOverlayComponent: InquiryDependency { }

MVVM で作られた機能に RIBs の一部の機能を追加することで RIBs を部分的に適用することができました。子 RIB 側への特別な対応は必要ないので、通常通り RIB 同士 のルーティングも問題ありません。子 → 親のコミュニケーションでは、ViewController に Listener を準拠させていることで可能になります。

今回例に出した乗務員キャンセル画面を今後 RIBs に置き換える際は、追加した処理を RIB 内の適切な Class に置き換えてあげるだけになります。RIBs の仕様上、依存が親 → 子の一方通行になっているため、子 RIB の問い合わせ画面は置き換えの影響を受けることはありません。

おわりに

アーキテクチャの部分適用によって、その後のアーキテクチャ変更へ負債を残さないで機能を開発することができました。

アーキテクチャは一度導入すると中々変更しない部分ですが、サービスの成長やチームの状況によって適切なものが変わってくると思います。変更対象のスコープを絞ったり、部分的な導入ができることで開発だけではなく、その後のテストへの影響も抑えることができるので良いなと今回感じました。


We're Hiring!

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

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

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