Jetpack Compose 初学者指南:Navigation 组件全面讲解

Navigation 3的用法和Navigation 2大体相似,但增加了许多新功能,比如大屏模式适配等。由于Navigation 3刚推出,目前还处于alpha版阶段,API不够稳定,后期可能存在变动。下面以Navigation 2的用法进行讲解。

首先,想使用Navigation组件,要先添加必要的依赖库:

dependencies {
    implementation "androidx.navigation:navigation-compose:2.9.3"
}

接下来修改MyApp()函数中的代码,如下:

const val SCREEN_A_ROUTE = "screen_a"
const val SCREEN_B_ROUTE = "screen_b"
const val SCREEN_C_ROUTE = "screen_c"

@Composable
fun MyApp(modifier: Modifier = Modifier) {
    val navController = rememberNavController()
    NavHost(
        navController = navController,
        startDestination = SCREEN_A_ROUTE,
        modifier = modifier
    ) {
        composable(route = SCREEN_A_ROUTE) {
            ScreenA(onClick = {
                navController.navigate(SCREEN_B_ROUTE)
            })
        }
        composable(route = SCREEN_B_ROUTE) {
            ScreenB(onClick = {
                navController.navigate(SCREEN_C_ROUTE)
            })
        }
        composable(route = SCREEN_C_ROUTE) {
            ScreenC(onClick = {
                navController.navigate(SCREEN_A_ROUTE)
            })
        }
    }
}

这段代码不长,是简化后包含Navigation基本用法的最小代码示例。

使用Navigation,首先要创建NavController对象,可借助rememberNavController()函数创建。通常要将NavController对象放在整个Compose的最顶层结构中,这样它能管理整个App所有页面的导航与跳转,同时这种写法也最符合Compose提倡的状态提升原则。

接着,调用NavHost()函数,将刚才创建的NavController对象作为参数传入。NavHost()还要求传入startDestination参数,这个参数指定程序刚启动时,App应展示的首个页面。上述代码中,传入的字符串常量代表ScreenA页面。

这就是Navigation中的“route”概念,即路由。在Navigation 2中,页面的路由定义很简单,就是一个字符串,但要保证字符串唯一。而在Navigation 3中,路由的定义方式会更丰富,这部分我们之后的文章再讲。

最后,要在NavHost()函数的闭包中定义每个路由字符串与对应Compose页面的映射关系。比如,遇到路由A,就显示ScreenA页面;遇到路由B,就显示ScreenB页面。

Navigation的组件定义到这里就完成了,但别忘了处理页面跳转逻辑。点击按钮时,调用NavController的navigate()函数,传入对应页面的路由字符串就行。

上述代码的写法是在每个页面(ScreenA、ScreenB、ScreenC)中单独处理导航事件,所以它们都要在各自按钮的点击事件回调中调用NavController的navigate()函数。

如果想在一个统一的函数中专门处理这些页面的跳转事件,该怎么做?

首先,得知晓当前页面的路由。只有知道当前在哪个页面,才能确定要跳转到的下一个页面。

在Navigation库中,可借助NavDestination获取当前页面的路由,代码如下:

navController.currentDestination?.route

有了当前页面的路由值,就能按如下方式修改刚才的代码:

@Composable
fun MyApp(modifier: Modifier = Modifier) {
    val navController = rememberNavController()
    val onClick = {
        when (navController.currentDestination?.route) {
            SCREEN_A_ROUTE -> navController.navigate(SCREEN_B_ROUTE)
            SCREEN_B_ROUTE -> navController.navigate(SCREEN_C_ROUTE)
            SCREEN_C_ROUTE -> navController.navigate(SCREEN_A_ROUTE)
        }
    }
    NavHost(
        ...
    ) {
        composable(SCREEN_A_ROUTE) {
            ScreenA(onClick = onClick)
        }
        composable(SCREEN_B_ROUTE) {
            ScreenB(onClick = onClick)
        }
        composable(SCREEN_C_ROUTE) {
            ScreenC(onClick = onClick)
        }
    }
}

这种写法也能实现和之前完全一样的页面跳转效果。

如果想在Composable函数中获取当前页面的路由值,而非在点击事件的回调中获取,就得使用带State版的NavDestination API。基本语法如下:

