使用 Compose 进行导航

Navigation 组件支持 Jetpack Compose 应用。您可以在利用 Navigation 组件的基础架构和功能的同时,在可组合项之间导航。

设置

如需支持 Compose,请在应用模块的 build.gradle 文件中使用以下依赖项:

Groovy

dependencies {
    def nav_version = "2.5.3"

    implementation "androidx.navigation:navigation-compose:$nav_version"
}

Kotlin

dependencies {
    def nav_version = "2.5.3"

    implementation("androidx.navigation:navigation-compose:$nav_version")
}

开始使用

NavController 是 Navigation 组件的中心 API。此 API 是有状态的,可以跟踪组成应用屏幕的可组合项的返回堆栈以及每个屏幕的状态。

您可以通过在可组合项中使用 rememberNavController() 方法来创建 NavController

val navController = rememberNavController()

您应该在可组合项层次结构中的适当位置创建 NavController,使所有需要引用它的可组合项都可以访问它。这遵循状态提升的原则,并且允许您使用 NavController 及其通过 currentBackStackEntryAsState() 提供的状态作为更新屏幕外的可组合项的可信来源。有关此功能的示例,请参阅与底部导航栏集成

创建 NavHost

每个 NavController 都必须与一个 NavHost 可组合项相关联。NavHostNavController 与导航图相关联,后者用于指定您应能够在其间进行导航的可组合项目的地。当您在可组合项之间进行导航时,NavHost 的内容会自动进行重组。导航图中的每个可组合项目的地都与一个路线相关联。

如需创建 NavHost,您需要使用之前通过 rememberNavController() 创建的 NavController,以及导航图的起始目的地的路线。NavHost 创建使用 Navigation Kotlin DSL 中的 lambda 语法来构建导航图。您可以使用 composable() 方法向导航结构添加内容。此方法需要您提供一个路线以及应关联到相应目的地的可组合项:

NavHost(navController = navController, startDestination = "profile") {
    composable("profile") { Profile(/*...*/) }
    composable("friendslist") { FriendsList(/*...*/) }
    /*...*/
}

如需导航到导航图中的可组合项目的地,您必须使用 navigate 方法。navigate 接受代表目的地路线的单个 String 参数。如需从导航图中的某个可组合项进行导航,请调用 navigate

navController.navigate("friendslist")

默认情况下,navigate 会将您的新目的地添加到返回堆栈中。您可以通过向我们的 navigate() 调用附加其他导航选项来修改 navigate 的行为:

// Pop everything up to the "home" destination off the back stack before
// navigating to the "friendslist" destination
navController.navigate("friendslist") {
    popUpTo("home")
}

// Pop everything up to and including the "home" destination off
// the back stack before navigating to the "friendslist" destination
navController.navigate("friendslist") {
    popUpTo("home") { inclusive = true }
}

// Navigate to the "search” destination only if we’re not already on
// the "search" destination, avoiding multiple copies on the top of the
// back stack
navController.navigate("search") {
    launchSingleTop = true
}

如需查看更多用例,请参阅 popUpTo 指南

NavControllernavigate 函数会修改 NavController 的内部状态。为了尽可能符合单一可信来源原则,只有提升 NavController 实例的可组合函数或状态容器,以及接受 NavController 作为参数的可组合函数才应进行导航调用。从界面层次结构中处于较低层级的其他可组合函数触发的导航事件需要使用函数将相应事件适当地公开给调用方。

以下示例显示了作为 NavController 实例的单一可信来源的 MyAppNavHost 可组合函数。ProfileScreen 将一个事件公开为用户点按按钮时调用的函数。MyAppNavHost 负责前往应用中的不同屏幕,它会在调用 ProfileScreen 时对正确的目的地进行导航调用。

@Composable
fun MyAppNavHost(
    modifier: Modifier = Modifier,
    navController: NavHostController = rememberNavController(),
    startDestination: String = "profile"
) {
    NavHost(
        modifier = modifier,
        navController = navController,
        startDestination = startDestination
    ) {
        composable("profile") {
            ProfileScreen(
                onNavigateToFriends = { navController.navigate("friendsList") },
                /*...*/
            )
        }
        composable("friendslist") { FriendsListScreen(/*...*/) }
    }
}

