こんにちは、MoTのソフトウェア開発部のパクです。私はタクシーアプリ「GO」のiOSユーザーアプリを開発しています。
今回は日々増えているサービス要件に従って、無限に肥大化し続けているアプリをどうやって開発破綻しないようにするかについて話したいと思います。
「GO」のiOSアプリ開発は現在3つのチームがそれぞれの開発プロジェクトを並行に進めています。
「JapanTaxi」アプリと「MOV」の統合により2020年に「GO」をローンチしましたが、両方のサービスを1つのアプリで提供しつつ、顧客とビジネスサイドを満足させるべき新たな機能を増やしているため、急速にコードベースが拡大しています。
そのためほぼ毎週のようにアプリアップデートがあり、また毎月何かしら大きい機能のアップデートを行っています。
このように激しい変化の上でアプリのビジネスロジックは肥大化される一方でどうやって開発サイドで破綻せずにコンスタントにアプリをアップデートし続けていくか、いつくかのアイディアを紹介します。
「GO」でタクシーを呼ぶまでの配車導線はユーザーからはとてもシンプルなUIに見えますが、実は内部的にかなり複雑で見えない機能が多く、アプリの状態によって変わる動的なUI要素が非常に多いです。
また開発容易性より、ユーザー観点での機能確保が大優先なのも複雑化する理由の1つです。
シンプルに見えて複雑なロジックを持つ「GO」
まず優先するべきことは1つのコンポーネント単位のコードを大きくしないことです。
作られたViewControllerまたはView Modelはよく使う機能ほど時間が経つと別機能が足されて大きくなり、それに依存する他のコンポーネントも増えてします。
その結果、一定の大きさを超えるとリファクタリングすることが非常に難しくなり、負の遺産として残り続けてしまう可能性があります。
小さくなったコンポーネントは以下のような利点があります。
・再利用しやすくなる
・コードが読みやすい
・Unit Testを書くのが楽 (DIされた前提) → ビジネスロジックが変更に強くなる → リファクタリングも簡単
では具体的にどのくらいのコード量であると、大きいコンポーネントになるのでしょうか。
厳密にルール化はされてはいませんが、私のチームでは、1つのコンポーネントのコードが500行を超えているかどうかを1つの目安にし、超える場合は、小さくするためにリファクタリングを考慮してもいい判断をしています (100 ~ 200行のロジックブロックが個人的にはストレスなしに把握しやすいボリュームでした)。
「GO」のタクシーを呼ぶ直前の表示を例にすると、1つの画面の状態を小さいコンポーネントの集合体として作っています。
行き先を表示するコンポーネントはアプリのステートによってさまざまな機能を持つ。
乗車地、行き先設定の構成
「GO」では、アーキテクチャに依存はしていますが、ロジックを構成するコンポーネントの関係性をビジュアライズするツールを利用して設計、またはリファクタリング時に利用しています。
上記、タクシーを呼ぶ直前の状態を構成するコンポーネントツリーの一部は、以下のようになっており、比較的に軽量なロジック単位で機能が分かれています。
ロジックの肥大化について、別の観点からも見ていきましょう。
先ほど、コンポーネントを小さく分離した方が良いと書きましたが、細かく分離されたコンポーネントは、その依存関係による意図せぬ副作用 (side effect)が起こる可能性があり、それも肥大化の要因となってきます(haskellのような完全な関数型言語でない限り避けられない問題ですが)。
常にDIを心がけてコードを書く、または依存性の解決をしやすくするためDI Librayを使うのをどうでしょうか。
メジャーなSwinjectを初め、UberのNeedleまたはPureなどを利用するのもアイディアの1つだと思います。
Pure DIはDI ContainerがなくComposition Rootの1箇所 (iOSとしてはApp Delegateが適切)で依存性が注入されますので直感的です(DI Contrainerを使うとインスタンス生成がContainer内に隠れてしまう)。
昔からmassive-view-controllerと呼ばれコンポーネントの肥大化を避けづらいMVC、広く知られてるMVPやMVVMなどのアーキテクチャパターンではviewという要素を完全に分離してロジックだけをコンポーネント構成に考えるのは容易ではありません。
「GO」で採用しているRIBsアーキテクチャでは純粋にビジネスロジックだけのコンポーネント作成を想定されてるため、必須要素のRouter, Interactor, BuilderにオプショナルなPresenterが加わる基本構成となっています。
RIBsでは論理的なアプリステートをツリーとして表現ができ、そして親子のRIB間のコミュニケーションができます。
基本的に、子から親に情報を伝達する手段はlistener functionを、親から子への静的な情報は、子を生成して関連づける(attach child)タイミングで子のBuilder経由で渡すことができます。
そして子から親、または論理関係で離れてるコンポーネント(RIB)に情報伝達するとき、または動的状態の更新をやりとりするときは ObservableなStreamを利用しています。
上記の図で、現在地ボタンを押すとマップを動かして、現在地にマップのカメラが移動できるようにする機能を例としてご紹介します。
実際の「GO」のRIBsツリーは複雑ですが、上記のように簡略化されたRIBsツリー構造で説明すると、「現在地ボタン」のコンポーネント「地図」のコンポーネントで同一のEvent Stream (MapCameraStream)を利用しており、「現在地ボタン」のコンポーネントでボタンのイベントに伴い、Event Streamにイベントを流しています。
「地図」コンポーネントではStreamを購読して、Event Streamから流れてくる情報に従ってカメラを実際動かす処理をします。
MapCameraStream設計
protocol MapCameraStream {
var cameraTarget: Observable<Location> { get }
}
protocol MutableMapCameraStream: MapCameraStream {
func update(location: Location)
}
final class MapCameraStreamImpl: MutableMapCameraStream {
private let cameraTargetSubject = PublishSubject<Location>()
// MARK: - Immutable
var cameraTarget: Observable<Location> {
cameraTargetSubject.asObservable()
}
// MARK: - Mutable
func update(location: Location) {
cameraTargetSubject.on(.next(locatio))
}
}
この時、各RIB構造でStreamのスコープは以下のようになります。
Streamの直接更新せずに参照だけ必要な場合はImmutableなStreamだけを使ってできるだけ意図してない更新を避けるようにしています。
上記の構造がもっと複雑化されてもカメラ動かす実態は、明確な一箇所に限られるため副作用を心配せずに安心して機能の拡張をすることができます。
RIBsアーキテクチャについてのもっと詳しい内容に関しては弊社のエンジニアが書いたこちらの記事とスライドを参考してください。
Project管理ツールとしてXcodeGenの変わりにTuistを使ってApp Targetを分離してモジュール化を狙うのもプロジェクト肥大化回避のアイディアの一つをして考えられます。
Tuistではマイクロフィーチャーアーキテクチャ(µFeatures , https://github.com/tuist/microfeatures-example) というアプローチでモジュール分離に活用できます。
またtuist graphでプロジェクト間の依存関係を簡単にビジュアライズする機能やモジュールキャッシングもサポートしています。
Generate project graph
サービス規模が急速に拡大されると、猛烈なスピードでレガシーロジックの上に新しいロジックが重なってしまい、作った本人でさえ数週間経つとわからないくなる巨大なブラックボックスのようなコードが増え続けてしまいます。
そして開発者たちは今日も理解できない遺産や地雷を避け(今回の自分は踏まないように願いながら)、綱渡りをしながらアプリの機能を増やし続けていることもあるかと思います。
ユーザーまたは事業からの要望による複雑化は開発者としては喜んで受け入れるべき宿命ですが、遠い未来に(実際そんな遠くないときも…)自分が追加した機能がレガシーと呼ばれて扱いづらい巨大コードができる前に、小さくて責任の範囲が明確なコンポーネントを作り、持続可能なコードとして長く生き延びるようにしましょう。
Uber RIBs https://github.com/uber/RIBs
Uber Needle https://github.com/uber/needle
Pure https://github.com/devxoul/Pure , Pure DI https://blog.ploeh.dk/2014/06/10/pure-di/
Tuist https://tuist.io/
JapanTaxi iOSアプリにRIBsアーキテクチャを導入して得られたこと https://lab.mo-t.com/blog/andonlabo-4-ribs-ios-app#リブランディング連載一覧
初めての RIBs https://speakerdeck.com/imairi/chu-metefalse-ribs
興味のある方は 採用ページ も見ていただけると嬉しいです。
Twitter @mot_techtalk のフォローもよろしくお願いします!