val currentBackStack by navController.currentBackStackEntryAsState()
currentBackStack?.destination?.route

这里拿到的当前页面路由是带状态的,能直接在Composable函数中使用。比如下面的代码:当访问页面C时,弹出一段Toast提示。

@Composable
fun MyApp(modifier: Modifier = Modifier) {
    val navController = rememberNavController()
    val currentBackStack by navController.currentBackStackEntryAsState()
    if (currentBackStack?.destination?.route == SCREEN_C_ROUTE) {
        Toast.makeText(LocalContext.current, "You are in Screen C", Toast.LENGTH_SHORT).show()
    }
    ...
}

大家都知道,Activity有4种launchMode,除了standard,还有singleTop、singleTask和singleInstance。

使用Navigation导航,也能实现类似launchMode的效果。

比如,在singleTask模式下,按同样顺序进行页面跳转:ScreenA -> ScreenB -> ScreenC -> ScreenA。

从ScreenC跳转到ScreenA时,系统会发现当前返回栈中已有一个ScreenA,不会再创建新的ScreenA,而是将ScreenC和ScreenB从返回栈中依次弹出,让返回栈最上面的页面变成ScreenA。

这是在Activity中用singleTask模式导航实现的效果,下面我们在Compose中借助Navigation选项实现类似效果:

@Composable
fun MyApp(modifier: Modifier = Modifier) {
    val navController = rememberNavController()
    val onClick = {
        when (navController.currentDestination?.route) {
            SCREEN_A_ROUTE -> navController.navigate(SCREEN_B_ROUTE)
            SCREEN_B_ROUTE -> navController.navigate(SCREEN_C_ROUTE)
            SCREEN_C_ROUTE -> navController.navigate(SCREEN_A_ROUTE) {
                popUpTo(SCREEN_A_ROUTE)
            }
        }
    }
    ...
}

观察上述代码:从ScreenC跳转到ScreenA时,通过闭包增加了一个Navigation选项——popUpTo(SCREEN_A_ROUTE)。这是什么意思?

popUpTo()的作用是:调用navigate()函数跳转到ScreenA时,不是从当前的ScreenC直接跳转,而是先执行导航出栈操作,一直出栈到popUpTo()函数传入参数对应的页面,之后再跳转到ScreenA。

这个过程和刚才Activity中singleTask模式的导航效果类似。

我们已成功实现用Navigation进行页面跳转,但目前只能单纯跳转页面。如果想在页面跳转的同时携带参数,该怎么做?

这个需求在Activity中很常见,也很简单,因为Activity用Intent跳转,而Intent本身就能传递参数。相比之下,在Compose中用Navigation同时携带参数会稍显麻烦,但不用着急,现在我们就解决这个问题。

这里用最简化的例子演示Navigation参数传递功能:从页面A跳转到页面B时,携带一个字符串参数和一个整型参数。

首先,要让页面B能接收这个字符串参数和整型参数。 修改ScreenB()函数的代码,如下:

@Composable
fun ScreenB(
    modifier: Modifier = Modifier,
    name: String? = null,
    age: Int? = null,
    onClick: () -> Unit
) {
    Column(
        modifier = modifier.fillMaxSize(),
        horizontalAlignment = Alignment.CenterHorizontally,
        verticalArrangement = Arrangement.Center
    ) {
        Text(text = "Screen B", fontSize = 50.sp)
        Button(onClick = onClick) {
            Text(text = "Go to Next Screen", fontSize = 20.sp)
        }
        if (name != null) {
            Text(text = "Name: $name", fontSize = 20.sp)
        }
        if (age != null) {
            Text(text = "Age: $age", fontSize = 20.sp)
        }
    }
}

能看到,给ScreenB新增了name参数和age参数,这两个参数都可为空。如果参数不为空,就将参数内容显示在界面上。

ScreenB虽已支持接收额外参数,但还不够,还得在Navigation的路由定义层面让ScreenB支持参数传递。 接着修改NavHost中的代码,如下:

