🤔

ViewModelが持っているViewの状態を更新する処理をまとめたい

2021/04/08に公開

動機付け

実際にはもう少しひどくて複雑だけど、私はこれまで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.coroutinesFlowを返してくると想定して、イベントストリームのインタフェースも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