【Jetpack Compose】NavBackStackEntry - Composable のライフサイクルと ViewModel の状態を確認する

NavBackStackEntry Lifecylecle State

 

■ きっかけ

普通に、トップレベルの Composable内で State/Flow/StateFlow を返す ViewModel に接続します。


Composable ↔ ViewModel (↔ Repository)

👉 Hilt と Navigation

一般的な NavHostController を使う場合には、それは NavBackStackEntryインスタンス となり 一つの Activity の上にスタック上に積まれていく。

👉 Composable の LifecycleOwner は誰なのか - collectAsStateWithLifecycle


NavBackStackEntry3 - Composable3 ↔ ViewModel3 
NavBackStackEntry2 - Composable2 ↔ ViewModel2
NavBackStackEntry1 - Composable1 ↔ ViewModel1
Activity

ライフサイクル上、Activity と NavBackStackEntry との関係は良さそうです。

外側のActivityやFragmentがonStop状態になっていればこのNavBackStackEntryのlifecycleはCREATED状態になってくれていそうにみえます。

👉 ComposeNavigationで使えるNavBackStackEntryのlifecycleとは?
👉 NavBackStackEntryのLifecycleについて | by Kenji Abe | Medium

Flow を扱う ViewModel と、

スタックされる NavBackStackEntry (Screen-Level Composable) のライフサイクル。

気になります。

目視したいです。

遷移によっては同じ Composable がスタックに積まれていくのもキモいし。

 

■ 方法

ViewModel の生き死には、


class TodoViewModel @Inject constructor(
) : ViewModel() {

  init {
    // log
  }

  override fun onCleared() {
    // log
  }

}

で確認します。

👉 ViewModel はいつ生まれていつ死ぬか 【→ Jetpack Compose】

Composable - NavBackStackEntry のライフサイクルは、


LifecycleEventObserver { _, event ->
  // log event
}

を使います。hash 付けてます。

👉 DisposableEffect: クリーンアップが必要な作用

遷移先となる Composale に貼ります。


