🗂

Jetpack ComposeではViewModel(相当のクラス)でStateFlowを持たない方が良さそう(でもないかも?)

2022/06/15に公開4

概要

Jetpack Composeではさまざまなオブザーバブルな型をサポートしています。ここにある通り、LiveDataFlowRxJavaをサポートしています。

これらをViewModelなどで利用し、コンポーザブル関数で、observeAsState()collectAsState()で、ComposeのStateで変換して利用する具合です。

自分の考えでは、ViewModelをComposeとは独立して存在させたい(依存性を持たせたくない)ので、ViewModelではFlowを持つべきだと考えています。単体テストを書く際も単純にできますし。
ですが、ここの記事に書いたように、公式では、observeAsState()collectAsState()で変換するよりも、ViewModelでコンポーズのStateを持たせる方が良いとのことです。

現在のComposeの動作を見ると確かにViewModelでComposeのStateを持たせた方が良いことがわかります(あくまでコンポーズ的にはそうであって、アーキテクチャ的には違うとは個人的には思っています)。それについての記事です。

追記
結論、インターフェイスStateFlow@Stableではないのが原因で、再コンポジションをすべきかの計算がされていない気がしました。試しにViewModelを@Stableにしたところ、CComposeはボタン押下しても再コンポジションされませんでした。
これだけ読んでもよくわからないと思うので、下も読んでもらえるとよいかと思います。
すいません、若干、考察怪しいです。

どういう問題があるか

ViewModelでFlowを持ち、コンポーザブル関数で変換するとどういう問題があるのか。
ソースを書いて実験してみるとすぐわかります。

まずViewModelを書きます。SateFlowです。


class MainViewModel : ViewModel() {
  private val _count: MutableStateFlow<Int> = MutableStateFlow(0)
  val count: StateFlow<Int> = _count.asStateFlow()
  fun increaseCount() {
    _count.value++
  }
}

前回の記事で書いたコードをViewModelを利用するように変えました。

@Composable
fun CountUpScreen(
  modifier: Modifier = Modifier,
  viewModel: MainViewModel = viewModel(),
) {
  val count: Int by viewModel.count.collectAsState()
  SideEffect { println("CountUpScreen") }
  Column(
    modifier = modifier.fillMaxSize(),
    verticalArrangement = Arrangement.SpaceEvenly,
    horizontalAlignment = Alignment.CenterHorizontally,
  ) {

    ACompose(
      count = count
    )
    BCompose()
    CCompose {
      viewModel.increaseCount()
    }
  }
}

A,B,Cのコンポーズは前回の記事を見て欲しいのですが、
ポイントは、val count: Int by viewModel.count.collectAsState()viewModel.increaseCount()に変わったところです。

この状態でCComposeのボタンを押下した時のログは以下の通りです。

I/System.out: CountUpScreen
I/System.out: ACompose
I/System.out: CCompose

変化しているのはAComposeだけなのに、CComposeまでも再コンポジションが起きてしまっています。
何故か、状態を変化させるメソッドを呼び出しているだけで、UIで変化のない部分が再コンポジションされてしまう のです。

改善方法

追加

以下方法でも解決できますが、

class MainViewModel : ViewModel() {
-  private val _count: MutableStateFlow<Int> = MutableStateFlow(0)
-  val count: StateFlow<Int> = _count.asStateFlow()

+  private val _count: MutableState<Int> = mutableStateOf(0)
+  val count: State<Int> = _count

  fun increaseCount() {
    _count.value++
  }
}

@Composable
fun CountUpScreen(
  modifier: Modifier = Modifier,
  viewModel: MainViewModel = viewModel(),
) {
-  val count: Int by viewModel.count.collectAsState()
+  val count: Int by viewModel.count

ViewModelで、@Stableを持たせるだけ。本来は、これだけで解決できます。

+ @Stable
  class MainViewModel : ViewModel() {

これで、無駄な再コンポジションが起きなくなりました。

I/System.out: CountUpScreen
I/System.out: ACompose

たぶん、こういうこと

無駄な再コンポジションを抑えるために利用できるのが@Stableです。

安定した型は次のコントラクトに従う必要があります。

  • 2 つのインスタンスの equals の結果が、同じ 2 つのインスタンスについて常に同じになる。
  • 型の公開プロパティが変化すると、Composition に通知される。
  • すべての公開プロパティの型も安定している。

公式にはこのように記述されています。

MainViewModelは、(3番が怪しいけど)この条件に合う?ので安定した型と言ってよいので@Stableをつけたところ、無駄に再コンポジションされなくなった。ということかな?

なんか違う気もするので、わかる方の意見を聞きたいところ。

NewsPicks の Zenn

Discussion

MorleyMorley

もう一つの解決方法として以下のやり方もあります!

        CCompose(
            lambda = remember(key1 = viewModel) {
                { viewModel.increaseCount() }
            }
        )

『Composable関数の引数に入れるラムダ関数』がunStableな値を参照している場合、親の関数が再実行されるたびに、新しいラムダ関数が毎回生成されてしまいますので、

rememberでラムダ式を括ってあげると、無駄な再コンポーズを防ぐことができます。

ko2icko2ic

情報ありがとうございます!
なるほどです。確かにこういう方法もよさそうですね!
コメントついでに質問させてください。

『Composable関数の引数に入れるラムダ関数』がunStableな値を参照している場合、親の関数が再実行されるたびに、新しいラムダ関数が毎回生成されてしまいます

実行してみるとそうなんだろうとなりますが、この件は公式かどこかで書いてあったりしますでしょうか? 公式のサンプル実装とか見ても、あんまり書いてない気がしてまして...

MorleyMorley

おっしゃる通りで、このあたりは全然公式ドキュメントに載っていません・・・
twitterや技術登壇会を見ているとやはり皆さん、同じようなところで混乱されているみたいです。

ただ、実はJetpack InternalsというComposeの技術解説本がありまして、
ここにはちゃんと、「再コンポーズ周りの挙動」「ラムダ式の保持」・・・等々
詳しくComposeの仕組みが書いてあります。
https://jorgecastillo.dev/book/

重たい本なので自分はまだほとんど理解できていないですが、
これを読み込めは、きっとCompose強強エンジニアになれます