返回结果(基于可序列化状态)

此配方演示了如何使用基于状态的方法将结果从一个界面返回到上一个界面,该方法利用 Kotlin 序列化和 rememberSerializable,可在配置更改和进程终止后保留结果。

运作方式

此示例以 ResultEventBus 为基础,并引入了自定义扩展函数 conflateAsSerializableState

  1. ResultEventBusNavEntryDecorator:一个 NavEntryDecorator,通过 LocalResultEventBus 提供 ResultEventBus
  2. ResultEventBus:系统会创建 ResultEventBus,并通过 LocalResultEventBus 使其可供可组合项使用。此 EventBus 会发送和接收结果。
  3. conflateAsSerializableStateResultEventBus 上的自定义扩展函数,使用 rememberSerializable 创建状态容器,并使用 ResultEffect 监听新结果并保留这些结果。
  4. 发送结果:生成结果的界面会调用 resultBus.sendResult(person) 以将数据发送回去。
  5. 观测结果:需要结果的界面会调用 LocalResultEventBus.current.conflateAsSerializableState<Person?>(null) 以获取 State 对象。界面会观测此状态,并在结果发生变化时进行重组。

当结果需要在配置更改和进程终止后保留时,此方法非常适用,而标准 conflateAsState 则不适用。

支持可为 null 的类型

标准 rememberSerializable 函数具有 T : Any 的泛型上限约束,这会阻止直接保留可为 null 的类型。

为了支持可为 null 的类型(例如 Person?),conflateAsSerializableState 通过在内部将值封装在泛型 @SerializableNullableWrapper 中来规避此限制:

@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()
                                }
                            )
                        }
                    }
                )
            }
        }
    }
}