AndroidにおけるReduxという選択肢

これはAndroid Advent Calendar 2018の22日目の記事です。

はじめに

突然ですが、Androidアプリ開発で悩ましい問題といえば何でしょうか?

ライフサイクル、端末やOS依存のバグ、状態管理などなど色々とありますが、今回は状態管理に焦点を当ててみたいと思います。本記事における状態管理とは、複数の画面に同一のデータが表示され、それらの一貫性を担保しつつ、リアルタイムに反映する必要があるものと定義します。この状態管理はいいねボタン問題と呼ばれることもあり、多くのAndroid開発者を悩ませてきた問題の1つです。

さて、Androidにおいていいねボタン問題を解決するためにはどのようなアプローチがあるでしょうか?

  • ActivityやFragmentのonActivityResultを使って画面を更新する
  • EventBusなどを使って状態変更を通知する
  • ReduxのStoreを各画面で共有する

大まかには3つのアプローチがありますが、今回はタイトルにもあるように3つ目のReduxというアプローチを紹介したいと思います。

基礎編

背景

そもそもReduxはどういった背景で登場したのでしょうか。

これは公式サイトのMotivationに明確に記載されています。

要点だけ抜き出すと、

  • 複数のデータソースを扱ったり、様々なUIコンポーネントの状態を制御したり、といった具合に近年のアプリは複雑化してきている
  • 複雑化したアプリはメンテンナンスが困難になり、新機能の追加が難しくなったり、再現性のないバグに苦しめられたりする
  • 複雑化の正体は非同期性と状態変更を混同していることにあり、Reduxは状態変更の方法に制限をかけることで複雑性に対抗しようとしている

一言でまとめると、Reduxは状態管理の複雑性を解消するためのアプローチであると言えます。

概要

Reduxは状態管理をスマートに行うためのアプローチであり、状態管理に以下3つの制限をかけています。

  • Single source of truth

The state of your whole application is stored in an object tree within a single store.

アプリが管理する状態を1つのオブジェクトとして表現し、このオブジェクトからアプリ内のUIを構築することで統一的な状態管理を実現します。全てを1つのオブジェクトとして表現することで、データの流れが1方向に制限されて処理が非常に追いやすくなります。

  • State is read-only

The only way to change the state is to emit an action, an object describing what happened.

状態の変更内容を表現したアクションを投げることでのみ状態を変更することが可能です。Reduxのない世界では誰でも状態変更を行うことが可能で、規模が大きくなるにつれて制御が難しくなりますが、Reduxは状態変更のフローを厳密に制限することで状態変更がどこで行われているのかが追いやすくなります。また、状態は読み取り専用として定義することで意図しない状態変更を防いでいます。

  • Changes are made with pure functions

To specify how the state tree is transformed by actions, you write pure reducers.

状態変更は必ず副作用のない純粋な関数として記述する必要があります。冪等性を担保することでテスタビリティを担保し、状態変更の予測可能性を高めることにも寄与しているでしょう。

登場人物

f:id:yuyakaido:20181222172752p:plain
Lessons Learned Implementing Redux on Android

Reduxではおおまかに上記の登場人物がいます。それぞれを軽く解説していきます。

  • State

まさに今回焦点を当てている状態そのものです。アプリに表示されるデータであったり、UIコンポーネントの状態であったりといった具合に様々なものがStateとして表現されます。

  • UI

実際にユーザーが目にすることになる画面です。裏側ではStateとして表現されたデータが、リストやボタンといった目に見える形で表現されます。

  • Action

状態の変更内容を表現したものであり、Reduxにおける状態変更を行うための唯一の手段です。

  • Reducer

状態変更を担当するものであり、1つ前のStateとActionから次のStateを計算する純粋な関数として表現されます。

  • Store

Stateを保持する役割を持ち、外部からActionが渡される度にReducerを使って次のStateを計算してStateを変更します。

実装編

ここからはサンプルとしてTODOアプリの実装を見ながら、コードレベルで実装方法を解説していきます。

f:id:yuyakaido:20181222214309p:plain

今回紹介するコードは以下のリポジトリで公開しています。

github.com

Interface

統一的な記述を行うために、各種インターフェースを定義します。

interface StateType

interface ActionType

interface ReducerType<STATE : StateType, ACTION : ActionType> {
    fun reduce(state: STATE, action: ACTION): STATE
}

Model

TODOを表現するモデルとしてTodoを定義します。

data class Todo(
    val title: String,
    val date: Date,
    val isCompleted: Boolean
)

State

アプリ全体の状態を管理するクラスとしてAppStateを定義します。Stateは読み取り専用として定義することを強く推奨します。

data class AppState(
    val todos: List<Todo> = emptyList()
) : StateType

Action

TODOアプリを実現する上で必要になる状態変更をAppActionとして定義します。今回はTODOの追加・削除・完了をActionとして定義します。

細かなテクニックとして、AppActionをsealed classとして定義することで、後述のReducerでのelseを省略することが可能になります。

sealed class AppAction : ActionType {
    data class AddTodo(val todo: Todo) : AppAction()
    data class CompleteTodo(val todo: Todo) : AppAction()
}

Reducer

先に紹介したActionに対応する状態変更をAppReducerとして定義します。AppReducerはAppStateとAppActionを引数に取るメソッドを持ち、このメソッドで次のAppStateを計算して返却します。ReducerはReduxの3原則にある通り、副作用のない純粋な関数として定義します。これによってメソッドの冪等性が担保され、テスタビリティが高まります。

class AppReducer : ReducerType<AppState, AppAction> {

