この記事は、JapanTaxi Advent Calendar 2018の9日目の記事です。
2018年3月から9月までの半年間、「JapanTaxi」iOSアプリのUIリニューアルを行いました。積み重なる技術的負債と闘いながら開発を続けてきましたが、これを良い機会とし、iOSアプリの設計も一から見直すことになりました。結果として、MVVMからRIBsへアーキテクチャを変更することに成功しました。この記事では、「JapanTaxi」iOSアプリの設計方針をはじめ、RIBsとは何か、そしてRIBsを採用したことによる変化について紹介します。
2018年3月、iOSチームメンバでアプリの設計について議論しました。そもそもの発端としては、既存アプリのコードが複雑になりつつあり、この状態では新機能開発はおろか運用保守も厳しいといった局面に立たされていたからです。そのときに議論した内容を以下に紹介します。
アーキテクチャを変更するにしても、まずはその根底となる思想や考えをチームメンバ内で認識合わせをしておく必要がありました。設計に対するモチベーションとして、以下の3つが挙げられました。
他社の導入事例は参考にすべきですが、「〇〇が最近流行っているから」などといった安直な理由で選定してはいけません。新しいアーキテクチャを導入することが目的となってしまっては本末転倒になりかねないです。自アプリが抱える問題を明確にし、それを対処できる最適な設計を行うことが大切でしょう。また、現状だけでなく将来的な課題も見据えて設計が行えるとなお良しです。
サービスやシステム固有の特性を解決できなくなったとき、設計の破綻をきたす可能性があります。JapanTaxiではリアルタイムに様々な状態の変化を扱うアプリです。そういった特性に耐えうる設計を選択する必要があります。
設計から外れた実装を発生させないようにするため、設計に対するチームメンバの納得感は非常に重要です。「なぜこんな書き方にしなければいけないのか……」といったストレスや、「何が正しいのか分からない……」といった迷いをなくすためにも、チーム内での設計方針に対する認識合わせは行っておくべきです。
これらを念頭に、アプリの再設計を進めていくことにしました。
当時のアプリも設計方針は存在していましたが、いつの間にかそれらが守られなくなり統制が取れない状況に陥っていました。設計に沿った適切な実装が行えていないことをチームメンバ内で再認識し、そもそも自アプリに対して今の設計がマッチしているのか見直すことにしました。
相乗りタクシーというアプリを2018年1月から3月まで期間限定で公開していました。フルスクラッチでMVVMで開発したアプリです。きちんとMVVMのアーキテクチャに沿った実装をしましたが、多種多様なタクシーの状態管理をプレゼンテーションロジックと組み合わせることに少々苦しむ場面も。(ViewModelが肥大化します。)その経験を踏まえ、MVVMは自アプリには不向きかもと感じていました。
現状適切に扱えていないことも加え、このままMVVMで実装しても、課題を根本的に解決することは難しいと判断しました。
そして、我々が抱えていた課題を解決できるアーキテクチャを模索します。FluxやClean Architectureをはじめ、様々なアーキテクチャの検討をしました。その過程で、新たに「RIBs」というアーキテクチャの存在を知り、深掘りしていくことになりました。
RIBsは、Uberが公開しているモバイルアーキテクチャです。Uberアプリでも実際に採用されているようです。以下のような特徴があります。
実際のコードを共通化するというわけではなく、設計レベルで共通化が図れます。後述しますが、「RIBツリー」というアプリのビジネスロジックを表す設計図が存在し、それに沿ってアプリの実装を行います。仕様が共通であれば、iOSとAndroidで同様の設計を利用することが可能です。
RIBsではビジネスロジックを軸にアプリの構成を組み立てていきます。独自のライフサイクルがあるおかげで、Viewに依存しません。Viewに依存しないことで、通信処理であったり複雑なビジネスロジックのみを独立させることが可能です。
後述しますが、RIBsでは「RIB」というコンポーネントをひとつの塊として扱います。一般的なコンポーネントに近い概念です。RIB同士には親子関係が存在し、そこにだけ依存関係があります。依存関係がないRIB同士は全く影響し合うことがありません。また、RIBを構成する要素は責務がきちんと分かれており、それぞれがProtocolで抽象化されています。どこに何を書けばよいのかが明確になっているので、コード全体の見通しがよくなります。
「RIBツリー」はRIBsアーキテクチャを採用したプロジェクトにおける「設計図」となります。上記は、「JapanTaxi」iOSアプリのRIBツリーの一部です。ビジネスロジックを軸に、RIB同士が親子関係でつながり、クラスを構成していきます。
RIBsは以下の要素で構成されます。
– Router
– Interactor
– Builder
これらの頭文字をとって「RIB」となり、ひとつの塊として扱います。RIBsはその集合体を表します。必要に応じてView(Controller)も加わります。RIBsでは、これらの要素をProtocolを用いて定義しそれぞれの関係性を紐付けています。
(https://github.com/uber/RIBs/wiki より)
RIBを構成する要素はそれぞれどのような役割を担っているのでしょうか。Twitterのタイムラインを簡易的に実装した場合を例に紹介します。タイムラインを表示させる「Timeline」RIBと、その子RIBとして、ツイートの詳細を表示させる「TweetDetail」RIBを想定します。
「Interactor」にはビジネスロジックを記述します。たとえば、以下のようなTimelineStreamというタイムラインの情報が流れてくるStreamがあるとします。StreamはProtocolで定義しておきます。
protocol TimelineStream {
var tweets: Observable<[Tweet]> { get }
func tweet(at indexPath: IndexPath) -> Tweet?
}
protocol MutableTimelineStream: TimelineStream {
func add(tweet: Tweet)
}
final class MutableTimelineStreamImpl: MutableTimelineStream {
var tweets: Observable<[Tweet]> {
return relay.asObservable()
}
func tweet(at indexPath: IndexPath) -> Tweet? {
guard indexPath.row < relay.value.count else {
return nil
}
return relay.value[indexPath.row]
}
func add(tweet: Tweet) {
var tweets = relay.value
tweets.append(tweet)
relay.accept(tweets)
}
private let relay = BehaviorRelay<[Tweet]>.init(value: [])
}
InteractorはこういったStreamを購読し、必要に応じてそれらを加工します。そして、View(Controller)や親RIBのInteractorへ処理を委譲させることができます。以下のコードは、購読しているTimelineStreamから流れてきた情報をViewControllerに対して表示させる処理の例です。また、ツイートがタップされたときのイベントを受け取り、別の画面を開くようRouterに対して処理を委譲しています。
protocol TimelineRouting: ViewableRouting {
// Routerで行う処理を記述する。主に画面遷移の処理。
func routeToTweetDetail(tweet: Tweet)
}
protocol TimelinePresentable: Presentable {
var listener: TimelinePresentableListener? { get set }
// View(Controller)で行う処理を記述する。
func update(timeline: [Tweet])
}
protocol TimelineListener: class {
// 親RIBのInteractorに委譲する処理を記述する。
}
final class TimelineInteractor: PresentableInteractor, TimelineInteractable, TimelinePresentableListener {
weak var router: TimelineRouting?
weak var listener: TimelineListener?
private let timelineStream: TimelineStream
init(presenter: TimelinePresentable, timelineStream: TimelineStream) {
self.timelineStream = timelineStream
super.init(presenter: presenter)
presenter.listener = self
}
// RIBがアクティブになったときに呼ばれる
override func didBecomeActive() {
super.didBecomeActive()
timelineStream.tweets
.subscribe(onNext: { [unowned self] in
self.presenter.update(timeline: $0)
})
.disposeOnDeactivate(interactor: self)
}
// RIBが非アクティブになるときに呼ばれる
override func willResignActive() {
super.willResignActive()
// disposeOnDeactiateで管理しているStreamはこの時点で流れなくなる
}
}
// MARK: - TimelinePresentableListener
extension TimelineInteractor {
func openDetail(indexPath: IndexPath) {
guard let selectedTweet = timelineStream.tweet(at: indexPath) else {
return
}
// Routerに対して次の画面への遷移を命令する
router?.routeToTweetDetail(tweet: selectedTweet)
}
}
「Router」は主にルーティングを行います。RIBツリーを見ると分かるように、RIBには親子関係が存在しています。RIBにおけるルーティングというのは、自身の子RIBのRouterを着脱することを表します。この着脱を、RIBsでは「attach」および「detach」と呼んでいます。以下はタイムラインのツイートをタップして詳細画面へ遷移する際の例です。
protocol TimelineInteractable: Interactable, TweetDetailListener {
var router: TimelineRouting? { get set }
var listener: TimelineListener? { get set }
}
protocol TimelineViewControllable: ViewControllable {
// RouterからViewControllerに対しての処理を記述する。
// 子RIBのViewControllerをpush, presentしたり、ルーティングと関わるUIの操作を行う。
func showTweetDetail(viewControllable: ViewControllable)
}
final class TimelineRouter: ViewableRouter, TimelineRouting {
private let tweetDetailBuilder: TweetDetailBuildable
init(interactor: TimelineInteractable, viewController: TimelineViewControllable, tweetDetailBuilder: TweetDetailBuildable) {
self.tweetDetailBuilder = tweetDetailBuilder
super.init(interactor: interactor, viewController: viewController)
interactor.router = self
}
}
// MARK: - TimelineRouting
extension TimelineRouter {
func routeToTweetDetail(tweet: Tweet) {
// 子RIBのBuilderを利用し、子RIBのRoutingを取得する
let child = tweetDetailBuilder.build(withListener: interactor)
// 画面遷移の処理
viewController.showTweetDetail(viewControllable: child.viewControllable)
// 子RIBのRoutingを attach することで、子RIBがアクティブになる
attachChild(child)
}
}
「Builder」の責任は、RIBを構成する要素の作成です。これまでに紹介してきたRouterとInteractor、そして必要に応じてView(Controller)も生成します。子RIBが存在する場合は、そのインスタンス化も行います。また、依存性の解決もBuilderが行います。RIBでは親と子の間にだけ依存関係が存在し、Builderを通じてそれらを解決します。以下のように、buildメソッド内でTimeline RIBの各要素と、子RIBであるTweetDetail RIBのインスタンス化を行っています。
// 親RIBと子RIBの間には依存関係があり、DependencyというProtocolでそれを解決する
protocol TimelineDependency: Dependency {
// RIBが依存する情報を定義する
}
final class TimelineComponent: Component {
// RIB内で利用する情報を定義する
fileprivate var timelineStream: TimelineStream {
return shared {
MutableTimelineStreamImpl.init()
}
}
}
// MARK: - Builder
protocol TimelineBuildable: Buildable {
func build(withListener listener: TimelineListener) -> TimelineRouting
}
final class TimelineBuilder: Builder, TimelineBuildable {
override init(dependency: TimelineDependency) {
super.init(dependency: dependency)
}
// RIBに必要な要素を順番に生成していく
func build(withListener listener: TimelineListener) -> TimelineRouting {
// Componentの作成。子RIBに対する依存性が解決される
let component = TimelineComponent(dependency: dependency)
// 必要に応じてView(Controller)を生成する
let viewController = TimelineViewController()
// ビジネスロジックを記述するInteractorを生成する
let interactor = TimelineInteractor(presenter: viewController, timelineStream: component.timelineStream)
interactor.listener = listener
// 子RIBのBuilderを生成する
let tweetDetailBuilder = TweetDetailBuilder.init(dependency: component)
// 親RIBのRouterに対して自身のRouterを返す
return TimelineRouter(interactor: interactor, viewController: viewController, tweetDetailBuilder: tweetDetailBuilder)
}
}
UIを表します。基本的に表示に関する処理のみ記述します。各Viewの生成、AutoLayoutの調整などを行います。View(Controller)は必要に応じてRIBに含まれることがあるだけで、基本的にはオプション扱いです。以下はタイムラインを表示させるViewControllerの例です。(TableViewの詳細については割愛させていただきます。) ツイートがタップされた後の処理をInteractorへ委譲したり、Routerから委譲された画面遷移の処理、Interactorから委譲されたUIまわりの処理を実装しています。
protocol TimelinePresentableListener: class {
// リスナー(Interactor)に委譲する処理を記述する。主にビジネスロジックに関する処理。
func openDetail(indexPath: IndexPath)
}
final class TimelineViewController: UIViewController, TimelinePresentable, TimelineViewControllable {
weak var listener: TimelinePresentableListener?
private let tableView = UITableView()
private let disposeBag = DisposeBag()
override func viewDidLoad() {
super.viewDidLoad()
setUpViews()
setUpBindings()
}
private func setUpViews() {
// Subviewをのせたり、必要なUI要素の設定を行う
view.addSubview(tableView)
// コードによるAutoLayoutを行う
tableView.snp.makeConstraints { maker in
maker.edges.equalToSuperview()
}
}
private func setUpBindings() {
// UIに関するイベント処理を記述
tableView.rx.itemSelected.asSignal()
.emit(onNext: { [unowned self] in
// タップされた後の処理はリスナー(Interactor)に委譲する
self.listener?.openDetail(indexPath: $0)
})
.disposed(by: disposeBag)
}
}
// MARK: - TimelinePresentable
extension TimelineViewController {
func update(timeline: [Tweet]) {
// TableViewの更新など、UIに関する処理をする
}
}
// MARK: - TimelineViewControllable
extension TimelineViewController {
func showTweetDetail(viewControllable: ViewControllable) {
// 次の画面を表示させる
present(viewControllable.uiviewController, animated: true, completion: nil)
}
}
RIBには独自のライフサイクルがあります。先ほどRouterの説明に挙げたattachとdetachが関係します。Routerが子RIBのRoutingをattachすると、そのInteractorの didBecomeActive() というメソッドが呼ばれ、そのRIBはアクティブになり、ビジネスロジックが処理されます。逆にdetachされると willResignActive() が呼ばれ、そのRIBは非アクティブになります。非アクティブになると、Interactor内のRxのSubscribeも止まり、無駄なStreamが流れることを防いでくれます。このライフサイクルがあるおかげで、ViewControllerのライフサイクルに依存せず、ビジネスロジックを軸にRIBを構成していくことが可能になります。
そして2018年9月12日に全国タクシー改め「JapanTaxi」としてリニューアルされたアプリが公開されました。MVVMからRIBsへアーキテクチャの変更を行い、メイン導線は一から実装し直しました。アーキテクチャの変更はもちろんのこと、UIも大幅に刷新されています。
より使いやすくなった「JapanTaxi」アプリをぜひお使いください。
そして、コードを書き換えることで既存バグを解消したり、実装の見直しを行うことで以下の功績があげられました。
上記がリニューアル前の未クラッシュ率です。クラッシュしていないユーザが96.97%、これはあまり良くない数字です。アプリのクラッシュを低減させるためにバグを潰そうにも、複雑なコードの解読に苦戦し、根本的な原因が突き止められない環境でした。
そしてこちらが、リニューアルしたバージョンでの直近一ヶ月間の未クラッシュ率です。99.95%まで上昇し、以前に比べ約3%あげることができました。まだクラッシュはゼロではないので、これからもバグ潰しに励みます。
あるAPIに対するリクエスト数のグラフです。9月12日のリニューアルを機に大幅に減少しています。(ユーザによるアプリのアップデートが必要なので、目に見え始めたのは翌日から。)画面上に表示されていないところで無駄にリクエストを送っていた処理を改善しました。ソースコード全体の見通しがよくなると、こういった問題に気づきやすくなります。 これは大幅なコスト削減に繋がりました。また、特需(雨の日の通勤時間など)におけるリクエストエラー率を下げることができました。
RIBsを採用することで得られたメリットや気付きについて紹介します。今後、アーキテクチャとしてRIBsを採用する検討材料としていただければ幸いです。
RIBsは「RIBツリー」にしたがって作成する必要があります。Root RIB(RIBツリーの始まり)から順番にRIBをつなげていくので、プロジェクト全体をRIB化(既存のコードをRIBとして再実装)する必要があります。ただ、ある機能の入口部分をRIB化してしまえば、その先は子RIBを作らずに既存の実装のまま放置することも可能です。(アプリの設計としては中途半端になるので、よろしくないですが。) 今回のUIリニューアル時点での「JapanTaxi」iOSアプリでは、メインの注文フローはすべて完全なRIB化がされていますが、アカウント登録やメニュー配下はまだ古い実装が残っています。時間的制約もありこれが限界でした。引き続き積極的にRIB化を進めています。
RIBsを採用後、前述の「RIBツリー」をまず作成しました。もちろん実装途中で改変することは多々あります。(現状のRIBツリーも改善の余地はかなりあります。)RIBツリーを作成している最中、ツリー構造が複雑になったりきれいな形に仕上がらないことがありました。ここで、なぜ複雑になっているか仕様を見直すきっかけが生まれます。実際に仕様が分かりづらいケースや見直すべきフローが発見され、改善することができました。ビジネスロジックを軸にアプリを組み立てていくので、ビジネスロジックのおかしな点に気づきやすいのではないかと思います。
RIBsには「Router」「Interactor」「Builder」と呼ばれるクラスが存在します。RIBsはこの仕組みをProtocolを用いて定義し、それぞれの関係を紐付けます。 この仕組みが提供されているおかげで、どこに何を書けばよいかは明確になり、誤った実装にならないようアーキテクチャ側である程度防いでくれます。たとえば、InteractorからViewControllerを直接参照できなかったり、依存関係のないRIB同士は全く関与しないように守られています。アーキテクチャによってガチガチに縛られているわけではなく、Protocolをうまく利用し、プロジェクト全体にゆるやかな制約が設けられている印象です。
責務が明確に分かれることで、コードは読みやすくなりますし、コードレビューでの着眼点も明瞭になるので助かりました。当時の設計の課題の一つにあった「どこに何が書かれているのか分からない」「新しい処理をどこに書けばよいか検討がつかない」といった状況は生まれなくなりました。
誰もが遭遇したことがあるであろう、クラスの肥大化。これをある程度防げる環境に変わりました。 現在「JapanTaxi」iOSアプリは、全部で119のRIBで構成され、ファイル数は約750あります。(※リニューアルした部分に限る。) 肥大化していないか、Viewとビジネスロジックのファイルの行数を調べてみました。
ViewControllerは平均150行、おおむね300行以内に収まっていました。一番肥大化したもので575行です。これは要リファクタリングですね。
ビジネスロジックの処理が書かれているInteractorは平均138行、こちらもおおむね300行以内に収まっています。500行超えは3つありましたが、これらは別RIBへ切り出すことでダイエット可能です。
肥大化を防げたのは、先ほど紹介したように責務をきちんと分けられるようになったことが大きく関わっています。以下が「JapanTaxi」iOSアプリでタクシーを呼んだ直後の画面です。(右側はViewDebuggingしたもの。)
OrderResultDetailViewController という画面の上に、複数のViewControllerがaddSubviewされています。注文結果の画面を機能ごとに別のRIBとして分割し、それぞれがタクシーの状態に連動して変化します。 目に見えない部分としては、動態情報(タクシーの向きやどれくらい動いたかなど)の取得とその加工を別のRIBとして切り出しています。Viewに依存しない設計であるため、ビジネスロジックだけを独立させることが可能となりました。
こういった機能ごとの細分化が容易に行うことができるようになり、ファイルの肥大化を防ぐことができました。
現在、JapanTaxiのiOSチームは5人です。5人同時に同じプロジェクトで開発をするのですが、以前だとViewModelやViewControllerの処理でコンフリクトが起きがちでした。 しかし、RIBsを導入後はRIB単位(機能単位)で作業を割り振ることができるので、基本的にはコンフリクトは発生しません。親子関係になっているRIBに手をいれる際に、依存性の解決でコンフリクトが生じることがありますが、ロジック面でのコンフリクトではないため解消も手軽です。 RIBツリーを見ていただくと分かるとおり、依存関係がはっきりしているので、思わぬ箇所でバグが発生したり、関連していない処理に影響を与えることが少なくなりました。以前に比べ、複数人での開発効率が圧倒的に向上したと思われます。
DIが前提になっており、それぞれのコンポーネントがProtocolで抽象化されたインタフェースを持っているので、Unitテストが書きやすいです。
現時点ではUberが発信した情報しかまともな情報源がありません。チュートリアルをやり、YouTubeにあがっているRIBs関連の動画を見たりして試行錯誤しながら開発しました。始めるハードルは少し高いのですが、設計方針を理解しそれに従って書かれたコードが存在していれば、RIBsに慣れることはそう難しくはないでしょう。 現に新人が2人加わりましたが、1週間ほどでRIBsによる開発は問題なくスタートできました。
RIBsに関する参考資料を以下に載せておきます。興味がある方はぜひご覧ください。
我々が抱えていた課題をチームメンバと熟考し、それを解決できるアーキテクチャへ移行することができました。時間も開発コストもかかるし失敗する可能性もあり、当時は不安が大きかったのですが、今振り返ってみると正しい決断ができていたと思います。 アプリの設計についてきちんと議論して設計方針を固め、チーム一丸となって取り組めたことは良い経験となりました。
Mobility Technologies では共に日本のモビリティを進化させていくエンジニアを募集しています。話を聞いてみたいという方は、是非 募集ページ からご相談ください!