ViewModelが持っているViewの状態を更新する処理をまとめたい
動機付け
実際にはもう少しひどくて複雑だけど、私はこれまでViewModel
の実装はだいたいこんな感じでやってきた(レイアウトリソースのファイルからapp:enabled="@{viewModel.state.isEnabled}"
とかapp:onClick="@{v -> viewModel.onButtonClicked()}"
みたいに使うのを想定している)。
class MainViewModel(
getCurrentUserSource: GetCurrentUserSourceUseCase,
...
) : ViewModel() {
private val currentUser: Flow<User> = getCurrentUserSource()
private val _state = MutableStateFlow<State>(State())
val state: LiveData<State> = combine(
_state,
currentUser
) { s, u ->
State(state = s, currentUser = u) // s.copy(...) でもいいかも
}
.asLiveData(viewModelScope.coroutineContext)
fun onTextUpdated(text: String) {
val currentState = _state.value
_state.value = currentState.copy(text = text)
}
fun onSendTextClicked() {
...
}
...
}
UIのイベントを起点にして状態を更新する処理と、データソースの更新を起点にして状態を更新する処理とが別々になっている。例に挙げたコードだとそこまででもないけど、プロパティやデータソースが増えるとそれぞれの間で依存関係ができ始めたりして、関係を読み解くのに上に行ったり下に行ったりしないといけない。書いているときはこんなもんだと思っていても、後になって読み返したときにアレ?ってなる。このプロパティはこのイベントが起きた時にこうやって更新する、というのをいい感じにまとめて書きたい。
step1: UIイベントをストリームにする
UIのイベントリスナーとして使っている関数が呼び出されたときにイベントオブジェクトを生成して、UIのイベントストリームに流す。関数の中でやっている処理は、ストリームの行き着く先であるプロパティを更新する処理の箇所へ移すことになる。
UIイベントのストリームの実装はデータソースで使っているものに合わせる。今回はRepositoryがkotlinx.coroutines
のFlow
を返してくると想定して、イベントストリームのインタフェースもFlow
になるようにする。UIイベントごとにストリーム(今回だとChannel
)を作ってもいいし、共通のイベントバスを用意して、受け取ってからwhen
などを使って分岐させてもよい。
+ private val uiEventChannel = Channel<Event>()
+ data class Event(val text: String) // sealed classにすることが多いと思うが今回は省略
fun onTextUpdated(text: String) {
- val currentState = _state.value
- _state.value = currentState.copy(text = text)
+ uiEventChannel.sendBlocking(Event(text))
}
viewModelScope
の中でchannel.send(...)
をしてもよさそう。
step2: イベントを状態を更新する関数に変換する
step1で作ったイベントストリーム(Channel<Event>
)を、状態を更新する関数のストリームに変換する。関数に変換するというのがミソ。
val updateText: Flow<(State) -> State> = uiEventChannel.receiveAsFlow().map { event ->
{ state: State -> state.copy(text = event.text) }
}
val updateCurrentUser: Flow<(State) -> State> = currentUser.map { u ->
{ state: State -> state.copy(currentUser = u) }
}
本当はsuspend関数にしたいということもあるのでもうちょっと書くことは増えるけど今回は省略。ここではstate.copy(...)
の部分だけ書ければいいので、次のような関数:
fun <STATE, EVENT> Flow<EVENT>.onEvent(
block: (STATE, EVENT) -> STATE
): Flow<(STATE) -> STATE> {
return map { e: EVENT -> { s: STATE -> block(s, e) } }
}
を用意すると次のように書ける。
val updateText: Flow<(State) -> State> = uiEventChannel.receiveAsFlow().onEvent { state, event ->
state.copy(text = event.text)
}
val updateCurrentUser: Flow<(State) -> State> = currentUser.onEvent { state, u ->
state.copy(currentUser = u)
}
step3: mergeでまとめてscanで更新する
Flow.merge()
を使ってFlow<(State) -> State>
をまとめ、Flow.scan()
のなかで(State) -> State
を実行して状態を更新する。scan
には初期値を渡せる。その代わりにrunningReduce
を使ってもいいケースがあるかもしれない。
val state: Flow<State> = merge(
update,
updateCurrentUser,
...
).scan(State()) { state, update ->
update(state)
}
merge
とかscan
の部分は決まりきったコードなので
fun <S> stateBuilder(init: S, vararg updates: Flow<(S) -> S>): Flow<S> {
return merge(updates).scan(init) { s, u -> u(s) }
}
みたいなのを用意すると
val state: Flow<State> = stateBuilder(
init = State(),
update,
updateCurrentUser,
...
)
のように書ける。update
とかupdateCurrentUser
を展開すると次のようになる。
val state: Flow<State> = stateBuilder(
init = State(),
uiEventChannel.receiveAsFlow().onEvent { state, event ->
state.copy(text = event.text)
},
currentUser.onEvent { state, u ->
state.copy(currentUser = u)
},
...
)
extra step: イベントリスナーの分離
MVIのIntentを参考にして、ここまでに実装したViewModelのイベントリスナーとストリームの実装をViewModel
の外に出す。イベントリスナーとして定義した関数をinterface
として改めて定義し、ViewModel
はそれを実装したクラスに処理を委譲する。MVIに倣ってuiEventChannel.receiveAsFlow()
の部分をユーザーの意図を表す言葉(writeMessage
)に置き換えたりして次のように実装する。
interface MainEventListener {
fun onTextUpdated(text: String)
fun onSendTextClicked()
...
}
class MainIntent : MainEventListener {
private val writeMessageChannel = Channel<State>()
val writeMessage: Flow<Event> = writeMessageChannel.receiveAsFlow()
override fun onTextUpdated(text: String) {
writeMessageChannel.sendBlocking(Event(text))
}
...
}
class MainViewModel(
action: MainIntent,
currentUser: GetCurrentUserSourceUseCase,
...
) : MainEventListener by action,
ViewModel() {
val state: LiveData<State> = stateBuilder(
init = State(),
action.writeMessage.onEvent { state, event ->
state.copy(text = event.text)
},
currentUser().onEvent { state, u ->
state.copy(currentUser = u)
},
...
)
.asLiveData(viewModelScope.coroutineContext)
}
イベントを受け付ける際の引数チェックや、イベントオブジェクトを作る際に現在の状態を使いたいということがあるかもしれないが、関数を呼び出す時に状態を渡してもらったり、onEvent { ... }
の中でチェックするとよい。
このプロパティはこのイベントが起きた時にこうやって更新するという形に少しは近づいただろうか。初期値を作るときにsuspend
関数を呼びたいとか、flatMap
的なことをやりたいという時にはさらなる工夫が必要かもしれない。
Discussion