    override fun reduce(state: AppState, action: AppAction): AppState {
        return when (action) {
            is AppAction.AddTodo -> {
                state.copy(todos = state.todos.plus(action.todo))
            }
            is AppAction.CompleteTodo -> {
                state.copy(todos = state.todos.map { if (it == action.todo) { action.todo } else { it } })
            }
        }
    }

}

Store

AppStateを保持するためのクラスとしてAppStoreを定義します。AppStoreは初期状態とReducerを引数に取り、内部では状態の保持とReducerを使った状態変更を行います。

今回はRxJavaを使って実装していますが、RxJavaは必ずしも必須ではなく、Observerパターンを自作する形でももちろん大丈夫です。

class AppStore(
    private val initial: AppState = AppState(),
    private val reducer: AppReducer = AppReducer()
) : StoreType<AppState, AppAction> {

    private val state = BehaviorRelay.createDefault(initial)

    override fun dispatch(action: AppAction) {
        state.value?.let { current ->
            state.accept(reducer.reduce(current, action))
        }
    }

    override fun observable(): Observable<AppState> {
        return state
    }

}

UI

val adapter = TodoAdapter()
val recyclerView = findViewById<RecyclerView>(R.id.recycler_view)
recyclerView.layoutManager = LinearLayoutManager(this)
recyclerView.adapter = adapter

store.observable()
    .map { state -> state.todos }
    .map { todos -> todos.filterNot { todo -> todo.isCompleted } }
    .subscribeBy { todos ->
        adapter.setTodos(todos)
        adapter.notifyDataSetChanged()
    }
    .addTo(disposables)

TODOの更新

TODOを追加する場合はAppAction.AddTodoをStoreに渡します。

store.dispatch(AppAction.AddTodo(Todo.new()))

TODOを完了する場合はAppAction.CompleteTodoをStoreに渡します。

store.dispatch(AppAction.CompleteTodo(todo.copy(isCompleted = true)))

Reduxでは1つのStoreをアプリ全体で共有するため、どこかの画面でTODOが変更されると、それらが即座に全ての画面に伝わることになります。つまり、いいねボタン問題が解決されますね!

応用編

Middleware

Reduxでは状態変更の過程に任意の処理を挟むことができるようになっています。この仕組みはMiddlewareと呼ばれており、本家のReduxでは非常に多くのMiddlewareが開発されています。良くある例としては、Storeに渡されたActionや状態変更の過程をログ出力したり、StoreにActionを渡す直前で非同期処理を実行したり、といったものがあります。

redux.js.org

非同期処理

今回のサンプルアプリでは非同期処理は登場しませんでしたが、現代のアプリでは非同期処理が必要になる状況が多く、Reduxでも非同期処理を行うための仕組みが用意されています。ReduxではMiddlewareで非同期処理をハンドリングすることが多く、公式サイトではいくつかのアプローチが紹介されています。

redux.js.org

データの正規化

規模が大きくなるにつれてStateが保持するデータ量が増えていくいきますが、データ量が増えてくるとデータの一貫性を担保するのが困難になってきます。例えば、1つのTODOが色々な画面で参照される状況においては、同じオブジェクトが複数存在することになってしまいます。

この問題の解決策として、Stateの中身を正規化することでデータの重複を抑制し、データの一貫性管理を効率的に行えるようにする手法が存在します。この手法については私が以前に書いた記事があるので、もし興味がある方は以下の記事をご覧ください。

medium.com

エコシステム

Reduxはエコシステムが充実しており、様々な周辺ライブラリ・ツールが存在します。

React

Reduxはあくまで状態管理に特化しており、UIをどのように組むかは利用者に一任されています。一般的にはReactと組み合わせて使われることが多く、Reduxの公式サイトでもReactと連携が紹介されており、ReduxとReactは相性が良いことが伺えます。

AndroidにおけるReactは、Android JetpackのViewModelとDataBindingの組み合わせが考え方として近いのではないでしょうか。また、Reactという統一的なUIライブラリは存在しませんが、RecyclerViewなどのUIコンポーネントはDiffUtilを組み合わせることで差分更新を行うことができるため、Reactと同じ考え方をAndroidにも導入することは可能そうです。

reactjs.org

Redux DevTools

f:id:yuyakaido:20181222194241p:plain
reduxjs / redux-devtools

Redux DevToolsはChrome/Firefox拡張機能スタンドアロンアプリでReduxのStateツリーの変化をリアルタイムに表示し、直接値を編集したり、履歴から任意の状態に戻ったりといったことが可能になるツールで、開発作業を効率化することが可能になります。私が探した限りは、Androidにおいては類似のツールは存在しませんでしたが、本記事で紹介したReduxKitの一部として同様の機能を開発中です。

github.com

おわりに

本記事では、AndroidにおけるReduxという選択肢について解説してきました。

  • Android開発において、状態管理は悩ましい問題の1つである
  • Web開発で広く利用されているReduxは、状態管理に特化したアプローチの1つである
    • 全ての状態を1つのオブジェクトで管理することで統一的な状態管理が可能になる
    • 状態を読み取り専用で定義することで意図しない状態変更を防ぐ
    • 状態変更を純粋な関数で実装することで状態変更の予測可能性を高める
  • AndroidにもReduxの考え方を取り入れることで、状態管理にまつわる問題を解決できるのではないか

また、本記事で紹介した内容についてはDroidKaigi 2019でも発表予定です。 今回は詳細に紹介することができなかった内容も含めて50分枠で詳細に発表予定ですので、興味のある方は是非聞きに来てみてください!

f:id:yuyakaido:20181222222549p:plain