Yappli Tech Blog

株式会社ヤプリの開発メンバーによるブログです。最新の技術情報からチーム・働き方に関するテーマまで、日々の熱い想いを持って発信していきます。

Compose × Fragment で画面が真っ白に!? Activity遷移で起きた謎の白画面を追う

こんにちは、Androidエンジニアの伊藤と申します!

今回は、Jetpack Composeと従来のFragmentを組み合わせた画面で、画面遷移後に戻ってきたときにコンテンツが表示されなくなる問題に遭遇しましたのでそれを共有しようかなと思います👀

不具合事象をざっくり説明すると、ComposeViewを持つFragment Aの中でAndroidFragmentを使用しており、そこから別のActivityを起動し、再びFragment Aに戻ってくると、画面が真っ白になってしまうという事象です。

本記事では、この問題の原因究明から解決までのプロセスと、最終的にsetViewCompositionStrategyという仕組みを使って解決した方法について紹介します。同じようにComposeとFragmentを組み合わせて開発している方の参考になれば幸いです🙏

問題が発生した画面の構成

問題が発生したのは、以下のような構成の画面でした。

Fragment自体はComposeで構築されており、onCreateViewComposeViewを返しています。その中でAndroidFragment Composableを使用して、動的に生成したFragmentを表示する実装になっていました。

