Android + Redux == 最高(かも)
とりあえず、サンプルコードです。tagを振っているので気になった人は見てください。
Androidを書いて数年が経ちました。
いつも何かに苦しみつつ書いてきましたが、現在一番気に入っているアーキテクチャの紹介をします。
私のAndroidアーキテクチャ変遷は以下のような感じ。
無法地帯 -> MVC -> MVVM -> Redux(イマココ)
導入
みなさまアプリケーションの状態管理のお悩みはありませんか?
以下のようなシチュエーションあるあるではないでしょうか。
ユーザによってボタンを出す出さないなど複雑な状態管理で頭がパンクする
設計時「管理者ユーザはこのボタンを押せるようにして、一般ユーザにはこのメッセージを見せて、一般ユーザでも仮登録中はこのメッセージは見せないようにして・・・」
実装時「うん?なるほどわからん。」
アプリケーションの状態と画面の状態が混ざってコードがぐちゃぐちゃに
実装時「アプリケーションがログイン中状態になったら、ボタンを非表示にしてー。ボタンが非表示になったらこっちのボタンも非表示にしてー。こっちのボタンが押されたらあっちのボタンの文字を変更してー」
テスト時「全部のボタンが押せない状態になった・・・。状態がどう波及しているのか分からん」
バグの報告が来たけど、その時の状態が分からない
頑張ってログを追うも重要な所に限ってログが出ていなかったり、欲しい情報が無かったりして原因究明が難航する。
その課題Reduxアーキテクチャで解決しよう
AndroidとRedux、そこにRxJavaを添えて
紹介するアーキテクチャのメリット
- アプリケーションの状態を一元的に管理できる
- 状態が変わった場合に、変わる前と変わった後の状態をログに吐ける(その気になれば状態のUndoも可能)
- 非同期ももちろん扱える
- 連続して複数のスレッドから状態が変更されても、前の処理が終わってから次の処理が実行される
- 処理が細かく分離しているので、テストが非常に書きやすい
紹介するアーキテクチャの辛いところ
- とにかくファイル数が増える
- 状態を持ちたがるviewライブラリを使うと状態の一元管理が崩れて辛い(e.g. ARCoreのsceneform)
- 学習コストが高め
少なくともRxJavaとAndroid JetpackのViewModelは知っている前提
紹介するアーキテクチャを簡単に言うと
- 1つのActivityと複数のFragmentからなる画面構成
- Reduxの流れをReactiveXのストリームに乗せる
- Reduxで問題になりがちな非同期処理はredux-observableライクに処理
キーワード
そもそもReduxとは何ですか?
今回の肝となる考え方。Reduxは主にReactで使われている状態管理の手法です。
Fluxを源流としていますが、異なる部分が多いので基本的には別物だと思っています。
https://stackoverflow.com/questions/45416237/axios-calls-in-actions-reduxより引用
Redux3原則
Reduxには3つの原則があり、これを守らなければなりません。Android風に解釈すると以下のようになります。
-
Single source of truth
1つのActivityにつき、Storeは1つとし、storeはstateを1つ保持する。(Activity:store:state = 1:1:1) -
State is read-only
stateの更新はできず、必ず再生成する。 -
Mutations are written as pure functions
stateを変更する関数(reducer)は副作用がない関数である。
※副作用がない関数とは、引数が決まれば出力結果が決まる関数のことである。内部でランダム関数を生成したり、前回の結果を使用したり、外部リソースへアクセスしたりしてはならない。
もしもAndroidエンジニアがReduxを適用したら
上の図を以下のようにAndroidに適用しました。
UI
ユーザのイベントを受付ける所です。AndroidではFragmentが該当します。(Activityでも良いのでは?という話もあるかと思いますが、今回はFragmentに絞ります)
イベントが発火することが上の図の"triggers"に該当します。
後述するstoreをViewModelとして保持します。
この時、複数のfragment間で状態の共有をしたいので、以下のようにStoreを呼び出します。
private val viewModel: Store by lazy {
ViewModelProviders.of(requireActivity()).get(Store::class.java)
}
Actions
Actionsはイベントの定義クラスです。
アプリケーション上の全てのイベントはここに定義します。
例えば、スタートボタンとストップボタンがあるアプリの場合は、Actionsは以下のような構成になります。
sealed class Actions {
// イベントにpayloadが不要な場合はobjectで定義
object StartButtonClicked: Actions()
// イベントにpayloadが必要な場合はdata classで定義
data class StopButtonClicked(val time: OffsetDateTime): Actions()
}
State
図の順序と異なりますが、Stateの説明をします。
Stateは文字通り、状態であり、アプリケーションの状態を持ちます。
例えば、「開始しているか」、「最後に停止した時間」の2つの状態を保持するStateは以下のように定義されます。
そして、これらの値は書き換え不可能です。
data class State(val isStarted: Boolean = false, val lastStopTime: OffsetDateTime? = null)
Reducer
ReducerはStateとActionsを受け取り、新たなStateを生成し返却する副作用がない関数です。
class Reducer {
fun reduce(state: State, action: Actions) {
return when(action) {
is StartButtonClicked -> state.copy(isStarted = true)
// kotlinのsmart castでaction.timeが使用可能
is StopButtonClicked -> state.copy(lastStopTime = action.time)
// seald classでActionsを定義したのでelseが不要
// Actionsが後で追加された時にreducerの更新を忘れるとコンパイルが通らなくなるので安心
}
}
Store
StoreはStateとReducerを保持します。
そして、FragmentからActionsを受け取り、それをReducerに渡し、Stateを上書きします。
class Store(
@Inject private val reducer: Reducer
): ViewModel {
private val _state = MutableLiveData(State()) // stateの初期状態を生成
val state: LiveData<State> = _state
// Actionsが渡されるとStateを更新する
fun dispatch(action: Actions) {
this._state.value = this.reducer.reduce(action)
}
}
dispatch関数は、引数のActionsをReducerに渡し、新しいStateを生成する。
新しいStateをStoreが保持しておき、アプリケーションの状態とする。
あとは、LiveDataとして公開されているStateをviewにdataBindingしてやればokです。
もしくは、Fragmentでobserve
して、viewを更新するようにしてもokです。
これで解決!第3部完!
本当にそうでしょうか?
こんな声が聞こえてきます。
ボタンが押されたらHTTPリクエスト送りたいんですけど・・・どこでやるんですか
このままだとそのような場所はありません。(絶望)
Reduxは状態管理に焦点を当てたアーキテクチャなのでネットワーク通信等の非同期処理のことは考慮されていません。
実装可能な場所はReducerくらいですが、Redux3原則の3番目に抵触するため実装してはいけません。
Reducerに重たい処理を書いたら画面が固まった
全てを同期的に処理しているので画面が固まってしまいます
StateをviewにdataBindingしてるんだけどStateが凄まじく肥大化した
Stateってアプリケーションの状態を保持するものだったはず。
全画面の全コンポーネントの状態を保持するのが本当にStateの役目なの?
React-Reduxでの解決方法
非同期処理の解決方法
いくつか解決方法が提案されていますが、(私が)筋が良いと思っているのはMiddlewareと言う概念を導入し解決する方法です。
そして、そのMiddlewareにRxjsを使っているのがRedux-observableです。
今回はこれを参考にAndroidでは非同期を倒していきます。
画面が固まる問題の解決方法
古来から重たい処理は別スレッドに追い出せと言われている。古事記にもそう書いてある。
そして、RxJavaがこれを解決してくれる。
Stateが肥大化する問題
Stateが画面を意識していることがおかしい。
ReduxではstateToProps
を用いて解決している。
stateToProps
はアプリケーションの状態から画面コンポーネントの状態に変換するメソッドである。
Androidでも同じようにしてみる。
Redux + Android + RxJavaで非同期を倒す
redux-observableを参考にRxJavaを用いて非同期処理を行います。
また、RxJavaであればバックグラウンドスレッドで処理を行うことができるので、2番目の問題も解決します。
まずはMiddlewareを導入する前に、StoreをRxJavaを用いたものに改造します。
fun <T> Publisher<T>.toLiveData() = LiveDataReactiveStreams.fromPublisher(this) as LiveData<T>
class Store(
@Inject private val reducer: Reducer
): ViewModel {
private val disposeBag = CompositeDisposable()
private val stream = BehaviorSubject.create<Actions>()
private val _state = BehaviorSubject.createDefault(State())
val state = LiveDataReactiveStreams.fromPublisher(_state.toFlowable(BackpressureStrategy.LATEST)) as LiveData<State>
init {
this.stream
.toFlowable(BackpressureStrategy.BUFFER) // 前の処理が終わるまで溜めて待つ
.observeOn(/* 任意のスレッド */) // メインスレッドで処理しない
.map { this.reducer.reduce(action = it, state = this._state.value!!) }
.distinctUntilChanged()
.subscribeBy(onNext = { newState ->
this._state.onNext(newState)
}).addTo(disposeBag)
}
fun dispatch(action: Actions) {
this.stream.onNext(action)
}
override fun onCleared() {
super.onCleared()
this.disposeBag.dispose()
}
}
これでメインスレッドが固まる問題は解決しました!
さらに、イベントが短期間に複数発火した場合にも前の処理が終わるまで待ってくれるようになりました。RxJavaは偉大だぁ
- observeOnでバックグラウンドスレッドを指定することで、メインスレッドの処理を妨げない。
- toFlowable(BackpressureStrategy.BUFFER)により、短時間に複数のActionがdispatchされても、前の処理が終わるまで待機してくれる。
次に、Reduxの構成要素のうちの一つ、Middleware
を導入し、非同期処理を解決します。
Middleware
MiddlewareはReducerの前に(あるいは並行して)Actionsを引数にとり、Actionsを返します。
一つのActionsから複数のActionsを返しても構いません。payloadを書き換えてもOKです。
ただし、ここでStateを更新してはいけません。絶対です。
例えば、前記したstartButtonClickedのActionが飛んできたら、HTTPリクエストを送信し、ResponseReceivedのActionsを流すMiddlewareは以下のように書けます。
class AsyncMiddleware @Inject constructor(
private val repository: NetworkRepository
) {
fun apply(
actionStream: Flowable<MainActivityActions>,
stateStream: Flowable<MainActivityState>
): Flowable<MainActivityActions> {
return doWhenPingButtonClicked(actionStream)
}
private fun doWhenStartButtonClicked(action: Flowable<MainActivityActions>): Flowable<MainActivityActions> {
val filtered = action.filter { it is Actions.StartButtonClicked }
.flatMap { action ->
Flowable.concat(
Flowable.just(action), // もともとのActionをそのまま流す
// その後にネットワークアクセスをし、結果を新しいActionのpayloadに乗せて流す
Flowable.create<MainActivityActions>({ emitter ->
emitter.onNext(
Actions.ResponseReceived(
this.repository.getNewStatus()
)
)
}, BackpressureStrategy.LATEST)
)
}
val other = action.filter { it !is MainActivityActions.PingButtonClicked }
return Flowable.merge(filtered, other)
}
}
ofTypeオペレータを使用してもう少しスマートに実装できる気もしますが、今回は目を瞑ってください。
Middlewareを使用することで、非同期処理を扱えるようになりました。
もう一つ、便利なMiddlewareの使い方を紹介しておきます。
Reducerの前に処理が挟まるので、log出力として便利です。
class LogMiddleware @Inject constructor() {
fun apply(
actionStream: Flowable<MainActivityActions>,
stateStream: Flowable<MainActivityState>
): Flowable<MainActivityActions> {
return actionStream
.withLatestFrom(stateStream)
.doOnNext { Log.d("tag", "before reducer. action=${it.first}, state=${it.second}") }
.map { it.first }
}
}
このようにログ出力Middlewareを挟むことで、全てのActionsとその時のStateをログに吐けます。
アプリケーションのバグはいつも思わぬところで起こるものです。Stateの変更を逐一吐いておけば、未知のバグへの対処もしやすくなります。
state肥大化問題への対策
3 stateにボタンの状態など、画面の構成情報を保持する必要があり、すぐにstateが肥大化する
そもそもstateはアプリの状態であったはず。画面の状態を保持すること自体がおかしい。
この課題を解決するためにstateToProps関数を作成します。この関数はアプリケーションの状態(state)から画面の状態(properties)に変換する関数です。
この関数を用いることで、stateはアプリケーションの状態のみを保持し、アプリケーションの状態から画面の状態に変換(stateToPropsで)し、それを画面に渡してやります。
この関数をどこに定義するのかはまだ私も悩んでいますが、とりあえずStoreに実装してみます。
以下の例ですと、stateが単純すぎてoffsetDataTime
を文字列に変換しているだけですが・・・
class MainActivityStore @Inject constructor(
private val reducer: Reducer,
private val logMiddleware: LogMiddleware
) : ViewModel() {
private val disposeBag = CompositeDisposable()
private val stream = BehaviorSubject.create<MainActivityActions>()
// stateは外に公開しない
private val _state = BehaviorSubject.createDefault(MainActivityState())
// その代わりPropsを公開する
val mainFragmentProps: MainFragmentProps = this.toMainFragmentProps()
init {
val actionStream = run {
val first = this.stream.toFlowable(BackpressureStrategy.BUFFER).observeOn(this.scheduler.io())
this.logMiddleware.apply(first, this._state.toFlowable(BackpressureStrategy.LATEST))
}
actionStream
.subscribeBy(onNext = { action ->
val newState = this.reducer.reduce(action = action, state = this._state.value!!)
if (this._state.value != newState) {
this._state.onNext(newState)
}
Timber.d("after reduce. action=$action, state=$newState")
}, onError = { error ->
Timber.e(error)
}, onComplete = {
Timber.i("MainActivityStore stream is closed.")
}).addTo(disposeBag)
}
fun dispatch(action: Actions) {
this.stream.onNext(action)
}
override fun onCleared() {
super.onCleared()
this.disposeBag.dispose()
}
// これがstateToProps関数
private fun toMainFragmentProps(): MainFragmentProps {
return MainFragmentProps(
lastStopTime = this._state
.toFlowable(BackpressureStrategy.LATEST)
.map { it.toString() } // stateからpropsに変換する処理を書く
.toLiveData()
)
}
}
最後に
Android + Reduxのアーキテクチャでアプリケーションの状態管理が容易になったと感じています。
改めて特徴を列挙しておきます。
- アプリケーションの状態を一元的に管理できる
-> Stateに状態を持つので実装者が管理しやすい - 状態が変わった場合に、変わる前と変わった後の状態をログに吐ける(さながら有限オートマトン)
-> ログ出力用Middlewareを使用することで、変更前のログを出力できる - 非同期ももちろん扱える
-> そうRxJavaならね - 連続して複数のスレッドから状態が変更されても、前の処理が終わってから次の処理が実行される
-> FlowableのBUFFERを使えば可能。RxJavaってすごい - 処理が細かく分離しているので、テストが非常に書きやすい
-> 特にReducerのテストが書きやすい。またstateToPropsも副作用が無いのでテストが書きやすい。
実はここまで書いていてある事実に気が付いてしまいました。
それは・・・
RxReduxって言うライブラリあるやんけ・・・
しかもかなり筋が良さそう。
("Android"や"Redux"、"Java"や"Kotlin"等のキーワードでは検索していましたが、まさか"RxRedux"とは・・・)
と言うわけで、次回はこのライブラリを試してみたいと思っています!