Jetpack Compose + Navigation で再コンポーズが無限に繰り返されてしまった

はじめに

アソビューでアプリ開発やバックエンド開発をしている上中です。
アソビューはWebとアプリ両方でサービス展開していますが、アプリの利用を促すために一部のURLに対してApp Linksでアプリを起動するようにしています。
今回は、そこで見つかったバグの原因と対策について共有したいと思います。同じ問題にぶち当たっている方の参考になれば幸いです。


事象

前述の通り、アソビューのアプリは一部のURLに対してApp Linksでアプリを起動するようにしています。
しかし、App Links経由でアプリを起動した際、アプリの画面描画が無限に繰り返されてしまい、いつまで経ってもTOP画面が表示されないというバグが見つかりました。

自分の手元では再現しなかったため特定の端末あるいはバージョンでだけ発生するレアな問題だと考えていましたが、アプリの利用者をどんどん増やしたい今の状況において、もし頻繁にApp Links経由の起動がうまくいかないとすると大きな問題だったため、念の為優先的に調査を行ったところ、コンポーズ中の副作用による描画の無限ループが原因でした。


コードの構造と原因

バグ発生時のコードは以下のようになっていました。
AppLinksDestinationで定義されているURIに合致する場合、そのURIを解析して遷移先の画面を特定し、navigateで遷移させます。

fun NavGraphBuilder.appLinksGraph(
    navigateToTop: () -> Unit
) {
    composable(
        ・・・
    ) {
        ・・・
        intent?.data?.toString()?.let { url ->
            when {
                url.matches(AppLinks.Top.regex) -> navigateToTop()
                ・・・
            }
        }
    }
}

・・・

fun NavController.navigateToTop() {
    navigate(TopDestination.route)
}

navigateToTop()は名前の通り、NavControllerのnavigate関数を使って画面遷移させる関数です。今回の直接的な原因は、このnavigateToTop()をコンポーズ可能な関数の中から呼んでいることでした。


なぜ問題なのか?

@Composableが付いた関数(コンポーズ可能な関数)は、何度呼ばれても同じ結果となるよう実装しておく必要があります。つまり、API呼び出しやデータ永続化、State(状態)の変更などの副作用を生じさせる処理を書くべきではありません。
なぜなら、コンポーズ可能な関数には以下のような特徴があるからです。

  • 再コンポーズは、可能な限り多くのコンポーズ可能な関数とラムダをスキップする。
  • 再コンポーズは厳密なものではなく、キャンセルされる場合がある。
  • コンポーズ可能な関数は、アニメーションのフレームごとに何度も実行される場合がある。
  • コンポーズ可能な関数は並行して実行できる。
  • コンポーズ可能な関数は任意の順序で実行できる。

