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

iOS アプリを木構造で組み立てるための 7 つのデザインパターン

iOSアーキテクチャRIBs
December 17, 2021

この記事は Mobility Technologies Advent Calendar 2021 の17日目です。

タクシーアプリ「GO」の iOS アプリを開発している今入です。日々複雑化するアプリをどのように設計して開発しているかについて紹介します。


はじめに

タクシーアプリ「GO」の iOS アプリでは RIBs アーキテクチャを採用しています。RIBs アーキテクチャは「RIB」と呼ばれるコンポーネントを組み合わせることで、木構造で iOS アプリを構築させることができます。「RIB」同士の依存関係をどのように組み立てていくかがアプリの設計の肝となります。

タクシーアプリ「GO」および「JapanTaxi」アプリに RIBs アーキテクチャを採用し 3 年が経ちました。様々な機能開発を通し RIBs アーキテクチャにおける iOS アプリの設計を行ってきました。振り返ってみるとその設計はいくつかのパターンに分類されていました。それらを RIBs アーキテクチャの「デザインパターン」として、タクシーアプリ「GO」での実際の採用事例を交えて紹介します。また、 RIBs アーキテクチャにおける木構造を組み立てる設計思想についてもまとめます。

💡
本記事にて RIBs アーキテクチャにおける木構造の設計を「RIBs tree」と表現します。 また、依存関係を伴う RIB をその関係性から「親 RIB」「子 RIB」といった表現をします。上位層にあたる RIB が親で、その配下に付随する RIB が子という位置づけです。

では、RIBs アーキテクチャにおける 7 つのデザインパターンについて以下に紹介します。

RIBs アーキテクチャにおけるデザインパターン

1.ユニークな状態

まず 1 つ目は状態による分岐です。状態の変化に応じて配下の RIBs tree が切り替わる設計となります。ここで挙げる「状態」は、特定の条件下で一意に定まるものを指します。

状態を表す RIB を同じ階層に並べると管理がしやすく統一のある実装になるでしょう。親 RIB が状態の変化に応じて子 RIB (各状態を表す RIB )の Router を attach / detach させることでルーティングを実現させます。

ここで、タクシーアプリ「GO」でタクシーを呼ぶときの注文(タクシー配車のリクエスト)を考えてみましょう。注文の状態は大きく分けて以下の 3 つが想定されます。

  • 注文の準備
    • ユーザがアプリ上で乗車地を選択したり支払い方法を変更したりする状態
  • 注文処理中
    • サーバサイドで近くのタクシーを探索している状態(ユーザはアプリ上で結果を待っている状態)
  • 注文処理後
    • タクシーが見つかり、タクシーとユーザが出会って乗車してから降車するまでの状態

これらすべてを完了させることで「アプリでタクシーを呼んで移動した」という体験が構築されます。

内部ではこれらの状態を Enum を使って定義しており、RIBs tree では以下のように表現されます。

An image from Notion

Order RIB では注文の状態を常に監視しており、その状態に従って PreparingOrder, ProcessingOrder, AcceptedOrder のいずれかの RIB へルーティングします。それぞれの状態を表す RIB の配下にはその詳細の状態が定義され、さらに同様のルーティング処理が続いていくという仕組みです。

状態に応じて RIBs tree を分岐させていく設計をすることで、ビジネスロジックを状態ごとに分割して整理することができます。状態をたくさん管理しなければならないアプリではその効果が得られやすいでしょう。

2.特定の機能

アプリ内に存在する機能単位で RIBs tree を分岐させるのも一つの手でしょう。様々なフローでよく利用される機能を束ねておくことで、UI を含め再利用がしやすい設計になります。また、別モジュールとしての切り出しも容易になると思われます。

An image from Notion

タクシーアプリ「GO」のメイン機能はタブを選択することで切り替えられます。機能ごとに抽象的な RIB を用意し、その詳細の機能(UI を含むコンポーネント群)の RIB が配下に連なるという設計です。

MainFeatures RIB は各メイン機能のルーティングを主に行います。URLScheme による遷移の場合に開いているメイン機能をすべて閉じるといった、メイン機能に関わる共通の処理も含まれます。

3.順次実行

順番に処理を行いたいというニーズもあります。それぞれの処理を RIB として切り出し、親 RIB で順番に attach / detach をしていくような設計にしておくと、その流れを把握しやすくなります。今後順番が変わったり、他の処理を追加したい場合も同様に拡張させることが可能です。

An image from Notion

