Android
flux
Kotlin
mvi

GitHubで人気なFlux(とMVI)のAndroidアプリのコードを読んでみるメモ

MVPやClean Architectureはなんとなくは雰囲気をつかめてきましたが、全くFluxやちょっと話題のMVIがわからないので調べてみました。

lgvalle/android-flux-todo-app ★1,170

flux androidでGitHubで検索すると一番スターが多いリポジトリです。
しかし、2015年に作られたということでRxJavaなどが利用されていないなど、多少古い気がしています。


https://github.com/lgvalle/android-flux-todo-app より

起動してADDボタンを押したときの動きを追ってみます。

screen.png

View -> Action

image.png

まず普通にActivityがあり、そこでイベントリスナのセットなどを行っています。
resetMainInput()メソッドはただEditTextの中身をsetText("")して空にするものでした。

TodoActivity.java
        Button mainAdd = (Button) findViewById(R.id.main_add);
        mainAdd.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View view) {
                addTodo();
                resetMainInput();
            }
        });

addTodoではバリデーションして、actionsCreator.create(String)を呼び出します。(・・・ここでバリデーションでいいの、、?)

TodoActivity.java
    private void addTodo() {
        if (validateInput()) {
            actionsCreator.create(getInputText());
        }
    }

    private boolean validateInput() {
        return !TextUtils.isEmpty(getInputText());
    }

ActionsCreator.create()メソッドの中を読んでいきます。
以下のようにdispatcherのdispatchメソッドを呼び出します。
TodoActions.TODO_CREATEもTodoActions.KEY_TEXTもただのキー("todo-create"など)が入った文字列のようです。
Actionのインスタンスはまだ作っていないけど、DispatcherにActionに必要なデータを渡しています。

ActionsCreator.java
    public void create(String text) {
        dispatcher.dispatch(
                TodoActions.TODO_CREATE,
                TodoActions.KEY_TEXT, text
        );

    }

Action -> Dispatcher

image.png

Dispatcher#dispatchメソッドでは固定の処理が書かれていました。
多分ここは固定で変えないみたい。
一言で言うとActionクラスのインスタンスを作って、それをEventBus(Otto)でイベントを飛ばす感じ。

    public void dispatch(String type, Object... data) {
        if (isEmpty(type)) {
            throw new IllegalArgumentException("Type must not be empty");
        }

        if (data.length % 2 != 0) {
            throw new IllegalArgumentException("Data must be a valid list of key,value pairs");
        }

        Action.Builder actionBuilder = Action.type(type);
        int i = 0;
        while (i < data.length) {
            String key = (String) data[i++];
            Object value = data[i++];
            actionBuilder.bundle(key, value);
        }
        post(actionBuilder.build());
    }

    private boolean isEmpty(String type) {
        return type == null || type.isEmpty();
    }

    private void post(final Object event) {
        bus.post(event);
    }

ちなみにActionクラスは以下のような形。

Action.java
public class Action {
    private final String type;
    private final HashMap<String, Object> data;
...

Dispatcher -> Store

image.png

TodoStoreクラスはシングルトンのようですね。
todoのリストを持っています。

EventBusからonAction()メソッドが呼び出されます。
自身が持っているタスクのリストに追加して、EventBusにTodoStoreChangeEventを飛ばします。

TodoStore.java
public class TodoStore extends Store {

    private static TodoStore instance;
    private final List<Todo> todos;
    private Todo lastDeleted;
...
    @Override
    @Subscribe
    @SuppressWarnings("unchecked")
    public void onAction(Action action) {
        long id;
        switch (action.getType()) {
            case TodoActions.TODO_CREATE:
                String text = ((String) action.getData().get(TodoActions.KEY_TEXT));
                create(text);
                emitStoreChange();
                break;
....
        }
    }
    private void create(String text) {
        long id = System.currentTimeMillis();
        Todo todo = new Todo(id, text);
        addElement(todo);
        Collections.sort(todos);
    }

