fresh digitable

めんどくさかったなってことを振り返ったり振り返らなかったりするための記録

fluxとかMVIみたいな構造のアプリを作ってみたかった その6

前の記事: akihito104.hatenablog.com

Viewの状態の更新処理はViewModelSourceで行っている。このクラスでの過去の実装を振り返り、反省点を踏まえてリファクタリングした時のことについて書く。その5の内容に強く依存しているので先にそちらを読むことをお勧めする。

これまでのこと

UIのイベントはRxのPublishSubjectをイベントバスにして流している。また、Repositoryの関数はLiveDataを返すようにしていたが、インタフェースをsuspend関数にしたりFlowを返すように統一していくことにした。この移行中はRxのObservableFlowLiveDataと3種類のイベントソースが入り乱れており相互運用に悩んだりした*1

イベントソースの多様さもさることながら、プロパティの更新処理にも課題があった。当初、プロパティの更新処理は、関連するイベントソースからのイベントを個々に受けて更新するというスタイルだった。個々のイベントソースやプロパティの中には依存関係があり、次のような感じで数珠つなぎにしたり、分岐させたり合流させる必要があった。

val user: LiveData<User> = repository.getUserSource(userId)

val isIconVisible: LiveData<Boolean> = user.map { it.iconUrl != null }

val isFollowerOfCurrentUser: LiveData<Boolean> = combine(  // combine() は2つのLiveDataを監視するプロジェクトオリジナルの関数
  appSettings.currentUserIdSource, 
  user
).switchMap { currentUserId, u ->
  relationshipRepository.getRelationship(currentUserId, u.id)
}.map {
  it.isSourceUserFollowedByTargetUser
}

ここからさらにプロパティが増えていくと依存関係も複雑になって壊れやすくなるし、書く順番にも気を配らなくてはならない。また、見た目では何となく上から順に処理が実行されそうだが、実際にはActivityFragmentから監視しているやつが間に割り込んだりしてくる。

反省点

  • イベントソースの型が複数種類ある: これは全部Flowにそろえた。ViewModelSourceFlowしか取り扱わない。ObservableActionsの中でFlowに変換しておく。
  • プロパティごとに変更処理を書くと複雑になる: LiveDataとかFlowをどんどん連結していくより、data classの中にまとめて書いた方が関連性が分かりやすい。
data class State(val user: User) {
  val isIconVisible: Boolean get() = user.iconUrl != null
  ...
}

イベントソースをFlowに統一して簡単に合流させられるようにし、別々のプロパティに分かれていたViewの状態を一つのクラスにまとめることにした。これで、あるイベントが流れてきた時、現在の状態を見て新しい状態オブジェクトを作れるようにしたい。

改善のアイディア

FlowのオペレータではrunningReducescanあたりが適当と思われる。Flow.scan()は初期値を与えられるので今回はこちらを使った。完成のイメージは

val state: Flow<State> = merge(
  actions.event1,
  actions.event2,
  repository.getSource(...), 
  ...
).scan(State()) { s, e ->
  when(e) {
    is E1 -> ...
    is E2 -> ...
    ...
  }
}

みたいな感じ。だた、Actionsでせっかく分けたのにmergeして再びwhen文で場合分けするのはかなり無駄なので工夫する必要がある。

どのイベントソースのFlowも自分がきっかけとなって状態を更新したいという思いは一つのはず。では、状態を更新する方法(関数)に変換して流したらどうか。中でsuspend関数を呼びたいので、

Flow<Event> -(何かして)→ Flow<suspend (State) -> State>

のようにしてscan()に渡す方向で考えてみる。また、イベントが流れてきた時に現在の状態と組み合わせて次の状態のオブジェクトを作りたいので、(何かして)の部分は

suspend (State, Event) -> State

みたいなものが必要*2。これらの条件を満たすものは次のような感じ。

val scanFun: suspend (State, Event) -> State = { s, e -> s.copy(...) /* 例として */ }

val update: Flow<suspend (State) -> State> = eventSource.mapLatest { event -> 
  val updateFun: suspend (State) -> State = { s -> scanFun(s, event) }
  updateFun
}

val state: Flow<State> = merge(update)
  .scan(State()) { s, u -> u(s) }

あとはscanFunの部分だけを書けばいいように便利関数とかを作る。

flatMap的な仕組み

Flow<Event>Flow<suspend (State) -> Flow<State>>Flow<suspend (State) -> State>

のようなケースにも対応したかったので、Flow.scan()に機能を足したようなものを作ることにした。Repositoryのデータソースではなく状態のFlowを受けて新しいFlowを作るということにしたい。現在のStateを流すために今回はStateFlowを使うことにしたがもっといい実装があるような気もする。

使っていて今のところ困ったことは起きていないが、もっと込み入ったことをやらせようとすると何か起きてしまうかもしれない。

まとめ

あまり洗練できていないけど、ユーザーがこれをしたときこうする、とか、このデータソースを監視して値が変わったらこれする、みたいな処理をまとめられて以前より*3はわかりやすくなったんじゃないかと思う。

すでに世に出ているAndroidのMVIのフレームワークと見比べると、やっていることが違いすぎてとてもじゃないけどMVIをやっていますとは言えない。記述量も圧倒的に多いのでもうちょっと考えたい。とくにActionsはもっと定型化できるように思う。

*1:イベントソースがObservableやLiveDataだとsuspend関数をどうやって呼ぶのがいいのかなど

*2:関数型のプログラミングを勉強してたらその用語とかを使ってもっといい感じに説明できるんだろうか

*3:当社比ってことです