Jetpack Compose(第九趴)——Jetpack

六、使用实参(argument)导航到

让我们为AccountsOverview屏幕添加一些新功能!目前,这些屏幕会显示一个包含多种不同类型账户(“Checking”“Home Savings”等)的列表

image.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()
    }
}

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及其对象中,因此我们会继续采用相同的方法(就像我们在前面为OverviewAccountsBills采取的方法一样)并将此实参列表迁移到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。然后,您需要将其进一步向下传递给SingleAccoutnScreenaccountType形参。

您也可以为实参提供一个默认值作为占位符,并包含这种极端情况,以提高代码的安全性。

现在,您的代码应如下所示:

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的实现,就会发现它已经将传递的accountTypeUserData源进行匹配,以获取相应的账户详细信息。

我们再来执行一项要优化任务,将"${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}")

将此导航操作回调传递给OverviewScreenAccountsScreenonAccountClick形参。请注意,这些形参已预定义为onAccountClick:(String) -> Unit,其中String作为输入。也就是说,当用户点按OverviewAccount中的特定账户类型时,该账户类型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

3.gif

七、启用深层链接支持

除了添加实参之外,您还可以添加深层链接,将特定网址、操作和/或MIME类型与可组合项关联起来。在Android中,深层链接是指将用户直接转到应用内特定目的地的链接。Navigation Compose支持隐式深层链接。调用隐式深层链接时,Android可以将应用打开到相应的目的地。

在此部分中,您将添加一个新的深层链接,用于导航到包含相应账户类型的SingleAccountScreen可组合项,还要让此深层链接向外部应用公开。我们来回顾一下,此可组合项的路线是"single_account/{account_type}",这也是您将用于深层链接的路线,其中包含一些与深层链接相关的细微更改。

默认情况下,系统不会启用向外部应用公开深层链接这一功能,因此您还必须向应用的manifest.xml文件添加<intent-filter>元素,这是第一步。

首先,向应用的AndroidManifest.xml添加深层链接。您需要通过<activity>内的<intent-filter>创建一个新的intent过滤器,相应操作位VIEW,类别为BROWSABLEDEFAULT

然后,在该过滤器内,您需要用data标记添加schemehost,以定义精确的深层链接。这将为您提供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://singleaccountintent-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中的navControllermodifier作为形参:

@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内容。您可以使用composeTestRulesetContent执行此操作。它接受一个可组合形参作为正文,并且支持您编写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”目的地。

image.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()
}

您可以按照此模式在应用中测试其余所有的点击导航操作。

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")
}

您可以按照这些模式涵盖任何其他导航路线、目的地和点击操作,以便完成测试类。立即运行整套测试,验证它们是否均已通过。

全部评论

相关推荐

牛客101244697号:这个衣服和发型不去投偶像练习生?
点赞 评论 收藏
分享
评论
点赞
收藏
分享
牛客网
牛客企业服务