@Composable
fun ProfileScreen(
    onNavigateToFriends: () -> Unit,
    /*...*/
) {
    /*...*/
    Button(onClick = onNavigateToFriends) {
        Text(text = "See friends list")
    }
}

您应仅在回调中调用 navigate(),而不能在可组合项本身中调用它,以避免每次重组时都调用 navigate()

在 Compose 中提升状态时的一项最佳实践是,将可组合函数中的事件公开给知道如何处理应用中的特定逻辑的调用方。

虽然将事件作为单独的 lambda 参数公开可能会使函数签名过载,但它可以最大限度提高可组合函数功能的可见性。您可以一目了然地了解它的功能。

其他可以减少函数声明中的参数数量的替代方案最初可能更易于编写,但从长远来看会隐藏一些缺陷。例如,创建一个像 ProfileScreenEvents 一样的封装容器类,将所有事件集中在一处。这样做会降低可组合项在执行其函数定义时的功能的可见性,它会将其他类和方法加入项目计数,并且无论如何,您每次调用此可组合函数时都需要创建并记住相应类的实例。此外,为了尽可能重复使用相应封装容器类,这种模式还会鼓励根据界面层次结构向下传递该类的实例,而不是按照最佳实践(即仅向可组合项传递其所需的内容)的要求操作。

Navigation Compose 还支持在可组合项目的地之间传递参数。为此,您需要向路线中添加参数占位符,就像在使用基础导航库时向深层链接中添加参数一样。

NavHost(startDestination = "profile/{userId}") {
    ...
    composable("profile/{userId}") {...}
}

默认情况下,所有参数都会被解析为字符串。composable()arguments 参数接受 NamedNavArgument 列表。您可以使用 navArgument 方法快速创建 NamedNavArgument,然后指定其确切 type

NavHost(startDestination = "profile/{userId}") {
    ...
    composable(
        "profile/{userId}",
        arguments = listOf(navArgument("userId") { type = NavType.StringType })
    ) {...}
}

您应该从 composable() 函数的 lambda 中提供的 NavBackStackEntry 中提取这些参数。

composable("profile/{userId}") { backStackEntry ->
    Profile(navController, backStackEntry.arguments?.getString("userId"))
}

若要将相应参数传递到目的地,您需要在执行 navigate 调用时将该参数添加到路线:

navController.navigate("profile/user1234")

如需查看支持的类型的列表,请参阅在目的地之间传递数据

在导航时检索复杂数据

强烈建议在导航时不要传递复杂的数据对象,而是在执行导航操作时将最少的必要信息(例如唯一标识符或其他形式的 ID)作为参数传递:

// Pass only the user ID when navigating to a new destination as argument
navController.navigate("profile/user1234")

复杂对象应以数据的形式存储在单一可信来源(例如数据层)中。在导航后到达目的地后,您可以使用所传递的 ID 从单一可信来源加载所需信息。如需检索 ViewModel 中负责访问数据层的参数,您可以使用 ViewModel’s SavedStateHandle

class UserViewModel(
    savedStateHandle: SavedStateHandle,
    private val userInfoRepository: UserInfoRepository
) : ViewModel() {

    private val userId: String = checkNotNull(savedStateHandle["userId"])

    // Fetch the relevant user information from the data layer,
    // ie. userInfoRepository, based on the passed userId argument
    private val userInfo: Flow<UserInfo> = userInfoRepository.getUserInfo(userId)

// …

}

这种方法有助于防止配置更改期间发生数据丢失,以及在更新或更改相关对象时造成任何不一致。

如需深入了解为何应避免将复杂数据作为参数传递,以及支持的参数类型列表,请参阅在目的地之间传递数据

添加可选参数

Navigation Compose 还支持可选的导航参数。可选参数与必需参数有以下两点不同:

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

这意味着,所有可选参数都必须以列表的形式显式添加到 composable() 函数:

composable(
    "profile?userId={userId}",
    arguments = listOf(navArgument("userId") { defaultValue = "user1234" })
) { backStackEntry ->
    Profile(navController, backStackEntry.arguments?.getString("userId"))
}

现在,即使没有向目的地传递任何参数,系统也会使用 defaultValue“user1234”。

通过路线处理参数的结构意味着可组合项将完全独立于 Navigation,并且更易于测试。

