干货-Jectpack Compose 通过Navigation 传递 Serializable / Parcelable三种实现

3,000 阅读3分钟

在Jetpack Compose中导航可以使用Jetpack中的Navigation数据传输组件进行数据传输。

先决条件

在app的build.gradle中引入Navigation依赖即可,如下:

dependencies {
    //导航依赖库 
    implementation "androidx.navigation:navigation-compose:2.4.2"
    
    //Gson解析,后边用到
    implementation 'com.google.code.gson:gson:2.9.0'  
}

备注:上述导航组件是没有动画的,如果需要增加跳转动画,则需要引入google开发的带动画的导航库,如下:

//带动画的导航依赖库
implementation "com.google.accompanist:accompanist-navigation-animation:0.24.3-alpha"

本文主要是通过navigation-compose导航来实现跳转,至于带动画的,可以自行查看别的文章,实现大体一致。

使用Navigation导航用到两个比较重要的对象NavHost和NavController。

  • NavHost用来承载页面,和管理导航图
  • NavController用来控制如何导航还有参数回退栈等

在官方给的例子中都是通过传递常用数据类型来实现跳转时的参数传递。

我们先用compose实现需要路由导航的两个界面FirstScreenSecondScreen,代码如下

@Composable
fun FirstScreen(navigateTo: () -> Unit) {
    Column(
        modifier = Modifier
            .fillMaxSize()
            .background(Color.Red),

        horizontalAlignment = Alignment.CenterHorizontally,  //横向居中
        verticalArrangement = Arrangement.Center,  //纵向居中
    ) {
        Text(text = "这是第一个界面")

        Button(onClick = {
            navigateTo.invoke()
        }) {
            Text(text = "跳转到第二页")
        }
    }
}

@Composable
fun SecondScreen(name: String?, age: Int?, navigateTo: () -> Unit) {
    Column(
        modifier = Modifier
            .fillMaxSize()
            .background(Color.Green),

        horizontalAlignment = Alignment.CenterHorizontally,  //横向居中
        verticalArrangement = Arrangement.Center,  //纵向居中
    ) {
        Text(text = "这是第二个界面 传递的参数为:姓名:$name; 年龄:${age}岁")

        Button(onClick = {
            navigateTo.invoke()
        }) {
            Text(text = "跳转到第三页")
        }
    }
}


@Composable
fun ThirdScreen(carName: String?, navigateTo: () -> Unit) {
    Column(
        modifier = Modifier
            .fillMaxSize()
            .background(Color.Gray),

        horizontalAlignment = Alignment.CenterHorizontally,  //横向居中
        verticalArrangement = Arrangement.Center,  //纵向居中
    ) {
        Text(text = "这是第三个界面 carName: $carName")

        Button(onClick = {
            navigateTo.invoke()
        }) {
            Text(text = "回到第一页")
        }
    }
}

上述代码中我们定义了两个界面,界面只包含一个Text和一个Button,另外为了避免navController在各个界面中的传递,我们定义了一个函数navigateTo,用来回调跳转操作,同时统一管理跳转实现,便于管理路由。

为了路由不容易出错,我们定义两个常量,如下

const val ROUTE_FIRST = "routeFirst"

const val ROUTE_SECOND = "routeSecond"

const val ROUTE_THIRD = "routeThird"

另外还需要一个实体类,如下

data class User(val name: String? = null, val age: Int = 0): Serializable

到这里我们的准备工作就做完了,接下来干货走起

无参数跳转

我们通过一个小例子来感受一下

val navController = rememberNavController()  //导航控制器
NavHost(
    navController = navController,
    startDestination = Route.ROUTE_FIRST, //启始页,该参数和`route`相对应,比如我现在启始页是第一个页面,也就是`First` 
    builder = {
        composable(route = Route.ROUTE_FIRST) {  //route: 表示路由名称,跳转时需要
            FirstScreen {
                navController.navigate(Route.ROUTE_SECOND)
            }
        }

        composable(route = Route.ROUTE_SECOND) {

            SecondScreen("name", 0) {
                navController.navigate("Third?carName=五菱宏光 Mini")
            }
        }
    }
)
  • 通过 rememberNavController() 方法创建一个navController对象。

  • 创建 NavHost 对象,传入navController并通过startDestination指定启动页

  • 通过 composable() 方法往NavHost中添加页面,构造方法中的route就代表该页面的路径,后面的函数就是具体的页面。

  • 通过navControllernavigate()实现最终路由跳转

