fresh digitable

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

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を受けとってそれぞれのイベントに変換するようなものなど)はまだ従来のままなので、なるべくシンプルな記述ですむ方法を考えたいと思っている。

*1:という言い方が正しいかどうかわからない。Javaでいうところのメソッド

ここまでの話をまとめてzennに投稿した

fluxとかMVIみたいな構造のアプリを作ってみたかったシリーズの主にその5と6をまとめたりまとめなかったりしてZennに投稿した。

zenn.dev

FeaturedにのったりPick upということで公式のTwitterアカウントで流れたりして、自分としては初めてのことなのでよかった。

以下、雑感。

  • いいね数:平日の深夜に投稿して数時間で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の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はもっと定型化できるように思う。

[追記] 続きです: akihito104.hatenablog.com

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

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

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