詳解MVIアーキテクチャ
これはAndroid Advent Calendar 2017の12日目の記事です。
この記事はMVIアーキテクチャの概要とその実装を紹介するものです。概要については、MODEL-VIEW-INTENT ON ANDROIDからの抜粋で、実装についてはTODO-MVI-RxJavaをもとにしています。
概要
MVI is inspired by some other js frameworks like redux and react, but the key principle comes from Model-View-Controller (MVC)
MVIはMVCのコンセプトがもとになっていて、そこにReduxやReactで採用されている考え方を取り入れています。
MVCのコンセプトとは、
A Model that is observed by the View and a Controller that manipulates the Model.
のことを指しており、それに加えて、
- 単方向データフロー
- StateをImmutableとして定義
というReduxやReactにあるような考え方を加えています。
MVIの特徴を以下にまとめます。
- The State Problem
- StateをImmutableとして定義し、単方向データフローで実装することで状態管理にまつわる問題を解決できる
- Screen orientation changes
- 画面回転ではViewが再生成されることになるが、再生成後にModelの状態をViewに反映することで画面状態を復元できる
- Navigation on the back stack
- 画面回転時と同様に、Modeの状態をViewに反映することで画面を復元できる
- Process death
- 画面回転と同様の方法で画面を復元できる
- Immutability and unidirectional data flow
- StateをImmutableとして定義し、単方向データフローで実装することでシンプルに実装できる
- Debuggable and reproducible states
- Stateを逐一ログ出力したり、クラッシュした際にCrashlyticsにStateを送信しておくことで、アプリの状態を容易に再現可能になりデバッグが簡単になる
- Testability
- StateはImmutableとして定義されており、単方向データフローであることからテストを容易に実装できる
実装
MVIアーキテクチャの全体像は以下のようになっています。
MVIにおける処理の流れは以下のようになっています。
- 処理の起点として、Intentが生成される
- IntentがIntentInterpretorに渡され、Actionに変換される
- ActionがProcessorに渡され、Repositoryを使ってデータアクセスを行う
- Processorがデータアクセスの結果をResultとして返す
- ResultがReducerに渡され、Stateに変換される
- StateがRenderに渡され、Viewに反映される
今回は参考にしているリポジトリでは、簡易的なTODOアプリを実装しており、その中でタスク一覧を表示する部分にフォーカスして処理の流れを追ってみたいと思います。
Intent
Intentは主に画面の変化やユーザーからの入力を表すもので、MVIにおける処理の起点になります。
例えば、今回フォーカスしているタスク一覧の初期化時は以下のIntentが発行されます。
interface TasksIntent extends MviIntent { @AutoValue abstract class InitialIntent implements TasksIntent { public static InitialIntent create() { return new AutoValue_TasksIntent_InitialIntent(); } } // 省略 }
Action
MVIはUIとデータフローを明確に区別しており、IntentをActionに変換します。これにより、異なるIntentで同じActionを再利用することを可能にしています。
例えば、「タスク一覧の初期化」は「データを読み込む」といった具合に変換されています。
private TasksAction actionFromIntent(MviIntent intent) { if (intent instanceof TasksIntent.InitialIntent) { return TasksAction.LoadTasks.loadAndFilter(true, TasksFilterType.ALL_TASKS); } // 省略 }
Processor
ProcessorはActionを受け取り、必要に応じてRepositoryを介したデータアクセスを行います。
まず、Actionから該当するProcessorを判定します。
ObservableTransformer<TasksAction, TasksResult> actionProcessor = actions -> actions.publish(shared -> Observable.merge( // Match LoadTasks to loadTasksProcessor shared.ofType(TasksAction.LoadTasks.class).compose(loadTasksProcessor), // Match ActivateTaskAction to populateTaskProcessor shared.ofType(TasksAction.ActivateTaskAction.class).compose(activateTaskProcessor), // Match CompleteTaskAction to completeTaskProcessor shared.ofType(TasksAction.CompleteTaskAction.class).compose(completeTaskProcessor), // Match ClearCompletedTasksAction to clearCompletedTasksProcessor shared.ofType(TasksAction.ClearCompletedTasksAction.class).compose(clearCompletedTasksProcessor)) .mergeWith( // Error for not implemented actions shared.filter(v -> !(v instanceof TasksAction.LoadTasks) && !(v instanceof TasksAction.ActivateTaskAction) && !(v instanceof TasksAction.CompleteTaskAction) && !(v instanceof TasksAction.ClearCompletedTasksAction)) .flatMap(w -> Observable.error( new IllegalArgumentException("Unknown Action type: " + w)))));
今回はタスク一覧を読み込むためのProcessorが選択されます。
private ObservableTransformer<TasksAction.LoadTasks, TasksResult.LoadTasks> loadTasksProcessor = actions -> actions.flatMap(action -> mTasksRepository.getTasks(action.forceUpdate()) // Transform the Single to an Observable to allow emission of multiple // events down the stream (e.g. the InFlight event) .toObservable() // Wrap returned data into an immutable object .map(tasks -> TasksResult.LoadTasks.success(tasks, action.filterType())) // Wrap any error into an immutable object and pass it down the stream // without crashing. // Because errors are data and hence, should just be part of the stream. .onErrorReturn(TasksResult.LoadTasks::failure) .subscribeOn(mSchedulerProvider.io()) .observeOn(mSchedulerProvider.ui()) // Emit an InFlight event to notify the subscribers (e.g. the UI) we are // doing work and waiting on a response. // We emit it after observing on the UI thread to allow the event to be emitted // on the current frame and avoid jank. .startWith(TasksResult.LoadTasks.inFlight()));
タスク一覧を読み込むためにTaskRepositoryのgetTasksメソッドを呼び出し、タスクの取得結果をTasksResultに変換しています。
Result
ResultはProcessorがActionを実行した結果として吐き出すもので、タスク一覧の表示では以下のようなResultが定義されています。
interface TasksResult extends MviResult { @AutoValue abstract class LoadTasks implements TasksResult { @NonNull abstract LceStatus status(); @Nullable abstract List<Task> tasks(); @Nullable abstract TasksFilterType filterType(); @Nullable abstract Throwable error(); @NonNull static LoadTasks success(@NonNull List<Task> tasks, @Nullable TasksFilterType filterType) { return new AutoValue_TasksResult_LoadTasks(SUCCESS, tasks, filterType, null); } @NonNull static LoadTasks failure(Throwable error) { return new AutoValue_TasksResult_LoadTasks(FAILURE, null, null, error); } @NonNull static LoadTasks inFlight() { return new AutoValue_TasksResult_LoadTasks(IN_FLIGHT, null, null, null); } } }
タスク一覧の取得に成功した場合はTasksResult.LoadTasks.successとして表現され、失敗した場合はTasksResult.LoadTasks.failureとして表現されます。また、読み込み状態はTasksResult.LoadTasks.isFlightとして表現されるようです。
Reducer
ReducerはResultを受け取り、Viewの状態を定義したViewStateを吐き出します。
private static BiFunction<TasksViewState, TasksResult, TasksViewState> reducer = (previousState, result) -> { TasksViewState.Builder stateBuilder = previousState.buildWith(); if (result instanceof TasksResult.LoadTasks) { TasksResult.LoadTasks loadResult = (TasksResult.LoadTasks) result; switch (loadResult.status()) { case SUCCESS: TasksFilterType filterType = loadResult.filterType(); if (filterType == null) { filterType = previousState.tasksFilterType(); } List<Task> tasks = filteredTasks(checkNotNull(loadResult.tasks()), filterType); return stateBuilder.isLoading(false).tasks(tasks).tasksFilterType(filterType).build(); case FAILURE: return stateBuilder.isLoading(false).error(loadResult.error()).build(); case IN_FLIGHT: return stateBuilder.isLoading(true).build(); } } };
Reducerは1つ前のViewStateと、新しく生成されたResultを引数に取り、その2つを使って新しいViewStateを組み上げます。 今回取り上げているタスク一覧画面のViewStateはTasksViewStateとして定義されており、TasksViewState.Builderを使ってインスタンスを組み立てています。
Render
RenderはReducerが吐き出したViewStateを受け取り、Viewに反映する作業を担当します。
@Override public void render(TasksViewState state) { if (state.tasks().isEmpty()) { // 省略 } else { mListAdapter.replaceData(state.tasks()); mTasksView.setVisibility(View.VISIBLE); mNoTasksView.setVisibility(View.GONE); } }
まとめ
今回はMVIアーキテクチャの概要と実装方法を紹介しました。
- 概要
- MVIはMVCにおけるModelのコンセプトを引き継いでいる
- それに加えて、ReduxやReactに見られるImmutabilityやUnidirectional Data Flowといった性質を付け加えている
- 実装
MVIのコードは初見では取っ付きにくいという印象を持つ方が多いと思いますが、そのコンセプトから紐解いていくとネイティブアプリにおける状態管理をうまくハンドリングしており、個人的にはかなり勉強になりました。この記事がMVIに興味を持っている人の参考になれば嬉しいです!