1
Help us understand the problem. What are the problem?

posted at

Organization

Unidirectinal data flow をまとめる

前書き

jetpack composeには、「状態」をうまく扱う仕組みが整っている。この仕組みを構築している思想に単方向データフロー(Unidirectinal data flow)がある。ドキュメントの状態ホイスティングのところにもさっくり書いてある。
PureなUnidirectinal data flow を知りたくなったので(そんなものがあるのかも含め)、以下参考に超シンプルに実装した。

Unidirectional data flow on Android using Kotlin: The blog post
(part1 ~ part3)

Unidirectinal data flow

Unidirectional data flowとはざっくりいうと。
「何をしたいか」 を「Action」で定義し、
「Action」に応じて「State」を変化させ、
「State」の変化を「View」に反映させる。
Actionが各種クラスを横断し、Storeと呼ばれるドメイン層にて新しいStateが作成される。作成されたStateはViewにCallback等で通知される。このActionの動きとStateの動きがそれぞれ単方向なので、その名前がついている、(と思ってます。)

Redux と Flux というFaceBookが作ったアーキテクチャがBaseになっているが、この辺はここでは書かない。

1_RQtbkAovDjoJWilUbVsgGw.png

Components

  • ControllerView
  • Store
  • Reducer
  • Action
  • State
  • SideEffect

ざっくり以下。

ControllerView

ビジネスロジックとUiUxを仲介。ViewModel(というよりPresenter)と似たようなもの。

Store

このアーキテクチャのCore。ビジネスロジックの記載場所。CleanArchitecterのDomainと似たようなもの。

Action

何をするか。プログラムに何をしてほしいか。を定義。
(ex:CreateItemAction, DeleteItemAction, UpdateItemWithColorAction..etc)

State

アプリの状態であり、Dataの状態であり、UIの状態であり、時間とともに変化するもの全てのうち、扱うべきものを定義する。

Reducer

Actionと現在のStateから、新しいStateを作るもの。

SideEffect

API通信とData Baseロジック。

ActionとStateは図で言うと矢印であり、controllerViewはお馴染みのViewmodelやPresenterと役割が似ているので、Store以降と矢印部分を意識して理解できれば問題ない。

コード

全体は、gitに

シンプルにボタンを押したらインジケータを表示、5秒後にコンテンツを表示する動きやってます。動きが分かればOKなコードです。
ランダムアップデートとか言いつつ、生成されるオブジェクトは固定。

untitled.gif

State と Action

state.kt
enum class UiState {
    Loading,
    Show
}
data class State (
    val contents: List<Content> = emptyList(),
    val uiState: UiState = UiState.Show
)
action.kt
sealed class GenerateAction: Action() {
    class GenerateTodo: GenerateAction()
}
sealed class LoadAction: Action() {
    data class LoadContent(val todos: List<Content> = emptyList()): LoadAction()
}

ActionとStateの実装。Actionが各レイヤを行き来するので、度々出てきます。

View → controllerView

composable.kt

...

