前の記事: akihito104.hatenablog.com
View
の状態の更新処理はViewModelSource
で行っている。このクラスでの過去の実装を振り返り、反省点を踏まえてリファクタリングした時のことについて書く。その5の内容に強く依存しているので先にそちらを読むことをお勧めする。
これまでのこと
UIのイベントはRxのPublishSubject
をイベントバスにして流している。また、Repositoryの関数はLiveData
を返すようにしていたが、インタフェースをsuspend
関数にしたりFlow
を返すように統一していくことにした。この移行中はRxのObservable
、Flow
、LiveData
と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 }
ここからさらにプロパティが増えていくと依存関係も複雑になって壊れやすくなるし、書く順番にも気を配らなくてはならない。また、見た目では何となく上から順に処理が実行されそうだが、実際にはActivity
やFragment
から監視しているやつが間に割り込んだりしてくる。
反省点
- イベントソースの型が複数種類ある: これは全部
Flow
にそろえた。ViewModelSource
はFlow
しか取り扱わない。Observable
はActions
の中でFlow
に変換しておく。 - プロパティごとに変更処理を書くと複雑になる:
LiveData
とかFlow
をどんどん連結していくより、data class
の中にまとめて書いた方が関連性が分かりやすい。
data class State(val user: User) { val isIconVisible: Boolean get() = user.iconUrl != null ... }
イベントソースをFlow
に統一して簡単に合流させられるようにし、別々のプロパティに分かれていたView
の状態を一つのクラスにまとめることにした。これで、あるイベントが流れてきた時、現在の状態を見て新しい状態オブジェクトを作れるようにしたい。
改善のアイディア
Flow
のオペレータではrunningReduce
やscan
あたりが適当と思われる。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
の部分だけを書けばいいように便利関数とかを作る。
- 実際のコード: implement account switch by akihito104 · Pull Request #211 · akihito104/UdonRoad2 · GitHub
- 使用例: implement account switch by akihito104 · Pull Request #211 · akihito104/UdonRoad2 · GitHub
flatMap的な仕組み
Flow<Event>
→ Flow<suspend (State) -> Flow<State>>
→ Flow<suspend (State) -> State>
のようなケースにも対応したかったので、Flow.scan()
に機能を足したようなものを作ることにした。Repositoryのデータソースではなく状態のFlow
を受けて新しいFlow
を作るということにしたい。現在のState
を流すために今回はStateFlow
を使うことにしたがもっといい実装があるような気もする。
- 実際のコード:add flatMap to state source by akihito104 · Pull Request #227 · akihito104/UdonRoad2 · GitHub
- 使用例: add flatMap to state source by akihito104 · Pull Request #227 · akihito104/UdonRoad2 · GitHub
使っていて今のところ困ったことは起きていないが、もっと込み入ったことをやらせようとすると何か起きてしまうかもしれない。
まとめ
あまり洗練できていないけど、ユーザーがこれをしたときこうする、とか、このデータソースを監視して値が変わったらこれする、みたいな処理をまとめられて以前より*3はわかりやすくなったんじゃないかと思う。
すでに世に出ているAndroidのMVIのフレームワークと見比べると、やっていることが違いすぎてとてもじゃないけどMVIをやっていますとは言えない。記述量も圧倒的に多いのでもうちょっと考えたい。とくにActions
はもっと定型化できるように思う。
[追記] 続きです: akihito104.hatenablog.com