Jetpack ComposeではViewModel(相当のクラス)でStateFlowを持たない方が良さそう(でもないかも?)
概要
Jetpack Composeではさまざまなオブザーバブルな型をサポートしています。ここにある通り、LiveData
、Flow
、RxJava
をサポートしています。
これらを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 メンバーの発信を集約しています。公式テックブログはこちら→ tech.uzabase.com/archive/category/NewsPicks
Discussion
もう一つの解決方法として以下のやり方もあります!
『Composable関数の引数に入れるラムダ関数』がunStableな値を参照している場合、親の関数が再実行されるたびに、新しいラムダ関数が毎回生成されてしまいますので、
rememberでラムダ式を括ってあげると、無駄な再コンポーズを防ぐことができます。
情報ありがとうございます!
なるほどです。確かにこういう方法もよさそうですね!
コメントついでに質問させてください。
実行してみるとそうなんだろうとなりますが、この件は公式かどこかで書いてあったりしますでしょうか? 公式のサンプル実装とか見ても、あんまり書いてない気がしてまして...
おっしゃる通りで、このあたりは全然公式ドキュメントに載っていません・・・
twitterや技術登壇会を見ているとやはり皆さん、同じようなところで混乱されているみたいです。
ただ、実はJetpack InternalsというComposeの技術解説本がありまして、
ここにはちゃんと、「再コンポーズ周りの挙動」「ラムダ式の保持」・・・等々
詳しくComposeの仕組みが書いてあります。
重たい本なので自分はまだほとんど理解できていないですが、
これを読み込めは、きっとCompose強強エンジニアになれます
おお、読みます!ありがとうございます!