OutlinedButton(onClick = {
  controllerView.onEvent(ContentListEvent.UpdateTodoList)
}) {
...

Unidirectinal Data flowのプロセスを開始点。
ContentListEvent.UpdateTodoListはユーザイベントを定義したsealed class。なんのEventが起こったかの識別子。
onEventでユーザ操作の発火が伝わる。

ControllerView → Store

controllerView.kt

...

override fun onEvent(userEvent: UserEvent) {
        when(userEvent) {
            is ContentListEvent.UpdateTodoList -> store.dispatch(GenerateAction.GenerateTodo())
        }
    }

...

ViewからのEventに応じて、storeへActionを送付。Event→Actionの変換が責務。
VtoStore.png

Store

store.kt
...
private var _state = MutableStateFlow( State() )
    val state = _state
override fun dispatch(action: Action) {
        _state.value = reduce(action, _state.value)
        sideEffect?.handle(action)
    }

private fun reduce(action: Action, currentState: State): State {
        return when(action) {
            is LoadAction -> ShowReducer.reduce(action, currentState)
            is GenerateAction -> GenerateReducer.reduce(action, currentState)
        }
    }

...

reduce(action: Action, currentState: State)
StateとActionから、新しいStateを作成。その変換ロジックはReducerがもつ。
Api通信やDatabase操作など重たい処理はSideEffectで行うので、Reducerはそれ以外(今回のコードだとインジケータの状態)を新しいStateに変更↓。

GenerateReducer.kt
...
override fun reduceUiState(action: GenerateAction, uiState: UiState): UiState {
        return when(action) {
            is GenerateAction.GenerateTodo -> UiState.Loading
        }
    }
...

GenerateReducer.reduceで↑の関数を呼び、UiStateをShow→Loadingに変更(インジケータの表示に使用。)して、新しいStateを返却。

StoH.png

Store → ControllerView

store.kt
...

private var _state = MutableStateFlow( State() )
    val state = _state

...

flowでcontrollerViewへ公開。

controllerView → View

controllerview.kt
...
private var _uiState = MutableStateFlow(UiState.Show)
    val uiState = _uiState
...

init {
        GlobalScope.launch {
            store.state.collect {
                _uiState.value = it.uiState
                ...
            }
        }
    }

...

同じく、View側にflowで公開。

View

val uiState by controllerView.uiState.collectAsState(initial = UiState.Show)

あとは再コンポーズしてもらえばOK.インジケータ表示のようなSideEffectが絡まないシンプルな制御。
VtoH.png

Actionが登っていき、Stateが返っていくのがわかる。

Store ⇄ SideEffect

ちょっと上で書いた、Storeの続き。
Side Effectの処理。

store.kt

sideEffect?.handle(action)

actionに応じて、重い処理。

sideEffect.kt
...
override fun handle(action: Action) {
        when(action) {
            is GenerateAction.GenerateTodo -> GenerateHandler.handle(action) { store.dispatch(it) }
            else -> Unit
        }
    }
...
GenerateHandler.kt
...
override fun handle(action: GenerateAction, actionDispatcher: (Action) -> Unit) {
        when(action) {
            is GenerateAction.GenerateContent -> {
                GlobalScope.launch {
                    delay(5000) // 重い処理
                    actionDispatcher.invoke(LoadAction.LoadContent(generateMockContent()))
                }
            }
        }
    }
...

actionDispatcherで処理結果をstoreへ(ここでは、generateMockContentが結果)
新しいデータ付きのActionがStoreへ流れる。
sideeffect.png

store#dispach(action)がLoadActionで動くので、
store#reduceでそのAcrionに応じた新しいStateが作成される

stoside.png

flowで公開しているStateが更新 される

private var _state = MutableStateFlow( State() )
    val state = _state

監視しているflowの更新によって再コンポーズが起こる

val contents by controllerView.contents.collectAsState(initial = emptyList()

の流れが動きます。

all.png

StoreへActionが向かい,ReducerでStateに変換され、更新されたStateがViewに通知されている。

終わり

Thread制御が必要なら、引数でThreadExcuterやらCroutine Scopeを送れば、制御が容易かと。テストもかける。
Viewの値(TextFieldの文字列や、トグルのTrueFalse)をStore側で処理したいのであれば、Actionに持たせてStoreへ伝達させる。
SideEffect、思想的には任意らしいのでnull許容。SideEffectが必要のないアプリはないと思いますが。

2021年のDroidKaigiのソースも、この考え方を使っているので、理解の一助になればなあ。。と学習。
最近主流?のFlow + JetpackComposeを使うアプリでは使いやすいと感じてます。

以上。
個人的な理解が主なので、ご意見いただけると嬉しいです。

Register as a new user and use Qiita more conveniently

  1. You can follow users and tags
  2. you can stock useful information
  3. You can make editorial suggestions for articles
What you can do with signing up
1
Help us understand the problem. What are the problem?