Clean-Architecture × Reduxで支えるAndroidアプリのリアルタイムUX

サーバーサイドのユーザー状態とアプリケーションの画面の同期

サーバーサイドでユーザーの状態が変化する

matching sequence

弊社のアプリケーションでは、CREWライダーとCREWパートナー(ドライバー)という2つのロールのユーザー同士がマッチすることによって乗車依頼が成立します。 その後、CREWパートナーが出発地に到着したり、目的地に向かっての移動が開始したり、高速道路料金を設定して配車を完了したりというアクションを通してユーザーのステートがサーバー上で変わります。

最新のユーザーステートと表示されている画面の一致が必要

CREWパートナーは自らアプリケーションを操作することによってステートが変わることが主ですが、 CREWライダー側はCREWパートナーが出発地に到着した場合にはアプリケーションの画面でも その状態を反映して出発地に集合するように促す必要があります。

CREWパートナーを待っている間にアプリケーションを閉じていて、再び開いたときにも画面に最新のステートが表示される必要があり、 そのようにするにはアプリケーションを開き直した際にサーバーから最新のユーザー状態をロードし、 前面に出ている画面を適切に変更する必要があります。

ここで適切に反映することに失敗してしまうと、 CREWパートナーは到着してもCREWライダーが来ることなく、 CREWライダー側もCREWパートナーが到着していることに気が付かずお互いに不幸な結果となってしまいます。 特に弊社のアプリケーションにおいては実際に路上でリアルタイムにサービスを体験しているので、 このような事態が発生することはUXに多大な悪影響を及ぼすことになってしまいます。

アーキテクチャ選定の観点

チーム開発でユーザーによりすばやく価値を届けるためには、 各メンバーがが書きやすいと同時にチームとしてスケールしやすいアーキテクチャを選定する必要があります。 そのため、主に以下の観点からReduxアーキテクチャを採用しました。

データ同期、データの変更の検知が容易

サーバーサイドでユーザー状態が変化したことを検知した際にどこにそのデータの変更を反映させるべきか、 アプリ内でデータ操作を行ったときに、どこにそのデータを送信するのか、どこでそれを検知できるのかを明確にし、 アプリケーションの表示に反映させるフローの管理がシンプルになると考えました。

新しい人がキャッチアップしやすい

一般的なiOS・Androidアプリケーションの構成とは異なりますが、 それぞれのコンポーネントの役割が明確でシンプルであるためサンプルタスクのドキュメント等を整備して 各コンポーネントに対する作業を一回りやっていただければその後の設計で迷うことは少ないと考えました。

コードレビューの観点を絞りやすい

データの流れの組み方やエラーの受け渡し方の場所に開発者ごとの余地が大きいと、 どうしても担当者ごとの違いが出てしまい、どちらの書き方のほうが良いか等について考える必要がが生まれてしまいますが、 Redux構成にすることによってエラーや画面遷移の方法を統一化することによって コードレビューの際に異変に気が付きやすく、チェックする観点が絞りやすくなると考えました。

実際にやってみた

Clean Architecture的な設計と少人数チームでの取捨選択

Clean Architectureにしたことによって、どこに何を置くべきかというのは明確になりましたが、一方で少人数のチームで開発するにあたって

  • 画面の情報設計の変化が多い
  • 一つの案件に関わっているエンジニアが1~2人程度
  • 実際に開発していく中で必要になる機能が明確化していく

などの日々の開発の状況を鑑みて、多くのケースでは現状データ層のモデルを直接利用する形にしたほうが現状ではリターンが多いかと考えています。 いくつかのケースではドメイン層にモデルを定義してデータ層のリポジトリの実装のなかで変換を行っていますが多くの場合はそのままデータ層のものを直接利用しています。

Reduxを拡張したデータフロー

弊社では通常のReduxを拡張した形をPresentation層に採用しています

reduxのデータフロー図

弊社で採用したReduxでは、通常のActionに加えてErrorとNavigationというメッセージタイプを定義し、 これらもStoreを経由して入出力ができるようにしています。

Navigationは画面遷移を表現するメッセージで、現在弊社のアプリケーションでは

  • Push
  • Pop
  • Renew(Backstackを破棄して新しいFragmentのみを置く)
  • ShowDialog
  • ShowSnackbar

というタイプを定義しています。

