通过组合导航传递 Parcelable 参数

Pass Parcelable argument with compose navigation

我想使用组合导航将可打包对象 (BluetoothDevice) 传递给可组合对象。

传递基本类型很容易:

composable(
  "profile/{userId}",
  arguments = listOf(navArgument("userId") { type = NavType.StringType })
) {...}
navController.navigate("profile/user1234")

但是我无法在路由中传递可打包对象,除非我可以将其序列化为字符串。

composable(
  "deviceDetails/{device}",
  arguments = listOf(navArgument("device") { type = NavType.ParcelableType(BluetoothDevice::class.java) })
) {...}
val device: BluetoothDevice = ...
navController.navigate("deviceDetails/$device")

上面的代码显然不起作用,因为它只是隐式调用了 toString().

有没有办法将 Parcelable 序列化为 String 以便我可以在路由中传递它或将导航参数作为具有 [=18= 以外的函数的对象传递]?

警告: Ian Lake is an Android Developer Advocate and he says in 传递复杂数据结构是一种反模式(参考文档)。他在这个图书馆工作,所以他有这方面的权力。自己使用下面的方法。

编辑: 已更新为撰写导航 2.4.0-beta07

似乎不​​再支持以前的解决方案。现在您需要创建自定义 NavType.

假设您 class 喜欢:

@Parcelize
data class Device(val id: String, val name: String) : Parcelable

那么你需要定义一个NavType

class AssetParamType : NavType<Device>(isNullableAllowed = false) {
    override fun get(bundle: Bundle, key: String): Device? {
        return bundle.getParcelable(key)
    }

    override fun parseValue(value: String): Device {
        return Gson().fromJson(value, Device::class.java)
    }

    override fun put(bundle: Bundle, key: String, value: Device) {
        bundle.putParcelable(key, value)
    }
}

请注意,我正在使用 Gson 将对象转换为 JSON 字符串。但是你可以使用你喜欢的转换器...

然后像这样声明您的可组合项:

NavHost(...) {
    composable("home") {
        Home(
            onClick = {
                 val device = Device("1", "My device")
                 val json = Uri.encode(Gson().toJson(device))
                 navController.navigate("details/$json")
            }
        )
    }
    composable(
        "details/{device}",
        arguments = listOf(
            navArgument("device") {
                type = AssetParamType()
            }
        )
    ) {
        val device = it.arguments?.getParcelable<Device>("device")
        Details(device)
    }
}

原回答

基本上你可以做到以下几点:

// In the source screen...
navController.currentBackStackEntry?.arguments = 
    Bundle().apply {
        putParcelable("bt_device", device)
    }
navController.navigate("deviceDetails")

并且在详细信息屏幕中...

val device = navController.previousBackStackEntry
    ?.arguments?.getParcelable<BluetoothDevice>("bt_device")

我有一个类似的问题,我必须传递一个包含斜杠的字符串,并且由于它们被用作深度 link 参数的分隔符,我无法做到这一点。逃离他们对我来说似乎并不“干净”。

我想出了以下解决方法,可以根据您的情况轻松调整。我从 androidx.navigation.compose 重写了 NavHostNavController.createGraphNavGraphBuilder.composable 如下:

@Composable
fun NavHost(
    navController: NavHostController,
    startDestination: Screen,
    route: String? = null,
    builder: NavGraphBuilder.() -> Unit
) {
    NavHost(navController, remember(route, startDestination, builder) {
        navController.createGraph(startDestination, route, builder)
    })
}

fun NavController.createGraph(
    startDestination: Screen,
    route: String? = null,
    builder: NavGraphBuilder.() -> Unit
) = navigatorProvider.navigation(route?.hashCode() ?: 0, startDestination.hashCode(), builder)

fun NavGraphBuilder.composable(
    screen: Screen,
    content: @Composable (NavBackStackEntry) -> Unit
) {
    addDestination(ComposeNavigator.Destination(provider[ComposeNavigator::class], content).apply {
        id = screen.hashCode()
    })
}

其中 Screen 是我的目标枚举

sealed class Screen {
    object Index : Screen()
    object Example : Screen()
}

请注意,我删除了深度 links 和参数,因为我没有使用它们。这仍然允许我手动传递和检索参数,并且可以重新添加该功能,我根本不需要它。

假设我希望 Example 接受一个字符串参数 path

const val ARG_PATH = "path"

