[Android] FragmentのViewBindingがnullでクラッシュする原因と対処法 ― onDestroyViewのライフサイクルを正しく理解する

Fragment で ViewBinding を使っていると、ある日突然 NullPointerException でアプリが落ちる ―― しかも再現性が低く、画面遷移を素早く繰り返したときや、非同期処理の戻りが遅れたときにだけ起きる。Crashlytics には「binding にアクセスした行で NPE」とだけ残っていて、ローカルでは中々再現しない。そんな経験はないでしょうか。

これは ViewBinding そのもののバグではなく、Fragment のライフサイクルと View のライフサイクルがズレていることに起因する、非常に典型的なクラッシュです。この記事では、なぜ onDestroyView のあとに binding が null になってクラッシュするのか、その仕組みを View.post{} の具体例で解き明かし、実務で使える4つの対処法をコード付きで整理します。

スポンサーリンク

ViewBindingがnullになるとはどういうことか

まず大前提として、Fragment と View のライフサイクルは別物です。Fragment インスタンス自体は生き続けたまま、その View だけが破棄・再生成されることがあります(バックスタックからの復帰など)。そのため公式が推奨する Fragment での ViewBinding の持ち方は、次のように onDestroyView で参照を明示的に手放すパターンです。

class SampleFragment : Fragment() {
 
    // View が存在する間だけ非nullになるバッキングフィールド
    private var _binding: FragmentSampleBinding? = null
 
    // 利用側はこちらを通してアクセスする(null時はIllegalStateException)
    private val binding get() = _binding!!
 
    override fun onCreateView(
        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View {
        _binding = FragmentSampleBinding.inflate(inflater, container, false)
        return binding.root
    }
 
    override fun onDestroyView() {
        super.onDestroyView()
        // View が破棄されたタイミングで参照を手放す(メモリリーク防止)
        _binding = null
    }
}

この _binding = null はメモリリークを防ぐための正しい作法です。しかし裏を返すと、onDestroyView が呼ばれた後に binding にアクセスすると必ずクラッシュするということでもあります(binding は内部で _binding!! しているため)。

つまり問題の本質は「_binding = null が悪い」ことではなく、View が破棄された後も、どこかのコードが binding を触りにきてしまうことにあります。その「どこか」の代表格が、非同期コールバックです。

なぜ onDestroyView 後に binding にアクセスしてしまうのか

クラッシュを起こすコードは、たいてい「View が生きている前提で書かれた処理が、View の寿命をまたいで実行されてしまう」形をしています。具体的には次のようなものです。

  • View.post{} / Handler.post() / postDelayed() でメインキューに積んだ処理
  • Retrofit のコールバックや RxJava の subscribe など、完了タイミングを制御できない非同期処理
  • GlobalScopelifecycleScope(Fragment 側)で起動したコルーチン

ここでは、実務でハマりやすい View.post{} のケースを掘り下げます。

コルーチンは安全でも post は危ない

次のコードを見てください。画像を API から取得して、レイアウト確定後にサイズ計算をしたい、というよくある実装です。

private fun setupImageLayout() {
    viewLifecycleOwner.lifecycleScope.launch {
        // suspend関数。内部はsuspendCancellableCoroutineで実装されている想定
        val items = fetchImageList()
 
        // ここまではコルーチンの構造化並行性の中なので、
        // viewLifecycleOwner のスコープが終了すればキャンセルされる=比較的安全
 
        binding.headerImage.post {
            // ★ここが落とし穴★
            // post でメインHandlerのキューに積まれたRunnableは、
            // コルーチンのライフサイクルの「外」で実行される
            val width = binding.headerImage.width   // ← onDestroyView後だとNPE
            applyAspectRatio(binding.headerImage, width)
        }
    }
}

注目すべきは、fetchImageList()suspendCancellableCoroutine で実装され、かつ viewLifecycleOwner.lifecycleScope で起動されている点です。この部分だけ見ると、View の破棄に合わせてコルーチンはキャンセルされるので安全です。

問題は post{ ... } です。View.post() は渡した Runnableメイン Handler のメッセージキューに積むだけで、その実行はコルーチンの構造化並行性からは完全に切り離されます。つまり、

