Jetpack Compose 导航

1. 简介

上次更新 2022-07-25

您需要什么

导航 是一个 Jetpack 库,它允许在应用内的不同目的地之间导航。导航库还提供了一个特定的构件,用于实现与 Jetpack Compose一致且符合习惯的导航。此构件(navigation-compose)是本代码实验室的重点。

您将做什么

您将使用 Rally Material 研究 作为本代码实验室的基础,以实现 Jetpack Navigation 组件并在可组合的 Rally 屏幕之间实现导航。

您将学到什么

  • 使用 Jetpack Compose 进行 Jetpack Navigation 的基础知识
  • 在可组合项之间导航
  • 将自定义标签栏可组合项集成到您的导航层次结构中
  • 使用参数导航
  • 使用深层链接导航
  • 测试导航

2. 设置

要继续学习,请克隆代码实验室的起点(main 分支)。

$ git clone https://github.com/android/codelab-android-compose.git

或者,您可以下载两个 zip 文件

下载代码后,在 Android Studio 中打开 **NavigationCodelab** 项目文件夹。现在您可以开始学习了。

3. Rally 应用概述

第一步,您应该熟悉 Rally 应用及其代码库。运行该应用并进行一些探索。

Rally 有三个作为可组合项的主要屏幕

  1. **OverviewScreen** — 所有财务交易和警报的概述
  2. **AccountsScreen** — 对现有账户的洞察
  3. **BillsScreen** — 预定的支出

Screenshot of the overview screen containing information on Alerts, Accounts and Bills. Screenshot of the Accounts Screen, containing information on several accounts. Screenshot of the Bills Screen, containing information on several outgoing bills.

在屏幕顶部,Rally 使用 **自定义标签栏可组合项(RallyTabRow)** 在这三个屏幕之间导航。点击每个图标都会展开当前选择并带您到相应的屏幕。

336ba66858ae3728.png e26281a555c5820d.png

导航到这些可组合屏幕时,您也可以将它们视为 *导航目的地*,因为我们希望在特定点到达每个目的地。这些目的地在 RallyDestinations.kt 文件中预定义。

在其中,您会发现所有三个主要目的地都被定义为对象(Overview、AccountsBills),以及一个 SingleAccount,稍后会添加到应用中。每个对象都扩展自 RallyDestination 接口,并包含每个目的地在导航目的方面所需的信息

  1. 顶部栏的 icon
  2. 一个 String route(对于 Compose 导航来说是必要的,作为通向该目的地的路径)
  3. 一个 screen,表示此目的地的整个可组合项

运行该应用时,您会注意到您实际上可以使用顶部栏在目的地之间导航。但是,该应用实际上并没有使用 Compose 导航,而是其当前的导航机制依赖于一些可组合项的手动切换和触发 重新组合 以显示新内容。因此,本代码实验室的目标是成功迁移和实现 Compose 导航。

4. 迁移到 Compose 导航

迁移到 Jetpack Compose 的基本步骤如下:

  1. 添加最新的 Compose Navigation 依赖项
  2. 设置 NavController
  3. 添加 NavHost 并创建导航图
  4. 准备在不同的应用目的地之间导航的路由
  5. 将当前导航机制替换为 Compose 导航

让我们更详细地逐一介绍这些步骤。

添加导航依赖项

打开应用的构建文件,位于 app/build.gradle。在 dependencies 部分,添加 navigation-compose 依赖项。

dependencies {
  implementation "androidx.navigation:navigation-compose:{latest_version}"
  // ...
}

您可以在这里找到 navigation-compose 的最新版本 此处

现在,同步项目,您就可以开始在 Compose 中使用 Navigation 了。

设置 NavController

在 Compose 中使用 Navigation 时,NavController 是核心组件。它跟踪返回栈可组合项条目,向前移动栈,启用返回栈操作,并在目的地状态之间导航。因为 NavController 对于导航至关重要,所以必须将其作为设置 Compose Navigation 的第一步来创建。

通过调用 rememberNavController() 函数可以获得 NavController。这会创建并 记住 一个在配置更改中能够保持的 NavController(使用 rememberSaveable)。

您应该始终在可组合层次结构的顶层创建和放置 NavController,通常在您的 App 可组合项内。然后,所有需要引用 NavController 的可组合项都可以访问它。这遵循 状态提升 的原则,并确保 NavController 是在可组合屏幕之间导航和维护返回栈的主要真相来源。

打开 RallyActivity.kt。通过在 RallyApp 中使用 rememberNavController() 获取 NavController,因为它是根可组合项和整个应用程序的入口点。

import androidx.navigation.compose.rememberNavController
// ...

