【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】ViewModel を捨てて Repository を Composable に直結する

 

気になるのはライフサイクル。

 

きっかけ

フル Compose でよくあるTodoのようなメモのようなアプリを作ってみました。

一通りの機能は実装しました。




👉 Jetpack Compose without ViewModel #shorts - YouTube hatena-bookmark

いろいろ Compose を試しながら進んでいくと ViewModel がスカスカになりました。


@HiltViewModel
class TodoViewModel @Inject constructor(
  private val repository: TodoRepositoryInterface
) : ViewModel() {

  val items: Flow<List<Todo>> = repository.load()

  fun insert(text: String) = repository.insert(text)

  fun update(id: Long, text: String) = repository.update(id, text)

  fun delete(id: Long) = repository.delete(id)

}

ViewModel いらなくね?

ViewModel を省略して、Repository を直結します。

 

結果

以下、少しの書き換えで問題なく動きます。


@Composable
fun TodoScreen(
  //viewModel: TodoViewModel = hiltViewModel()
  repository: TodoRepository = TodoRepository(
    Database(
      AndroidSqliteDriver(
        schema = Database.Schema,
        context = LocalContext.current,
        name = "database.db"
      )
    )
  )
) {


//val items by viewModel.items.collectAsState(initial = emptyList())
val items by repository.load().collectAsState(initial = emptyList())


//viewModel.insert(target.text)
repository.insert(target.text)


//viewModel.update(target.id, target.text) 
repository.update(target.id, target.text)


//viewModel.delete(target.id)
repository.delete(target.id)


//viewModel.delete(target.id)
repository.delete(target.id)

画面回転問題なし、メモリーリークもありません。

すんなりです。

Square製 SQLDelight + Flow(coroutine extension) を使っていますが、

Room + LiveData でもいけると思います。

Composable で Flow(LiveData) を受け取った瞬間に、

collectAsState(ObserveAsState) で State に変換できるんなら、

それのほうが良くね?

ライフサイクルの差も気にしなくていいし。

しかし、緩衝国がなくなるのはなんだか不安です。

Hilt で @Singleton で、ぶち込んでやりたかったです。

あ、でもこれ、 re-compose のたびに、Repository インスタンスが...

(つづく...)

👉 「SwiftUIでMVVMを採用するのは止めよう」と思い至った理由 - Qiita hatena-bookmark
👉 ViewModel はいつ生まれていつ死ぬか 【→ Jetpack Compose】 hatena-bookmark
👉 Jetpack ComposeとViewModelについて考える - Blog - Mori Atsushi hatena-bookmark


【Jetpack Compose】よくあるボタンの有効化/無効化

読み込み待ち、SwipeRefresh などで見かけるやつ。

ボタン2度押し対策にも使えます。


// @Composable

val sending by viewModel.sending

Button(
  onClick = { 
    viewModel.send() 
  },
  enabled = !sending
) {
  Text("SEND")
}


// ViewModel

private val _sending = mutableStateOf(false)
val sending: State<Boolean> = _sending // *

fun send() {
  viewModelScope.launch(Dispatchers.IO) {
    _sending.value = true
    delay(5000) // heavy
    _sending.value = false
  }
}

【Jetpack Compose】 よくあるボタンの有効化/無効化

ViewModel から露出している sendingState<Boolean> として @Composable 側からの書き換えは許しません。

👉 【Jetpack Compose】rememberCoroutineScope() vs LaunchedEffect hatena-bookmark

👉 【MVVM】 ViewModel の_プロパティ記述 hatena-bookmark
👉 StateFlow の View への公開 hatena-bookmark

👉 Jetpack Compose 二度押しを避けるボタン | Zenn hatena-bookmark