    private void addElement(Todo clone) {
        todos.add(clone);
        Collections.sort(todos);
    }
Store.java
    void emitStoreChange() {
        dispatcher.emitChange(changeEvent());
    }
TodoStore.java
    @Override
    StoreChangeEvent changeEvent() {
        return new TodoStoreChangeEvent();
    }

Store -> View

image.png

EventBusでイベントを受け取って、Storeからデータを取り出して使う感じみたいです。

TodoActivity.java
    @Subscribe
    public void onTodoStoreChange(TodoStore.TodoStoreChangeEvent event) {
        updateUI();
    }

    private void updateUI() {
        listAdapter.setItems(todoStore.getTodos());

        if (todoStore.canUndo()) {
            Snackbar snackbar = Snackbar.make(mainLayout, "Element deleted", Snackbar.LENGTH_LONG);
            snackbar.setAction("Undo", new View.OnClickListener() {
                @Override
                public void onClick(View view) {
                    actionsCreator.undoDestroy();
                }
            });
            snackbar.show();
        }
    }

satorufujiwara/kotlin-android-flux ★202

ふじわらさんのリポジトリです。
TODOではなく、GitHubからリポジトリを取得する内容になっています。
KotlinでRxJavaなどなどモダンなものをたくさん使っています。

いくつか発表されていたりして、勉強になります。
リストの内容が表示されるまでを追ってみます。

説明としては以下の方が良いので、みてみてください。

RxJava + Flux (+ Kotlin)によるAndroidアプリ設計

screen.png

View -> Action

image.png

MainFragmentが表示され、Storeの監視とリフレッシュアクションの処理を行います。
RxJavaのObservableを返すメソッドがMainStoreに生えている感じみたいです。
そしてmainAction.refreshList(screenId)を呼び出しています。
ちなみにscreenIdはhashCode()を呼び出した結果が入っています。
おそらくmainAction.refreshList()を呼ぶとAPI通信を行って、変更が通知されてくると思われるので、そこから見ていきましょう。

MainFragment.kt
    override fun onViewCreated(view: View?, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        ...
        mainStore.repos(screenId)
                .observeOn(AndroidSchedulers.mainThread())
                .bindToLifecycle(this)
                .subscribe {
                    adapter.replaceAll(MainSection.CONTENTS, it.map { MainRepoBinder(activity, it) })
                    adapter.notifyDataSetChanged()
                }
        savedInstanceState ?: mainAction.refreshList(screenId)
    }

Action -> Dispatcher

image.png

repository.getRepos()を呼び出します。repositoryはただRetrofitからAPI通信して取得してくるのみでした。(おそらくRepositoryでキャッシュの実装とかすると思われます。)

MainAction.kt
    fun refreshList(screenId: String) = repository.getRepos("satorufujiwara")
            .subscribeOn(Schedulers.io())
            .subscribe({
                dispatcher.dispatch(screenId, it)
            }, {
                dispatcher.dispatchError("Couldn't get items... ")
            })

Dispatcher -> Store

image.png

DispatcherでDBの更新を行っています。
screenIdはハッシュコードで、まずDBから同じidであれば削除します。
そして、DBにinsertしていきます。
markSuccessful()はsqlbriteのメソッドで特に通知のためのメソッドを呼び出していません。
ではどうやってStoreからViewに通知しているかというと、ここにsqlbriteを使っているようです。sqlbriteを使うとDBの変更をRxJavaのイベントとして流すことができます。

MainDispatcher.kt
    fun dispatch(screenId: String, list: List<Repo>) = db.newTransaction().run {
        var rows = 0
        try {
            db.delete(TABLE_NAME, "$SCREEN_ID=?", screenId)
            list.filterNotNull().map { repo ->
                ContentValues().apply {
                    put(SCREEN_ID, screenId)
                    put(REPO_ID, repo.id)
                    put(REPO_NAME, repo.name)
                }
            }.forEach {
                db.insert(TABLE_NAME, it)
                rows++
            }
            markSuccessful()
        } finally {
            end()
        }
        if (list.size == 0 && rows == 0) emptyObservable.onNext(screenId)
        rows
    }

Store -> View

image.png

MainFragmentでsubscribeしていたのを思い出してください。
dispacherでsqlbriteによる監視が働いて、DBに変更があれば、RxJavaによりViewまで通知されてきます。

MainFragment.kt
    override fun onViewCreated(view: View?, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        ...
        mainStore.repos(screenId)
                .observeOn(AndroidSchedulers.mainThread())
                .bindToLifecycle(this)
                .subscribe {
                    adapter.replaceAll(MainSection.CONTENTS, it.map { MainRepoBinder(activity, it) })
                    adapter.notifyDataSetChanged()
                }
MainStore.kt
class MainStore(val dispatcher: MainDispatcher) {

    fun errorEvents() = dispatcher.errorEventObservable

    fun repos(screenId: String) = dispatcher.repos(screenId)
}

DBの変更の監視を行っている。

MainDispatcher.kt
    fun repos(screenId: String) =
            db.createQuery(TABLE_NAME, "SELECT * FROM $TABLE_NAME WHERE $SCREEN_ID=?", screenId)
                    .mapToList { Repo(it.getStringByName(REPO_ID), it.getStringByName(REPO_NAME)) }
                    .mergeWith(emptyObservable.filter { it == screenId }.map { emptyList<Repo>() })

oldergod/android-architecture ★22

Android Architecture BlueprintsのMVI(Model-View-Intent)版を開発しているという話を本人から聞いて、ちょっと読んでみたいと思っていました。(IntentはAndroidのIntentじゃないですよ!)

image.png

今回は、フィルターを変更したときの動きを見ていきます(タスク追加は別画面に遷移してしまうので、、)
screen.png

USER -> EVENT -> INTENT

image.png

まず、ClearCompletedTasksIntentを作成し、それをRxJavaのPublisherのmClearCompletedTaskIntentPublisherに流します。

TasksFragment.java
    @Override
    public boolean onOptionsItemSelected(MenuItem item) {
        switch (item.getItemId()) {
            case R.id.menu_clear:
                mClearCompletedTaskIntentPublisher.onNext(TasksIntent.ClearCompletedTasksIntent.create());
                break;
...

AutoValueによってequalsメソッドなどを自動的に作ったクラスのインスタンスを作っているようです。

TasksIntent.java
...
    @AutoValue
    abstract class ClearCompletedTasksIntent implements TasksIntent {
        public static ClearCompletedTasksIntent create() {
            return new AutoValue_TasksIntent_ClearCompletedTasksIntent();
        }
    }
...

mClearCompletedTaskIntentPublisherに流したClearCompletedTasksIntentはどこに行くのかというと、まず、イベントは他のIntentを流すObservableとマージされます。(changeFilterIntent()でmClearCompletedTaskIntentPublisherを返す。)

TasksFragment.java
    @Override
    public Observable<TasksIntent> intents() {
        return Observable.merge(initialIntent(), refreshIntent(), adapterIntents(),
                clearCompletedTaskIntent()).mergeWith(changeFilterIntent());
    }

で、実際Intentはどこでどう処理されていくのかというと、まずこのintents()で返ってくるものをmViewModelに渡します。

TasksFragment.java
mViewModel.processIntents(intents());

Intent -> Action -> Result -> State

image.png

図にVIEW MODELとあるように、ViewModelのクラスの中で、上のすべてを行います。
まずmIntentsSubjectでsubscribeします。

TasksViewModel.java
    @Override
    public void processIntents(Observable<TasksIntent> intents) {
        intents.subscribe(mIntentsSubject);
    }

そのmIntentsSubjectから以下のようにStateのObservableまで一気に変換しています。

TasksViewModel.java
    private Observable<TasksViewState> compose() {
        return mIntentsSubject.doOnNext(MviViewModel::logIntent)
                .scan(initialIntentFilter)
                .map(this::actionFromIntent)
                .doOnNext(MviViewModel::logAction)
                .compose(mActionProcessorHolder.actionProcessor)
                .doOnNext(MviViewModel::logResult)
                .scan(TasksViewState.idle(), reducer)
                .doOnNext(MviViewModel::logState);
    }

それぞれactionFromIntent()でIntent -> Action、
mActionProcessorHolder.actionProcessorで Processorで処理してResultを取得、
scan()でreducerを使ってStateを取得
を行っています。

それぞれみていきましょう

Intent -> Action

image.png

actionFromIntent()は以下のようになっており、intentのインスタンスを判定して、Actionに変換しています。今回はFilterしたいので、以下の処理を出しています。

TasksViewModel.java
private TasksAction actionFromIntent(MviIntent intent) {
...
        if (intent instanceof TasksIntent.ChangeFilterIntent) {
            return TasksAction.LoadTasks.loadAndFilter(false,
                    ((TasksIntent.ChangeFilterIntent) intent).filterType());
        }
...
        throw new IllegalArgumentException("do not know how to treat this intent " + intent);
    }

((TasksIntent.ChangeFilterIntent) intent).filterType())ではTasksAction.LoadTasksのインスタンスを作って返します。このクラスはfilterTypeを保持しています。(AutoValueにより自動生成したクラスを利用します)

TasksAction.java
    @AutoValue
    abstract class LoadTasks implements TasksAction {
        public abstract boolean forceUpdate();

        @Nullable
        public abstract TasksFilterType filterType();

        public static LoadTasks loadAndFilter(boolean forceUpdate, TasksFilterType filterType) {
            return new AutoValue_TasksAction_LoadTasks(forceUpdate, filterType);
        }

        public static LoadTasks load(boolean forceUpdate) {
            return new AutoValue_TasksAction_LoadTasks(forceUpdate, null);
        }
    }

Action -> Result

image.png

ProcessorによるActionの実際の実行になります。
圧倒的RxJava感。ちょっと最初見たときはわかりにくかったですが、ofTypeで絞り込んで、それぞれのメンバで処理するみたいです。
つまり今回は shared.ofType(TasksAction.LoadTasks.class).compose(loadTasksProcessor)が実行されそう。

TasksActionProcessorHolder.java
    ObservableTransformer<TasksAction, TasksResult> actionProcessor =
            (Observable<TasksAction> actions) -> actions.publish(shared -> Observable.merge(
                    shared.ofType(TasksAction.LoadTasks.class).compose(loadTasksProcessor),
                    shared.ofType(TasksAction.GetLastState.class).compose(getLastStateProcessor),
                    shared.ofType(TasksAction.ActivateTaskAction.class).compose(activateTaskProcessor),
                    shared.ofType(TasksAction.CompleteTaskAction.class).compose(completeTaskProcessor))
                    .mergeWith(shared.ofType(TasksAction.ClearCompletedTasksAction.class)
                            .compose(clearCompletedTasksProcessor))
                    .mergeWith(
                            // Error for not implemented actions
                            shared.filter(v -> !(v instanceof TasksAction.LoadTasks)
                                    && !(v instanceof TasksAction.GetLastState)
                                    && !(v instanceof TasksAction.ActivateTaskAction)
                                    && !(v instanceof TasksAction.CompleteTaskAction)
                                    && !(v instanceof TasksAction.ClearCompletedTasksAction))
                                    .flatMap(w -> Observable.error(
                                            new IllegalArgumentException("Unknown Action type: " + w)))));
}

実際に処理をするloadTasksProcessorはどうなっているかというと以下のようになっています。
mTasksRepository.getTasks()でレポジトリから取得する処理が出てきましたね!
そして結果を.map(tasks -> TasksResult.LoadTasks.success(tasks, action.filterType()))でResultに変換します。

    private ObservableTransformer<TasksAction.LoadTasks, TasksResult.LoadTasks> loadTasksProcessor =
            actions -> actions.flatMap(action -> mTasksRepository.getTasks(action.forceUpdate())
                    .toObservable()
                    .map(tasks -> TasksResult.LoadTasks.success(tasks, action.filterType()))
                    .onErrorReturn(TasksResult.LoadTasks::failure)
                    .subscribeOn(mSchedulerProvider.io())
                    .observeOn(mSchedulerProvider.ui())
                    .startWith(TasksResult.LoadTasks.inFlight()));

ちなみにTasksResult.LoadTasks.successは以下のような形。AutoValueにも見慣れてきました。

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);
        }
    }

