結果を返す(シリアル化可能な状態ベース)
このレシピでは、Kotlin シリアル化と rememberSerializable を活用して、構成の変更やプロセスの終了後も保持される状態ベースのアプローチを使用して、ある画面から前の画面に結果を返す方法を示します。
仕組み
この例は ResultEventBus をベースに構築されており、カスタム拡張関数 conflateAsSerializableState を導入しています。
- ResultEventBusNavEntryDecorator:
LocalResultEventBusを介してResultEventBusを提供するNavEntryDecorator。 ResultEventBus:ResultEventBusが作成され、LocalResultEventBusを介してコンポーザブルで使用できるようになります。この EventBus は結果を送受信します。conflateAsSerializableState:ResultEventBusのカスタム拡張関数。rememberSerializableを使用して状態コンテナを作成し、ResultEffectを使用して新しい結果をリッスンして永続化します。- 結果の送信: 結果を生成する画面が
resultBus.sendResult(person)を呼び出してデータを返送します。 - 結果の確認: 結果が必要な画面は
LocalResultEventBus.current.conflateAsSerializableState<Person?>(null)を呼び出してStateオブジェクトを取得します。UI はこの状態を監視し、結果が変更されるたびに再コンポーズします。
このアプローチは、標準の conflateAsState とは異なり、構成の変更やプロセスの終了後も結果を保持する必要がある場合に適しています。
Nullable 型のサポート
標準の rememberSerializable 関数には T : Any の汎用的な上限制約があり、null 値許容型を直接保持できません。
null 値を許容する型(Person? など)をサポートするため、conflateAsSerializableState は、値を汎用 @Serializable クラス NullableWrapper で内部的にラップすることで、この制限を回避します。
@Serializable
private data class NullableWrapper<T>(val value: T)
カスタム型のシリアル化
このアプローチでは androidx.compose.runtime.saveable パッケージの rememberSerializable を使用するため、結果として使用されるカスタムクラス(Person など)は Kotlin シリアル化の @Serializable アノテーションでマークする必要があります。
@Serializable
data class Person(val name: String, val favoriteColor: String)
conflateAsSerializableState 拡張関数は、具現化されたバージョンを使用するときに、serializer<T>() ヘルパーを介して適切な KSerializer を自動的に取得します。
@Composable
inline fun <reified T> ResultEventBus.conflateAsSerializableState(
defaultValue: T,
vararg inputs: Any?,
configuration: SavedStateConfiguration = SavedStateConfiguration.DEFAULT,
): State<T>
/* * Copyright 2025 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.example.nav3recipes.results.common import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.lifecycle.ViewModel class HomeViewModel : ViewModel() { var person by mutableStateOf<Person?>(null) }
/* * Copyright 2025 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.example.nav3recipes.results.common import androidx.navigation3.runtime.NavKey import kotlinx.serialization.Serializable @Serializable data object Home : NavKey @Serializable class PersonDetailsForm : NavKey
/* * Copyright 2025 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.example.nav3recipes.results.common import kotlinx.serialization.Serializable @Serializable data class Person(val name: String, val favoriteColor: String)
/* * Copyright 2025 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.example.nav3recipes.results.common import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.height import androidx.compose.foundation.text.input.rememberTextFieldState import androidx.compose.material3.Button import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.dropUnlessResumed import com.example.nav3recipes.content.ContentBlue import com.example.nav3recipes.content.ContentGreen @Composable fun HomeScreen( person: Person?, onNext: () -> Unit ) { ContentBlue("Hello ${person?.name ?: "unknown person"}") { if (person != null) { Text("Your favorite color is ${person.favoriteColor}") } Spacer(Modifier.height(16.dp)) Button(onClick = dropUnlessResumed(block = onNext)) { Text("Tell us about yourself") } } } @Composable fun PersonDetailsScreen( onSubmit: (Person) -> Unit ) { ContentGreen("About you") { val nameTextState = rememberTextFieldState() OutlinedTextField( state = nameTextState, label = { Text("Please enter your name") } ) val favoriteColorTextState = rememberTextFieldState() OutlinedTextField( state = favoriteColorTextState, label = { Text("Please enter your favorite color") } ) Button( onClick = dropUnlessResumed { val person = Person( name = nameTextState.text.toString(), favoriteColor = favoriteColorTextState.text.toString() ) onSubmit(person) }, enabled = nameTextState.text.isNotBlank() && favoriteColorTextState.text.isNotBlank() ) { Text("Submit") } } }
/* * Copyright 2026 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.example.nav3recipes.results.serializable import androidx.compose.runtime.Composable import androidx.compose.runtime.State import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.saveable.rememberSerializable import androidx.navigation3.runtime.result.ResultEffect import androidx.navigation3.runtime.result.ResultEventBus import androidx.savedstate.serialization.SavedStateConfiguration import kotlinx.serialization.KSerializer import kotlinx.serialization.Serializable import kotlinx.serialization.serializer @Serializable private data class NullableWrapper<T>(val value: T) /** * Reusable extension function on [ResultEventBus] to provide a single [State] that preserves * its value across configuration changes and process death using [rememberSerializable]. * * @param T The type of the result value. * @param resultKey The unique key associated with this result. * @param defaultValue The default initial value to be remembered and saved when no previously saved state exists. * @param inputs A set of inputs such that, when any of them have changed, the state will reset and re-initialize with [defaultValue]. * @param stateSerializer A [KSerializer] used to serialize and deserialize the state value. * @param configuration Optional [SavedStateConfiguration] to customize how the serialization is * handled, such as specifying a custom format (e.g., JSON). * @return A [State] containing the current value of the result, which updates dynamically when new results are received. */ @Composable fun <T> ResultEventBus.conflateAsSerializableState( resultKey: String, defaultValue: T, vararg inputs: Any?, stateSerializer: KSerializer<T>, configuration: SavedStateConfiguration = SavedStateConfiguration.DEFAULT, ): State<T> { val wrapperSerializer = remember(stateSerializer) { NullableWrapper.serializer(stateSerializer) } val wrapper = rememberSerializable( inputs = inputs, stateSerializer = wrapperSerializer, configuration = configuration, ) { mutableStateOf(NullableWrapper(defaultValue)) } // ResultEffect's internal LaunchedEffect does not restart when the onResult lambda changes. // If inputs change, rememberSerializable recreates the savedState instance. Using // rememberUpdatedState ensures that the ongoing collection coroutine inside ResultEffect // always writes to the latest savedState instance without needing to cancel and restart. // https://issuetracker.google.com/531709234 val currentWrapper = rememberUpdatedState(wrapper) ResultEffect<T>(resultKey = resultKey, resultEventBus = this) { result -> currentWrapper.value.value = NullableWrapper(result) } // Return a custom State wrapper rather than using derivedStateOf. Since unwrapping NullableWrapper // is O(1), an anonymous State avoids the snapshot read-tracking overhead and extra allocations // of derivedStateOf while maintaining a stable reference for the caller. return remember(wrapper) { object : State<T> { override val value: T get() = wrapper.value.value } } } /** * Reified version of [conflateAsSerializableState] using the class name as the key. * * @warning Do not use this overload for generic types (e.g., [List], [Map]) because * JVM type erasure will cause key collisions. Instead, use the version with an explicit `resultKey`. */ @Composable inline fun <reified T> ResultEventBus.conflateAsSerializableState( defaultValue: T, vararg inputs: Any?, configuration: SavedStateConfiguration = SavedStateConfiguration.DEFAULT, ): State<T> = conflateAsSerializableState( resultKey = T::class.toString(), defaultValue = defaultValue, inputs = inputs, stateSerializer = serializer<T>(), configuration = configuration, )
/* * Copyright 2026 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.example.nav3recipes.results.serializable import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.compose.foundation.layout.padding import androidx.compose.material3.Scaffold import androidx.compose.ui.Modifier import androidx.navigation3.runtime.entryProvider import androidx.navigation3.runtime.rememberNavBackStack import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator import androidx.navigation3.runtime.result.LocalResultEventBus import androidx.navigation3.runtime.result.rememberResultEventBusNavEntryDecorator import androidx.navigation3.ui.NavDisplay import com.example.nav3recipes.results.common.Home import com.example.nav3recipes.results.common.HomeScreen import com.example.nav3recipes.results.common.Person import com.example.nav3recipes.results.common.PersonDetailsForm import com.example.nav3recipes.results.common.PersonDetailsScreen import com.example.nav3recipes.ui.setEdgeToEdgeConfig class ResultSerializableActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { setEdgeToEdgeConfig() super.onCreate(savedInstanceState) setContent { Scaffold { paddingValues -> val backStack = rememberNavBackStack(Home) NavDisplay( backStack = backStack, modifier = Modifier.padding(paddingValues), onBack = { backStack.removeLastOrNull() }, entryDecorators = listOf( rememberSaveableStateHolderNavEntryDecorator(), rememberResultEventBusNavEntryDecorator() ), entryProvider = entryProvider { entry<Home> { val resultState = LocalResultEventBus .current .conflateAsSerializableState<Person?>(null) val person = resultState.value HomeScreen( person = person, onNext = { backStack.add(PersonDetailsForm()) } ) } entry<PersonDetailsForm> { val resultBus = LocalResultEventBus.current PersonDetailsScreen( onSubmit = { person -> resultBus.sendResult(result = person) backStack.removeLastOrNull() } ) } } ) } } } }