タクシーアプリ「GO」ではこのような設計をしている箇所がなかったので、「JapanTaxi」アプリの設計を実例として紹介します。 「JapanTaxi」アプリでは起動直後に行う処理がいくつかあり、その順序が定められていました。

  1. アプリを強制アップデートする必要があるかどうかの確認
  2. サービスがメンテナンス状態かどうかの確認
  3. 利用規約に更新があるかどうかの確認

このような順番で処理したい場合、それらの処理を管理させる目的の RIB として PrepareStart を設け、その中で子 RIB である ForceUpgradeChecker,MaintenanceChecker,TermsOfUseRenewalChecker を順にルーティングをさせるという仕組みです。

4.再帰的なルーティング

ほとんどの iOS アプリでは NavigationController を利用した push / pop の遷移があります。タクシーアプリ「GO」もそこまで多くはありませんが push / pop による遷移が利用されています。 このような画面遷移を伴う場合、 RIBs tree はどのように設計すればよいのでしょうか。

An image from Notion

たとえば、上記のように画面遷移の順番通りに RIB を連ねていくことで実現させることができます。NavigationController による遷移で階層が深くならない場合はこのような設計で問題ありません。

タクシーアプリ「GO」の場合、アカウント登録のフローに NavigationController による遷移が伴います。さらにその登録ステップがどこまで完了したかを端末内に保持し、途中で離脱した場合もその続きから処理が行える仕様になっています。つまり、NavigationController の root となる RIB が 1 つに定まりません。

An image from Notion

そのため上記のようにすべてのパターンを網羅すべく、複数の直列な繋がりを持たせる設計にしていましたが、これはあまりよい設計ではありませんでした。すべての根幹に当たる SignUpStepChecker の責務が他の RIB の責務と重複しているからです。たとえば、RegisterAccount への遷移処理は SignUpStepChecker に定義され、その子 RIB として配置されている Walkthrough / Verification にも同様の処理が定義されています。同様に、RegisterAccountListener の実装も重複が避けられないでしょう。

また、NavigationController を利用する際に直列に RIB を繋いでいくことの弊害として、再帰的なルーティングがしづらいという点も挙げられます。

An image from Notion

上記のように、すでに attach されている RIB がその配下で再利用されるような場合では、依存関係がループしてしまいあまりよい設計とはいえません。必要な依存が少ないのなら大きな問題は起きづらいですが、多くの依存を必要としたり複数の子 RIB が配下に連なる場合は急激に複雑度が増します。先祖の RIB が自身の子 RIB として存在することで依存関係の見通しも悪くなるでしょう。

こうした再帰的なルーティングは一般的によくある事例だと思います。上述したタクシーアプリ「GO」におけるルーティングと再帰的なルーティングの問題を解決すべく以下のような設計を提案します。

An image from Notion

NavigationController による UI 上は直線的な繋がりである RIB を、あえて並列に配置させることでこれらの課題が解決されます。親 RIB (SignUpStepChecker)にて次にルーティングすべき RIB を特定させ、ルーティング情報を親 Router で管理させることで実現できます

通常は NavigationController 自体にどの順番で画面を表示させたかを管理させていましたが、それを自前の Router で行うことで実現が可能となります。

汎用的に利用できるように NavigationController による遷移用の Router を用意しておくと実装コストが減らせると思います。

5.UI のベース

状態や特定の条件にしたがって UI が切り替わるような場合、UI パーツごとに RIB を作成しそれを取りまとめる RIB があると管理が楽になります

An image from Notion

タクシーアプリ「GO」の場合、上記のように状態によって様々な UI が適用される場面があります。当アプリでは豊富な支払い方法に対応しているため、その一つ一つの UI を RIB 単位で独立させておき、親 RIB で切り替えられるような仕組みになっています。デザインが異なるだけでなく、支払い方法によってそれぞれ独自の処理を持つことが多いため、UI 単位ではなく RIB 単位で分割されています

この設計では、親 RIB に hidden 設定など共通の UI まわりの処理を記述できることもメリットの一つとなります

RIBs アーキテクチャでアプリを開発した場合、小さな UI 単位で RIB を分けることが多くなりますが、ある程度の範囲でこういった UI のベースを差し込んでおくと、コードの肥大化防止にも貢献できるでしょう。

6.一時的な利用

特定の処理だけを RIB として切り出し、その処理が終わったら破棄するといった使い方があります。UI を持たずビジネスロジックだけを切り出した場合によくある設計です。

たとえば、通信処理を行うために子 RIB の Router を attach し、その結果を親 RIB の Interactor へ伝えたら detach するといった、一時的な利用が考えられます。