Navigation Compose 支持隐式深层链接,此类链接也可定义为 composable() 函数的一部分。其 deepLinks 参数接受一系列 NavDeepLink,后者可使用 navDeepLink 方法快速创建:

val uri = "https://www.example.com"

composable(
    "profile?id={id}",
    deepLinks = listOf(navDeepLink { uriPattern = "$uri/{id}" })
) { backStackEntry ->
    Profile(navController, backStackEntry.arguments?.getString("id"))
}

借助这些深层链接,您可以将特定的网址、操作或 MIME 类型与可组合项关联起来。默认情况下,这些深层链接不会向外部应用公开。如需向外部提供这些深层链接,您必须向应用的 manifest.xml 文件添加相应的 <intent-filter> 元素。如需启用上述深层链接,您应该在清单的 <activity> 元素中添加以下内容:

<activity …>
  <intent-filter>
    ...
    <data android:scheme="https" android:host="www.example.com" />
  </intent-filter>
</activity>

当其他应用触发该深层链接时,Navigation 会自动深层链接到相应的可组合项。

这些深层链接还可用于构建包含可组合项中的相关深层链接的 PendingIntent

val id = "exampleId"
val context = LocalContext.current
val deepLinkIntent = Intent(
    Intent.ACTION_VIEW,
    "https://www.example.com/$id".toUri(),
    context,
    MyActivity::class.java
)

val deepLinkPendingIntent: PendingIntent? = TaskStackBuilder.create(context).run {
    addNextIntentWithParentStack(deepLinkIntent)
    getPendingIntent(0, PendingIntent.FLAG_UPDATE_CURRENT)
}

然后,您可以像使用任何其他 PendingIntent 一样,使用此 deepLinkPendingIntent 在相应深层链接目的地打开您的应用。

嵌套导航结构

您可以将目的地归入一个嵌套图,以便在应用界面中对特定流程进行模块化。例如,您可以对独立的登录流程进行模块化。

嵌套图可以封装其目的地。与根图一样,嵌套图必须具有被其路径标识为起始目的地的目的地。此目的地是当您导航到与嵌套图相关联的路径时所导航到的目的地。

如需向 NavHost 添加嵌套图,您可以使用 navigation 扩展函数:

NavHost(navController, startDestination = "home") {
    ...
    // Navigating to the graph via its route ('login') automatically
    // navigates to the graph's start destination - 'username'
    // therefore encapsulating the graph's internal routing logic
    navigation(startDestination = "username", route = "login") {
        composable("username") { ... }
        composable("password") { ... }
        composable("registration") { ... }
    }
    ...
}

强烈建议您在导航图变大时将其拆分为多个方法。这也允许多个模块提交各自的导航图。

fun NavGraphBuilder.loginGraph(navController: NavController) {
    navigation(startDestination = "username", route = "login") {
        composable("username") { ... }
        composable("password") { ... }
        composable("registration") { ... }
    }
}

通过将该方法设为 NavGraphBuilder 上的扩展方法,您可以将其与预构建的 navigationcomposabledialog 扩展方法一起使用:

NavHost(navController, startDestination = "home") {
    ...
    loginGraph(navController)
    ...
}

与底部导航栏集成

通过在可组合项层次结构中的更高层级定义 NavController,您可以将 Navigation 与其他组件(例如底部导航组件)相关联。这样,您就可以通过选择底部栏中的图标来进行导航。

如需使用 BottomNavigationBottomNavigationItem 组件,请将 androidx.compose.material 依赖项添加到您的 Android 应用中。

Groovy

dependencies {
    implementation "androidx.compose.material:material:1.3.1"
}

android {
    buildFeatures {
        compose true
    }

    composeOptions {
        kotlinCompilerExtensionVersion = "1.3.2"
    }

    kotlinOptions {
        jvmTarget = "1.8"
    }
}

Kotlin

dependencies {
    implementation("androidx.compose.material:material:1.3.1")
}

android {
    buildFeatures {
        compose = true
    }

    composeOptions {
        kotlinCompilerExtensionVersion = "1.3.2"
    }

    kotlinOptions {
        jvmTarget = "1.8"
    }
}