然后我像这样初始化 NavHost

NavHost(navController, startDestination = Screen.Index) {
    composable(Screen.Index) { IndexScreen(::navToExample) }

    composable(Screen.Example) { navBackStackEntry ->
        navBackStackEntry.arguments?.getString(ARG_PATH)?.let { path ->
            ExampleScreen(path, ::navToIndex)
        }
    }
}

这就是我通过 path

导航到 Example 的方式
fun navToExample(path: String) {
    navController.navigate(Screen.Example.hashCode(), Bundle().apply {
        putString(ARG_PATH, path)
    })
}

我确信这可以改进,但这些是我最初的想法。 要启用深度 links,您需要恢复使用

// composable() and other places
val internalRoute = "android-app://androidx.navigation.compose/$route"
id = internalRoute.hashCode()

根据 nglauber 建议,我创建了两个对我有帮助的扩展程序

@Suppress("UNCHECKED_CAST")
fun <T> NavHostController.getArgument(name: String): T {
    return previousBackStackEntry?.arguments?.getSerializable(name) as? T
        ?: throw IllegalArgumentException()
}

fun NavHostController.putArgument(name: String, arg: Serializable?) {
    currentBackStackEntry?.arguments?.putSerializable(name, arg)
}

我这样使用它们:

Source:
navController.putArgument(NavigationScreens.Pdp.Args.game, game)
navController.navigate(NavigationScreens.Pdp.route)

Destination:
val game = navController.getArgument<Game>(NavigationScreens.Pdp.Args.game)
PdpScreen(game)

如果我们在 navigate(...).

上弹出 (popUpTo(...)) 后退堆栈,@nglauber 给出的 backStackEntry 解决方案将不起作用

所以这是另一个解决方案。我们可以通过将对象转换为 JSON 字符串来传递对象。

示例代码:

val ROUTE_USER_DETAILS = "user-details?user={user}"


// Pass data (I am using Moshi here)
val user = User(id = 1, name = "John Doe") // User is a data class.

val moshi = Moshi.Builder().build()
val jsonAdapter = moshi.adapter(User::class.java).lenient()
val userJson = jsonAdapter.toJson(user)

navController.navigate(
    ROUTE_USER_DETAILS.replace("{user}", userJson)
)


// Receive Data
NavHost {
    composable(ROUTE_USER_DETAILS) { backStackEntry ->
        val userJson =  backStackEntry.arguments?.getString("user")
        val moshi = Moshi.Builder().build()
        val jsonAdapter = moshi.adapter(User::class.java).lenient()
        val userObject = jsonAdapter.fromJson(userJson)

        UserDetailsView(userObject) // Here UserDetailsView is a composable.
    }
}


// Composable function/view
@Composable
fun UserDetailsView(
    user: User
){
    // ...
}

这是另一种解决方案,它也可以通过将 Parcelable 添加到正确的 NavBackStackEntry 而不是先前的条目 。思路是先调用navController.navigate,然后把参数加到NavController.backQueue中的最后一个NavBackStackEntry.arguments。请注意,这确实使用了另一个库组限制 API(用 RestrictTo(LIBRARY_GROUP) 注释),因此可能会中断。一些其他人发布的解决方案使用受限制的 NavBackStackEntry.arguments,但是 NavController.backQueue 也受限制。

以下是 NavController 的一些扩展,用于导航和 NavBackStackEntry 用于检索路由可组合项中的参数:


fun NavController.navigate(
    route: String,
    navOptions: NavOptions? = null,
    navigatorExtras: Navigator.Extras? = null,
    args: List<Pair<String, Parcelable>>? = null,
) {
    if (args == null || args.isEmpty()) {
        navigate(route, navOptions, navigatorExtras)
        return
    }
    navigate(route, navOptions, navigatorExtras)
    val addedEntry: NavBackStackEntry = backQueue.last()
    val argumentBundle: Bundle = addedEntry.arguments ?: Bundle().also {
        addedEntry.arguments = it
    }
    args.forEach { (key, arg) ->
        argumentBundle.putParcelable(key, arg)
    }
}

inline fun <reified T : Parcelable> NavController.navigate(
    route: String,
    navOptions: NavOptions? = null,
    navigatorExtras: Navigator.Extras? = null,
    arg: T? = null,
    
) {
    if (arg == null) {
        navigate(route, navOptions, navigatorExtras)
        return
    }
    navigate(
        route = route,
        navOptions = navOptions,
        navigatorExtras = navigatorExtras,
        args = listOf(T::class.qualifiedName!! to arg),
    )
}