Result -> State

image.png

reducerは以下のようになっています。
最後にTasksViewStateをResultを使ってTasksViewStateのBuilderを使って組み立てているようです。ちゃんとエラーとかは考えられていそうです。

TasksViewModel.java
    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();
                    }
                } else if (result instanceof TasksResult.GetLastState) {
                    ....
            };

    private static List<Task> filteredTasks(@NonNull List<Task> tasks,
                                            @NonNull TasksFilterType filterType) {
        List<Task> filteredTasks = new ArrayList<>(tasks.size());
        switch (filterType) {
            case ALL_TASKS:
                filteredTasks.addAll(tasks);
                break;
            case ACTIVE_TASKS:
                for (Task task : tasks) {
                    if (task.isActive()) filteredTasks.add(task);
                }
                break;
            case COMPLETED_TASKS:
                for (Task task : tasks) {
                    if (task.isCompleted()) filteredTasks.add(task);
                }
                break;
        }
        return filteredTasks;
    }

State -> SCREEN

image.png

以下のTaskViewStateのObservableはmStatesSubjectによってsubscribeされ、mStatesSubjectはFragmentのメソッドによりSubscribeされます。

TasksViewModel.java
        compose().subscribe(this.mStatesSubject);
TasksViewModel.java
    private Observable<TasksViewState> compose() {
        return mIntentsSubject.doOnNext(MviViewModel::logIntent)
                .scan(initialIntentFilter)
                .map(this::actionFromIntent)
                .doOnNext(MviViewModel::logAction)
                .compose(mActionProcessorHolder.actionProcessor)
                .doOnNext(MviViewModel::logResult)
                .scan(TasksViewState.idle(), reducer)
                .doOnNext(MviViewModel::logState);
    }