@Composable
fun RallyApp() {
    RallyTheme {
        var currentScreen: RallyDestination by remember { mutableStateOf(Overview) }
        val navController = rememberNavController()
        Scaffold(
            // ...
        ) { 
            // ...
       }
}

Compose 导航中的路由

如前所述,Rally 应用有三个主要目的地,以及一个稍后要添加的附加目的地(SingleAccount)。这些在 RallyDestinations.kt 中定义。我们提到每个目的地都有一个定义的 iconroutescreen

Screenshot of the overview screen containing information on Alerts, Accounts and Bills. Screenshot of the Accounts Screen, containing information on several accounts. Screenshot of the Bills Screen, containing information on several outgoing bills.

下一步是将这些目的地添加到您的导航图中,启动应用时将 Overview 作为起始目的地。

在 Compose 中使用 Navigation 时,导航图中的每个可组合目的地都与一个 **route** 关联。路由表示为定义可组合路径并指导您的 navController 到达正确位置的字符串。您可以将其视为通向特定目的地的隐式深层链接。**每个目的地都必须具有唯一的路由**。

为此,我们将使用每个 RallyDestination 对象的 route 属性。例如,Overview.route 是将带您到 Overview 屏幕可组合项的路由。

使用导航图调用 NavHost 可组合项

下一步是添加一个NavHost并创建你的导航图。

导航的三个主要部分是NavControllerNavGraphNavHostNavController始终与单个NavHost组合体相关联。NavHost充当容器,负责显示图形的当前目标。当你在组合体之间导航时,NavHost的内容会自动重新组合。它还将NavController与导航图(NavGraph)链接起来,该导航图规划了组合体目标之间的导航路径。它本质上是可获取目标的集合。

返回RallyActivity.kt中的RallyApp组合体。替换Scaffold内的Box组合体(它包含当前屏幕的内容,用于手动切换屏幕),替换为一个新的NavHost,你可以按照下面的代码示例创建它。

传入我们在上一步中创建的navController,将其连接到此NavHost。如前所述,每个NavController必须与单个NavHost关联。

NavHost还需要一个startDestination路由来知道应用程序启动时显示哪个目标,因此将其设置为Overview.route。此外,传递一个Modifier来接受外部Scaffold填充并将其应用于NavHost

最终参数builder: NavGraphBuilder.() -> Unit负责定义和构建导航图。它使用Navigation Kotlin DSL的lambda语法,因此可以作为尾随lambda传递到函数体中并从括号中提取。

import androidx.navigation.compose.NavHost
...

Scaffold(...) { innerPadding ->
    NavHost(
        navController = navController,
        startDestination = Overview.route,
        modifier = Modifier.padding(innerPadding)
    ) { 
       // builder parameter will be defined here as the graph
    }
}

向Nav**Graph**添加目标

现在,你可以定义你的导航图以及NavController可以导航到的目标。如前所述,builder参数需要一个函数,因此Navigation Compose提供了NavGraphBuilder.composable扩展函数,以便轻松地将单个组合体目标添加到导航图并定义必要的导航信息。

第一个目标将是Overview,因此你需要通过composable扩展函数添加它并设置其唯一的String route。这只是将目标添加到你的导航图中,因此你还需要定义导航到此目标时要显示的实际UI。这也将通过composable函数体内的尾随lambda完成,这是一个Compose中经常使用的模式

import androidx.navigation.compose.composable
// ...

NavHost(
    navController = navController,
    startDestination = Overview.route,
    modifier = Modifier.padding(innerPadding)
) { 
    composable(route = Overview.route) { 
        Overview.screen()
    }
}

按照此模式,我们将添加所有三个主要屏幕组合体作为三个目标。

NavHost(
    navController = navController,
    startDestination = Overview.route,
    modifier = Modifier.padding(innerPadding)
) { 
    composable(route = Overview.route) {
        Overview.screen()
    }
    composable(route = Accounts.route) {
        Accounts.screen()
    }
    composable(route = Bills.route) {
        Bills.screen()
    }
}

现在运行应用程序 - 你将看到Overview作为起始目标及其对应的UI。

我们之前提到过一个自定义顶部标签栏RallyTabRow组合体,它以前处理屏幕之间的手动导航。此时,它尚未与新的导航连接,因此你可以验证点击选项卡不会更改显示的屏幕组合体目标。我们下一步来解决这个问题!

5. 集成RallyTabRow与导航

在此步骤中,你将RallyTabRownavController和导航图连接起来,以使其能够导航到正确的目标。

为此,你需要使用新的navControllerRallyTabRowonTabSelected回调定义正确的导航操作。此回调定义了选择特定选项卡图标时应该发生什么,并通过navController.navigate(route).执行导航操作。

按照此指导,在RallyActivity中,找到RallyTabRow组合体及其回调参数onTabSelected

由于我们希望选项卡在点击时导航到特定目标,因此你还需要知道选择了哪个确切的选项卡图标。幸运的是,onTabSelected: (RallyDestination) -> Unit参数已经提供了这一点。你将使用该信息和RallyDestination路由来引导你的navController,并在选择选项卡时调用navController.navigate(newScreen.route)

@Composable
fun RallyApp() {
    RallyTheme {
        var currentScreen: RallyDestination by remember { mutableStateOf(Overview) }
        val navController = rememberNavController()
        Scaffold(
            topBar = {
                RallyTabRow(
                    allScreens = rallyTabRowScreens,
                    // Pass the callback like this,
                    // defining the navigation action when a tab is selected:
                    onTabSelected = { newScreen ->
                        navController.navigate(newScreen.route)
                    },
                    currentScreen = currentScreen,
                )
            }

如果你现在运行应用程序,你可以验证点击RallyTabRow中的各个选项卡确实会导航到正确的组合体目标。但是,你可能已经注意到目前存在两个问题。

  1. 连续点击同一个选项卡会启动多个相同目标的副本。
  2. 选项卡的UI与显示的正确目标不匹配 - 也就是说,选中选项卡的展开和折叠功能无法按预期工作。

336ba66858ae3728.png e26281a555c5820d.png

让我们来解决这两个问题!

启动单个目标副本

为了解决第一个问题并确保在回退栈顶部最多只有一个给定目标的副本,Compose Navigation API 提供了一个launchSingleTop标志,你可以将其传递给你的navController.navigate()操作,如下所示:

navController.navigate(route) { launchSingleTop = true }

由于你希望在整个应用程序中对每个目标都具有此行为,因此无需将此标志复制粘贴到所有.navigate(...)调用中,你可以在RallyActivity的底部将其提取到一个辅助扩展函数中。

import androidx.navigation.NavHostController
// ...

fun NavHostController.navigateSingleTopTo(route: String) =
    this.navigate(route) { launchSingleTop = true }

现在,你可以用.navigateSingleTopTo(...)替换navController.navigate(newScreen.route)调用。重新运行应用程序,并验证当多次点击顶部栏中的图标时,现在只会得到单个目标的一个副本。

@Composable
fun RallyApp() {
    RallyTheme {
        var currentScreen: RallyDestination by remember { mutableStateOf(Overview) }
        val navController = rememberNavController()
        Scaffold(
            topBar = {
                RallyTabRow(
                    allScreens = rallyTabRowScreens,
                    onTabSelected = { newScreen ->
                        navController
                            .navigateSingleTopTo(newScreen.route)
                    },
                    currentScreen = currentScreen,
                )
            }

控制导航选项和回退栈状态

除了launchSingleTop之外,你还可以从NavOptionsBuilder中使用其他标志来进一步控制和自定义你的导航行为。由于我们的RallyTabRow类似于BottomNavigation,你还应该考虑在导航到和离开目标时是否要保存和恢复目标状态。例如,如果你滚动到“概述”的底部,然后导航到“帐户”再返回,你是否要保留滚动位置?你是否希望重新点击RallyTabRow中的相同目标来重新加载屏幕状态?这些都是有效的问题,应该由你自己的应用程序设计的需求来决定。

我们将介绍一些你可以在相同的navigateSingleTopTo扩展函数中使用的附加选项。

  • launchSingleTop = true - 如前所述,这确保在回退栈顶部最多只有一个给定目标的副本。
  • 在Rally应用程序中,这意味着多次重新点击同一个选项卡不会启动多个相同目标的副本。
  • popUpTo(startDestination) { saveState = true } - 弹出到图的起始目标,以避免在你选择选项卡时在回退栈上构建大量目标。
  • 在Rally中,这意味着从任何目标按下后退箭头都会将整个回退栈弹出到“概述”。
  • restoreState = true - 确定此导航操作是否应恢复先前由PopUpToBuilder.saveStatepopUpToSaveState属性保存的任何状态。请注意,如果先前没有使用要导航到的目标 ID 保存状态,**则此操作无效**。
  • 在Rally中,这意味着重新点击同一个选项卡将保留屏幕上的先前数据和用户状态,而无需再次重新加载。

你可以将所有这些选项逐一添加到代码中,在添加每个选项后运行应用程序并验证添加每个标志后的确切行为。这样,你就可以在实践中看到每个标志如何更改导航和回退栈状态。

import androidx.navigation.NavHostController
import androidx.navigation.NavGraph.Companion.findStartDestination
// ...

fun NavHostController.navigateSingleTopTo(route: String) =
    this.navigate(route) { 
        popUpTo(
            this@navigateSingleTopTo.graph.findStartDestination().id
        ) {
            saveState = true
        }
        launchSingleTop = true
        restoreState = true
}

修复选项卡UI

在Codelab的开始,当仍然使用手动导航机制时,RallyTabRow使用currentScreen变量来确定是展开还是折叠每个选项卡。

但是,在你进行更改之后,currentScreen将不再更新。这就是为什么RallyTabRow内选中选项卡的展开和折叠功能不再起作用的原因。

要使用 Compose Navigation 重新启用此行为,您需要在每个点都知道当前显示的目标位置,或者用导航术语来说,就是当前回退栈条目的顶部,然后在每次此内容更改时更新您的RallyTabRow

要以State的形式实时更新回退栈中的当前目标位置,您可以使用navController.currentBackStackEntryAsState(),然后获取其当前的destination:

import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.compose.runtime.getValue
// ...

@Composable
fun RallyApp() {
    RallyTheme {
        val navController = rememberNavController()

        val currentBackStack by navController.currentBackStackEntryAsState()
        // Fetch your currentDestination: 
        val currentDestination = currentBackStack?.destination
        // ...
    }
}

currentBackStack?.destination 返回NavDestination.要正确更新currentScreen,您需要找到一种方法将返回的NavDestination与Rally的三个主要屏幕组件之一匹配。您必须确定当前显示的是哪个组件,以便您可以将此信息传递给RallyTabRow。如前所述,每个目标都有一个唯一的路由,因此我们可以使用此字符串路由作为某种 ID 来进行验证比较并找到唯一的匹配项。

要更新currentScreen,您需要遍历rallyTabRowScreens列表以查找匹配的路由,然后返回相应的RallyDestination。Kotlin 提供了一个方便的.find()函数来实现此目的。

import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.compose.runtime.getValue
// ...

@Composable
fun RallyApp() {
    RallyTheme {
        val navController = rememberNavController()

        val currentBackStack by navController.currentBackStackEntryAsState()
        val currentDestination = currentBackStack?.destination

        // Change the variable to this and use Overview as a backup screen if this returns null
        val currentScreen = rallyTabRowScreens.find { it.route == currentDestination?.route } ?: Overview
        // ...
    }
}

由于currentScreen已传递给RallyTabRow,您可以运行应用程序并验证选项卡栏 UI 现在已相应更新。

6. 从 RallyDestinations 中提取屏幕组件

到目前为止,为简便起见,我们使用RallyDestination接口及其扩展的屏幕对象中的screen属性,在NavHost (RallyActivity.kt)中添加可组合的 UI。

import com.example.compose.rally.ui.overview.OverviewScreen
// ...

NavHost(
    navController = navController,
    startDestination = Overview.route,
    modifier = Modifier.padding(innerPadding)
) { 
    composable(route = Overview.route) { 
        Overview.screen()
    }
    // ...
}

但是,此代码实验室中的后续步骤(例如点击事件)需要直接向您的可组合屏幕传递其他信息。在生产环境中,肯定会有更多需要传递的数据。

实现此目标的正确且更简洁的方法是直接在NavHost导航图中添加可组合项,并从RallyDestination中提取它们。之后,RallyDestination和屏幕对象将只保存与导航相关的特定信息,如iconroute,并且将与任何 Compose UI 相关的内容解耦。

打开RallyDestinations.kt。将每个屏幕的可组合项从RallyDestination对象的screen参数中提取到NavHost中的相应composable函数中,替换之前的.screen()调用,如下所示:

import com.example.compose.rally.ui.accounts.AccountsScreen
import com.example.compose.rally.ui.bills.BillsScreen
import com.example.compose.rally.ui.overview.OverviewScreen
// ...

NavHost(
    navController = navController,
    startDestination = Overview.route,
    modifier = Modifier.padding(innerPadding)
) {
    composable(route = Overview.route) {
        OverviewScreen()
    }
    composable(route = Accounts.route) {
        AccountsScreen()
    }
    composable(route = Bills.route) {
        BillsScreen()
    }
}

此时,您可以安全地从RallyDestination及其对象中删除screen参数。

interface RallyDestination {
    val icon: ImageVector
    val route: String
}

/**
 * Rally app navigation destinations
 */
object Overview : RallyDestination {
    override val icon = Icons.Filled.PieChart
    override val route = "overview"
}
// ...

再次运行应用程序,并验证一切是否仍按之前的方式工作。完成此步骤后,您就可以在可组合屏幕中设置点击事件了。

启用 OverviewScreen 上的点击

目前,OverviewScreen中的任何点击事件都会被忽略。这意味着“帐户”和“账单”小节的“查看全部”按钮是可点击的,但实际上不会带您去任何地方。此步骤的目标是启用这些点击事件的导航。

Screen recording of the overview screen, scrolling to eventual click destinations, and attempting to click. Clicks don't work as they aren't implemented yet.

OverviewScreen可组合项可以接受多个函数作为回调来设置为点击事件,在这种情况下,应该是将您带到AccountsScreenBillsScreen的导航操作。让我们将这些导航回调传递给onClickSeeAllAccountsonClickSeeAllBills,以导航到相关目标。

打开RallyActivity.kt,在NavHost中查找OverviewScreen,并将navController.navigateSingleTopTo(...)与相应的路由一起传递给两个导航回调。

OverviewScreen(
    onClickSeeAllAccounts = {
        navController.navigateSingleTopTo(Accounts.route) 
    },
    onClickSeeAllBills = { 
        navController.navigateSingleTopTo(Bills.route) 
    }
)

navController现在将拥有足够的信息,例如确切目标的路由以便在点击按钮时导航到正确的目标。如果您查看OverviewScreen的实现,您会看到这些回调已经被设置为相应的onClick参数:

@Composable
fun OverviewScreen(...) {
    // ...
    AccountsCard(
        onClickSeeAll = onClickSeeAllAccounts,
        onAccountClick = onAccountClick
    )
    // ...
    BillsCard(
        onClickSeeAll = onClickSeeAllBills
    )
}

如前所述,将navController保留在导航层次结构的顶层,并提升到App可组合组件的级别(而不是直接将其传递到例如OverviewScreen),可以轻松地单独预览、重用和测试OverviewScreen可组合组件,而无需依赖实际或模拟的navController实例。相反,传递回调还可以快速更改点击事件!

7. 使用参数导航到 SingleAccountScreen

让我们为AccountsOverview屏幕添加一些新功能!目前,这些屏幕显示了多种不同类型的帐户列表 - “支票账户”、“住房储蓄账户”等等。

2f335ceab09e449a.png 2e78a5e090e3fccb.png

但是,点击这些帐户类型没有任何作用(尚未!)。让我们修复它!当我们点击每种帐户类型时,我们希望显示一个包含完整帐户详细信息的新屏幕。为此,我们需要向我们的navController提供有关我们正在点击的确切帐户类型的其他信息。这可以通过参数来完成。

参数是一个非常强大的工具,它通过向路由传递一个或多个参数来使导航路由动态化。它允许根据提供的不同参数显示不同的信息。

RallyApp中,通过向现有NavHost添加新的composable函数,向图中添加一个新的目标SingleAccountScreen,该函数将处理显示这些单个帐户:

import com.example.compose.rally.ui.accounts.SingleAccountScreen
// ...

NavHost(
    navController = navController,
    startDestination = Overview.route,
    modifier = Modifier.padding(innerPadding)
) {
    ...
    composable(route = SingleAccount.route) {
        SingleAccountScreen()
    }
}

设置 SingleAccountScreen 登录目标

当您登录到SingleAccountScreen时,此目标需要其他信息才能知道打开时应该显示哪个确切的帐户类型。我们可以使用参数来传递此类信息。您需要指定其路由还需要一个参数{account_type}。如果您查看RallyDestination及其SingleAccount对象,您会注意到此参数已经为您定义为accountTypeArg字符串。

要在导航时将参数与您的路由一起附加,您需要将它们组合在一起,遵循以下模式:"route/{argument}"。在您的情况下,它看起来像这样:"${SingleAccount.route}/{${SingleAccount.accountTypeArg}}"。请记住,$ 符号用于转义变量。

import androidx.navigation.NavType
import androidx.navigation.compose.navArgument
// ...

composable(
    route =
        "${SingleAccount.route}/{${SingleAccount.accountTypeArg}}"
) { 
    SingleAccountScreen()
}

这将确保当触发操作以导航到SingleAccountScreen时,也必须传递accountTypeArg参数,否则导航将失败。可以将其视为需要由想要导航到SingleAccountScreen的其他目标遵循的签名或契约。

此步骤的第二步是使此composable知道它应该接受参数。您可以通过定义其arguments参数来实现。您可以根据需要定义任意数量的参数,因为composable函数默认接受参数列表。在您的情况下,您只需要添加一个名为accountTypeArg的参数,并通过将其指定为String类型来添加一些额外的安全性。如果您没有显式设置类型,它将从此参数的默认值推断出来。

import androidx.navigation.NavType
import androidx.navigation.compose.navArgument
// ...

composable(
    route =
        "${SingleAccount.route}/{${SingleAccount.accountTypeArg}}",
    arguments = listOf(
        navArgument(SingleAccount.accountTypeArg) { type = NavType.StringType }
    )
) { 
    SingleAccountScreen()
}

这将完美运行,您可以选择保持代码不变。但是,由于我们所有特定于目标的信息都在RallyDestinations.kt及其对象中,让我们继续使用相同的方法(就像我们上面对OverviewAccountsBills所做的那样),并将此参数列表移入SingleAccount:

object SingleAccount : RallyDestination {
    // ...
    override val route = "single_account"
    const val accountTypeArg = "account_type"
    val arguments = listOf(
        navArgument(accountTypeArg) { type = NavType.StringType }
    )
}

现在将之前的参数替换为SingleAccount.arguments,然后返回到NavHost相应的composable。这也可以确保我们保持NavHost尽可能简洁易读。

composable(
    route = "${SingleAccount.route}/{${SingleAccount.accountTypeArg}}",
    arguments =  SingleAccount.arguments
) {
    SingleAccountScreen()
}

现在您已经为SingleAccountScreen定义了完整的带参数的路由,下一步是确保此accountTypeArg向下传递到SingleAccountScreen可组合项,以便它知道应该正确显示哪个帐户类型。如果您查看SingleAccountScreen的实现,您会看到它已经设置好并等待接受accountType参数。

fun SingleAccountScreen(
    accountType: String? = UserData.accounts.first().name
) { 
   // ... 
}

回顾一下,到目前为止

  • 我们确保定义路由以请求参数,作为对其前序目标的信号。
  • 我们确保composable知道它需要接受参数。

我们的最后一步是实际上检索传递的参数值。

在 Compose Navigation 中,每个NavHost可组合函数都可以访问当前的NavBackStackEntry - 一个类,它保存回退栈中条目的当前路由和传递参数的信息。您可以使用它从navBackStackEntry获取所需的arguments列表,然后搜索并检索您需要的精确参数,以将其进一步传递到您的可组合屏幕。

在这种情况下,您将从navBackStackEntry请求accountTypeArg。然后,您需要将其进一步传递到SingleAccountScreenaccountType参数。

您也可以为参数提供默认值作为占位符,以防它未提供,并通过覆盖此边缘情况来使您的代码更安全。

您的代码现在应如下所示

NavHost(...) {
    // ...
    composable(
        route =
          "${SingleAccount.route}/{${SingleAccount.accountTypeArg}}",
        arguments = SingleAccount.arguments
    ) { navBackStackEntry ->
        // Retrieve the passed argument
        val accountType =
            navBackStackEntry.arguments?.getString(SingleAccount.accountTypeArg)

        // Pass accountType to SingleAccountScreen
        SingleAccountScreen(accountType)
    }
}

现在您的SingleAccountScreen拥有显示正确账户类型所需的信息,当您导航到它时。如果您查看SingleAccountScreen的实现,您会发现它已经完成了将传递的accountTypeUserData源匹配以获取相应账户详细信息的操作。

让我们再次执行一项小的优化任务,并将"${SingleAccount.route}/{${SingleAccount.accountTypeArg}}"路由也移到RallyDestinations.kt及其SingleAccount对象:中。

object SingleAccount : RallyDestination {
    // ...
    override val route = "single_account"
    const val accountTypeArg = "account_type"
    val routeWithArgs = "${route}/{${accountTypeArg}}"
    val arguments = listOf(
        navArgument(accountTypeArg) { type = NavType.StringType }
    )
}

同样,在相应的NavHost组合函数中替换它:

// ...
composable(
    route = SingleAccount.routeWithArgs,
    arguments = SingleAccount.arguments
) {...}

设置账户和概览起始目的地

既然您已经定义了SingleAccountScreen路由及其成功导航到SingleAccountScreen所需和接受的参数,您需要确保从之前的目的地(即您来自的任何目的地)传递相同的accountTypeArg参数。

如您所见,这有两个方面——提供并传递参数的起始目的地和接受该参数并使用它来显示正确信息的着陆目的地。**两者都需要明确定义。**

例如,当您位于Accounts目的地并点击“Checking”账户类型时,Accounts目的地需要将“Checking”字符串作为参数传递,附加到“single_account”字符串路由,以便成功打开相应的SingleAccountScreen。其字符串路由如下所示:"single_account/Checking"

当使用navController.navigateSingleTopTo(...),时,您将使用此完全相同的路由和传递的参数,如下所示

navController.navigateSingleTopTo("${SingleAccount.route}/$accountType").

将此导航操作回调传递到OverviewScreenAccountsScreenonAccountClick参数。请注意,这些参数预定义为:onAccountClick: (String) -> Unit,输入为字符串。这意味着,当用户在OverviewAccount中点击特定账户类型时,该账户类型字符串将可供您使用,并且可以轻松地作为导航参数传递。

OverviewScreen(
    // ...
    onAccountClick = { accountType ->
        navController
          .navigateSingleTopTo("${SingleAccount.route}/$accountType")
    }
)
// ...
                    
AccountsScreen(
    // ...
    onAccountClick = { accountType ->
        navController
          .navigateSingleTopTo("${SingleAccount.route}/$accountType")
    }
)

为了保持代码的可读性,您可以将此导航操作提取到一个私有帮助程序扩展函数中。

import androidx.navigation.NavHostController
// ...
OverviewScreen(
    // ...
    onAccountClick = { accountType ->
        navController.navigateToSingleAccount(accountType)
    }
)

// ...
                    
AccountsScreen(
    // ...
    onAccountClick = { accountType ->
        navController.navigateToSingleAccount(accountType)
    }
)

// ...

private fun NavHostController.navigateToSingleAccount(accountType: String) {
    this.navigateSingleTopTo("${SingleAccount.route}/$accountType")
}

在这一点上运行应用程序时,您可以点击每个帐户类型,并将被带到其对应的SingleAccountScreen,显示给定帐户的数据。

Screen recording of the overview screen, scrolling to eventual click destinations, and attempting to click. Clicks lead to destinations now.

8.启用深度链接支持

除了添加参数外,您还可以添加深度链接,以将特定的URL、操作和/或MIME类型与组合函数关联。在Android中,深度链接是指直接将您带到应用程序内特定目的地的链接。导航组合支持隐式深度链接。当调用隐式深度链接时(例如,当用户点击链接时),Android 就可以将您的应用程序打开到相应的目的地。

在本节中,您将为导航到具有相应帐户类型的SingleAccountScreen组合函数添加一个新的深度链接,并启用此深度链接以暴露给外部应用程序。为了刷新您的记忆,此组合函数的路由为"single_account/{account_type}",这也就是您将用于深度链接的内容,只有一些与深度链接相关的细微更改。

由于默认情况下不会将深度链接暴露给外部应用程序,因此您还必须向应用程序的manifest.xml文件添加<intent-filter>元素,所以这将是您的第一步。

首先将深度链接添加到应用程序的AndroidManifest.xml。您需要在<activity>内通过<intent-filter>创建一个新的意图过滤器,其操作为VIEW,类别为BROWSABLEDEFAULT

然后在过滤器内,您需要data标签来添加**scheme**(rally - 您的应用程序名称)和**host**(single_account - 到您组合函数的路由)来定义您的精确深度链接。这将为您提供rally://single_account作为深度链接URL。

请注意,您不需要在AndroidManifest中声明account_type参数。这将在以后的NavHost组合函数中添加。

<activity
    android:name=".RallyActivity"
    android:windowSoftInputMode="adjustResize"
    android:label="@string/app_name"
    android:exported="true">
    <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="rally" android:host="single_account" />
    </intent-filter>
</activity>

现在您可以从RallyActivity内响应传入的意图。

组合函数SingleAccountScreen已经接受参数,但现在它还需要接受新创建的深度链接,以便在触发其深度链接时启动此目的地。

SingleAccountScreen的组合函数内部,添加另一个参数deepLinks。与arguments类似,它也接受navDeepLink列表,因为您可以定义多个指向同一目的地的深度链接。传递uriPattern,与您在清单中定义的intent-filter匹配 - rally://singleaccount,但这次您还将附加其accountTypeArg参数。

import androidx.navigation.navDeepLink
// ...

composable(
    route = SingleAccount.routeWithArgs,
    // ...
    deepLinks = listOf(navDeepLink {
        uriPattern = "rally://${SingleAccount.route}/{${SingleAccount.accountTypeArg}}"
    })
)

您知道接下来是什么,对吧?将此列表移到RallyDestinations SingleAccount:中。

object SingleAccount : RallyDestination {
    // ...
    val arguments = listOf(
        navArgument(accountTypeArg) { type = NavType.StringType }
    )
    val deepLinks = listOf(
       navDeepLink { uriPattern = "rally://$route/{$accountTypeArg}"}
    )
}

同样,在相应的NavHost组合函数中替换它。

// ...
composable(
    route = SingleAccount.routeWithArgs,
    arguments = SingleAccount.arguments,
    deepLinks = SingleAccount.deepLinks
) {...}

现在您的应用程序和SingleAccountScreen已准备好处理深度链接。为了测试它的行为是否正确,请在新连接的模拟器或设备上全新安装Rally,打开命令行并执行以下命令,以模拟深度链接启动。

adb shell am start -d "rally://single_account/Checking" -a android.intent.action.VIEW

这将直接带您进入“Checking”帐户,但您也可以验证它对所有其他帐户类型是否也能正常工作。

9.将NavHost提取到RallyNavHost

现在您的NavHost已完成。但是,为了使其可测试,并保持您的RallyActivity更简洁,您可以将当前的NavHost及其辅助函数(如navigateToSingleAccount)从RallyApp组合函数提取到其自身的组合函数中,并将其命名为RallyNavHost

RallyApp是唯一一个应该直接使用navController的组合函数。如前所述,每个嵌套的组合屏幕都只能获得导航回调,而不是navController本身。

因此,新的RallyNavHost将接受navControllermodifier作为来自RallyApp的参数。

@Composable
fun RallyNavHost(
    navController: NavHostController,
    modifier: Modifier = Modifier
) {
    NavHost(
        navController = navController,
        startDestination = Overview.route,
        modifier = modifier
    ) {
        composable(route = Overview.route) {
            OverviewScreen(
                onClickSeeAllAccounts = {
                    navController.navigateSingleTopTo(Accounts.route)
                },
                onClickSeeAllBills = {
                    navController.navigateSingleTopTo(Bills.route)
                },
                onAccountClick = { accountType ->
                   navController.navigateToSingleAccount(accountType)
                }
            )
        }
        composable(route = Accounts.route) {
            AccountsScreen(
                onAccountClick = { accountType ->
                   navController.navigateToSingleAccount(accountType)
                }
            )
        }
        composable(route = Bills.route) {
            BillsScreen()
        }
        composable(
            route = SingleAccount.routeWithArgs,
            arguments = SingleAccount.arguments,
            deepLinks = SingleAccount.deepLinks
        ) { navBackStackEntry ->
            val accountType =
              navBackStackEntry.arguments?.getString(SingleAccount.accountTypeArg)
            SingleAccountScreen(accountType)
        }
    }
}

fun NavHostController.navigateSingleTopTo(route: String) =
    this.navigate(route) { launchSingleTop = true }

private fun NavHostController.navigateToSingleAccount(accountType: String) {
    this.navigateSingleTopTo("${SingleAccount.route}/$accountType")
}

现在将新的RallyNavHost添加到您的RallyApp中,并重新运行应用程序以验证一切是否如前所述工作。

fun RallyApp() {
    RallyTheme {
    ...
        Scaffold(
        ...
        ) { innerPadding ->
            RallyNavHost(
                navController = navController,
                modifier = Modifier.padding(innerPadding)
            )
        }
     }
}

10.测试组合导航

从本代码实验室开始,您确保不将navController直接传递到任何组合函数(高级应用程序除外),而是将导航回调作为参数传递。这允许所有组合函数单独测试,因为它们不需要在测试中使用navController的实例。

您应该始终测试应用程序中整个组合导航机制是否按预期工作,方法是测试RallyNavHost和传递给组合函数的导航操作。这些将是本节的主要目标。要单独测试各个组合函数,请务必查看Jetpack Compose中的测试代码实验室。

要开始测试,我们首先需要添加必要的测试依赖项,因此请返回到您的应用程序的构建文件,该文件位于app/build.gradle。在测试依赖项部分,添加navigation-testing依赖项。

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

准备NavigationTest类

您的RallyNavHost可以与Activity本身隔离测试。

由于此测试仍然将在Android设备上运行,因此您需要创建测试目录/app/src/androidTest/java/com/example/compose/rally,然后创建一个新的测试文件测试类,并将其命名为NavigationTest

第一步,要使用Compose测试API,以及使用Compose测试和控制组合函数和应用程序,请添加Compose测试规则

import androidx.compose.ui.test.junit4.createComposeRule
import org.junit.Rule

class NavigationTest {

    @get:Rule
    val composeTestRule = createComposeRule()

}

编写您的第一个测试

创建一个公共rallyNavHost测试函数,并使用@Test对其进行注释。在此函数中,您首先需要设置要测试的Compose内容。使用composeTestRulesetContent执行此操作。它将组合函数参数作为主体,并允许您在测试环境中编写Compose代码并添加组合函数,就像您在常规的生产环境应用程序中一样。

setContent内部,您可以设置当前测试主题RallyNavHost,并向其传递一个新的navController实例。导航测试工件提供了一个方便的TestNavHostController供使用。因此,让我们添加此步骤。

import androidx.compose.ui.platform.LocalContext
import androidx.navigation.compose.ComposeNavigator
import androidx.navigation.testing.TestNavHostController
import org.junit.Assert.fail
import org.junit.Test
// ...

class NavigationTest {

    @get:Rule
    val composeTestRule = createComposeRule()

    lateinit var navController: TestNavHostController

    @Test
    fun rallyNavHost() {
        composeTestRule.setContent {
            // Creates a TestNavHostController
            navController = 
                TestNavHostController(LocalContext.current)
            // Sets a ComposeNavigator to the navController so it can navigate through composables
            navController.navigatorProvider.addNavigator(
                ComposeNavigator()
            )
            RallyNavHost(navController = navController)
        }
        fail()
    }
}

如果您复制了上面的代码,则fail()调用将确保您的测试失败,直到进行实际断言。它提醒您完成测试的实现。

要验证是否显示了正确的屏幕组合函数,您可以使用其contentDescription并断言它已显示。在此代码实验室中,先前已设置了Accounts和Overview目的地的contentDescription,因此您已经可以使用它们进行测试验证。

作为第一个验证,您应该检查在第一次初始化RallyNavHost时是否显示了概览屏幕作为第一个目的地。您还应该重命名测试以反映这一点——将其命名为rallyNavHost_verifyOverviewStartDestination。通过将fail()调用替换为以下内容来执行此操作。

import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.onNodeWithContentDescription
// ...

class NavigationTest {

    @get:Rule
    val composeTestRule = createComposeRule()

    lateinit var navController: TestNavHostController

    @Test
    fun rallyNavHost_verifyOverviewStartDestination() {
        composeTestRule.setContent {
            navController = 
                TestNavHostController(LocalContext.current)
            navController.navigatorProvider.addNavigator(
                ComposeNavigator()
            )
            RallyNavHost(navController = navController)
        }

        composeTestRule
            .onNodeWithContentDescription("Overview Screen")
            .assertIsDisplayed()
    }
}

再次运行测试,并验证它是否通过。

由于您需要以相同的方式设置RallyNavHost用于即将进行的每个测试,因此您可以将其初始化提取到一个带有@Before注释的函数中,以避免不必要的重复并使您的测试更简洁。

import org.junit.Before
// ...

class NavigationTest {

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

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

    @Test
    fun rallyNavHost_verifyOverviewStartDestination() {
        composeTestRule
            .onNodeWithContentDescription("Overview Screen")
            .assertIsDisplayed()
    }
}

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

通过UI点击和屏幕内容描述进行测试

由于您需要测试具体应用的实现,因此最好通过点击UI进行测试。下面的文本可以验证,在“概览”屏幕中,点击“账户”子部分的“查看全部”按钮会将您带到“账户”目的地。

5a9e82acf7efdd5b.png

您将再次使用在OverviewScreenCard 组合式中设置的contentDescription,通过performClick() 模拟点击它,并验证随后是否显示“账户”目的地。

import androidx.compose.ui.test.performClick
// ...

@Test
fun rallyNavHost_clickAllAccount_navigatesToAccounts() {
    composeTestRule
        .onNodeWithContentDescription("All Accounts")
        .performClick()

    composeTestRule
        .onNodeWithContentDescription("Accounts Screen")
        .assertIsDisplayed()
}

您可以遵循此模式来测试应用中所有剩余的点击导航操作。

通过UI点击和路由比较进行测试

您还可以使用navController 通过将当前字符串路由与预期路由进行比较来检查您的断言。为此,请执行与上一节相同的UI点击操作,然后使用navController.currentBackStackEntry?.destination?.route 将当前路由与您期望的路由进行比较。

还需要额外的一步,确保您首先滚动到“概览”屏幕上的“账单”子部分,否则测试将失败,因为它将无法找到具有contentDescription“所有账单”的节点。

import androidx.compose.ui.test.performScrollTo
import org.junit.Assert.assertEquals
// ...

@Test
fun rallyNavHost_clickAllBills_navigateToBills() {
    composeTestRule.onNodeWithContentDescription("All Bills")
        .performScrollTo()
        .performClick()

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

按照这些模式,您可以通过涵盖任何其他导航路由、目的地和点击操作来完成您的测试类。现在运行整套测试以验证它们是否全部通过。

11. 恭喜您

恭喜您成功完成了本 Codelab!您可以在此处找到解决方案代码,并将其与您的代码进行比较。

您已将 Jetpack Compose 导航添加到 Rally 应用,并且现在熟悉其关键概念。您学习了如何设置组合式目的地的导航图、定义导航路由和操作、通过参数向路由传递附加信息、设置深度链接以及测试您的导航。

有关更多主题和信息,例如底部导航栏集成、多模块导航和嵌套图,您可以查看Now in Android GitHub 仓库,并了解它在那里是如何实现的。

下一步是什么?

查看这些资料以继续您的Jetpack Compose 学习路径

有关 Jetpack Navigation 的更多信息

参考文档