Navigationが流れてくるストリームをMainActivityがObserveしており、メッセージに応じてFragment Transactionを実行して画面のコンテンツを変更しています。 また、実際のFragmentを指定するのではなく、どのような役割をする画面を表示してほしいのかを意味するURLのような、 Routeを定義し実際の画面との対応を管理するRouterを通してFragmentのインスタンスを取得するようにしています。

遷移依頼側:

Store.navigate(Navigation.Renew(Route.REQUEST_ORIGIN_NEW))

MainActivity側:

Store.navigation()
     .subscribeOn(Schedulers.io())
     .bindToLifecycle(this)
     .observeOn(AndroidSchedulers.mainThread())
     .subscribe {
        Log.d("main_activity", "Navigate: ${it}")
        handleNavigation(it)
     }

fun handleNavigation(nav: Navigation) {
  when(nav) {
    is Navigation.Renew -> {
      //ルーターから実際のFragmentのインスタンスを取得
      val newFragment = this.routes(nav.route)
      ...
    }
  }
}

このような抽象化を挟むことによって、各画面を開発する際に実際どの画面が使われるのか意識することなく遷移先を指定することができます。

Error

Errorについても直接受け取るのではなくErrorTagというエラーの種類を表すEnumとオプショナルなエラー原因のThrowableを流すことによって必要な画面で取得するようにしています。 これによってバックグラウンドで実行されたタスクによって発生したエラー等もStoreを通して取得することが出来ます。 エラーデータにはリカバリーアクションも指定できるので、もし再試行とかデータ復元とかの復帰処理等が可能ならば エラー送信側から指定しておいて、受信側でSnackbarのボタンから呼び出すようにする等も可能です。

エラー送信側:

carAllocationRepository.requestDriver(context, id)
                       .subscribeOn(Schedulers.io())
                       .subscribe({
                         //データ取得成功時
                        }, {
                          //エラー発生
                          Store.onError(ErrorTag.CreateDriverRequest, it)
                        })

エラー受信側:

Store.errors(ErrorTag.CreateDriverRequest)
     .subscribeOn(Schedulers.io())
     .subscribe({
        Store.navigate(Navigation.ShowSnackbar("ドライバーリクエストに失敗しました",Snackbar.LENGTH_LONG))
     })

フロー制御

redux_event_flow

Action, Navigation, ErrorはStoreへの送受信の際にはそれぞれのタイプに応じた窓口を用意してありますが、 Storeの内部では単一のストリームにまとめられて順番に処理されています。 このようにすることで、コード上での前後関係がそのままアプリでの処理される順序に反映されるため、 登録フロー等の「ユーザーが入力したデータを保存して、次の入力画面へ」といった処理がしたいときに、 処理のタイミングによって画面遷移とデータ更新の前後関係が入れ替わってしまうというようなバグを防ぐことができます。

また、このように単一のストリームに集約させることによって、アプリケーションにどのような変化があったのかのログを一箇所で とることができるようになり、ログを見るだけでユーザーがどのような処理や画面遷移をしたあとにエラーが発生したのかが容易に確認できるようになりました。

今後の課題

ルーティングで必要になったパラーメータを静的に保証したい

ほとんどのデータは各画面が自主的にストアから取得できるのでBundleで渡すケースはないのですが 画面をパラメータに応じて切り分けたいようなケースも当然一部ではあるので、このような ケースでもコンパイル時に誤ったパラメータを渡してしまっていないことを保証したいと考えています。

コチラについてはRouteにデータをもたせRouterがFragmentのインスタンスを生成するタイミングでこれらを保証する等で対応できるかと思っています。

すべての画面がフラットな階層で扱われているので、ネストしたワークフローの管理方法を確定したい

現在ではすべての画面がルーティングでもフラットに扱われています。 しかしユーザー登録のようなアプリサイドだけで決まったフローで画面遷移をさせたい場合があります。 現在は各画面が「次はこの画面」というのを判断しているのですが、この部分のワークフロー管理を適切な役割のクラスをつくって集約したいと思っています。

また、このようなワークフローではユーザー状態をロードした際に、 サーバー上の状態と現在のワークフローのレベルがマッチしているので強制的な画面上書きは行わなくても良い等の判定が煩雑になっています。

ここについてはUberがアプリケーションで採用しているRibsアーキテクチャのようにネストした Routerの仕組みを構築することによってワークフロー管理を分割できると考えています。

Page 13 of 12