Fragmentのメソッドとなります。

TasksFragment.java
mDisposables.add(mViewModel.states().subscribe(this::render));

そして以下のように表示します。シンプル!

TasksFragment.java
    @Override
    public void render(TasksViewState state) {
        mSwipeRefreshLayout.setRefreshing(state.isLoading());
        if (state.error() != null) {
            showLoadingTasksError();
            return;
        }

        if (state.taskActivated()) showMessage(getString(R.string.task_marked_active));

        if (state.taskComplete()) showMessage(getString(R.string.task_marked_complete));

        if (state.completedTasksCleared()) showMessage(getString(R.string.completed_tasks_cleared));

        if (state.tasks().isEmpty()) {
            switch (state.tasksFilterType()) {
                case ACTIVE_TASKS:
                    showNoActiveTasks();
                    break;
                case COMPLETED_TASKS:
                    showNoCompletedTasks();
                    break;
                default:
                    showNoTasks();
                    break;
            }
        } else {
            mListAdapter.replaceData(state.tasks());

            mTasksView.setVisibility(View.VISIBLE);
            mNoTasksView.setVisibility(View.GONE);

            switch (state.tasksFilterType()) {
                case ACTIVE_TASKS:
                    showActiveFilterLabel();
                    break;
                case COMPLETED_TASKS:
                    showCompletedFilterLabel();
                    break;
                default:
                    showAllFilterLabel();
                    break;
            }
        }
    }

まとめ(感想)

それぞれ全てActionのようなもので命令を送り、円を描いて?めぐって返ってきます。
FluxではStoreに何かしらの形で状態を保持します。
その上で、一時データ保存領域として、lgvalle/android-flux-todo-appではメンバ変数、satorufujiwara/kotlin-android-fluxではDB利用しています。
MVIは状態を持つところがないのが本当にすごいですが、自分がまだIntentとActionの違いなどを理解できていないので、修行が必要そうです。
またMVIで利用されていたStateの作成してUIに反映はとてもシンプルになりそうで良さそうです。
他の発見としてはFluxとMVIで共通していたのは、だいたいちゃんとデータを保存するものの場合はRepositoryがありそうということです。
あとはコードでなく、ちゃんとどういう思想でやっているかなどを理解していきたいですね!