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 导航依赖项
  2. 设置 NavController
  3. 添加 NavHost 并创建导航图
  4. 准备在不同应用目标之间导航的路由
  5. 用 Compose 导航替换当前的导航机制

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

添加导航依赖项

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

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

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

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

设置 NavController

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

通过调用 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 中使用导航时,导航图中的每个可组合目标都与一个 路由 相关联。路由表示为字符串,定义可组合项的路径并指导您的 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
    }
}

向 NavGraph 添加目标

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

第一个目标将是 Overview,因此您需要通过 composable 扩展函数添加它并设置其唯一的字符串 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 }

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

@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(
            [email protected]().id
        ) {
            saveState = true
        }
        launchSingleTop = true
        restoreState = true
}

修复选项卡 UI

在代码实验室的开始时,在仍然使用手动导航机制时,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 中替换它:

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

设置 Accounts 和 Overview 的起始目的地

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

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

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

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

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

.

将此导航操作回调传递给OverviewScreenAccountsScreenonAccountClick参数。请注意,这些参数预定义为:onAccountClick: (String) -> Unit,其中String作为输入。这意味着,当用户在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 中,深层链接是指直接将您带到应用内特定目标的链接。Navigation Compose 支持隐式深层链接。当调用隐式深层链接时(例如,当用户点击链接时),Android 随后可以将您的应用打开到相应的目标。

在本节中,您将为导航到SingleAccountScreen可组合项(以及相应的账户类型)添加一个新的深层链接,并启用此深层链接以供外部应用使用。为了刷新您的记忆,此可组合项的路由是"single_account/{account_type}",这也就是您也将用于深层链接的内容,并进行一些细微的与深层链接相关的更改。

由于默认情况下不会将深层链接公开给外部应用,因此您还必须将<intent-filter>元素添加到应用的manifest.xml文件中,这将是您的第一步。

首先将深层链接添加到应用的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更简洁,您可以从RallyApp可组合项中提取当前的NavHost及其辅助函数(如navigateToSingleAccount),将其放到自己的可组合函数中,并将其命名为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. 测试 Compose Navigation

从本 Codelab 的开始,您就确保不将navController直接传递到任何可组合项(除了高级应用)中,而是将导航回调作为参数传递。这允许您的所有可组合项单独进行测试,因为它们不需要在测试中使用navController的实例。

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

要开始测试,我们首先需要添加必要的测试依赖项,因此请返回到应用的构建文件,该文件位于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实例。Navigation 测试工件提供了一个方便的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并断言它是否显示。在本 Codelab 中,之前已为“Accounts”和“Overview”目标设置了contentDescription,因此您现在可以将其用于测试验证。

作为第一个验证,您应该检查在第一次初始化RallyNavHost时是否显示“Overview”屏幕作为第一个目标。您还应该重命名测试以反映这一点 - 将其命名为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 点击和屏幕 contentDescription 进行测试

由于您希望测试您具体应用的实现,因此 UI 点击是首选。下一段文本可以验证,在“Overview”屏幕中,点击“Accounts”部分中的“SEE ALL”按钮将带您到“Accounts”目标。

5a9e82acf7efdd5b.png

您将再次使用OverviewScreenCard可组合项中为此特定按钮设置的contentDescription,通过performClick()模拟对其进行点击,并验证是否随后显示“Accounts”目标。

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将当前路由与您期望的路由进行比较。

还有一步是确保您首先滚动到“Overview”屏幕上的“Bills”部分,否则测试将失败,因为它将无法找到具有contentDescription“All Bills”的节点。

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 的更多信息

参考文档