class FragmentA : Fragment() {
    override fun onCreateView() = ComposeView(requireContext()).apply {
      setContent {
        YappliTheme {
            Scaffold(
                modifier = modifier,
                topBar = topBar,
                content = {
                    ContentFragmentView(
                        modifier,
                        contentUri = contentUri
                     )
                }
            )
        }
    }
}

@Composable
private fun ContentFragmentView(modifier: Modifier, contentUri: Uri) {
    key(contentUri) {
        AndroidFragment(
            clazz = hogeFragment::class.java,
            modifier = modifier,
            arguments = hogeFragment.arguments ?: Bundle()
        )
    }
}

このような構成で、Fragment Aから別のActivityを起動し、Fragment Aに戻ってくると、ContentFragmentViewで表示しているAndroidFragmentが描画されず、画面が真っ白になってしまうという問題が発生しました。

問題の深堀り:なぜ白画面になるのか

結論として、原因は「FragmentのViewライフサイクルとComposeViewのCompositionライフサイクルのズレ」でした。

この問題を調査していく中で、チームメンバーと議論を重ね、徐々に原因が見えてきました👀

最初は「AndroidFragment Composableがrecomposeされていないのではないか」という仮説を立てましたが、調査を進めるうちに、より根本的な問題が見つかりました。それは、ViewのWindowTokenがnullになっているという状態です。

ここで重要なのは、Fragment間の通常遷移(replace)とActivity起動では、Fragmentのライフサイクルの挙動が異なる点です。

Fragment間遷移でreplaceを使う場合、Fragment AはonDestroyViewまで進みます。そのため、Fragment Aに戻ってきた時にはonCreateViewが再度呼ばれ、ViewもComposeも再作成されるため、この問題は発生しません。

しかし、今回のケースはFragment Aから別のActivityを起動するパターンでした。この場合、Fragment AはonDestroyViewまで進まず、Viewが破棄されることなく保持されたままonPause→onResumeのサイクルで動きます。

ところが、ComposeViewはデフォルトの挙動として、WindowからdetachされるタイミングでCompositionをdisposeします。 Activity起動時、Fragment AのViewはWindowから一時的にdetachされるため、ComposeViewはdisposeされ、内部のCompose UIが破棄されます。

その後、Activityから戻ってFragment Aが再び表示されようとした際、Fragment側は既存のViewをそのまま使おうとしますが、Compose側は既にdisposeされた後です。 この時点で内部で使用していたAndroidFragmentはViewが破棄された状態のため、Attachされていない状況になっており、結果として表示できない状況に陥っていました。

つまり、FragmentのViewライフサイクルとComposeViewのCompositionライフサイクルのズレが問題の根本原因だったのです。

試行錯誤の記録

この問題を解決するために、いくつかのアプローチを試みました。

アプローチ1: レイアウト構造の変更

最初に試したのは、ComposeViewを使わず、従来のレイアウトXMLベースの実装に変更するという方法でした。具体的には、他の画面で動作している実装方法を参考に、レイアウト構造自体を変更するアプローチです。

この方法では確かに白画面問題は解消されましたが、せっかくComposeで構築していた画面を、レイアウトXMLに戻すことになってしまいます。可能であれば、Composeの利点を活かしたまま問題を解決したいと考え、別の方法を模索することにしました。

アプローチ2: AndroidViewでの置き換え

次に試したのは、AndroidFragmentの代わりにAndroidViewを使う方法でした。

@Composable
private fun ContentFragmentView(modifier: Modifier) {
    val context = LocalContext.current

    AndroidView(
        modifier = modifier,
        factory = { ctx ->
            FragmentContainerView(ctx).apply {
                id = View.generateViewId()
            }
        },
        update = { container ->
            val fragment = // Fragmentを生成

            childFragmentManager.commit {
                setReorderingAllowed(true)
                if (fragment != null) {
                    replace(container.id, fragment)
                }
            }
        }
    )
}

この実装では、白画面問題は解消されました。しかし、新たな問題が発生しました。Activityから戻ってくるたびにupdateブロックが実行され、Fragmentが毎回再読み込みされてしまうのです😢

これは、ユーザー体験としては望ましくありません。画面の状態が保持されず、スクロール位置なども失われてしまいます。この方法も、妥協案としては考えられましたが、より良い解決策を探すことにしました。

解決策: setViewCompositionStrategy

様々な試行錯誤を経て、最終的にたどり着いたのがsetViewCompositionStrategyを使った解決策でした。

この方法は非常にシンプルで、ComposeViewの生成時に、Compositionのdisposeタイミングを調整するだけです。

override fun onCreateView(
    inflater: LayoutInflater,
    container: ViewGroup?,
    savedInstanceState: Bundle?
) = ComposeView(requireContext()).apply {
    setViewCompositionStrategy(
        ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed
    )

    setContent {
        YappliTheme {
            Scaffold(
                modifier = modifier,
                topBar = topBar,
                content = {
                    ContentFragmentView(
                        modifier,
                        contentUri = contentUri
                     )
                }
            )
        }
    }
}

なぜこれで解決するのでしょうか。それは、setViewCompositionStrategyがCompositionのdisposeタイミングを制御するためです。デフォルトでは、ComposeViewがWindowからdetachされた時点でCompositionがdisposeされていましたが、DisposeOnViewTreeLifecycleDestroyedを指定することで、ViewTreeのLifecycleがDestroyされるまでdisposeを遅延させることができます。

これにより、Activity起動でFragment AのViewが一時的にWindowからdetachされても、Fragment AのonDestroyViewが呼ばれるまではComposeViewのCompositionは破棄されません。ViewとAndroidFragmentが保持された状態が維持されるため、Activityから戻ってFragment AがonResumeされた時も正常に表示できるようになったのです。

setViewCompositionStrategyとは

setViewCompositionStrategyは、ComposeViewのCompositionをいつdisposeするかを制御するためのAPIです。Android公式ドキュメントでは、ViewにComposeを統合する際の重要な設定として紹介されています。

デフォルトではDisposeOnDetachedFromWindowOrReleasedFromPoolが使われます。これは、ViewがWindowからdetachされた時、またはpoolingメカニズムから解放された時にdisposeするという挙動です。多くの場合はこれで問題ありませんが、今回のようにFragmentのライフサイクルと組み合わせる場合には、意図しないタイミングでdisposeされてしまうことがあります。

主な戦略としては、以下のようなものがあります。

DisposeOnDetachedFromWindowは、ViewがWindowからdetachされた時にdisposeします。シンプルな挙動ですが、RecyclerViewなど、Viewがpoolされる場合には適切ではありません。

DisposeOnLifecycleDestroyedは、指定したLifecycleがDestroyされた時にdisposeします。LifecycleOwnerを明示的に指定する必要があります。

DisposeOnViewTreeLifecycleDestroyedは、ViewTreeのLifecycleOwnerがDestroyされた時にdisposeします。今回採用したのがこの戦略で、FragmentのViewライフサイクルに合わせてdisposeタイミングを調整できます。

どのobjectを選ぶべきかは、ComposeViewをどのような場面で使用するかによって変わってきます。

まとめ

ComposeとFragmentを組み合わせる際には、それぞれのライフサイクルの違いに注意する必要があります。特に、ComposeViewの中でAndroidFragmentを使用する場合、デフォルトのdisposeタイミングでは意図しない挙動を引き起こす可能性があるんですね

今回の問題は、setViewCompositionStrategyを適切に設定することで解決できました。ComposeとViewの相互運用を行う際には、このようなライフサイクル制御のAPIがあることを知っておくと、トラブルシューティングの選択肢が広がりますね!

同じような問題に遭遇した方、またはComposeとFragmentを組み合わせた開発をしている方の参考になれば幸いです🙏

備考

検証環境

※ 2026年3月25日現在

本記事で紹介する事象と解決策は、以下の環境で検証を行いました。

  • Android SDK

    • Min SDK: 29
    • Target SDK: 35
    • Compile SDK: 35
  • Kotlin: 2.0.21

  • Jetpack Compose

    • Compose BOM: 2025.05.01
  • AndroidX

    • Fragment: 1.8.5
    • Activity: 1.10.1
    • Lifecycle: 2.6.1

Jetpack Composeはアップデートによる挙動変更が多いため、将来的にこの問題の発生条件や推奨される解決策が変わる可能性があります