(出典:https://developer.android.com/develop/ui/compose/mental-model?hl=ja)
副作用をコンポーズ可能な関数の中に書いてしまうと、副作用がスキップされたり、逆に想定以上に何度も実行されたりする可能性があります。
そして今回のケースでは、コンポーズ可能な関数の中でnavigate()を呼んでいました。
navigate()は、内部で_visibleEntriesというMutableStateFlowに書き込みを行っています。この_visibleEntriesのStateFlow、visibleEntriesを、NavHostがcollectAsState()でSubscribeしています。

// Navhost.kt
val allVisibleEntries by navController.visibleEntries.collectAsState()

つまりnavigate()を呼ぶと_visibleEntriesが書き込まれ、それをSubscribeしているNavHostの再コンポーズが実行されます。

NavHostが再コンポーズされたことでappLinksGraphも引きずられて再コンポーズされ、再びnavigateToTop()→navigate()→再コンポーズ→またnavigateToTopが呼ばれる・・・
が永遠にループしていつまでもTOP画面が表示されませんでした(つまり、想定では1回だけ呼ばれるはずだったnavigate()が何度も呼ばれた)。
appLinksGraph自体は特に何の状態も参照しておらず、親であるNavHostが内部で参照している状態をnavigate()で変更してしまったために再コンポーズが走っており、このような再コンポーズのケースをすべて予見することは困難なため、いつ再コンポーズが実行されても同じ結果となる(副作用がない)ことが重要になります。

解決策:LaunchedEffect で副作用を一度だけ実行する

まず行った対策は、以下の通り、LaunchedEffectで副作用のある部分を囲うことでした。
urlはApp Linksで起動する先のURLなので、何度コンポーズが走っても同じ値となります。
LaunchedEffectは引数に渡したkeyの値が同じであれば、再コンポーズしてもコルーチンは再実行しないため、結果1度だけnavigateToTop()が実行されることになり無限ループを避けられます。

fun NavGraphBuilder.appLinksGraph(
    navigateToTop: () -> Unit
) {
    composable(
        ・・・
    ) {
        ・・・
    LaunchedEffect(url) {
        url?.let { urlString ->
            when {
                urlString.matches(AppLinks.Top.regex) -> navigateToTop()
                ・・・
            }
        }
    }
}

真・解決策:副作用をコンポーズ可能な関数内で実行しない

前述の解決策で問題は解決したように見えましたが、そもそもnavigate()のような副作用のある処理をコンポーズ可能な関数の中で呼ぶ必要があるのか?というのが疑問です。
おそらくApp Linksによる画面遷移処理を1箇所に集約し、どんなApp Linksを扱っていて、それぞれどの画面に遷移させてるのかを1箇所見ればわかるようにしたかったのだと思います。設計意図としては理解できますが、Androidの作法からは乖離してしまっています。
特に今回はコンポーズ可能な関数内でApp LinksのURLによってnavigate()先を変えることしかやっておらず、composeを行っていること自体無駄です。
やりたいことはApp Linksで画面を起動したいだけなので、実はすごくシンプルです。

fun NavGraphBuilder.topGraph(
    ・・・
    ) {
        composable(
            TopDestination.route,
            deepLinks = listOf(
                navDeepLink { uriPattern = "<App Links URL>" },        // 追加        
                ・・・
            )
        ) {
            // TOP画面のUI
        }
    }

このように、Top画面のGraphにDeep Linkを追加してやれば良いだけです。appLinksGraph自体が必要ありませんでした。

補足

ちなみに、さらにわかったこととして、NavControllerImplには以下の処理があります。
ここで起動先のNavGraphを決定してnavigateしていますが、先にDeep Linkをチェックしてます。

  // NavControllerImpl.kt
  if (_graph != null && backQueue.isEmpty()) {
      if (!navController.checkDeepLinkHandled()) {
          // Navigate to the first destination in the graph
          // if we haven't deep linked to a destination
          navigate(_graph!!, startDestinationArgs, null, null)
      }
  }

つまり、マッチするDeep Linkがあればその画面を開き、なければnavGraphのstartDestinationに遷移させています。
NavHostに設定してあるstartDestinationがTop画面になっている場合、NavGraphにApp LinksにマッチするDeep Link設定がなかった場合も、startDestinationであるTOPに遷移します。つまり.

navDeepLink { uriPattern = "<App Links URL>"},

がなかったとしても、指定したURLでTop画面は起動されます。
ただし、これはたまたまstartDestinationがTOPだったというだけなので、Deep Link設定をしておくのが正しいです。


まとめ

冒頭に記載した通り、当初は特定の端末あるいはバージョンでのみ発生するレアな問題だと考えていましたが、よくよく調査してみるとComposeの理解不足からくるバグを踏んでいました。ちゃんと調査・修正できてよかったです。
今回の問題から、

  • 再現頻度より、再現した場合の影響の大きさで判断した方が良い。
  • 再コンポーズがいつ発生するかはすべて予期できないため、コンポーズ可能な関数の中は、いつ何度再コンポーズが走っても同じ結果になるような構造にすべき。(副作用の回避)
  • LaunchedEffectで副作用の再実行は回避できるが、そもそも本当にコンポーズ可能な関数内で実行すべき副作用なのかどうかを疑った方が良い

など多くの学びが得られました。

最後に

アソビューでは、「生きるに、遊びを。」を実現するためのより良いプロダクトを世の中に届けられるよう、共に挑戦していく仲間を募集しています。

カジュアル面談のご希望も随時お受けしておりますので、お気軽にエントリーください!お待ちしております。

www.asoview.com

speakerdeck.com