通过上面的代码我们就实现了一个最简单的跳转,但是实际项目中页面之间的跳转免不了传参。那么Compose是如何传参的呢?

带参(基本数据类型)跳转

参数传递肯定有发送端和接受端,在Compose中,navController就是发送端,通过navController.navigate(路由名+参数值)发送,接受端通过NavHostcomposable(route=路由名+参数名, arugments = listOf())的route定义参数名以及通过arguments定义参数类型。如下伪代码

NavHost(
    navController = navController,
    startDestination = "启动页路由名A"
    builder = {
        composable(route = "启动页路由名A") {
            FirstScreen {
                navController.navigate("路由名B/待传递参数b")
            }
        }

        composable(route = "路由名B/{参数名b}", arguments = listOf(
                    navArgument("参数名b") {
                        type = NavType.IntType   //参数类型
                        defaultValue = 18        //默认值
                        nullable = true          //是否可空
                    }
                )
        ) {
        
            val name = it.arguments?.getString("参数名b")  //通过`参数名b`获取参数返回值
        }
    }
)

完整示例:

@Composable
fun NavSample() {
    val navController = rememberNavController()  //导航控制器
    NavHost(
        navController = navController,
        startDestination = Route.ROUTE_FIRST,  //启始页,该参数和`route`相对应,比如我现在启始页是第一个页面,也就是`First`
        builder = {
            composable(route = Route.ROUTE_FIRST) {  //route: 表示路由名称,跳转时需要
                FirstScreen {
                    navController.navigate("${Route.ROUTE_SECOND}/Kevin/10")
                }
            }

            composable(
                    route = "${Route.ROUTE_SECOND}/{name}/{age}", arguments = listOf(
                    navArgument("age") {
                        type = NavType.IntType  //类型
                    }
                )
            ) {
                val name = it.arguments?.getString("name")
                val age = it.arguments?.getInt("age")
                SecondScreen(name, age) {
            
                }
            }
        }
    )
}

带参(可选基本数据类型参数)跳转

上面传递的参数为必传参数,Navigation Compose还支持可选参数。可选参数和必传参数有以下两点不同:

  • 可选参数必须使用查询参数语法?argName={argName} 来添加
  • 可选参数必须具有 defaultValue 或 nullability = true (将默认值设置为 null)

这意味着,所有可选参数都必须以及列表的形式显式添加到 composable 方法中

即使没有传递任何参数,系统也会使用 Default Value 来作为参数值传递到目的地页面

完整示例:

@Composable
fun NavSample() {
    val navController = rememberNavController()  //导航控制器
    NavHost(
        navController = navController,
        startDestination = Route.ROUTE_FIRST,  //启始页,该参数和`route`相对应,比如我现在启始页是第一个页面,也就是`First`
        builder = {
            composable(route = Route.ROUTE_FIRST) {  //route: 表示路由名称,跳转时需要
                FirstScreen {
                    navController.navigate("${Route.ROUTE_SECOND}/Kevin/10")
                }
            }

            composable(
                    route = "${Route.ROUTE_SECOND}/{name}/{age}", arguments = listOf(
                    navArgument("age") {
                        type = NavType.IntType  //类型
//                    defaultValue = 18  //默认值
//                    nullable = true //是否可空
                    }
                )
            ) {
                val name = it.arguments?.getString("name")
                val age = it.arguments?.getInt("age")
                SecondScreen(name, age) {
                    navController.navigate("${Route.ROUTE_THIRD}?carName=五菱宏光 Mini")
                }
            }

            composable(
                route = "${Route.ROUTE_THIRD}?carName={carName}", arguments = listOf(
                    navArgument("carName") {
                        defaultValue = "保时捷卡宴"
                    }
                )
            ) {
                val carName = it.arguments?.getString("carName")
                ThirdScreen(carName) {
                    navController.popBackStack(
                        route = Route.ROUTE_FIRST,
                        inclusive = false //是否包含要跳转的路由页
                    )
                }
            }
        }
    )
}