fun NavBackStackEntry.requiredArguments(): Bundle = arguments ?: throw IllegalStateException("Arguments were expected, but none were provided!")

@Composable
inline fun <reified T : Parcelable> NavBackStackEntry.rememberRequiredArgument(
    key: String = T::class.qualifiedName!!,
): T = remember {
    requiredArguments().getParcelable<T>(key) ?: throw IllegalStateException("Expected argument with key: $key of type: ${T::class.qualifiedName!!}")
}

@Composable
inline fun <reified T : Parcelable> NavBackStackEntry.rememberArgument(
    key: String = T::class.qualifiedName!!,
): T? = remember {
    arguments?.getParcelable(key)
}

要使用单个参数导航,您现在可以在 NavGraphBuilder:

的范围内执行此操作
composable(route = "screen_1") {
    Button(
        onClick = {
            navController.navigate(
                route = "screen_2",
                arg = MyParcelableArgument(whatever = "whatever"),
            )
        }
    ) {
        Text("goto screen 2")
    }
}
composable(route = "screen_2") { entry ->
    val arg: MyParcelableArgument = entry.rememberRequiredArgument()
    // TODO: do something with arg
}

或者如果你想传递相同类型的多个参数:

composable(route = "screen_1") {
    Button(
        onClick = {
            navController.navigate(
                route = "screen_2",
                args = listOf(
                    "arg_1" to MyParcelableArgument(whatever = "whatever"),
                    "arg_2" to MyParcelableArgument(whatever = "whatever"),
                ),
            )
        }
    ) {
        Text("goto screen 2")
    }
}
composable(route = "screen_2") { entry ->
    val arg1: MyParcelableArgument = entry.rememberRequiredArgument(key = "arg_1")
    val arg2: MyParcelableArgument = entry.rememberRequiredArgument(key = "arg_2")
    // TODO: do something with args
}

这种方法的主要好处是类似于使用 Moshi 序列化参数的答案,当在 navOptions 中使用 popUpTo 时它会起作用,但也会更有效率因为不涉及 JSON 序列化。

这当然不适用于深度链接,但它会在进程或 activity 重新创建时继续存在。对于需要支持深度链接或什至只是导航路由的可选参数的情况,您可以使用 entry.rememberArgument 扩展。与 entry.rememberRequiredArgument 不同,它将 return null 而不是抛出 IllegalStateException.

我为 NavController 写了一个小扩展。

import android.os.Bundle
import androidx.core.net.toUri
import androidx.navigation.*

fun NavController.navigate(
    route: String,
    args: Bundle,
    navOptions: NavOptions? = null,
    navigatorExtras: Navigator.Extras? = null
) {
    val routeLink = NavDeepLinkRequest
        .Builder
        .fromUri(NavDestination.createRoute(route).toUri())
        .build()

    val deepLinkMatch = graph.matchDeepLink(routeLink)
    if (deepLinkMatch != null) {
        val destination = deepLinkMatch.destination
        val id = destination.id
        navigate(id, args, navOptions, navigatorExtras)
    } else {
        navigate(route, navOptions, navigatorExtras)
    }
}

正如您所看到的,至少有 16 个函数“navigate”具有不同的参数,所以它只是一个转换器供使用

public open fun navigate(@IdRes resId: Int, args: Bundle?) 

因此,使用此扩展,您可以使用 Compose Navigation,而无需这些可怕的深层 link 参数作为路由参数。

因为 nglauber 的 答案在前进时有效,而在向后导航时无效,你得到一个空值。我想也许至少暂时我们可以在我们的可组合项中使用 remember 保存传递的参数,并希望他们将 Parcelable 参数类型添加到路由导航中。

目标可组合目标:

composable("yourRout") { backStackEntry ->
                backStackEntry.arguments?.let {
                    val rememberedProject = remember { mutableStateOf<Project?>(null) }
                    val project =
                        navController.previousBackStackEntry?.arguments?.getParcelable(
                            PROJECT_ARGUMENT_KEY
                        ) ?: rememberedProject.value
                    rememberedProject.value = project
                    TargetScreen(
                        project = project ?: throw IllegalArgumentException("parcelable was null"),
                    )
                }

这是源代码:触发导航:

navController.currentBackStackEntry?.arguments =
            Bundle().apply {
                putParcelable(PROJECT_ARGUMENT_KEY, project)
            }
        navController.navigate("yourRout")

这是我使用 BackStackEntry

的版本

用法:

composable("your_route") { entry ->
    AwesomeScreen(entry.requiredArg("your_arg_key"))
}
navController.navigate("your_route", "your_arg_key" to yourArg)

扩展:

fun NavController.navigate(route: String, vararg args: Pair<String, Parcelable>) {
    navigate(route)
    
    requireNotNull(currentBackStackEntry?.arguments).apply {
        args.forEach { (key: String, arg: Parcelable) ->
            putParcelable(key, arg)
        }
    }
}

inline fun <reified T : Parcelable> NavBackStackEntry.requiredArg(key: String): T {
    return requireNotNull(arguments) { "arguments bundle is null" }.run {
        requireNotNull(getParcelable(key)) { "argument for $key is null" }
    }
}

一个非常简单和基本的方法如下

1.First 创建要传递的可打包对象,例如

@Parcelize
data class User(
    val name: String,
    val phoneNumber:String
) : Parcelable

2.Then 在您所在的当前可组合项中,例如主屏幕

 val userDetails = UserDetails(
                            name = "emma",
                             phoneNumber = "1234"
                            )
                        )
navController.currentBackStackEntry?.arguments?.apply {
                            putParcelable("userDetails",userDetails)
                        }
                        navController.navigate(Destination.DetailsScreen.route)

3.Then 在 details composable 中,确保将 navcontroller 作为参数传递给它,例如

@Composable
fun Details (navController:NavController){
val data = remember {
        mutableStateOf(navController.previousBackStackEntry?.arguments?.getParcelable<UserDetails>("userDetails")!!)
    }
}

N.B: 如果 parcelable 没有被传递到状态,你会在返回时收到一个错误

我对 Moshi 的处理方式:

路线

sealed class Route(
    private val route: String,
    val Key: String = "",
) {
    object Main : Route(route = "main")
    object Profile : Route(route = "profile", Key = "user")

    override fun toString(): String {
        return when {
            Key.isNotEmpty() -> "$route/{$Key}"
            else -> route
        }
    }
}

扩展

import android.net.Uri
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.core.net.toUri
import androidx.navigation.*
import com.squareup.moshi.Moshi

inline fun <reified T> NavController.navigate(
    route: String,
    data: Pair<String, T>,
    navOptions: NavOptions? = null,
    navigatorExtras: Navigator.Extras? = null,
) {
    val count = route
        .split("{${data.first}}")
        .size
        .dec()

    if (count != 1) {
        throw IllegalArgumentException()
    }

    val out = Moshi.Builder()
        .build()
        .adapter(T::class.java)
        .toJson(data.second)
    val newRoute = route.replace(
        oldValue = "{${data.first}}",
        newValue = Uri.encode(out),
    )

    navigate(
        request = NavDeepLinkRequest.Builder
            .fromUri(NavDestination.createRoute(route = newRoute).toUri())
            .build(),
        navOptions = navOptions,
        navigatorExtras = navigatorExtras,
    )
}

inline fun <reified T> NavBackStackEntry.getData(key: String): T? {
    val data = arguments?.getString(key)

    return when {
        data != null -> Moshi.Builder()
            .build()
            .adapter(T::class.java)
            .fromJson(data)
        else -> null
    }
}

@Composable
inline fun <reified T> NavBackStackEntry.rememberGetData(key: String): T? {
    return remember { getData<T>(key) }
}

用法示例

data class User(
    val id: Int,
    val name: String,
)

@Composable
fun RootNavGraph() {
    val navController = rememberNavController()
    
    NavHost(
        navController = navController,
        startDestination = "${Route.Main}",
    ) {
        composable(
            route = "${Route.Main}",
        ) {
           Button(
              onClick = {
                  navController.navigate(
                      route = "${Route.Profile}",
                      data = Route.Profile.Key to User(id = 1000, name = "John Doe"),
                  )
              },
              content = { Text(text = "Go to Profile") },
        }

        composable(
            route = "${Route.Profile}",
            arguments = listOf(
                navArgument(name = Route.Profile.Key) { type = NavType.StringType },
            ),
        ) { entry ->
            val user = entry.rememberGetData<User>(key = Route.Profile.Key)

            Text(text = "$user")
        }
    }
}