如需将底部导航栏中的项与您的导航图中的路线相关联,建议您定义密封的类(例如此处所示的 Screen),其中包含相应目的地的路线和字符串资源 ID。

sealed class Screen(val route: String, @StringRes val resourceId: Int) {
    object Profile : Screen("profile", R.string.profile)
    object FriendsList : Screen("friendslist", R.string.friends_list)
}

然后,将这些项放置在 BottomNavigationItem 可以使用的列表中:

val items = listOf(
   Screen.Profile,
   Screen.FriendsList,
)

BottomNavigation 可组合项中,使用 currentBackStackEntryAsState() 函数获取当前的 NavBackStackEntry。此条目允许您访问当前的 NavDestination。然后,可通过 [NavDestination][13] 层次结构将该项的路由与当前目的地及其父目的地的路由进行比较来确定每个 BottomNavigationItem 的选定状态(以处理使用嵌套导航的情况)。

该项目的路由还用于将 onClick lambda 连接到对 navigate 的调用,以便在点按该项时会转到该项。通过使用 saveStaterestoreState 标志,当您在底部导航项之间切换时,系统会正确保存并恢复该项的状态和返回堆栈。

val navController = rememberNavController()
Scaffold(
  bottomBar = {
    BottomNavigation {
      val navBackStackEntry by navController.currentBackStackEntryAsState()
      val currentDestination = navBackStackEntry?.destination
      items.forEach { screen ->
        BottomNavigationItem(
          icon = { Icon(Icons.Filled.Favorite, contentDescription = null) },
          label = { Text(stringResource(screen.resourceId)) },
          selected = currentDestination?.hierarchy?.any { it.route == screen.route } == true,
          onClick = {
            navController.navigate(screen.route) {
              // Pop up to the start destination of the graph to
              // avoid building up a large stack of destinations
              // on the back stack as users select items
              popUpTo(navController.graph.findStartDestination().id) {
                saveState = true
              }
              // Avoid multiple copies of the same destination when
              // reselecting the same item
              launchSingleTop = true
              // Restore state when reselecting a previously selected item
              restoreState = true
            }
          }
        )
      }
    }
  }
) { innerPadding ->
  NavHost(navController, startDestination = Screen.Profile.route, Modifier.padding(innerPadding)) {
    composable(Screen.Profile.route) { Profile(navController) }
    composable(Screen.FriendsList.route) { FriendsList(navController) }
  }
}

在这里,您可以利用 NavController.currentBackStackEntryAsState() 方法从 NavHost 函数中获取 navController 状态,并与 BottomNavigation 组件共享此状态。这意味着 BottomNavigation 会自动拥有最新状态。

Navigation Compose 中的类型安全

本页面上的代码不符合类型安全要求。您可能使用无效路由或错误的参数调用了 navigate() 函数。不过,您可以设计 Navigation 代码的结构,使其在运行时符合类型安全要求。这样做可以避免崩溃,并确保:

  • 导航到目的地或导航图时您提供的参数的类型正确,并且所需的所有参数都存在。
  • 您从 SavedStateHandle 中检索到的参数的类型正确。

如需了解详情,请参阅 Navigation 类型安全文档

互操作性

如果您想将 Navigation 组件与 Compose 配合使用,有以下两种选择:

  • 使用基于 fragment 的 Navigation 组件定义导航图。
  • 使用 Compose 目的地在 Compose 中通过 NavHost 定义导航图。只有在导航图中的所有界面都是可组合项的情况下,才可以这么做。

因此,若要构建 Compose 和 View 混合应用,我们建议使用基于 Fragment 的 Navigation 组件。然后,使用 Fragment 存储基于 View 的界面、Compose 界面和同时使用 View 和 Compose 的界面。每个 Fragment 的内容都在 Compose 中以后,下一步是将所有这些界面与 Navigation Compose 结合在一起,并移除所有 Fragment。

要在 Compose 代码内更改目的地,您可以公开可传递到并由层次结构中的任何可组合项触发的事件:

