Fragmentのライフサイクルを甘く見た代償 〜画面は消えても、地雷は残る〜

※ChatGPTを使用して記事を作成しています。

「Activityよりも柔軟に画面を分割・再利用できる」

Fragmentが登場した当初、私はそう信じて疑いませんでした。

しかし実際に開発を始めると、意味不明なクラッシュ謎のnullポインタ画面遷移後にUI更新される事故のオンパレード。

「え、もう破棄されたはずのFragmentの中でコードが動いてる!?」

「onViewCreatedが2回呼ばれてる!?」

「isAdded()がtrueなのにFragmentがnullってなに!?」

今回は、Fragmentのライフサイクルに無理解なまま実装を進め、運用後に地獄を見た話を赤裸々に語ります。

すべての始まり:ViewModelとFragmentの“自然な組み合わせ”

私がこの失敗に陥ったのは、あるニュースアプリの「記事詳細画面」を作っていたときのことです。

詳細画面には、記事タイトル・本文・いいねボタン・コメントなどがあり、ユーザーが戻るたびに状態が保持されている必要がありました。

私は当然のように、Fragment + ViewModel + LiveData の構成を選びました。

今や定番ともいえる組み合わせ。

FragmentでViewModelを取得し、LiveDataをobserveしてUIを更新する……そのはずでした。

ところが。

問題発生:戻るボタンでクラッシュ!?

アプリのテスト中、「詳細画面を開いて戻る→再度開く」と落ちる現象が発生。

エラーログはこちら:

java.lang.IllegalStateException: Fragment ArticleDetailFragment{…} not attached to a context.

最初は「一時的なバグかな」と思いましたが、ユーザー報告も続出。

調べていくと、ViewModelからのLiveData更新が、破棄済みのFragmentに届いていたことが原因でした。

落とし穴1:破棄されたFragmentでLiveData.observe()

override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
    super.onViewCreated(view, savedInstanceState)

    viewModel.article.observe(viewLifecycleOwner) {
        // UI更新処理(ここで落ちる)
        titleTextView.text = it.title
    }
}

この実装、一見正しそうに見えます。

実際、viewLifecycleOwnerを使っているので Fragment が破棄されれば自動的にobserveは解除されるはずです。

が、ViewModel自体がActivityスコープで生成されていたため、状態が生き残り、UIより先にデータを吐いていたのです。

落とし穴2:非同期処理との競合

別の日には、以下のようなコードで事故が発生:

fun fetchArticle() {
    viewModelScope.launch {
        val article = repository.getArticle()
        _article.postValue(article)
    }
}

これを呼び出しているFragmentが画面回転などで破棄された後でも、postValueでLiveDataにデータが流れ、それを古いFragmentが受け取ってUI更新しようとしてクラッシュ。

つまり、

  1. launch中にFragmentが破棄
  2. データ取得成功→LiveData更新
  3. 古いFragmentが更新を受け取る
  4. UIコンポーネントはもう存在しない
  5. NullPointerExceptionIllegalStateException が発生

落とし穴3:Lifecycleが複雑すぎる

Fragmentには以下のようなライフサイクルイベントがあります:

  • onAttach()
  • onCreate()
  • onCreateView()
  • onViewCreated()
  • onStart()
  • onResume()
  • onPause()
  • onStop()
  • onDestroyView()
  • onDestroy()
  • onDetach()

初心者の頃は「onDestroy()が呼ばれたら終わり」くらいに思っていました。

しかし実際は、ViewのライフサイクルとFragment自体のライフサイクルが別々に存在するという二重構造が混乱を招く元凶。

特に危険なのが、「Viewは破棄されたがFragmentは生きている」状態です。

// NG例:onDestroyViewの後にUI更新を試みる
viewModel.article.observe(viewLifecycleOwner) {
    titleTextView.text = it.title // ここでnullになる
}

私が採った対策

FragmentスコープのViewModelを使う

  • by viewModels() を使って、Fragment単位でインスタンスを持つように修正。
  • ActivityスコープでLiveDataを共有する必要がないなら、局所的にした方が安全。

UI更新はViewが存在するか確認

  • isAddedview != null をチェック(とはいえ根本解決ではない)

viewLifecycleOwnerを正しく使う

  • viewLifecycleOwnerでobserveすることで、Viewが破棄されたら自動解除される仕組みに頼る。

JobやCoroutineのキャンセルを意識

  • viewLifecycleOwner.lifecycleScope.launch でCoroutineをViewと一緒に破棄できるようにする。
viewLifecycleOwner.lifecycleScope.launch {
    val article = repository.getArticle()
    // Viewがまだ存在するか確認してから更新
    if (view != null) {
        titleTextView.text = article.title
    }
}

Jetpack Navigationを使ってBackStack管理を簡略化

  • 自前でFragmentTransactionを管理していた頃は、BackStackのミスも頻発していた。
  • Jetpack Navigationにより、Fragmentの再生成・破棄・引数受け渡しの問題を大幅に解消。

まとめ:Fragmentは強力だが扱いは慎重に

Fragmentは「画面を小さく分割して再利用」できる便利な仕組みですが、ライフサイクルが複雑で、きちんと理解して設計しないとすぐに破綻します

私がこの問題でリリース後に苦しんだ最大の原因は、「画面が消えればコードも止まるはず」と勝手に思い込んでいたことです。

画面が消えても、非同期処理は走り続け、データは流れ続け、そしてクラッシュログだけが静かに残る──

タイトルとURLをコピーしました