はじめに
BottomNavigationView
+ Jetpack Navigation
の組み合わせで以下のようなサンプルアプリを作ってみたのだが、ふと BottomNavigationView
をタップしてタブを切り替えた時に Fragment#onDestroy
は呼ばれるが、ViewModel#onCleared
は呼ばれないという事象に気づいた。
どうやら実装の仕方によって Fragment#onDestroy
だけが呼ばれる場合と Fragment#onDestroy
と ViewModel#onCleared
がセットで呼ばれる2つのケースがあるようなのでまとめておく。
※ この記事はあくまで BottomNavigationView
をタップしてタブを切り替えした時にフォーカスしており、バックキーを押したときや、各タブのサブ画面に遷移するときについては対象外となることをご了承いただきたい。
setupWithNavController による影響
公式ドキュメントでも紹介されているように
BottomNavigationView
と Jetpack Navigation
は setupWithNavController
で連携させることができる。本記事はこの setupWithNavController
を使用していることを前提とする。以下は setupWithNavController
を使ったサンプルコードである。
class MainActivity : AppCompatActivity() {
// レイアウト acivitiy_main.xml の ViewBinding インスタンス
private lateinit var binding: ActivityMainBinding
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityMainBinding.inflate(layoutInflater)
setContentView(binding.root)
val bottomNav = binding.bottomNav
// acivitiy_main.xml に id: nav_host_fragment の FragmentContainerView を配置
val fragment = supportFragmentManager.findFragmentById(R.id.nav_host_fragment) as NavHostFragment
setupWithNavController(bottomNav, fragment.navController)
}
・・・
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".ui.MainActivity">
<androidx.fragment.app.FragmentContainerView
android:id="@+id/nav_host_fragment"
android:name="androidx.navigation.fragment.NavHostFragment"
android:layout_width="0dp"
android:layout_height="0dp"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toTopOf="@id/bottom_nav"
app:defaultNavHost="true"
app:navGraph="@navigation/root" />
<com.google.android.material.bottomnavigation.BottomNavigationView
android:id="@+id/bottom_nav"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?android:attr/windowBackground"
app:menu="@menu/bottom_nav_menu"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toBottomOf="@id/nav_host_fragment"
app:layout_constraintBottom_toBottomOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
そしてこの setupWithNavController
の使い方が ViewModel#onCleared
が呼ばれるかどうかに影響する。
上記のサンプルコードにおいて以下のように setupWithNavController
を呼ぶと ViewModel#onCleared
が呼ばれない。
setupWithNavController(bottomNav, fragment.navController)
以下のように setupWithNavController
を呼ぶと ViewModel#onCleared
が呼ばれる。
setupWithNavController(bottomNav, fragment.navController, false)
第3引数の false
は何なのか? Reference を見ると
Whether the NavController should save the back stack state. This must always be false: leave this parameter off entirely to use the non-experimental version of this API, which saves the state by default.
とあり、この値が"状態の保持しない"と意味を表しているようだ。
ちなみに
setupWithNavController(bottomNav, fragment.navController, true)
とすると、以下のエラーを吐いて異常終了するので注意すること!
Caused by: java.lang.IllegalStateException: Leave the saveState parameter out entirely to use the non-experimental version of this API, which saves the state by default
BottomNavigationView#setOnItemSelectedListener による影響
各タブのサブ画面に遷移した後タブを切り替えた際にタブの選択状態を正しく反映させるため、 BottomNavigationView#setOnItemSelectedListener
を使用することが多いと思われるがこれも ViewModel#onCleared
が呼ばれるかどうかに影響する。
class MainActivity : AppCompatActivity() {
// レイアウト acivitiy_main.xml の ViewBinding インスタンス
private lateinit var binding: ActivityMainBinding
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityMainBinding.inflate(layoutInflater)
setContentView(binding.root)
val bottomNav = binding.bottomNav
// acivitiy_main.xml に id: nav_host_fragment の FragmentContainerView を配置
val fragment = supportFragmentManager.findFragmentById(R.id.nav_host_fragment) as NavHostFragment
setupWithNavController(bottomNav, fragment.navController)
bottomNav.setOnItemSelectedListener { item ->
NavigationUI.onNavDestinationSelected(item, fragment.navController)
true
}
}
・・・
BottomNavigationView#setOnItemSelectedListener
を使用すると常にViewModel#onCleared
が呼ばれない。つまり、以下の効力がない。
setupWithNavController(bottomNav, fragment.navController, false)
まとめ
ViewModel#onCleared
が呼ばれるどうかは、次にそのタブに戻ってきた時に ViewModel
が再利用されるか新規に生成されるかを意味しており、重要な関心事である。画面の初期化処理を ViewModel
の init
に実装してしまうエンジニアもいることから注意してほしい。
補足
本記事はNavigation のパッケージバージョン 2.5.3 で検証している。それより古いバージョン(おそらく 2.4.0 未満)は本記事の内容に該当しないと思われる。
参考
本記事は reddit の Why is a ViewModel scoped to the fragment of a BottomNavigationView
not cleared when the fragment is destroyed?というスレッドを参考にしているのでこちらも合わせて参照されたし。