An image from Notion

タクシーアプリ「GO」ではアプリ起動時に必要であればユーザに対して告知を行う場面があります。OneshotPromotionChecker で告知が必要かどうかの判断をし、必要であれば告知をしてから自身を detach するといった動きになります。これらの処理はアプリ起動後に一度だけ行えばよいので、そのときに必要な class などを生成し、該当処理が行われたらきちんと破棄しておくといった流れになります。こうすることで無駄にメモリを消費させないメリットが生まれます

7.グルーピング

同じ性質を持つような RIB を束ねる役割として抽象的な RIB を設けることをおすすめします。これを行うことにより、Router 内のルーティング処理や Listener の実装を分離させることができ、RIB の肥大化を防ぐことができます。また、連なる配下の子 RIB 群に対して共通の意味をもたせることができ、RIBs tree の構成やプロジェクトの構造が理解しやすくなります

An image from Notion

タクシーアプリ「GO」では、特定の条件下で常に attach しておかなければならない Router がいくつか存在します。

たとえば、状態の監視系がそれに当たります。これらを束ねるものとして ConfirmNormalDispatchMonitoring という名の抽象的な RIB を配置させています。同じ性質を持つ複数の RIB が並列で配置される場合は、それらを束ねる役割の親 RIB を配置しておくとコードの肥大化防止にも繋がります。

前述の「特定の機能」や「UI のベース」も広義でこのグルーピングと類似した設計パターンとなります。

設計する際に考慮すべきこと

いくつかのデザインパターンを紹介しましたが、以下を念頭において設計することを心がけるとよいでしょう。

RIBs tree の変化を意識する

Router を attach / detach するたびに RIBs tree は変化していきます。どのような順番で attach され、どのタイミングで detach されるかを意識して設計することが望ましいです

ルーティング処理が複雑になる場合は、一度立ち止まって依存関係を見直すことをおすすめします。抽象化した RIB を間に差し込んだりするだけで見通しのよい構成になることも。

何か設計が誤っていないか検討したり、そもそも仕様に課題がある場合もあるので、無理して実装を進めないように熟考することが大事です。

共有したい情報の伝播範囲を意識する

どの RIB 同士で情報の共有が必要かを念頭において設計するとよいでしょう。

設計した RIBs tree を見ながら、処理した情報を Listener 経由で渡すのか Stream を作って伝搬させるのかなどを検討しましょう。Stream 経由で行う場合は、どの RIB を起点に伝播すべきかも考慮すべきです。

あまりにもかけ離れた RIB 同士のコミュニケーションが必要な場合は、設計に課題があるかもしれないので、一度立ち止まって考え直してみるとよいと思います。

再利用しやすいようにする

複数箇所で利用される機能や処理の場合、再利用されることを考慮した設計にしておくことが望ましいです。その際、再利用される RIB 群の中で処理が完結されるように心がけたいです。単体の RIB でも再利用される場合はあるので、常に RIB を作成する際に念頭においておくとよいでしょう。

きれいに切り出された RIB 群は別モジュールとして独立させることも容易になるはずです

抽象化して拡張性をもたせる

事業成長に伴い、様々な機能が増えることで、ビジネスロジックの肥大化が予想されます。そういった場合に適切に対処すべく、(可能な範囲で)あらかじめ拡張性をもたせた設計にしておくことが重要です。肥大化し問題が顕著に現れてから対処することも可能ですが、現時点である程度見通しが立っている場合は、最初から抽象化したレイヤーを設けるなど拡張性を考慮した設計にしておきたいです。 抽象化したレイヤーをむやみに作成してしまうと、逆に扱いづらい設計になりかねないので、このあたりはチームメンバーと一緒に検討しバランスよく作り上げられるとよいでしょう

まとめ

タクシーアプリ「GO」の iOS アプリに RIBs アーキテクチャを採用し、そこから得られた設計の知見を紹介しました。

アプリ開発における設計は非常に重要で、うまく設計できるかどうかで開発効率や今後のメンテナンス性が大きく変わります。

設計力を高めるためには、とにかく設計を繰り返し、チームメンバと一緒に議論を重ね試行錯誤していくことだと思います。自分たちで設計したアプリを長期的にメンテナンスしていくことで、さらにその力が養われていくことでしょう。よりよい設計のアプリに仕上げられるよう、チームメンバと日々努力していきたいです。

この記事が今後 RIBs アーキテクチャを使った iOS アプリ開発をされる方の設計の参考になれば幸いです。


We're Hiring!

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

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

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