  1. コルーチンが post{} を呼んで Runnable をキューに積む
  2. その直後にユーザーが画面を離れ、onDestroyView が走って _binding = null になる
  3. キューに残っていた Runnable が実行され、binding.headerImage にアクセス → _binding!! が null で NPE

という順序が成立し得ます。コルーチンをキャンセルしても、すでにキューに積まれた Runnable は止まりません。ここが「コルーチン部分は安全なのに post で落ちる」という分かりにくさの正体です。

対処法

対処の方向性は大きく4つあります。状況に応じて選んでください。

対処1:postブロックの先頭でnullガードする

もっとも局所的で確実なのが、コールバックの入り口で _binding を取り直し、null なら何もしないパターンです。

binding.headerImage.post {
    // 実行時点で View がまだ生きているかをここで確認する
    val b = _binding ?: return@post
 
    val width = b.headerImage.width
    applyAspectRatio(b.headerImage, width)
}

ポイントは、binding(= _binding!!)ではなく、ローカル変数 b_binding を退避してから使うことです。これで「チェックは通ったのに直後に別スレッドで null 化された」という隙間も塞げます。すべての非同期コールバックに適用できる、汎用的で安全なイディオムです。

対処2:onDestroyViewでコールバックを除去する

そもそもキューに残った Runnable を実行させない、というアプローチもあります。View.post() で積んだものは、同じ View の removeCallbacks() で取り消せます。

private val imageLayoutRunnable = Runnable {
    val b = _binding ?: return@Runnable
    applyAspectRatio(b.headerImage, b.headerImage.width)
}
 
private fun setupImageLayout() {
    // ...
    binding.headerImage.post(imageLayoutRunnable)
}
 
override fun onDestroyView() {
    // View破棄前にキューから取り消す
    _binding?.headerImage?.removeCallbacks(imageLayoutRunnable)
    super.onDestroyView()
    _binding = null
}

ただしこの方法は、Runnable をフィールドに保持しないと removeCallbacks できない(ラムダ式は毎回別インスタンスになる)点に注意が必要です。コールバックが多いと管理が煩雑になるため、単発の処理なら対処1のほうが手軽です。

対処3:そもそもpostが必要かを見直す

意外と多いのが、「レイアウト確定を待つ必要が本当はない」のに、なんとなく post で包んでいるケースです。View.post{} が使われる主な理由は、その時点ではまだ View の幅・高さが確定しておらず 0 が返るのを避けるためです。

もし固定サイズが dimens.xml などで分かっている、あるいは ConstraintLayout のアスペクト比制約(layout_constraintDimensionRatio)で XML 側に寄せられるなら、そもそも実行時のサイズ計算自体が不要になります。

<!-- 16:9 のアスペクト比をXMLで宣言してしまう例 -->
<ImageView
    android:id="@+id/headerImage"
    android:layout_width="0dp"
    android:layout_height="0dp"
    android:scaleType="centerCrop"
    app:layout_constraintDimensionRatio="16:9"
    app:layout_constraintStart_toStartOf="parent"
    app:layout_constraintEnd_toEndOf="parent"
    app:layout_constraintTop_toTopOf="parent" />

クラッシュの最善の対処は、危ない非同期処理を「そもそも書かない」ことです。設計段階で消せる post なら消してしまうのが一番堅牢です。

対処4:コルーチンならviewLifecycleOwnerのスコープで完結させる

レイアウト確定を待つ処理そのものを、post ではなくコルーチンの仕組みに寄せる手もあります。doOnPreDraw(androidx.core 拡張)は内部的には post と同様にコールバックを使いますが、これも viewLifecycleOwner.lifecycleScope の中で _binding ガードと併用すれば、ライフサイクル管理を一箇所に集約できます。

viewLifecycleOwner.lifecycleScope.launch {
    val items = fetchImageList()
    val b = _binding ?: return@launch
 
    // androidx.core.view.doOnPreDraw を利用
    b.headerImage.doOnPreDraw {
        val current = _binding ?: return@doOnPreDraw
        applyAspectRatio(current.headerImage, current.headerImage.width)
    }
}

非同期の入れ子が深くなるほど null チェックの抜け漏れが起きやすいので、「View に触る直前で必ず _binding を取り直す」というルールを徹底するのがコツです。

クラッシュしやすい非同期コールバックの見分け方

最後に、コードレビューや調査の際に「ここは危ない」と当たりをつけるためのチェックポイントを挙げておきます。次の条件に当てはまるコールバックの中で binding に素のままアクセスしていたら、要注意です。

  • post / postDelayed / Handler でメインキューに積んでいる ―― コルーチンのキャンセルでは止まらない
  • 完了タイミングをこちら側で制御できない非同期処理(ネットワーク、ディスクI/O、外部SDKのコールバック)
  • viewLifecycleOwner ではなく、Fragment 側の lifecycleScopeGlobalScope で起動されたコルーチン ―― View より寿命が長い
  • アニメーションの完了リスナーなど、画面遷移中も発火し得るもの

逆に言えば、viewLifecycleOwner.lifecycleScope + suspend 関数で完結している処理は、View 破棄に合わせてキャンセルされるため比較的安全です。「コルーチンの外に出る瞬間」だけを警戒すると、調査の効率が上がります。

まとめ

Fragment の ViewBinding が null でクラッシュする問題は、ViewBinding の不具合ではなく、Fragment と View のライフサイクルのズレに非同期コールバックが「またいで」しまうことが原因でした。要点を振り返ります。

  • onDestroyView_binding = null にするのは正しい作法。問題はその後に binding を触るコードがあること。
  • View.post{} はコルーチンの構造化並行性の外で実行されるため、コルーチン部分が安全でもクラッシュし得る。
  • もっとも汎用的な対処は、コールバックの入り口で val b = _binding ?: return とガードすること。
  • removeCallbacks での除去、そもそも post を消す設計、コルーチンへの集約も選択肢になる。

「View に触る直前で _binding を取り直す」という一つのルールを習慣にするだけで、この種のクラッシュは大幅に減らせます。Crashlytics で再現性の低い NPE を追っているなら、まずは非同期コールバックの中の素の binding アクセスを疑ってみてください。

コメント

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