@Composable
fun TodoScreen(
  viewModel: TodoViewModel = hiltViewModel()
) {

  LocalLifecycleStateLogger(" TodoScreen") // *


@Composable
fun PokeScreen(
  viewModel: TodoViewModel = hiltViewModel()
) {

  LocalLifecycleStateLogger(" PokeScreen") // *

ついでに、これらの親となる NavHostController をセットしている Composale にも貼ります。


@Composable
fun MainScreen() {
  SystemUi(rememberSystemUiController())

  LocalLifecycleStateLogger(screenName = "MainScreen") // *

  val navHostController = rememberNavController()

 

■ 結果

BottomNavigation をつかっていますが、スタックや saveState の設定はデフォルトのままなので、navigation を使っている場合は同様ではないかと思います。


アプリ起動

  ↓ ① click

TodoScreen

  ↓ ② click

PokeScreen

  ↓ ③ click

TodoScreen

  ↓ ④ back

PokeScreen

  ↓ ⑤ back

TodoScreen

  ↓ ⑥ back

アプリ終了



ログ出力結果。


D: @@@ MainScreen - MainActivity@a542ab9 -> ON_CREATE
D: @@@ MainScreen - MainActivity@a542ab9 -> ON_START
D: @@@ MainScreen - MainActivity@a542ab9 -> ON_RESUME
D: @@@  TodoViewModel@abae21d initialized.
D: @@@  TodoScreen - NavBackStackEntry@8ad648d7 -> ON_CREATE
D: @@@  TodoScreen - NavBackStackEntry@8ad648d7 -> ON_START
D: @@@  TodoScreen - NavBackStackEntry@8ad648d7 -> ON_RESUME
D: @@@  TodoScreen - NavBackStackEntry@8ad648d7 -> ON_PAUSE
D: @@@  TodoScreen - NavBackStackEntry@8ad648d7 -> ON_STOP
D: @@@  PokeViewModel@ad0cefd initialized.
D: @@@  PokeScreen - NavBackStackEntry@4049e1c3 -> ON_CREATE
D: @@@  PokeScreen - NavBackStackEntry@4049e1c3 -> ON_START
D: @@@  PokeScreen - NavBackStackEntry@4049e1c3 -> ON_RESUME
D: @@@  PokeScreen - NavBackStackEntry@4049e1c3 -> ON_PAUSE
D: @@@  PokeScreen - NavBackStackEntry@4049e1c3 -> ON_STOP
D: @@@  TodoViewModel@577b5b1 initialized.
D: @@@  TodoScreen - NavBackStackEntry@f1d51055 -> ON_CREATE
D: @@@  TodoScreen - NavBackStackEntry@f1d51055 -> ON_START
D: @@@  TodoScreen - NavBackStackEntry@f1d51055 -> ON_RESUME
D: @@@  TodoScreen - NavBackStackEntry@f1d51055 -> ON_PAUSE
D: @@@  TodoScreen - NavBackStackEntry@f1d51055 -> ON_STOP
D: @@@  PokeScreen - NavBackStackEntry@4049e1c3 -> ON_CREATE
D: @@@  PokeScreen - NavBackStackEntry@4049e1c3 -> ON_START
D: @@@  TodoScreen - NavBackStackEntry@f1d51055 -> ON_DESTROY
D: @@@  TodoViewModel@577b5b1 onCleared.
D: @@@  PokeScreen - NavBackStackEntry@4049e1c3 -> ON_RESUME
D: @@@  PokeScreen - NavBackStackEntry@4049e1c3 -> ON_PAUSE
D: @@@  PokeScreen - NavBackStackEntry@4049e1c3 -> ON_STOP
D: @@@  TodoScreen - NavBackStackEntry@8ad648d7 -> ON_CREATE
D: @@@  TodoScreen - NavBackStackEntry@8ad648d7 -> ON_START
D: @@@  PokeScreen - NavBackStackEntry@4049e1c3 -> ON_DESTROY
D: @@@  PokeViewModel@ad0cefd onCleared.
D: @@@  TodoScreen - NavBackStackEntry@8ad648d7 -> ON_RESUME
D: @@@  TodoScreen - NavBackStackEntry@8ad648d7 -> ON_PAUSE
D: @@@ MainScreen - MainActivity@a542ab9 -> ON_PAUSE
D: @@@  TodoScreen - NavBackStackEntry@8ad648d7 -> ON_STOP
D: @@@ MainScreen - MainActivity@a542ab9 -> ON_STOP

ログから 各Screen = NavBackStackEntry のスタック状態を考えます。


  -

  ↓ ① click

 TodoScreen1 ↔ TodoViewModel1
MainScreen1 - MainActivity

  ↓ ② click

 PokeScreen1 ↔ PokeViewModel1
 TodoScreen1 ↔ TodoViewModel1
MainScreen1 - MainActivity

  ↓ ③ click

 TodoScreen2 ↔ TodoViewModel2
 PokeScreen1 ↔ PokeViewModel1
 TodoScreen1 ↔ TodoViewModel1
MainScreen@1 - MainActivity

  ↓ ④ back

 PokeScreen1 ↔ TodoViewModel2
 TodoScreen1 ↔ TodoViewModel1
MainScreen1 - MainActivity

  ↓ ⑤ back

 TodoScreen1 ↔ TodoViewModel1
MainScreen1 - MainActivity

  ↓ ⑥ back

  -

それぞれの NavBackStackEntry のライフサイクルと ViewModel の状態が確認できました。

ホストの Composable のライフサイクルオーナーは、Activity であることも分かりました。

画面が遷移していくと、NavBackStackEntry はスタックされていき、 back (戻る操作) すると、NavBackStackEntry は ON_DESTROY され、スタックから取り除くかれ、それに対応する ViewModel も onCleard() されていきます。

なんとなく思うのは、今現在としては、ViewModel は、やっぱりあったほうがいいように思います。

👉 【Jetpack Compose】 ViewModel を捨てて Repository を Composable に直結する

そのうち、


hiltViewModel<ViewModel>()

のように、Reposiitory や DataProvider を注入する

androidx.hilt.navigation.compose メソッドやアノテーションが出れば嬉しいです。


👉 Jetpack Compose without ViewModel / Twitter hatena-bookmark

👉 Jim Sproch (@JimSproch) - Jetpack Composeのコンポーネントはなぜ返り値がないのか - Blog - Mori Atsushi hatena-bookmark

もうこれは「UIもマルチスレッドな時代になった」と言っていいんすかね。

👉 User interface frameworks typically are single-threaded. Why is this so and what are the performance implications of this single-threading? - Quora hatena-bookmark
👉 Are Multiple UI Threads Possible? | Apple Developer Forums hatena-bookmark

(つづく...)


【Jetpack Compose】Compose (androidx.compose.*) のバージョンが分かれている件

元は、こんな感じで問題ありませんでした。


ext.versions = [
  'kotlin'  : '1.6.21'
  'compose' : '1.2.0-rc02'
]

dependencies {
  classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:${versions.kotlin}"
}

composeOptions {
  kotlinCompilerExtensionVersion versions.compose
}


implementation "androidx.compose.material:material:${versions.compose}"
implementation "androidx.compose.material:material-icons-extended:${versions.compose}"
implementation "androidx.compose.ui:ui:${versions.compose}"
implementation "androidx.compose.ui:ui-tooling-preview:${versions.compose}"
debugImplementation "androidx.compose.ui:ui-tooling:${versions.compose}"
debugImplementation "androidx.compose.ui:ui-test-manifest:${versions.compose}"
androidTestImplementation "androidx.compose.ui:ui-test-junit4:${versions.compose}"

アップデート通知が出たので、いつものように


1.3.0-alpha01

に上げました。


ext.versions = [
  'kotlin'  : '1.6.21'
  'compose': '1.3.0-alpha01'
]

...

ビルドできなくなりました。

なんでや。

 

■ kotlin と compose の関係

kotlin と compose には、お互いに対応するバージョンが決まっていましたね!

Compose to Kotlin Compatibility Map

👉 Compose to Kotlin Compatibility Map  |  Android Developers hatena-bookmark

あれ、compose「1.3.0-alpha01」がないよ!

 

■ 「Compose」 は1つではない

Jetpack Compose is multiple things under one name:

- A compiler plugin that helps efficiently calculate the difference between two in-memory tree data structures
- A new UI toolkit for Android
- A new UI toolkit for desktop apps (Compose Desktop)

👉 Drop "androidx" from Jetpack Compose package name, for multiplatform, before 1​.​0 release. · Change.org hatena-bookmark

どうやら、Android でいうと compose は

- compose compiler
- compose ui toolkit

の2つに分かれているようです

👉 This version (1.2.0-alpha08) of the Compose Compiler requires Kotlin version 1.6.20 but you appear to be using Kotlin version 1.6.21 which is not known to be compatible. hatena-bookmark

 

■ まとめ

上記2つのことを考慮して書き換えます。


ext.versions = [
  'kotlin'    : '1.7.0'
  'compose'   : '1.2.0',         // compose-compiler
  'composeUi' : '1.3.0-alpha01'  // compose-ui
]

dependencies {
  classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:${versions.kotlin}"
}

composeOptions {
  kotlinCompilerExtensionVersion versions.compose // compose-compiler
}

// compose-ui
implementation "androidx.compose.material:material:${versions.composeUi}"
implementation "androidx.compose.material:material-icons-extended:${versions.composeUi}"
implementation "androidx.compose.ui:ui:${versions.composeUi}"
implementation "androidx.compose.ui:ui-tooling-preview:${versions.composeUi}"
debugImplementation "androidx.compose.ui:ui-tooling:${versions.composeUi}"
debugImplementation "androidx.compose.ui:ui-test-manifest:${versions.composeUi}"
androidTestImplementation "androidx.compose.ui:ui-test-junit4:${versions.composeUi}"

👉 This version (1.2.0-alpha08) of the Compose Compiler requires Kotlin version 1.6.20 but you appear to be using Kotlin version 1.6.21 which is not known to be compatible. hatena-bookmark


@Composable の LifecycleOwner は誰なのか - collectAsStateWithLifecycle

 

Compose までの Flow の collect

coroutine など非同期処理を行う場合ライフサイクルの考慮が必要でしたね!


viewLifecycleOwner.lifecycleScope.launch {
  viewLifecycleOwner.lifecycle.repeatOnLifecycle(STARTED) {
    myViewModel.myUiState.collect {
      // ... 
    }
  }
}

これは、Fragment のビューが STARTED になったときに収集を開始し、RESUMED まで継続し、STOPPED に戻ったときに停止します。

@Composable の LifecycleOwner は誰なのか - collectAsStateWithLifecycle

👉 【MVVM】 Kotlin Flow で使える5つの利用パターン hatena-bookmark

生き死にだけでではないのです。

collect する期間も考えなくてはなりません。

 

Compose では

@Composable 内で、


val items by viewModel.items.collectAsState(initial = emptyList())

というような形で、かんたんに Flow や StateFlow を 収集できます。

しかし、

バックスタック中に、無駄に APIにリクエストしたり、DBにクエリーを投げたりしてません?

逆に、更新されずに古いままの更新されてない画面見せられたりして萎えたりもします。

 

collectAsStateWithLifecycle() の登場

👉 collectAsStateWithLifecycleが追加されたぞ - Qiita hatena-bookmark

所属は以下のようです。

androidx.lifecycle.lifecycle-runtime-compose


implementation "androidx.lifecycle:lifecycle-runtime-compose:2.6.0-alpha01"

実装を見てみます。

ホットな StateFlow と コールドな Flow に向けて2つずつ公開されています。


fun <T> StateFlow<T>.collectAsStateWithLifecycle(
    lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current,
    minActiveState: Lifecycle.State = Lifecycle.State.STARTED,
    context: CoroutineContext = EmptyCoroutineContext
): State<T> 

fun <T> StateFlow<T>.collectAsStateWithLifecycle(
    lifecycle: Lifecycle,
    minActiveState: Lifecycle.State = Lifecycle.State.STARTED,
    context: CoroutineContext = EmptyCoroutineContext
): State<T> 

fun <T> Flow<T>.collectAsStateWithLifecycle(
    initialValue: T,
    lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current,
    minActiveState: Lifecycle.State = Lifecycle.State.STARTED,
    context: CoroutineContext = EmptyCoroutineContext
): State<T>

fun <T> Flow<T>.collectAsStateWithLifecycle(
    initialValue: T,
    lifecycle: Lifecycle,
    minActiveState: Lifecycle.State = Lifecycle.State.STARTED,
    context: CoroutineContext = EmptyCoroutineContext
): State<T>

ライフサイクルや期間を与えて collect 動作を設定できます。

ありがとうございます。

 

LifecycleOwner は誰なのか

前述引用の実装コードより。


...
lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current,
...


LocalLifecycleOwner

ライフサイクルのオーナーといえば、Compose 以前は、

Activity、Fragment、View

ぐらいで考えていましたが。

きっとオーナーは、Activity ではなく、

最上位のルートの @Composable

ではなかろうか。

いや、ワンチャン Activity かもしれん。

見てみましょう。


println("オーナー → ${LocalLifecycleOwner.current}")

 

結果


D: オーナー → androidx.navigation.NavBackStackEntry@4dc222c4

「NavBackStackEntry」さんらしいですわ。

(つづく...)