もう雰囲気で使わない。rememberを理解するためのポイント

この記事をシェア

Jetpack Composeで再コンポジションを超えて変数を保持するために使うrememberですが、なかなか概念を理解するのが難しく、なんとなく雰囲気で書いて、期待通りの動作になるまで何度も試行錯誤を繰り返しながら実装していました。そんな状態を脱出して、ちゃんと理解してrememberを使うために調べたことをいくつか紹介します。

rememberの定義

最初に定義を確認しておきます。

rememberandroidx.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をクリックしても表示は更新されません。

この記事をシェア