Jetpack Composeで再コンポジションを超えて変数を保持するために使うremember
ですが、なかなか概念を理解するのが難しく、なんとなく雰囲気で書いて、期待通りの動作になるまで何度も試行錯誤を繰り返しながら実装していました。そんな状態を脱出して、ちゃんと理解してremember
を使うために調べたことをいくつか紹介します。
目次
rememberの定義
最初に定義を確認しておきます。
remember
はandroidx.compose.runtimeパッケージに定義されています。いくつかの形式がありますが、大きく分けて引数にkeyを受け取らないものと受け取るものがあります。calculationラムダの戻り値がrememberの戻り値になります。
@Composable
inline fun <T : Any?> remember(
crossinline calculation: @DisallowComposableCalls () -> T
): T
@Composable
inline fun <T : Any?> remember(
key1: Any?,
crossinline calculation: @DisallowComposableCalls () -> T
): T
このほかにも複数のkeyを受け取る形式のものがあります。
rememberはComposableである
先ほどの定義を見ると分かるように、remember
はComposable関数です。したがって、UIコンポーネントのComposableと同様のライフサイクルや再コンポジションが適用されます。
ではremember
がコンポーズされると何が起きるでしょうか。初回のコンポーズでは、calculationラムダが実行されてその計算結果を返すと同時に、結果を記憶します。二回目以降の再コンポーズではcalculationラムダは実行されず、初回コンポーズ時に記憶した計算結果を返します。
@Composable
fun RememberSample1() {
var counter by remember {
Log.d("rememberSample", "Calculation!") // 初回コンポーズ時にしかログ出力されない。
mutableStateOf(0)
}
Button(onClick = { counter++ }) {
Text("Click $counter")
}
}
このようにremember
のcalculationラムダにログを仕込むと、初回コンポーズ(最初に画面が表示されたとき)にはログが出力されますが、2回目以降のコンポーズ(ボタンをクリックしたとき)にはログが出力されません。
keyでcalculationの再評価のタイミングを制御する
初回コンポーズ以外のタイミングでcalculationラムダを再実行したい場合は、remember
にkeyを指定します。再コンポーズ時にkeyが変化していた場合はラムダが再実行され、新しい計算結果を記憶します。再コンポーズ時にkeyが変化していなかった場合はラムダは実行されず、記憶している直近の計算結果を返します。
@Composable
fun RememberSample2() {
var counter by remember {
mutableStateOf(0)
}
val evenCounter = remember(counter) {
Log.d("rememberSample2", "Calculation!") // counterが変化するたびにログ出力される
counter / 2 * 2
}
Button(onClick = { counter++ }) {
Text("Click $evenCounter")
}
}
この例ではボタンをクリックしてcounter
が変化するたびにラムダが実行され、evenCounter
が更新されます。この例のように単純な処理の場合は、わざわざremember
を使わなくてもTextに表示するときに偶数に変換すればよいのですが、いろいろな要因で何度も再コンポーズが発生する状況で無駄に何度も同じ計算をしたくない場合や、remember
関数を自作してUIロジックと表示を分離するときなどに、keyを使うことによってラムダを再実行するタイミングをコントロールすることができます。
rememberは必ずしもStateを返す必要はない
Stateと組み合わせて使うことが多いremember
ですが、remember
自体はState以外のオブジェクトを返しても問題ありません。最初に一度だけ値を算出して、その値を保持しておきたい場合などに、Stateではない普通の型をremember
を使って保持することができます。
val x = remember { longTimeFunction() }
変更を監視する必要がないけど保持しておきたい変数は、remember
単体で使えば実現できます。
Stateはrememberと組み合わせて使う必要がある
remember
はState以外を返すこともできますが、Stateはremember
と組み合わせて使う必要があります。Stateを単体で定義すると、再コンポーズのたびに値が初期化されてしまうので状態変数として使うことができないからです。
@Composable
fun RememberSample3() {
val counter = mutableStateOf(0) // 再コンポーズの度にゼロになる
Button(onClick = { counter.value++ }) {
Text("Click ${counter.value}")
}
}
この例ではremember
を使わずにcounterをMutableStateとして定義しています。RememberSample3
が再コンポーズされるとcounter
はゼロに戻ってしまいます。RememberSample3
が再コンポーズされずに内部のButton
だけが再コンポーズされる状況だと、期待通りにcounter
がインクリメントされるので、一見正しく実装できているように感じてしまうかもしれません。ですが仮にその時点でうまく動いていたとしても、いろいろ変更を加えていくうちに誤動作を起こすようになります。Stateはremember
と組み合わせて使いましょう。
ちなみに上記のコードはUnrememberedMutableState
というエラーが出ます。エラーだけどビルドは通ってしまうようです(すみません、詳しくは調べられていません)。
by rememberはStateを返さなければならない
先ほどremember
はStateを返さなくてもよいと書きましたが、by remember
の形をとる場合は別です。この形の場合はremember
のラムダがStateを返さなければなりません。
var x by remember { mutableStateOf( ... ) }
by remember
で初期化した変数は(Mutable)Stateの委譲プロパティとして定義され、この変数にアクセスするとState / MutableStateの拡張関数の getValue
/ setValue
が呼ばれる仕組みになっているためです。
この形でStateを定義すると、Stateを利用するたびにvalue
プロパティを使わなくて済むので便利です。一方で、変数定義部分を見ないと、それがStateなのか普通の変数なのか区別がつかないというデメリットもあります。
var or val
by remember
を使って初期化する変数をvarにするかvalにするかは、remember
のラムダが返すオブジェクトがMutableStateかStateかによって決まります。
var x by remember { mutableStateOf( ... ) }
mutableStateOf()
はその名の通りMutableStateを返すので、remember
が返すオブジェクトもMutableStateです。MutableStateは値を変更できるので、varになります。(valでもエラーにはなりませんが、当然変更できなくなります。)
val x by remember { derivedStateOf { ... } }
derivedStateOf()
はStateを返すので、remember
が返すのもStateです。Stateは値を変更できないので、valになります。(varだとエラーになります。)
Stateの委譲プロパティは代入すると普通の変数になる
by remember
で初期化した変数は、見た目はIntなど普通の変数ですが、ComposeではStateとして扱われます。つまりComposeによって値が監視され、値が変化した場合はこの変数を参照しているコンポーザブルが再コンポジションされます。
しかし、この変数を別のStateでない変数に代入すると、代入先の変数は名実ともに普通の変数になります。Composeによって監視されないので、値が変化しても再コンポジションのトリガにはなりません。関数の引数に渡された場合も同じです。
@Composable
fun RememberSample4() {
var counter by remember { mutableStateOf(0) }
var intCounter = counter
Button(onClick = { intCounter++ }) {
Text("Click $intCounter")
}
}
この例ではcounter
はMutableStateの委譲プロパティですが、これを代入したintCounter
は単なるIntの変数です。そのためこのButtonをクリックしても表示は更新されません。