@Composable
fun MyScreen(onNavigate: (Int) -> ()) {
    Button(onClick = { onNavigate(R.id.nav_profile) } { /* ... */ }
}

在您的 fragment 中,您可以通过找到 NavController 并导航到目的地,在 Compose 和基于 fragment 的 Navigation 组件之间架起桥梁:

override fun onCreateView( /* ... */ ) {
    setContent {
        MyScreen(onNavigate = { dest -> findNavController().navigate(dest) })
    }
}

或者,您可以将 NavController 传递到 Compose 层次结构下方。不过,公开简单的函数的可重用性和可测试性更高。

测试

建议您将 Navigation 代码与可组合项目的地分离开,以便独立于 NavHost 可组合项单独测试每个可组合项。

这意味着,您不应直接将 navController 传入任何可组合项,而应将导航回调作为参数传递。这样一来,您的所有可组合项均可单独测试,因为它们不需要在测试中使用 navController 实例。

借助 composable lambda 提供的间接层,您可以将 Navigation 代码与可组合项本身分离开。这在以下两个方向上可行:

  • 仅将解析后的参数传递到可组合项
  • 传递应由要导航的可组合项触发的 lambda,而不是 NavController 本身。

例如,如果某个 Profile 可组合项接受 userId 作为输入,并允许用户导航到好友的个人资料页面,则可以采用以下签名:

@Composable
fun Profile(
    userId: String,
    navigateToFriendProfile: (friendUserId: String) -> Unit
) {
 …
}

这样,Profile 可组合项可独立于 Navigation 运行,从而可单独进行测试。composable lambda 会封装弥合 Navigation API 与您的可组合项之间的差距所需的基本逻辑:

composable(
    "profile?userId={userId}",
    arguments = listOf(navArgument("userId") { defaultValue = "user1234" })
) { backStackEntry ->
    Profile(backStackEntry.arguments?.getString("userId")) { friendUserId ->
        navController.navigate("profile?userId=$friendUserId")
    }
}

建议通过测试 NavHost、传递给可组合项的导航操作以及各个屏幕可组合项来编写涵盖应用导航要求的测试。

测试 NavHost

如需开始测试 NavHost,请添加以下导航测试依赖项:

dependencies {
// ...
  androidTestImplementation "androidx.navigation:navigation-testing:$navigationVersion"
  // ...
}

您可以设置 NavHost 测试对象,并将 navController 实例的实例传递给该对象。为此,Navigation 测试工件提供了一个 TestNavHostController。用于验证应用启动目的地和 NavHost 的界面测试将如下所示:

class NavigationTest {

    @get:Rule
    val composeTestRule = createComposeRule()
    lateinit var navController: TestNavHostController

    @Before
    fun setupAppNavHost() {
        composeTestRule.setContent {
            navController = TestNavHostController(LocalContext.current)
            navController.navigatorProvider.addNavigator(ComposeNavigator())
            AppNavHost(navController = navController)
        }
    }

    // Unit test
    @Test
    fun appNavHost_verifyStartDestination() {
        composeTestRule
            .onNodeWithContentDescription("Start Screen")
            .assertIsDisplayed()
    }
}

测试导航操作

您可以通过多种方式测试导航实现,方法是对界面元素执行点击,然后验证所显示的目的地,或将预期路线与当前路线进行比较。

如果您要测试具体应用的实现,最好在界面中执行点击操作。如需了解如何单独测试各个可组合函数,请务必查看在 Jetpack Compose 中进行测试 Codelab。

您还可以使用 navController 检查您的断言,只需使用 navControllercurrentBackStackEntry 将当前 String 路线与预期路线进行比较即可:

@Test
fun appNavHost_clickAllProfiles_navigateToProfiles() {
    composeTestRule.onNodeWithContentDescription("All Profiles")
        .performScrollTo()
        .performClick()

    val route = navController.currentBackStackEntry?.destination?.route
    assertEquals(route, "profiles")
}

如需详细了解 Compose 测试基础知识,请参阅 Compose 测试文档以及在 Jetpack Compose 中进行测试 Codelab。如需详细了解导航代码的高级测试,请参阅测试导航指南。

了解详情

如需详细了解 Jetpack Navigation,请参阅 Navigation 组件使用入门或完成 Jetpack Compose Navigation Codelab

如需了解如何设计应用导航,使其适应不同的屏幕尺寸、屏幕方向和设备外形规格,请参阅自适应界面的导航

如需了解模块化应用中的更高级 Navigation Compose 实现(包括嵌套图和底部导航栏集成等概念),请查看 Now in Android 代码库