使用 Compose 进行导航

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

设置

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

Groovy

dependencies {
    def nav_version = "2.8.0"

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

Kotlin

dependencies {
    val nav_version = "2.8.0"

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

开始使用

在应用中实现导航时,请实现导航宿主、导航图和导航控制器。如需了解详情,请参阅导航概览。

如需了解如何在 Compose 中创建 NavController,请参阅创建导航控制器的 Compose 部分。

创建 NavHost

如需了解如何在 Compose 中创建 NavHost,请参阅设计导航图的 Compose 部分。

如需了解如何导航到可组合项,请参阅架构文档中的导航到目的地

如需了解如何在可组合项目的地之间传递参数,请参阅设计导航图的 Compose 部分。

在导航时检索复杂数据

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

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

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

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

    private val profile = savedStateHandle.toRoute<Profile>()

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

// …

}

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

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

Navigation Compose 支持深层链接,此类链接也可定义为 composable() 函数的一部分。其 deepLinks 参数接受 NavDeepLink 对象的列表,您可以使用 navDeepLink() 方法快速创建这些对象:

@Serializable data class Profile(val id: String)
val uri = "https://www.example.com"

composable<Profile>(
  deepLinks = listOf(
    navDeepLink<Profile>(basePath = "$uri/profile")
  )
) { backStackEntry ->
  ProfileScreen(id = backStackEntry.toRoute<Profile>().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/profile/$id".toUri(),
    context,
    MyActivity::class.java
)

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

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

嵌套导航结构

如需了解如何创建嵌套导航图,请参阅嵌套图

与底部导航栏集成

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

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

Groovy

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

android {
    buildFeatures {
        compose true
    }

    composeOptions {
        kotlinCompilerExtensionVersion = "1.5.15"
    }

    kotlinOptions {
        jvmTarget = "1.8"
    }
}

Kotlin

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

android {
    buildFeatures {
        compose = true
    }

    composeOptions {
        kotlinCompilerExtensionVersion = "1.5.15"
    }

    kotlinOptions {
        jvmTarget = "1.8"
    }
}

如需将底部导航栏中的项与您的导航图中的路线相关联,建议您定义一个类(例如此处所示的 TopLevelRoute),其中包含路线类和图标。

data class TopLevelRoute<T : Any>(val name: String, val route: T, val icon: ImageVector)

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

val topLevelRoutes = listOf(
   TopLevelRoute("Profile", Profile, Icons.Profile),
   TopLevelRoute("Friends", Friends, Icons.Friends)
)

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

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

val navController = rememberNavController()
Scaffold(
  bottomBar = {
    BottomNavigation {
      val navBackStackEntry by navController.currentBackStackEntryAsState()
      val currentDestination = navBackStackEntry?.destination
      topLevelRoutes.forEach { topLevelRoute ->
        BottomNavigationItem(
          icon = { Icon(topLevelRoute.icon, contentDescription = topLevelRoute.name) },
          label = { Text(topLevelRoute.name) },
          selected = currentDestination?.hierarchy?.any { it.hasRoute(topLevelRoute.route::class) } == true,
          onClick = {
            navController.navigate(topLevelRoute.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 = Profile, Modifier.padding(innerPadding)) {
    composable<Profile> { ProfileScreen(...) }
    composable<Friends> { FriendsScreen(...) }
  }
}

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

互操作性

如果您想将 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) -> Unit) {
    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 本身。

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

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

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

@Serializable data class Profile(id: String)

composable<Profile> { backStackEntry ->
    val profile = backStackEntry.toRoute<Profile>()
    ProfileScreen(userId = profile.id) { friendUserId ->
        navController.navigate(route = Profile(id = friendUserId))
    }
}

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

测试 NavHost

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

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

将应用的 NavHost 封装在一个可组合项中,该可组合项接受 NavHostController 作为参数。

@Composable
fun AppNavHost(navController: NavHostController){
  NavHost(navController = navController){ ... }
}

现在,您可以通过传递导航测试工件 TestNavHostController 的实例来测试 AppNavHost 以及 NavHost 内定义的所有导航逻辑。用于验证应用起始目的地和 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 将当前路线与预期路线进行比较即可:

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

    assertTrue(navController.currentBackStackEntry?.destination?.hasRoute<Profile>() ?: false)
}

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

了解详情

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

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

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

示例