Androidにおける ViewModel のライフサイクル
はじめに
Jetpack Compose で ViewModel を使うとき、「この ViewModel はいつ破棄されるんだろう?」と疑問に思ったことはないでしょうか。
ViewModel のライフサイクルは、どの ViewModelStoreOwner にスコープするかによって大きく変わります。スコープを誤ると、意図せず画面間で状態が共有されたり、逆にデータが早期に破棄されてしまうといった問題につながります。
本記事では、ViewModelStoreOwner の仕組みを起点に、AOSPのソースコードを交えながら解説します。
前提条件
本記事はシングルアクティビティで Jetpack Compose・Kotlin を利用した構成を前提とします。
説明で利用しているリポジトリはこちらです。3つのスコープをそれぞれブランチで表現しています。
ViewModelStoreOwner とは
Jetpack が提供する、ViewModel を保持・管理するためのインターフェース。
- ViewModel のライフサイクルを管理し、構成変更時でも ViewModel を保持する
-
ViewModelProviderを通じて ViewModel を取得する際の基盤となる
ViewModelStoreOwner を実装している主なクラスは以下の通り。
-
ComponentActivity(AppCompatActivityはComponentActivityを継承) FragmentNavBackStackEntry
スコープによってライフサイクルが変わる
ViewModel は ViewModelStoreOwner のライフサイクルに紐付き、ViewModelStoreOwner が完全に削除されるまでメモリ内に残る。
ViewModelStoreOwner の実装は一つではないため、どの実装をスコープとするかによってライフサイクルが変わる。
Activity-scoped ViewModel
ComponentActivity の ViewModelStore に紐づく ViewModel。ViewModelStoreOwner は ComponentActivity となる。
特徴
| 項目 | 内容 |
|---|---|
| 生存期間 |
Activity.onCreate() 〜 Activity.onDestroy()
|
| 破棄タイミング |
isChangingConfiguration == false のとき(バックボタンで Activity が終了するとき) |
| 共有範囲 | 同じ Activity インスタンス内のすべての画面(Single Activity 構成ではアプリ全体) |
基本的な使い方
import androidx.activity.viewModels
class MyActivity : AppCompatActivity() {
// The ViewModel is scoped to `this` Activity
val viewModel: MyViewModel by viewModels()
}
なぜ画面回転(構成変更)で破棄されないのか
ComponentActivity の初期化時に ON_DESTROY イベントを監視し、isChangingConfigurations が false の場合のみ ViewModelStore を破棄しているため。
ON_DESTROY が呼ばれるケースは以下の2種類で、ViewModel の扱いが異なる。
| ケース | 具体例 | ViewModel |
|---|---|---|
構成変更(isChangingConfigurations = true) |
画面回転・言語変更・ダークモード切り替え | 破棄されない |
通常の破棄(isFinishing = true) |
バックボタンでの終了・タスクキル | 破棄される |
Navigation-scoped ViewModel
ViewModelStoreOwner は NavBackStackEntry(各 Destination)となる。
特徴
| 項目 | 内容 |
|---|---|
| 生存期間 |
navigate() 〜 popBackStack()
|
| 破棄タイミング | その画面がバックスタックから消えたとき |
| 共有範囲 | その画面のみ |
基本的な使い方
// MyApp.kt
@Composable
fun MyApp() {
val navController = rememberNavController()
NavHost(navController, startDestination = "home") {
composable("home") { HomeScreen() }
composable("detail/{id}") { DetailScreen() }
}
}
// DetailScreen.kt
@Composable
fun DetailScreen() {
// viewModel() と書くだけで自動的に Navigation-scoped になる
val viewModel: DetailViewModel = viewModel()
}
なぜ自動的に Navigation-scoped になるのか
viewModel() が内部で LocalViewModelStoreOwner.current を参照しているためで、以下の流れで設定される。
1. NavHost が ViewModelStoreOwner を設定する
2. 各画面に NavBackStackEntry を提供する
LocalOwnersProvider の中で NavBackStackEntry が各画面の ViewModelStoreOwner と LifecycleOwner として設定される。
3. viewModel() が LocalViewModelStoreOwner を参照する
この仕組みにより、NavHost 内の Composable で viewModel() を呼ぶと、自動的に NavBackStackEntry がスコープとして選ばれる。
NavGraph-scoped ViewModel
Compose Navigation において、特定の画面(Destination)ではなく、複数の画面を束ねたネストされたナビゲーショングラフに対して ViewModel をスコープする手法。
特徴
| 項目 | 内容 |
|---|---|
| 生存期間 | 対象の NavGraph(例:login_flow)に進入してから、その NavGraph 全体がバックスタックからポップされるまで |
| 破棄タイミング | フローが完了する、またはユーザーがバックボタン等でフロー開始前の画面に戻り、login_flow がバックスタックから消去されたとき |
| 共有範囲 | 対象の navigation ブロック(NavGraph)内で定義されたすべての画面 |
基本的な使い方
各 Destination で navController.getBackStackEntry("login_flow") を使って親グラフの BackStackEntry を取得し、それを viewModel() に渡すことで共有インスタンスを得る。
Jetpack Compose における ViewModel のライフサイクル
Composable のライフサイクルと ViewModel は無関係
ViewModel のライフサイクルは Composable と完全に独立している。Composable が再コンポーズされようと、コンポジションから外れようと、ViewModel の生存期間はあくまで ViewModelStoreOwner によって決まる。
ComposableはViewModelStoreOwnerではないため、ViewModel のスコープになることはできない。
| Composable | ViewModel | |
|---|---|---|
| 生成タイミング | コンポジションツリーに追加されたとき |
ViewModelStoreOwner が生成されたとき |
| 再生成 | リコンポーズのたびに関数が再実行される | 構成変更では再生成されない |
| 破棄タイミング | コンポジションツリーから除去されたとき |
ViewModelStoreOwner が破棄されたとき |
たとえば if (condition) { MyScreen() } という条件付き表示で MyScreen がコンポジションから消えても、その ViewModel は popBackStack() されるまで破棄されない。
viewModel() が参照する ViewModelStoreOwner の決まり方
@Composable
inline fun <reified VM : ViewModel> viewModel(
viewModelStoreOwner: ViewModelStoreOwner =
checkNotNull(LocalViewModelStoreOwner.current),
// ...
): ViewModel
LocalViewModelStoreOwner.current が何を指すかは、その Composable がどこに配置されているかによって変わる。
| 配置場所 | LocalViewModelStoreOwner.current |
|---|---|
| Activity 直下 | ComponentActivity |
| NavHost 内の Destination |
NavBackStackEntry(各 Destination) |
| NavGraph スコープ(手動指定) |
NavBackStackEntry(NavGraph ルート) |
Activity 直下での注意点
Navigation を使わない Activity 直下では、LocalViewModelStoreOwner.current は ComponentActivity を指す。これにより、同じ Activity 配下の複数の Composable で viewModel() を呼ぶと、すべてが同一インスタンスを共有してしまう。
setContent {
Column {
ScreenA(viewModel = viewModel()) // 同じインスタンス
ScreenB(viewModel = viewModel()) // 同じインスタンス
}
}
これを避けるために Compose Navigation を使い、各画面を Destination として定義することが推奨される。
まとめ
ViewModel のライフサイクルは主に以下の3種類の ViewModelStoreOwner と紐づく。
| スコープ | ViewModelStoreOwner | 破棄タイミング |
|---|---|---|
| Activity-scoped | ComponentActivity |
Activity が完全に終了したとき |
| Navigation-scoped |
NavBackStackEntry(Destination) |
その画面が BackStack からポップされたとき |
| NavGraph-scoped |
NavBackStackEntry(NavGraph ルート) |
NavGraph 全体が BackStack からポップされたとき |
参考
Discussion