fluxとかMVIみたいな構造のアプリを作ってみたかった その7
前回: akihito104.hatenablog.com の最後に、
とくにActionsはもっと定型化できるように思う。
というようなことを書いた。ここまでの実装は、
class HogeActions( private val dispatcher: EventDispatcher, ) : HogeEventListener { val selectAccount: Flow<HogeEvent.AccountSelected> = dispatcher.toActionFlow() // EventDispatcherから所望のAppEventを取り出して下流に流すFlowを作る関数 override fun onAccountSelected(account: UserAccount) { dispatcher.post(HogeEvent.AccountSelected(account) } val login: Flow<HogeEvent.LoginClicked> = dispatcher.toActionFlow() override fun onLoginClicked() { dispatcher.post(HogeEvent.LoginClicked) } ... }
のように、イベントバスにイベントを流す処理と、イベントバスを監視して所望のイベントオブジェクトが流れてきたら下流に流す処理とを別々に書いている。それぞれが対応しているということをお互いに近づけて書くことで表現しているのだが、流すイベントのクラスと受け取るイベントのクラスを取り違える危険性があるし、同じような言葉を何度も書かなければならず冗長すぎる。本家Cycle.jsのIntentクラスのようにすっきりさせたい。例えば次のようなイメージで、プロパティの方だけ書けばいいというようなもの。
val selectAccount: ??? = dispatcher.to??? { account -> HogeEvent.AccountSelected(account) } val login: ??? = dispatcher.to???(HogeEvent.LoginClicked)
EventListener
の関数でもありFlow
でもあるようないい感じのクラスと、それを作るファクトリ関数を考えたい。関数をfun
として*1定義するとこのようにはできないので、関数型のクラスを定義して置き換えてみる。例えば、
interface AppEventListener { fun dispatch() } interface AppAction<E : AppEvent> : AppEventListener, Flow<E : AppEvent> fun <E : AppEvent> EventDispatcher.toAction(event: E): AppAction<E> { return object : AppAction<E>, Flow<E> by this.toActionFlow() { override fun dispatcher() { this@toAction.post(event) } } }
のようなinterface
を定義して、ついでにEventDispatcher
からAppAction
を作る便利関数を作ると、例で挙げたHogeActions
クラスは次のように書ける。
interface HogeEventListener { - fun onLoginClicked() + val login: AppEventListener ... } class HogeActions( private val dispatcher: EventDispatcher, ) : HogeEventListener { ... - val login: Flow<HogeEvent.LoginClicked> = dispatcher.toActionFlow() + override val login = dispatcher.toAction(HogeEvent.LoginClicked) - override fun onLoginClicked() { - dispatcher.post(HogeEvent.LoginClicked) - } ... }
ちなみに、DataBindingでレイアウトリソースからEventListener
のプロパティを呼び出すと android:onClick="@{ viewModel.login.dispatch() }"
のような感じになる。レイアウトリソースファイルはJavaの世界なので、AppEventListener
の中の関数をoperator fun invoke
にしてもinvoke
と書かなければならない。invoke
でもいいような気もするけどEventDispatcher
でも使っている言葉をここでも使うことにしてdispatch
を選んだ。
EventListener
の関数が引数をとるような場合も同様に、と言いたいところだけどいろいろ考えだしたらよく分からなくなってしまったので、とりあえず引数を1つだけとるものを用意してみた。
interface AppEventListener1<T> { fun dispatch(t: T) } interface AppAction1<T, E : AppEvent> : AppEventListener1<T>, Flow<E : AppEvent> fun <T, E : AppEvent> EventDispatcher.toAction(block: (T) -> E): AppAction1<T, E> { return object : AppAction1<T, E>, Flow<E> by this.toActionFlow() { override fun dispatcher(t: T) { val event = block(t) this@toAction.post(event) } } }
このようなinterface
及びファクトリ関数を使うことで、次のように書ける。
interface HogeEventListener { - fun onAccountSelected(account: UserAccount) + val selectAccount: AppEventListener1<UserAccount> ... } class HogeActions( private val dispatcher: EventDispatcher, ) : HogeEventListener { - val selectAccount: Flow<HogeEvent.AccountSelected> = dispatcher.toActionFlow() + override val selectAccount = dispatcher.toAction { account: UserAccount -> + HogeEvent.AccountSelected(account) + } - override fun onAccountSelected(account: UserAccount) { - dispatcher.post(HogeEvent.AccountSelected(account) - } ... }
少しはすっきりしただろうか。それとも悪乗りがすぎるだろうか。引数を2つ以上とるものや、1つの関数から複数種類のイベントを発生させるもの(MenuItem
を受けとってそれぞれのイベントに変換するようなものなど)はまだ従来のままなので、なるべくシンプルな記述ですむ方法を考えたいと思っている。
ここまでの話をまとめてzennに投稿した
fluxとかMVIみたいな構造のアプリを作ってみたかったシリーズの主にその5と6をまとめたりまとめなかったりしてZennに投稿した。
FeaturedにのったりPick upということで公式のTwitterアカウントで流れたりして、自分としては初めてのことなのでよかった。
🤔 ピックアップ ✨
— Zenn公式 (@zenn_dev) 2021年4月9日
ViewModelのプロパティを更新する処理を一つにまとめたい
by @akihito104https://t.co/8nYGGW44x3
以下、雑感。
- いいね数:平日の深夜に投稿して数時間で2、朝になってもう2、全部で4。決して多くはないけど反応がもらえるのがよかった。
- いいねされつくした後にGAを入れた:いわゆるPVは40ぐらい、平均エンゲージメント時間は11秒らしい。ちゃんと計れてるのか分からないけどあんまり読まれてない様子で厳しい。
- AndroidとKotlinのtrendingにはもうしばらく残り続けそうなのでまだ何か起きるかな?
- タイトルとか内容とかをもう少し見直した方がいいだろうか。やりたいことが伝わっているのか分からない。こんなんMVIじゃないやろ的なツッコミがあるかなとも思っていたけどそこまで優しくなかった。
- 今年はもう少しZennに書いていきたいと思っている。いろんな人の役に立てる記事を1本でもいいから書けるようになりたい。
fluxとかMVIみたいな構造のアプリを作ってみたかった その6
前の記事: 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