NavHost(
    navController = navController,
    startDestination = SCREEN_A_ROUTE,
    modifier = modifier
) {
    ...
    composable(
        "${SCREEN_B_ROUTE}/{name}/{age}",
        arguments = listOf(
            navArgument("name") { type = NavType.StringType },
            navArgument("age") { type = NavType.IntType }
        )) { navBackStackEntry ->
        val name = navBackStackEntry.arguments?.getString("name")
        val age = navBackStackEntry.arguments?.getInt("age")
        ScreenB(name = name, age = age, onClick = onClick)
    }
    ...
}

这段代码是整个Navigation参数传递部分较复杂的部分,但因为用的是极简示例,理解起来相对容易。

首先看路由定义层面:之前为每个页面定义路由用的是一个字符串,现在还是一个字符串,但可通过斜杠(/)增加对参数的支持。上述代码中,就是在路由上增加了name和age两个参数。注意,参数一定要用{}包裹。

不过,在字符串中指定额外参数后,无法指定这些参数的数据类型。因此要给composable函数传递arguments参数,构建一个list集合,然后分别调用navArgument()给每个路由参数指定数据类型。

接下来,借助NavBackStackEntry获取传入的name和age参数内容,再传递给ScreenB就行。

路由定义完成后,最后一步:在执行页面跳转的地方传入name和age参数值。 修改页面A点击事件处的代码,如下:

@Composable
fun MyApp(modifier: Modifier = Modifier) {
    val navController = rememberNavController()
    val onClick = {
        when (navController.currentDestination?.route) {
            SCREEN_A_ROUTE -> {
                val name = "Hello world"
                val age = 100
                navController.navigate("${SCREEN_B_ROUTE}/$name/$age")
            }
        }
    }
    ...
}

这里从页面A跳转到页面B时,在navigate()函数中指定了name和age两个参数。

最后介绍Navigation对deep link的支持。deep link能直接打开App中的指定页面,不用先从App首页开始一层层点击进入目标页面,这个功能在Activity模式的页面导航中很常见。

在Compose中也能实现deep link功能,Navigation还专门为deep link功能加入了相应的API支持。学完Navigation传递参数后,再学deep link会很简单,因为它们的用法比较相似。

下面我们尝试让页面B能通过deep link链接直接打开。

首先,要让当前的Activity支持deep link,需先修改AndroidManifest.xml文件的配置,如下:

<activity
    android:name=".MainActivity"
    android:exported="true"
    android:label="@string/app_name"
    android:theme="@style/Theme.NavigationTest">
    <intent-filter>
        <action android:name="android.intent.action.MAIN" />
        <category android:name="android.intent.category.LAUNCHER" />
    </intent-filter>
    <intent-filter>
        <action android:name="android.intent.action.VIEW" />
        <category android:name="android.intent.category.DEFAULT" />
        <category android:name="android.intent.category.BROWSABLE" />
        <data android:scheme="myapp" android:host="screen_b" />
    </intent-filter>
</activity>

这里给MainActivity增加了一个新的intent-filter,通过标签指定了myapp的scheme和screen_b的host。MainActivity能支持类似myapp://screen_b这种格式的deep link。

接下来的步骤很关键:在ScreenB的路由定义处给它增加deep link支持,代码如下:

composable(
    "${SCREEN_B_ROUTE}/{name}/{age}",
    arguments = listOf(
        navArgument("name") { type = NavType.StringType },
        navArgument("age") { type = NavType.IntType }
    ),
    deepLinks = listOf(
        navDeepLink { uriPattern = "myapp://${SCREEN_B_ROUTE}/{name}/{age}" }
    )) { navBackStackEntry ->
    val name = navBackStackEntry.arguments?.getString("name")
    val age = navBackStackEntry.arguments?.getInt("age")
    ScreenB(name = name, age = age, onClick = onClick)
}

能看到,composable函数可传递deepLinks参数,显然这是用于给当前页面增加deep link支持的。 然后调用navDeepLink函数给ScreenB创建deep link格式:

myapp://${SCREEN_B_ROUTE}/{name}/{age}

这样,用如下链接就能直接打开页面B,还能额外给页面B传递参数:

myapp://screen_b/Tom/35

测试方式有多种:可制作简单的测试网页,配置好上述超链接,点击后查看能否直接打开页面B;也能更简单些,直接用下面的adb命令测试:

adb shell am start -d "myapp://screen_b/Tom/35" -a android.intent.action.VIEW
我的笔记