Composeの新API retainの動作をrememberと比較する


Compose 1.10で、retainというAPIが追加される見込みです。 retainrememberと似たようにComposable関数内で値を保持するために利用できるようです。 この記事では、retainがどのような条件で値を保持するのか、rememberなどの既存のAPIと比較しながら実際に動作を確認してみます。

検証環境

本記事の動作検証環境は下記です。

  • Compose 1.10.0-rc01
  • Navigation Compose 2.9.6

retainが導入されるCompose Runtime 1.10は、記事執筆時点ではまだstableになっていません。 今後、動作が変更になる可能性があることにご注意ください。

動作の比較

retainrememberrememberSaveableの3つのAPIについて、いくつかの条件で値を保持するかどうかをまとめました。また、比較のためにViewModelのプロパティについても同様に確認しました。

API再コンポーズ構成変更プロセス再作成画面遷移
remember✅ 保持する⚠️ 保持しない⚠️ 保持しない⚠️ 保持しない
rememberSaveable✅ 保持する✅ 保持する✅ 保持する✅ 保持する
retain✅ 保持する✅ 保持する⚠️ 保持しない⚠️ 保持しない
ViewModel✅ 保持する✅ 保持する⚠️ 保持しない✅ 保持する

結果として、retain構成変更を超えて値を保持しますが、プロセス再作成や画面遷移では値を保持しないことが確認できました。

検証コード

検証に用いたコードは以下のとおりです。

@Composable
fun Screen1(viewModel: Screen1ViewModel = viewModel(), onNext: () -> Unit) {
Column( ... ) {
Text("Screen1", ...)
var rememberText by remember { mutableStateOf("初期値 ⚠️") }
var rememberSaveableText by rememberSaveable { mutableStateOf("初期値 ⚠️") }
var retainText by retain { mutableStateOf("初期値 ⚠️") }
Text("remember: $rememberText")
Text("rememberSaveable: $rememberSaveableText")
Text("retain: $retainText")
Text("ViewModel: ${viewModel.viewModelText}")
Button(onClick = {
rememberText = "変更済 ✅"
rememberSaveableText = "変更済 ✅"
retainText = "変更済 ✅"
viewModel.viewModelText = "変更済 ✅"
}) {
Text("変更")
}
Button(onClick = onNext) {
Text(text = "画面遷移")
}
}
}
class Screen1ViewModel() : ViewModel() {
var viewModelText: String = "初期値 ⚠️"
}

Screen1ではまず、rememberrememberSaveableretainを用いて文字列の変数を定義します。 また、ViewModelにも文字列の変数を定義します。 そして、それぞれの文字列を表示します。

「変更」ボタンがタップされると、それぞれの変数の値を変更します。

画面遷移を経て値が保持されるかどうかの確認のために、Navigation Composeを利用した画面遷移も実装しました。 「画面遷移」ボタンがタップされるとScreen2に遷移します。 ナビゲーションとScreen2のコードは下記のようなシンプルなものです。

@Composable
fun Sample() {
val navController = rememberNavController()
NavHost(
navController = navController,
startDestination = "screen1"
) {
composable("screen1") {
Screen1(onNext = { navController.navigate("screen2") })
}
composable("screen2") {
Screen2(onBack = { navController.popBackStack() })
}
}
}
@Composable
fun Screen2(onBack: () -> Unit) {
Column( ... ) {
Text("Screen2", ...)
Button(onClick = onBack) {
Text("もどる")
}
}
}

このコードを使って、順に確認していきます。

初期状態

最初に画面を表示した時は、すべて「初期値 ⚠️」になっています。

初期状態
初期状態

再コンポジション

「変更」ボタンを押すと、すべて「変更済 ✅」になります。 4つの方法すべてで、再コンポジションを超えて値を保持できていることがわかります。

再コンポジション後の状態
再コンポジション後の状態

以降の検証では、まず「変更」ボタンを押してすべて「変更済 ✅」にした状態を起点として、それぞれの条件で値が保持されるかを確認します。

構成変更

すべて「変更済 ✅」になっている状態で、画面を回転します。 これによりデバイスの構成変更が発生し、Activityが再作成されます。

結果は以下のようになりました。rememberは値が初期値に戻っていますが、retainを含むその他の方法では値が保持されていることがわかります。

構成変更後の状態
構成変更後の状態

プロセス再作成

Androidアプリのプロセスは、アプリがバックグラウンドにいる間に端末のメモリが足りなくなった場合に破棄され、再びフォアグラウンドに移る際に再作成されます。 これを再現するために、開発者向けオプションの「アクティビティを保持しない」を有効にして、すべて「変更済 ✅」になっている状態で、ホーム画面を表示してアプリをバックグラウンドに移行し、再びアプリをフォアグラウンドに戻します。

この時の画面の状態は以下のようになりました。 rememberSaveableは値を保持できていますが、それ以外は初期値に戻りました。 retainはプロセス再作成を超えて値を保持しないようです。

なお今回は実装していませんが、ViewModelでSavedStateHandleを使えばプロセス再作成を超えて値を保持できます。

プロセス終了から復帰後の状態
プロセス終了から復帰後の状態

画面遷移

最後に、すべて「変更済 ✅」の状態で別の画面に遷移し、再びもとの画面に戻った時の状態を確認します。 「画面遷移」ボタンをタップしてScreen2に遷移し、再びScreen1に戻ってきて状態を確認します。

結果は以下のようになりました。 retainの値は初期値に戻ってしまうようです。

画面遷移後の状態
画面遷移後の状態

retainのドキュメントには以下のような記述があり、画面遷移後にも値が保持されていることを期待していたため、意外な結果でした。

Some examples of when content is transiently destroyed include:

  • Navigation destinations that are on the back stack, not currently visible, and not composed

Navigationライブラリ側がまだ対応していないのか、画面遷移を超えて値を保持するには追加の実装が必要なのか、現時点ではよくわかりませんでした。

まとめ

retainを使うと、構成変更では値が保持されるが、プロセス再作成や画面遷移では保持されないことがわかりました。 プロセス再作成や画面遷移でも値が保持されてくれると便利そうなので、ちょっと残念です。 とはいえまだ情報が少なく、上記のコードも想定とは違う使い方なのかもしれません。 引き続きretainには注目していきたいと思います。