はじめに
今回はAndroidの実装しているときに割と忘れがちな画面回転やアプリから離脱した時にActivityの再生成が走った時のUI保存について書いていきます。
まず、従来のViewシステムではActivityまたはFragmentのonSaveInstanceStateを内でBundleに保存する。
またはViewModelにSavedStateHandleコンストラクタ引数を渡して保持するという選択肢もあるかと思います。
一方JetpackComposeではFragmentは基本的に使わず、さらにrememberSaveableというUIの状態を保存するAPIが用意されているためそちらを使用します……というのはComposeの状態保存では必須と言っても過言ではないrememberの派生で存在自体は把握している人がほとんどかと思います。
そこで今回はrememberとrememberSaveableのおさらいをしつつ、rememberSaveableの少し踏み込んだ使い方としてSavedStateHandleとの連携についても紹介していきます。
remember・remenberSaveble
remember、remenberSavebleを使って宣言した変数はどちらもメモリ保存されますが生存期間が異なります。
通常の宣言も含めた簡単な例で見てみます。
コード内のコメント①はrememberAPIで宣言していないためそもそも再コンポーズがされないのでキーボードを入力しても値が変動しません。
②、③は値が変更されると再コンポーズが走り、値が変わります。
画面を回転(アクティビティの再生成)するとコンポジションは破棄されるためrememberで宣言している②は破棄されます。
remenberSavebleで宣言している③は破棄前にBundleに保持されており、再構成時に復元するため残ります。
ここでのちょっとした注意、というより意識しておいた方がいいポイントとしては画面移動等で抜けた場合でも値が保持されている、つまりバックした時に③が復元されるということです。
バックした時に値を復元したくない場合は例えば、遷移時にクリアする、戻る処理を上書きしてComposeを使いまわさないなどの対策が必要になります。
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MainScreenLayout() {
var firstName = ""
var middleName by remember { mutableStateOf("") }
var lastName by rememberSaveable { mutableStateOf("") }
Column(Modifier.padding(20.dp)) {
Text(
modifier = Modifier.padding(bottom = 20.dp),
text = Screens.Screen1.name,
fontSize = 16.sp,
fontWeight = FontWeight.Bold,
)
// ①
OutlinedTextField(
modifier = Modifier.padding(bottom = 10.dp),
value = firstName,
onValueChange = { firstName = it },
label = { Text("FirstName") }
)
// ②
OutlinedTextField(
modifier = Modifier.padding(bottom = 10.dp),
value = middleName,
onValueChange = { middleName = it },
label = { Text("MiddleName") }
)
// ③
OutlinedTextField(
modifier = Modifier.padding(bottom = 10.dp),
value = lastName,
onValueChange = { lastName = it },
label = { Text("LastName") }
)
Button(
onClick = { /*何かしらの送信処理*/ }) {
Text(text = "Submit")
}
}
}
参考:
AndroidDevelopers コンポーザブル内の状態
AndroidDevelopers Compose 内の状態を復元する
余談ですが、この「再コンポーズが行われた」はLayoutInspector上にある目のアイコン内の項目「Show Recomposition Counts」をチェックすることで確認することができます。
以下のスクリーンショットは三項目を入力後のコンポーズ/コンポーズスキップ数です。②③入力時のみ再コンポーズが走るためレイアウトのコンポーズは2回、また②③は入力時のみ再コンポーズされるので1回はスキップされていることがわかります。
(画面回転後はアクティビティの再構成が行われるためカウントはリセットされます)
複雑な実装をしたりパフォーマンスを意識して実装する際はお世話になるかもしれないです。
また、LayoutInspectorでは定義したCompose内で設定してない初期属性値まで確認できたり、実画面上から実装部分へ跳べたりと使うと開発効率が上がるような機能が用意されているので覚えておいて損はないかと思います。
SavedStateHandle
Fragment1.2.0以降ではSavedStateHandleをViewModelの引数として定義して上げるだけで利用できることもあり、Composeでなくても利用しているプロジェクトは多いかと思います。
SavedStateHandleは定義したViewModelに接続されたActivity/FragmentのonSaveInstanceStateが呼び出される時に保存されるため、もちろんComposeでも利用できます。
さらに、Fragment1.5.0ではsaveableAPIが用意されており、mutableStateととして読み書きできるようになったためComposeとの連携もしやすくなっています。
特別な事情が無い限りはFragment1.5.0以上で実装するのがおすすめです。
参考:AndroidDevelopers 試験運用版 Compose の State のサポート
1画面で完結する保持したいデータに関してはrememberSaveableで済むため、SavedStateHandle.saveableの使い所としては一度入力した情報をあとで使い回したい場合、例えば複数ページ存在する入力フォームやアプリを落としたら初期化されるタイプの検索フィルターや検索履歴あたりでしょうか。
今回はremember remenberSavebleでフォーム入力の保持を例に挙げたのでそちらを例にします。
まずViewModelです。特に難しいこともなくremember等で宣言していたように記述するだけです。
注意としては(rememberSavebleもですが)実態はBundleを利用しているのでクラスをそのまま使うことはできません。
方法としてはParcelizeにする、Saverを利用するの2点があります。
@OptIn(SavedStateHandleSaveableApi::class)
@HiltViewModel
class ScreenViewModel @Inject constructor(savedStateHandle: SavedStateHandle) :
ViewModel() {
// コメントアウト部はSaverを利用する場合
var formData by savedStateHandle.saveable(/*stateSaver = FormData.Saver*/) {
mutableStateOf(FormData())
}
private set
fun setFormData(
firstName: String? = null,
middleName: String? = null,
lastName: String? = null,
) {
formData = formData.let {
it.copy(
firstName = firstName ?: it.firstName,
middleName = middleName ?: it.middleName,
lastName = lastName ?: it.lastName
)
}
}
}
参考:AndroidDevelopers Bundleのサポートされている型
Parcelizeの場合はParcelizeを継承して@Parcelize
アノテーションを付与するだけです。
クラス内の全てのパラメータが復元されます。
@Parcelize
data class FormData(
var firstName: String = "",
var middleName: String = "",
var lastName: String = ""
) : Parcelable
Saverを利用する場合は以下のように記載します。
listSaverの場合、saveのListに羅列するのはActivityが停止した時に保存する値です。また、restoreは復元する値です。
mapSaverの場合は各キーを定義してあげてキーと保存する値を対応させます。
クラスに存在する値でsaveも記載しなければ保存されないですし、restreで復元する値も自由に書き換えることができるのがParcelize化と比べてのメリットかと思います。
今回のケースでは全ての値をそのまま保存、復元するので特にsaverを利用する必要性はなさそうですが複雑なケースでは利用を検討してみてもいいかもしれません。
data class FormData(
var firstName: String = "",
var middleName: String = "",
var lastName: String = ""
) {
companion object {
// ListSaverを使用する場合
val Saver = Saver<FormData, List>(
save = { listOf(it.firstName, it.middleName, it.lastName) },
restore = {
FormData(
firstName = it[0] as String,
middleName = it[1] as String,
lastName = it[2] as String,
)
}
)
// MapSaverを使用する場合
val MapSaver = run {
val fNameKey = "fName"
val mNameKey = "mName"
val lNameKey = "lName"
mapSaver(
save = {
mapOf(
fNameKey to it.firstName,
mNameKey to it.middleName,
lNameKey to it.lastName
)
},
restore = {
FormData(
it[fNameKey] as String,
it[mNameKey] as String,
it[lNameKey] as String
)
}
)
}
}
}
参考:AndroidDevelopers 状態を保存する方法
これでComposeとsavedStateHandleの連携をする準備ができました。
Form情報をViewModelに移したので合わせてremember・remenberSavebleで書いた画面も修正すると以下のようになります。
今回はFormの更新と表示、画面移動だけという単純な内容なのですが、例えばここでデータ送信をするので結果を監視して状況に応じて遷移orエラー表示したいみたいな場合はStateクラスを用意してあげてその中で監視してあげると良いでしょう。
@Composable
fun MainScreenLayout(
navigateToNext: () -> Unit,
screenViewModel: ScreenViewModel = hiltViewModel(),
) {
MainScreenLayout(
navigateToNext = navigateToNext,
firstName = screenViewModel.formData.firstName,
lastName = screenViewModel.formData.lastName,
middleName = screenViewModel.formData.middleName,
) { f, m, l ->
screenViewModel.setFormData(f, m, l)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MainScreenLayout(
navigateToNext: () -> Unit,
firstName: String,
lastName: String,
middleName: String,
setFormData: (
firstName: String?,
middleName: String?,
lastName: String?,
) -> Unit
) {
Column(Modifier.padding(20.dp)) {
Text(
modifier = Modifier.padding(bottom = 20.dp),
text = Screens.Screen1.name,
fontSize = 16.sp,
fontWeight = FontWeight.Bold,
)
// ①
OutlinedTextField(
modifier = Modifier.padding(bottom = 10.dp),
value = firstName,
onValueChange = { setFormData(it, null, null) },
label = { Text("FirstName") }
)
// ②
OutlinedTextField(
modifier = Modifier.padding(bottom = 10.dp),
value = middleName,
onValueChange = { setFormData(null, it, null) },
label = { Text("MiddleName") }
)
// ③
OutlinedTextField(
modifier = Modifier.padding(bottom = 10.dp),
value = lastName,
onValueChange = { setFormData(null, null, it) },
label = { Text("LastName") }
)
Button(
onClick = { navigateToNext() }) {
Text(text = "Submit")
}
}
}
navigateToNext
で遷移する画面はフォームで入力した情報を表示するだけです。
ただし、1点注意があります。NextScreenではscreenViewModel: ScreenViewModel
と初期値としてhiltviewmodel()
を指定していないことに注目してください。
仮にここで指定してしまうとNextScreenにはフォーム情報が表示されません。個人的には地味に躓きポイントだと思っています。
@Composable
fun NextScreen(
screenViewModel: ScreenViewModel,
) {
NextScreen(
firstName = screenViewModel.formData.firstName,
middleName = screenViewModel.formData.middleName,
lastName = screenViewModel.formData.lastName
)
}
@Composable
fun NextScreen(
firstName: String,
middleName: String,
lastName: String
) {
Column(
Modifier
.padding(20.dp)
) {
Text(
modifier = Modifier.padding(bottom = 20.dp),
text = Screens.Screen2.name,
fontSize = 16.sp,
fontWeight = FontWeight.Bold,
)
Text(
modifier = Modifier.padding(bottom = 10.dp),
text = "firstName: $firstName",
)
Text(
modifier = Modifier.padding(bottom = 10.dp),
text = "middleName: $middleName",
)
Text(
modifier = Modifier.padding(),
text = "lastName: $lastName",
)
}
}
AndroidDevelopers Hilt と Navigationに記載されていたのですが、引数なしでhiltViewModelを呼び出した場合のスコープは呼び出したデスティネーションに設定されます。
つまりMainScreen,NextScreenどちらもLayout画面で引数を指定せずに呼び出している場合は各画面をスコープとした別インスタンスのScreenViewModelが作成されるため、MainScreenで入力したフォームがNextScreenに反映されません。
これを回避するには
①ModuleObjectでSingletonのViewModelを注入する
②NavHostクラス等の親時点でviewModel引数を用意して各composableでは親のViewModelを呼ぶ
③viewmodelの引数にスコープを指定する
あたりが考えられます。①②に関してはアプリ全体に関わる情報を処理するようなViewModel、③は今回のように特定の複数画面で引き継ぐ場合に有効かと思います。
今回の③パターンの場合はNavHostクラスに以下のように記載しています。
以上でアクティビティの再生成でも生き残り、かつ複数画面で利用できるUI情報を定義することができました。
@Composable
fun AppNavHost(
navController: NavHostController = rememberNavController(),
viewModel: ScreenViewModel = hiltViewModel(),
) {
NavHost(navController = navController, startDestination = Screens.Screen1.name) {
composable(Screens.Screen1.name) {
MainScreenLayout(
navigateToNext = { navController.navigate(Screens.Screen2.name) },
)
}
composable(Screens.Screen2.name) {
// Screen1(MainScreen)をスコープとしたViewmodelを指定する
// composable内でtry~catchできないので以前の画面にスコープがある確証が無いケースでは①②も検討する
val parentEntry = remember(it) {
navController.getBackStackEntry(Screens.Screen1.name)
}
NextScreen(
hiltViewModel(parentEntry)
)
}
}
}
まとめ
以上、remember、remenberSavebleについてとComposeとSavedStateHandleとの連携についてでした。 remenberSavebleもsavedStateHandleでも簡単にActivity再生成に耐え得るデータを作ることができる一方でBundleには容量制限があるため、ユースケースに合わせ適切に使用していきましょう。