1. 简介
上次更新时间 2022-07-25
您需要准备什么
- 最新版 Android Studio
- 了解 Kotlin 和 trailing lambdas(末尾 lambda 表达式)
- 基本了解导航及其术语,如返回栈
- 基本了解 Compose
- 在此之前,建议先完成 Jetpack Compose 基础知识 Codelab
- 基本了解 Compose 中的状态管理
- 在此之前,建议先完成 Jetpack Compose 中的状态 Codelab
使用 Compose 进行导航
Navigation 是一个 Jetpack 库,可让您在应用中的不同目标位置之间进行导航。Navigation 库还提供了一个特定 artifact,以便使用 Jetpack Compose 实现一致且惯用的导航。此 artifact (navigation-compose
) 是本 Codelab 的重点。
您将执行的操作
您将使用 Rally Material 研究 作为本 Codelab 的基础,以实现 Jetpack Navigation 组件,并在可组合的 Rally 屏幕之间启用导航。
您将学到的知识
- 将 Jetpack Navigation 与 Jetpack Compose 结合使用的基础知识
- 在可组合项之间导航
- 将自定义标签栏可组合项集成到您的导航层次结构中
- 通过参数进行导航
- 使用深层链接进行导航
- 测试导航
2. 设置
如需继续学习,请克隆本 Codelab 的起点 (main
分支)。
$ git clone https://github.com/android/codelab-android-compose.git
此外,您还可以下载两个 zip 文件
下载代码后,在 Android Studio 中打开 NavigationCodelab 项目文件夹。现在,您就可以开始了。
3. Rally 应用概览
第一步,您应该熟悉 Rally 应用及其代码库。运行应用并稍微探索一下。
Rally 有三个主要的可组合屏幕
OverviewScreen
— 所有财务交易和提醒的概览AccountsScreen
— 现有账户的洞察分析BillsScreen
— 预定支出
在屏幕顶部,Rally 使用了一个自定义标签栏可组合项 (RallyTabRow
) 在这三个屏幕之间进行导航。点按每个图标应会展开当前选项并带您进入相应的屏幕
导航到这些可组合屏幕时,您也可以将它们视为导航目标,因为我们希望在特定点进入每个屏幕。这些目标位置在 RallyDestinations.kt
文件中预定义。
在文件中,您会发现所有三个主要目标位置都定义为对象(Overview、Accounts
和 Bills
),还有一个 SingleAccount
,该对象将在稍后添加到应用中。每个对象都扩展自 RallyDestination
接口,并包含每个目标位置用于导航目的的必要信息
- 顶部栏的
icon
- 一个 String
route
(Compose Navigation 需要它作为通往该目标位置的路径) - 一个
screen
,表示此目标位置的整个可组合项
运行应用时,您会注意到您实际上可以使用顶部栏在当前目标位置之间导航。但是,该应用实际上并未采用 Compose Navigation,而是其当前的导航机制依赖于手动切换可组合项并触发重组以显示新内容。因此,本 Codelab 的目标是成功迁移和实现 Compose Navigation。
4. 迁移到 Compose Navigation
迁移到 Jetpack Compose 的基本步骤如下
- 添加最新的 Compose Navigation 依赖项
- 设置
NavController
- 添加
NavHost
并创建导航图 - 准备用于在不同应用目标位置之间导航的路由
- 将当前的导航机制替换为 Compose Navigation
接下来,我们将详细介绍这些步骤。
添加 Navigation 依赖项
打开应用的构建文件,该文件位于 app/build.gradle
。在依赖项部分,添加 navigation-compose
依赖项。
dependencies {
implementation "androidx.navigation:navigation-compose:{latest_version}"
// ...
}
您可以在此处找到最新版本的 navigation-compose。
现在,同步项目,您就可以开始在 Compose 中使用 Navigation 了。
设置 NavController
NavController
是在 Compose 中使用 Navigation 时的中心组件。它会跟踪返回栈的可组合项条目,将栈向前移动,实现返回栈操控,并在目标位置状态之间进行导航。由于 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 Navigation 中的路由
如前所述,Rally 应用有三个主要目标位置,以及一个稍后添加的额外目标位置 (SingleAccount
)。这些都在 RallyDestinations.kt
中定义。我们提到每个目标位置都有已定义的 icon
、route
和 screen
下一步是将这些目标位置添加到您的导航图中,并将 Overview
设置为应用启动时的起始目标位置。
在 Compose 中使用 Navigation 时,导航图中的每个可组合目标位置都关联一个 route。路由用字符串表示,用于定义通往您的可组合项的路径,并引导 navController
转到正确的位置。您可以将其视为通往特定目标位置的隐式深层链接。每个目标位置都必须有一个唯一路由。
为此,我们将使用每个 RallyDestination
对象的 route
属性。例如,Overview.route
是将您带到 Overview
屏幕可组合项的路由。
使用导航图调用 NavHost 可组合项
下一步是添加一个 NavHost
并创建您的导航图。
Navigation 的三个主要部分是 NavController
、NavGraph
和 NavHost
。NavController
始终与单个 NavHost
可组合项关联。NavHost
充当容器,负责显示图的当前目标位置。当您在可组合项之间导航时,NavHost
的内容会自动重组。它还将 NavController
与导航图 (NavGraph
) 关联起来,该导航图会映射出要在其中导航的可组合目标位置。它本质上是可以获取的目标位置的集合。
回到 RallyActivity.kt
中的 RallyApp
可组合项。将 Scaffold
内的 Box
可组合项(该可组合项包含当前屏幕的内容,用于手动切换屏幕)替换为新的 NavHost
,您可以按照以下代码示例创建该 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
扩展函数添加它,并设置其唯一的 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 与导航集成
在此步骤中,您将把 RallyTabRow
与 navController
和导航图关联起来,使其能够导航到正确的目标位置。
为此,您需要使用新的 navController
为 RallyTabRow
的 onTabSelected
回调定义正确的导航操作。此回调定义了选择特定标签图标时应执行的操作,并通过 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
中点按各个标签页确实会导航到正确的合成目标位置。但是,目前您可能已经注意到两个问题
- 连续重新点按同一标签页会启动同一目标位置的多个副本
- 标签页的 UI 与显示的正确目标位置不匹配 - 也就是说,所选标签页的展开和收起功能未按预期工作
让我们解决这两个问题!
启动一个目标位置的单一副本
为了解决第一个问题并确保返回栈顶部最多只有一个给定目标位置的副本,Compose Navigation API 提供了一个 launchSingleTop
标志,您可以将其传递给 navController.navigate()
操作,如下所示
navController.navigate(route) { launchSingleTop = true }
由于您希望在应用的每个目标位置都采用此行为,因此您可以将此标志提取到 RallyActivity
底部的辅助扩展中,而不是将其复制粘贴到所有 .navigate(...)
调用中
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
,您还应该考虑是否要在导航到和导航出某个目标位置时保存和恢复其状态。例如,如果您滚动到 Overview 底部,然后导航到 Accounts 再返回,您是否希望保持滚动位置?您是否希望在 RallyTabRow
中重新点按同一目标位置以重新加载屏幕状态?这些都是有效的问题,应根据您自己的应用设计要求来确定。
我们将介绍您可以在同一个 navigateSingleTopTo
扩展函数中使用的其他一些选项
launchSingleTop = true
- 如前所述,这可确保返回栈顶部最多只有一个给定目标位置的副本- 在 Rally 应用中,这意味着多次重新点按同一标签页不会启动同一目标位置的多个副本
popUpTo(startDestination) { saveState = true }
- 弹出到图的起始目标位置,以避免在您选择标签页时在返回栈上构建大量目标位置堆栈- 在 Rally 中,这意味着从任何目标位置按返回箭头都会将整个返回栈弹出到 Overview
restoreState = true
- 确定此导航操作是否应恢复之前由PopUpToBuilder.saveState
或popUpToSaveState
属性保存的任何状态。请注意,如果之前未为正在导航到的目标位置 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。
如前所述,每个目标位置都有一个唯一的路由,因此我们可以使用此 String 路由作为一种 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 } ?: Accounts
// ...
}
}
由于 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()
}
// ...
}
但是,本 Codelab 的以下步骤(例如点击事件)需要直接向您的可组合屏幕传递额外信息。在生产环境中,肯定会有更多数据需要传递。
实现此目的的正确(且更简洁)方法是将可组合项直接添加到 NavHost
导航图中,并从 RallyDestination
中提取它们。之后,RallyDestination
和屏幕对象将只包含导航特定信息,例如 icon
和 route
,并且将与任何 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
中的任何点击事件都会被忽略。这意味着 Accounts 和 Bills 子部分中的“查看全部”按钮是可点击的,但实际上不会带您到任何地方。此步骤的目标是为这些点击事件启用导航。
OverviewScreen
可组合项可以接受多个函数作为回调来设置点击事件,在本例中,这些应是带您导航到 AccountsScreen
或 BillsScreen
的导航操作。我们将这些导航回调传递给 onClickSeeAllAccounts
和 onClickSeeAllBills
,以导航到相关目标位置。
打开 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
让我们为 Accounts
和 Overview
屏幕添加一些新功能!目前,这些屏幕显示了多种不同类型账户的列表 - 例如“Checking”、“Home Savings”等。
但是,点击这些账户类型目前没有任何作用!让我们来修复这个问题!当我们点按每个账户类型时,我们希望显示一个新屏幕,其中包含完整的账户详细信息。为此,我们需要向 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
String。
要在导航时将参数与路由一起传递,您需要按照模式将它们附加在一起:"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
及其对象中,因此让我们继续采用相同的方法(就像我们对 Overview
、Accounts
和 Bills
所做的那样),并将此参数列表移到 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
。然后,您需要将其进一步传递给 SingleAccountScreen
的 accountType
参数。
您还可以为参数提供一个默认值作为占位符,以防未提供该参数,并通过涵盖此边缘情况使您的代码更安全。
您的代码现在应该如下所示
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
的实现,您会看到它已经将传递的 accountType
与 UserData
源进行匹配,以获取相应的账户详细信息。
让我们再次进行一个小优化任务,将 "${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
目标位置并点按“Checking”账户类型时,Accounts 目标位置需要将“Checking”字符串作为参数传递,并附加到“single_account”字符串路由后,以成功打开相应的 SingleAccountScreen
。其字符串路由如下所示:"single_account/Checking"
当使用 navController.navigateSingleTopTo(...),
时,您将使用完全相同的带传递参数的路由,如下所示
.navController.navigateSingleTopTo("${SingleAccount.route}/$accountType")
将此导航操作回调传递给 OverviewScreen
和 AccountsScreen
的 onAccountClick
参数。请注意,这些参数预定义为:onAccountClick: (String) -> Unit
,输入为 String。这意味着当用户在 Overview
和 Account
中点按特定账户类型时,该账户类型 String 已可用,并可以轻松地作为导航参数传递
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
,其中显示了给定账户的数据。
8. 启用深层链接支持
除了添加参数之外,您还可以添加深层链接,将特定的 URL、操作和/或 MIME 类型与可组合项关联起来。在 Android 中,深层链接是一种可直接将您带到应用内特定目标位置的链接。Navigation Compose 支持隐式深层链接。调用隐式深层链接时(例如,当用户点击链接时),Android 即可打开您的应用到相应的目标位置。
在本部分中,您将添加一个新的深层链接,用于导航到带有相应账户类型的 SingleAccountScreen
可组合项,并使该深层链接也能暴露给外部应用。回忆一下,此可组合项的路由是 "single_account/{account_type}"
,这也正是您将用于深层链接的路由,只需进行一些与深层链接相关的细微更改。
由于默认情况下不启用将深层链接暴露给外部应用的功能,您还必须向应用的 manifest.xml
文件中添加 <intent-filter>
元素,因此这将是您的第一步。
首先将深层链接添加到应用的 AndroidManifest.xml
。您需要在 <activity>
内部通过 <intent-filter>
创建一个新的 intent 过滤器,其操作为 VIEW
,类别为 BROWSABLE
和 DEFAULT
。
然后,在过滤器内部,您需要 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
内部对传入的 intent 作出响应。
可组合项 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
) {...}
使用 adb 测试深层链接
现在您的应用和 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
将接受来自 RallyApp
的 navController
和 modifier
作为参数
@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 内容。使用 composeTestRule
的 setContent
来完成此操作。它将一个可组合项参数作为主体,并使您能够在测试环境中编写 Compose 代码并添加可组合项,就像您在常规的生产环境应用中一样。
在 setContent
内部,您可以设置当前的测试对象 RallyNavHost
,并将新的 navController
实例传递给它。Navigation 测试 artifact 提供了一个方便的 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 子部分中的“查看全部”按钮可以将您带到 Accounts 目标位置
您将再次使用 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
为“所有账单”的节点
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 navigation 添加到 Rally 应用中,现在熟悉其主要概念。您学习了如何设置可组合目标位置的导航图、定义导航路由和操作、通过参数将额外信息传递给路由、设置深层链接以及测试导航。
如需更多主题和信息,例如底部导航栏集成、多模块导航和嵌套图,您可以查看Now in Android GitHub 仓库,了解其实现方式。
下一步是什么?
查看这些资料以继续您的Jetpack Compose 学习路线
关于 Jetpack Navigation 的更多信息