fresh digitable

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

NavController.currentBackstackEntryをみながら戻りたい画面までpopBackStack()する

前提とやりたいこと

関係しそうな環境:

"androidx.navigation:navigation-fragment-ktx:2.3.0"
"androidx.navigation:navigation-ui-ktx:2.3.0"

ツイッタークライアントを作っている。起動したらホームタイムラインを表示して、そこからツイートの詳細やユーザーのプロフィールのFragmentに遷移したり、会話を読んだりできる。また、自分で管理しているユーザーリストを表示して、そのユーザーリストのタイムラインを見ることもできる。他にもいくつか機能はあるけど、今回は割愛。

基本的な画面遷移の構成はいわゆるmaster/detail flowとかいうやつで、このアプリでは次の3つの遷移ができるようになっている。

  • リスト(master)→詳細(detail)
  • 詳細(detail)→リスト(master) 例:会話とかタグで検索(未実装だけどこれから作る)とか
  • リスト(master)→リスト(master) 例:会話とかユーザーリスト(ナビゲーションドロワーから飛べる)とか

リストのFragmentにはどのエンドポイントを叩くかを決めるパラメータをArgumentとして渡している。論理的には無限に画面遷移できるので、たくさん画面遷移した後でも簡単にホームタイムラインに戻ってこられるように、ナビゲーションドロワーでホームタイムラインへ遷移する機能を作りたい。

困ったことと解決策

普通にandroidx.navigationのNavController.popBackstack(Int, Boolean)を呼ぶと最初に見つかったFragmentまでしか戻れない。

setGraph()をやる時にバックスタックの状態をクリアするかな?と考えて内部の処理を読んでみたら、今まで持ってたグラフのIDとinclusive: trueを渡していたので、inclusive: falseに変えてやってみた。すると画面自体は巻き戻るものの、バックスタックの状態が変わった時に呼ばれるコールバックが呼ばれず、画面の状態に追従できなくなっていた。

実際のところ、これはナビゲーショングラフのルートまで戻る処理であって、ホームタイムラインのFragmentNavControllerのバックスタックからポップされている。ここでようやく、NavControllerのバックスタックというのがFragmentのバックスタックとは違うのだという事に気づいた。

NavControllercurrentBackstackEntryというプロパティを持っていて、これがNavControllerが管理しているバックスタックである。私はこれをFragmentが管理しているバックスタックエンティティ(か、あるいはそれに追従するもの)だと思っていたのだが実際のところはそこまで関連がない。

currentBackstackEntryFragmentのIDや渡したArgumentを持っている。また、popBackStack()を呼んだらすぐに変わるので、ホームタイムラインを表示するためのクエリパラメータを持ったBackstackEntryが出てくるまでpopBackStack()を呼び続ければよい。

github.com

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本でもいいから書けるようになりたい。