詳解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アーキテクチャの全体像は以下のようになっています。

f:id:yuyakaido:20171212234204p:plain

MVIにおける処理の流れは以下のようになっています。

  1. 処理の起点として、Intentが生成される
  2. IntentがIntentInterpretorに渡され、Actionに変換される
  3. ActionがProcessorに渡され、Repositoryを使ってデータアクセスを行う
  4. Processorがデータアクセスの結果をResultとして返す
  5. ResultがReducerに渡され、Stateに変換される
  6. StateがRenderに渡され、Viewに反映される

今回は参考にしているリポジトリでは、簡易的なTODOアプリを実装しており、その中でタスク一覧を表示する部分にフォーカスして処理の流れを追ってみたいと思います。

Intent

f:id:yuyakaido:20171212234222p:plain

Intentは主に画面の変化やユーザーからの入力を表すもので、MVIにおける処理の起点になります。

例えば、今回フォーカスしているタスク一覧の初期化時は以下のIntentが発行されます。

interface TasksIntent extends MviIntent {
    @AutoValue
    abstract class InitialIntent implements TasksIntent {
        public static InitialIntent create() {
            return new AutoValue_TasksIntent_InitialIntent();
        }
    }
    // 省略
}

Action

f:id:yuyakaido:20171212234242p:plain

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

f:id:yuyakaido:20171212234303p:plain

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

f:id:yuyakaido:20171212234323p:plain

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

f:id:yuyakaido:20171212234339p:plain

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

f:id:yuyakaido:20171212234358p:plain

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といった性質を付け加えている
  • 実装
    • Intent:画面の変化やユーザーの入力を表すもので、MVIにおける処理の起点になる
    • Action:Intentをデータフローに変換したもの
    • Processor:Actionを受け取り、データアクセスなどを行うレイヤー
    • Result:Processorが処理の結果として吐き出すもの
    • Reducer:1つ前のViewStateとResultから新しいViewStateを吐き出すレイヤー
    • ViewState:Viewの状態を定義したもの
    • Render:ViewStateを受け取り、Viewに反映させるためのレイヤー

MVIのコードは初見では取っ付きにくいという印象を持つ方が多いと思いますが、そのコンセプトから紐解いていくとネイティブアプリにおける状態管理をうまくハンドリングしており、個人的にはかなり勉強になりました。この記事がMVIに興味を持っている人の参考になれば嬉しいです!