带参(序列化data class)跳转

上述跳转以及带基本数据类型的跳转其实Google官网说的比我清楚,更多客参考 使用Compose 进行导航,但是我们的目的不止如此,我们最终的目的是如何实现 Compose Navigation 传递 data class 实体类

方案一 使用NavController自带的Argument属性

我们先来看看最终代码,如下

@Composable
fun NavGraphSample1() {
    val navController = rememberNavController()
    NavHost(
        navController = navController,
        startDestination = Route.ROUTE_FIRST
    ) {
        composable(Route.ROUTE_FIRST) { navBackStackEntry ->
            FirstScreen1 {
                val args = listOf(Pair("intentText", User("Kevin", 10)))
                navController.navigateAndArgument(
                    Route.ROUTE_SECOND,
                    args = args
                )
            }
        }

        composable(Route.ROUTE_SECOND) {
            val intentText = it.arguments?.get("intentText") as User
            SecondScreen1(name = intentText.name, intentText.age) {

            }
        }
    }
}

fun NavController.navigateAndArgument(
    route: String,
    args: List<Pair<String, Any>>? = null,
    navOptions: NavOptions? = null,
    navigatorExtras: Navigator.Extras? = null,

    ) {
    navigate(route = route, navOptions = navOptions, navigatorExtras = navigatorExtras)

    if (args == null && args?.isEmpty() == true) {
        return
    }

    val bundle = backQueue.lastOrNull()?.arguments
    if (bundle != null) {
        bundle.putAll(bundleOf(*args?.toTypedArray()!!))
    } else {
        println("The last argument of NavBackStackEntry is NULL")
    }
}

基本上就是这样获取和添加Route对应的NavBackStackEntry。

  • ⓵ :当调用 NavController#navigate 时,根据传递的路由找到匹配的 DeepLinkbackQueue并添加。
  • ⓶ : 获取操作⓵添加的 NavBackStackEntry 的 Argument。
  • ⓷:将必要的数据添加到 Argument。

代码说明

NavController 内部包含我们添加到 BackStack 的 Entry。我们得到这里包含的 NavBackStackEntry 并使用它。

public open class NavController { 
        // ... 
        /** 
        * Retrieve the current back stack. 
        * 
        * @return The current back stack. 
        * @hide 
        */ 
        @get:RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) public open val backQueue: ArrayDeque<NavBackStackEntry> = ArrayDeque()
        // ... 
}

backQueue 属性是 LIBRARY_GROUP,因此将来可能无法从外部访问它。

让我们看看在 NavBackStackEntry 中添加数据的参数。arguments 属性定义为可空且只读,因此实际访问时有可能为空,因此数据传递可能会失败。

public class NavBackStackEntry private constructor( 
        // ...
        /** 
        * The arguments used for this entry 
        * @return The arguments used when this entry was created 
        */
        public val arguments: Bundle? = null,
)

因此,该方案可能出现参数传递为空的情况。

方案二 共享 ViewModel

共享ViewModel相对就比较简单了,直接看代码

/**
 * ViewModel共享数据
 */
@Composable
fun NavGraphSample2() {

    val viewModel: Sample2ViewModel = viewModel()

    val navController = rememberNavController()

    NavHost(navController = navController, startDestination = Route.ROUTE_FIRST) {

        composable(route = Route.ROUTE_FIRST) {

            FirstScreen1 {

                viewModel.user = User("Kevin", 11)

                navController.navigate(route = Route.ROUTE_SECOND)
            }
        }

        composable(route = Route.ROUTE_SECOND) { navBackStackEntry ->

            println("NavGraphSample2 print: user: ${viewModel.user}")
        }
    }
}

class Sample2ViewModel: ViewModel() {
    var user: User? = User()
}

通过对ViewModel中的user对象赋值和取值来达到效果

注意:上述ViewModel只能实例化一次,也就是赋值和取值应该用同一个ViewModel,否则数据将无法共享。

方案三 自定义 NavType 实现

