Jetpack Compose(第九趴)——Jetpack
六、使用实参(argument)导航到
让我们为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()
}
}
6.1、设置SingleAccountScreen到达目的地
当您到达SingleAccountScreen
时,此目的地将需要更多信息才能知道打开时应显示的确切账户类型。我们可以使用实参传递此类信息。您需要指明,其路线还需要一个实参{account_type}
。如果您查看RallyDestination
及其SingleAccount
对象,便会发现该实参已定义为accountTypeArg
String,供您使用。
如需在导航时随路线一起传递实参,您需要按照以下模式将它们附加在一起:"route/{argument}"
。在本示例中,应如下所示:"${SingleAccount.route}/{@{SingleAccount.accountTypeArg}}"
。请注意,$符号用于转义变量:
import androidx.navigation.NatType
import androidx.navigation.compose.navArgument
// ...
composable(
route = "${SingleAccount.route}/{${SingleAccount.accountTypeArg}}"
) {
SingleAccountScreen()
}
这样可确保当操作被触发以导航到SingleAccountScreen
时,还必须传递accountTypeArg
实参,否则导航将会失败。您可以将其视为其他要导航到SingleAccoutnScreen
的目的地需要遵循的签名或协定。
第二步是让此composable
知道它应该接受实参。为此,您可以定义其arguments
形参。您可以根据需要定义任意数量的实参,因为composable
函数默认接受实参列表。在本示例中,您只需添加一个名为accountTypeArg
的实参,并将其类型指定为String
,即可提高安全性。如果您未明确设置类型,系统将根据此实参的默认值推断出其类型:
import androidx.navigation.NavType
import androidx.navigation.compose.navArgument
// ...
composable(
route = "${SingleAccount.route}/{${SingleAccount.accountTypeArg}}",
argument = 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 argument = listOf(
navArgument(accountTypeArg) { type = NavType.StringType }
)
}
将之前的实参替换为SingleAccount.argument
,现在返回到相应的NavHost composable
。这还可以确保让NavHost
尽可能简洁且易读:
composable(
route = "${SingleAccount.route}/{${SingleAccount.accountTypeArg}}",
arguments = SingleAccount.arguments
) {
SingleAccountScreen()
}
现在,您已使用实参定义了SingleAccountScreen
的完整路线,接下来要确保将此accountTypeArg
进一步向下传递给SingleAccountScreen
可组合项,这样它就会知道要正确显示哪个账户类型。如果您查看SingleAccountScreen
的实现,就会发现它已设置完毕且正在等待接受accountType
形参:
fun SingleAccountScreen(
account: String? = UserData.accounts.first().name
) {
// ...
}
简而言之,到目前为止:
- 您已确保我们定义请求实参的路线,作为先前目的地的信号
- 您已确保
composable
知道它需要接受实参
最后一步是以某种方式真正检索传递的实参值。
在Compose Navigation中,每个NavHost
可组合函数都可以访问当前的NavBackStackEntry
,该类用于保存当前路线的相关信息,以及返回堆栈中条目的已传递实参。您可以使用该类从navBackStackEntry
中获取所需的arguments
列表,然后搜索并检索所需的确切实参,将其进一步向下传递给可组合屏幕。
在这种情况下,您需要从navBackStackEntry
请求accountTypeArg
。然后,您需要将其进一步向下传递给SingleAccoutnScreen
的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
SingleAccountScren(accountType)
}
}
您的SingleAccountScreen
现已获得在您导航到那里时显示正确账户类型所需的必要信息。如果您查看SingleAccountScreen
的实现,就会发现它已经将传递的accountType
与UserData
源进行匹配,以获取相应的账户详细信息。
我们再来执行一项要优化任务,将"${SingleAccoung.route}/{${SingleAccount.accountTypeArg}}"
路线也移至RallyDestinations.kt
及其SingleAccount
对象中:
object SingleAccount: RallyDestination {
// ...
override val route = "single_account"
const val accountTypeArg = "account_type"
val routeWithArgs = "${route}/{${accountTypeArg}}"
val argument = listOf(
navArgument(accountTypeArg) { type = NavType.StringType}
)
}
同样,在相应的NavHost composable:
中进行替换
composable(
route = SingleAccount.routeWithArgs,
argument = SingleAccount.argument
) { ... }
6.2、设置“Accounts”和“Overview”的起始目的地
现在,您已经定义了SingleAccountScreen
路线及其成功导航到SingleAccountScreen
所需要和接受的实参,接下来您需要确保是从上一个目的地传递同一accountTypeArg
实参。
如您所见,这包含两个方面:一个是提供并传递实参的起始目的地,另一个是接受该实参并用它显示正确信息的到达目的地。这两者都需要进行明确定义。
例如,当您位于Accounts
目的地,并点按“Checking”账户类型时,Accounts
目的地需要将“Checking” String作为实参传递,以便成功打开相应的SingleAccountScreen
。其String路线将如下所示: "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(account)
}
)
// ....
AccountsScreen(
// ...
onAccountClick = { accountType ->
navController.navigateToSingleAccount(accountType)
}
)
// ...
private fun NavHostController.navigateToSingleAccount(accountType: String) {
this.navigateSingleTopTo("${SingleAccount.route}/$accountType")
}
此时,当您运行应用时,可以点击每个账户类型,系统会将您转到显示给定账户数据的对应SingleAccountScreen
七、启用深层链接支持
除了添加实参之外,您还可以添加深层链接,将特定网址、操作和/或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
和host
,以定义精确的深层链接。这将为您提供rally://single_account
作为深层链接网址。
请注意,您无需在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>
7.1、触发并验证深层链接
现在,您可以在RallyActivity
中相应传入的intent。
可组合项SingleAccountScreen
已经接受实参,但现在还需要接受新创建的深层链接,才能在其深层链接出发时启动此目的地。
在SingleAccountScreen
的可组合函数内,再添加一个形参deepLinks
。与arguments
类似,它还接受navDeepLink
列表,因为您可以定义多个指向同一目的地的深层链接。传递uriPattern
,匹配清单rally://singleaccount
的intent-filter
中定义的一个uriPattern
,但这次您还需附加其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
) { ... }
7.2、使用adb测试深层链接
现在,您的应用和SingleAccountScreen
已准备好处理深层链接。为了测试它能够正常运行,请在已连接的模拟器设备上重新安装Rally,打开命令并执行以下命令,以便模拟深层链接启动:
adb shell am start -d "rally://single_account/Checking" -a android.intent.action.VIEW
系统会带您直接进入“Checking”账户,不过您也可以针对所有其他账户类型验证它能够正常运行。
八、将NavHost提取到RallyNavHost
您的NavHost
现已完成。不过,为了使其可测试并保持RallyActivity
更加简洁,您可以将当前NavHost
及其辅助函数(如navigateToSingleAccount
)从RallyApp
可组合项提取到它自己的可组合函数,并将其命名为RallyNavHost
。
RallyApp
是应使用navController
直接处理的唯一一个可组合项。如前所述,其他每个嵌套的可组合屏幕应该仅获得导航回调,而不是navController
本身。
因此,新的RallyNavHost
将接受RallyApp
中的navController
和modifier
作为形参:
@Composable
fun RallyVavHost(
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,
argument = 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)
)
}
}
}
九、测试Compose Navigation
确保不将navController
直接传入任何可组合项,而是将导航回调作为形参传递。这样一来,您的所有可组合项均可单独吃啥, 因为它们不需要再测试中使用navController
实例。
一定要测试整个Compose Navigation机制能够在应用中按预期运行,方法是测试传递给可组合项的RallyNavHost
和导航操作。
若要开始测试,我们首先需要添加必要的测试依赖项,因此请返回到应用的build文件(位于app/build.gradle
)。在测试依赖项部分中,添加navigation-testing
依赖项:
dependencies {
// ...
androidTestImplementation "androidx.navigation:navigation-testing:$rootProject.composeNavigationVersion"
}
10.1、准备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()
}
10.2、编写首个测试
创建一个公共rallyNavHost
测试函数,并为其添加@Test
注解。在该函数中,首先需要设置要测试的Compose内容。您可以使用composeTestRule
的setContent
执行此操作。它接受一个可组合形参作为正文,并且支持您编写Compose代码,以及在测试环境中添加可组合项,就像您在常规生产环境应用中一样。
在setContent
内,您可以设置当前测试对象RallyNavHost
,并将新的navController
实例传递给该对象。Navigation Testing工作提供了一个便捷的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 {
navController = TestNavHostController(LocalContext.current)
navController.navigatorProvider.addNavigator(
ComposeNavigator()
)
RallyNavHost(nacController = navController)
}
fail()
}
}
如果您复制了上述代码,fail()
调用会确保您的测试一直失败,知道出现实际断言位置。它用于提醒您完成测试的实现。
如需验证是否显示了正确的屏幕可组合项,您可以使用其contentDescription
并断言它会显示。
首次验证时,您应该检查“Overview”屏幕在RallyNavHost
首次初始化时是否会作为第一个目的地显示。您还应重命名测试来反映这一点,不妨将其命名为rallyNavHost_verfyOverviewStartDestination
。为此,请将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_vertifyOverviewStartDestination() {
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()
}
}
10.3、在测试中导航
您可以通过多种方式测试导航实现,具体方法是:点击界面元素,然后验证显示的目的地;或者比较预期路线与当前路线。
10.3.1、通过界面点击和屏幕contentDescription进行测试
如果您要测试具体应用的实现,最好在界面中执行点击操作。接下来的文本内容可以验证这一点:在“Overview”屏幕中,点击“Accounts”子部分中的“SEE ALL”按钮后,您便会转到“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()
}
您可以按照此模式在应用中测试其余所有的点击导航操作。
10.3.2、通过界面点击和路线对比进行测试
您还可以使用navController
检查您的断言,只需将当前String路线与预期路线进行比较即可。为此,请按照与上一部分中相同的步骤,在界面中执行点击操作,然后使用navController.currentBackStackEntry?.destination?.route
来比较当前路线与预期路线。
您还需要再执行一个步骤,即确保现在“Overview”屏幕上滚动到“Bill”子部分,否则测试将会失败,因为它无法找到具有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")
}
您可以按照这些模式涵盖任何其他导航路线、目的地和点击操作,以便完成测试类。立即运行整套测试,验证它们是否均已通过。