我们将通过 Serializable/Parcelable。而且,序列化处理使用Kotlin Serialization。使用下面建模的类实现了一个简单的形式。

我们定义两个实体类,如下

@kotlinx.serialization.Serializable
data class UserSerializable(val name: String? = null, val age: Int = 0) : Serializable

@Parcelize
data class UserParcelable(val name: String? = null, val age: Int = 0) : Parcelable

然后,创建自定义 NavType,出于封装的考虑,我们还为 Serializable/Parcelable 类型创建了工厂函数。下面的函数在每次调用时创建并返回一个新的 NavType

inline fun <reified T : Serializable> createSerializableNavType(
    isNullableAllowed: Boolean = false
): NavType<T> {
    return object : NavType<T>(isNullableAllowed) {

        override val name: String
            get() = "SupportSerializable"

        override fun get(bundle: Bundle, key: String): T? {  //从Bundle中检索 Serializable类型
            return bundle.getSerializable(key) as? T
        }

        override fun put(bundle: Bundle, key: String, value: T) {  //作为 Serializable 类型添加到 Bundle
            bundle.putSerializable(key, value)
        }

        override fun parseValue(value: String): T {  //定义传递给 String 的 Parsing 方法
            return Gson().fromJson(value, T::class.java)
        }
    }
}

inline fun <reified T : Parcelable> createParcelableNavType(isNullableAllowed: Boolean = false): NavType<T> {
    return object : NavType<T>(isNullableAllowed) {

        override val name: String
            get() = "SupportParcelable"

        override fun get(bundle: Bundle, key: String): T? {  //从Bundle中检索 Parcelable类型
            return bundle.getParcelable(key)
        }

        override fun parseValue(value: String): T {  //定义传递给 String 的 Parsing 方法
            return Gson().fromJson(value, T::class.java)
        }

        override fun put(bundle: Bundle, key: String, value: T) {  //作为 Parcelable 类型添加到 Bundle
            bundle.putParcelable(key, value)
        }

    }
}

接下来,我们使用前面自定义的 NavTypeNavGraphBuilder 中定义 Composable

@Composable
fun NavGraphSample3() {

    val navController = rememberNavController()

    NavHost(
        navController = navController,
        startDestination = Route.ROUTE_FIRST,
        builder = {
            composable(Route.ROUTE_FIRST) {
                FirstScreen1 {
                    val jsonSerializable = Gson().toJson(UserSerializable("KevinSerializable", 100))
                    val jsonParcelable = Gson().toJson(UserParcelable("KevinParcelable", 200))
                    navController.navigate(Route.ROUTE_SECOND + "?key=${Uri.encode(jsonSerializable)}&key1=${Uri.encode(jsonParcelable)}")
                }
            }
            composable(
                Route.ROUTE_SECOND + "?key={test_serializable}&key1={test_parcelable}",
                arguments = listOf(
                    navArgument("test_serializable") {
                        type = createSerializableNavType<UserSerializable>()
                    },
                    navArgument( "test_parcelable") {
                        type = createParcelableNavType<UserParcelable>()
                    }
                )
            ) { navBackStackEntry ->  //根据导航规范定义路由和参数

                val arguments = navBackStackEntry.arguments

                val userBean = arguments?.getSerializable("test_serializable") as? UserSerializable

                val userParcelableBean = arguments?.getParcelable<UserParcelable>("test_parcelable")

                println("NavGraphSample3 serializable print: name: ${userBean?.name}; age: ${userBean?.age}")

                println("NavGraphSample3 parcelable print: name: ${userParcelableBean?.name}; age: ${userParcelableBean?.age}")

                SecondScreen1(name = userBean?.name, age = userBean?.age) {

                }
            }
        }
    )
}
  • 首先将 data class实体类转换成json,通过navigate(路由名?key={参数1}&key1={参数2})执行数据传递

  • 其次通过composable进行路由定位,并且通过不同的arguments定义不同的类型

  • 最后使用NavBackStackEntry提供的argumentsgetSerializable()或者getParcelable()来获取实体数据

通过上述做法,我们就可以很方便的实现携带Serializable/Parcelable参数数据跳转。