构建具有自适应布局的应用

1. 简介

在之前的 Codelab 中,您开始使用窗口大小类和实现动态导航来转换 Reply 应用以使其具有自适应性。这些功能是重要的基础,也是为所有屏幕尺寸构建应用的第一步。如果您错过了构建具有动态导航的自适应应用 Codelab,我们强烈建议您返回并从那里开始。

在本 Codelab 中,您将基于学到的概念进一步在您的应用中实现自适应布局。您将实现的自适应布局是规范布局的一部分——一组用于大屏幕显示的常用模式。您还将了解更多工具和测试技术,以帮助您快速构建健壮的应用。

先决条件

  • 已完成构建具有动态导航的自适应应用 Codelab
  • 熟悉 Kotlin 编程,包括类、函数和条件语句
  • 熟悉ViewModel
  • 熟悉Composable 函数
  • 有使用 Jetpack Compose 构建布局的经验
  • 有在设备或模拟器上运行应用的经验
  • 有使用WindowSizeClass API 的经验

您将学到什么

  • 如何使用 Jetpack Compose 创建列表视图模式的自适应布局
  • 如何为不同的屏幕尺寸创建预览
  • 如何测试多个屏幕尺寸的代码

您将构建什么

  • 您将继续更新 Reply 应用,使其适用于所有屏幕尺寸。

完成后的应用将如下所示

您需要什么

  • 一台连接互联网的电脑、一个网络浏览器和 Android Studio
  • 访问 GitHub

下载入门代码

要开始,请下载入门代码

或者,您可以克隆代码的 GitHub 存储库

$ git clone 
https://github.com/google-developer-training/basic-android-kotlin-compose-training-reply-app.git
$ cd basic-android-kotlin-compose-training-reply-app
$ git checkout nav-update

您可以在Reply GitHub 存储库中浏览入门代码。

2. 不同屏幕尺寸的预览

为不同的屏幕尺寸创建预览

构建具有动态导航的自适应应用 Codelab 中,您学习了如何使用预览组合函数来帮助您的开发过程。对于自适应应用,最佳实践是创建多个预览以在不同的屏幕尺寸上显示应用。使用多个预览,您可以同时查看所有屏幕尺寸上的更改。此外,预览还可以作为其他开发者审查您的代码的文档,以查看您的应用是否与不同的屏幕尺寸兼容。

以前,您只有一个支持紧凑屏幕的预览。接下来您将添加更多预览。

要为中等和扩展屏幕添加预览,请完成以下步骤

  1. 通过在Preview 注解参数中设置中等widthDp 值,并在ReplyApp 组合函数的参数中指定WindowWidthSizeClass.Medium 值,来添加中等屏幕的预览。

MainActivity.kt

...
@Preview(showBackground = true, widthDp = 700)
@Composable
fun ReplyAppMediumPreview() {
    ReplyTheme {
        Surface {
            ReplyApp(windowSize = WindowWidthSizeClass.Medium)
        }
    }
}
... 
  1. 通过在Preview 注解参数中设置较大的widthDp 值,并在ReplyApp 组合函数的参数中指定WindowWidthSizeClass.Expanded 值,来添加另一个扩展屏幕的预览。

MainActivity.kt

...
@Preview(showBackground = true, widthDp = 1000)
@Composable
fun ReplyAppExpandedPreview() {
    ReplyTheme {
        Surface {
            ReplyApp(windowSize = WindowWidthSizeClass.Expanded)
        }
    }
}
... 
  1. 构建预览以查看以下内容

5577b1d0fe306e33.png

f624e771b76bbc2.png

3. 实现自适应内容布局

列表详情视图简介

您可能会注意到,在扩展屏幕上,内容看起来被拉伸了,并没有很好地利用可用的屏幕空间。

56cfa13ef31d0b59.png

您可以通过应用其中一个规范布局 来改进此布局。规范布局是大屏幕组合,可作为设计和实施的起点。您可以使用三种可用的布局来指导您如何在应用中组织常见元素,列表视图、支持面板和提要。每个布局都考虑了常见的用例和组件,以满足对应用如何在屏幕尺寸和断点之间适应的期望和用户需求。

对于 Reply 应用,让我们实现列表详情视图,因为它最适合浏览内容并快速查看详细信息。使用列表详情视图布局,您将在电子邮件列表屏幕旁边创建另一个窗格以显示电子邮件详细信息。此布局允许您使用可用的屏幕向用户显示更多信息,并使您的应用更具生产力。

实现列表详情视图

要为扩展屏幕实现列表详情视图,请完成以下步骤

  1. 为了表示不同类型的內容布局,在WindowStateUtils.kt 中,为不同的内容类型创建一个新的Enum 类。当使用扩展屏幕时,使用LIST_AND_DETAIL 值,否则使用LIST_ONLY 值。

WindowStateUtils.kt

...
enum class ReplyContentType {
    LIST_ONLY, LIST_AND_DETAIL
}
... 
  1. ReplyApp.kt 上声明contentType 变量,并为各种窗口大小分配适当的contentType,以帮助确定适当的内容类型选择,具体取决于屏幕尺寸。

ReplyApp.kt

...
import com.example.reply.ui.utils.ReplyContentType
...

    val navigationType: ReplyNavigationType
    val contentType: ReplyContentType

    when (windowSize) {
        WindowWidthSizeClass.Compact -> {
            ...
            contentType = ReplyContentType.LIST_ONLY
        }
        WindowWidthSizeClass.Medium -> {
            ...
            contentType = ReplyContentType.LIST_ONLY
        }
        WindowWidthSizeClass.Expanded -> {
            ...
            contentType = ReplyContentType.LIST_AND_DETAIL
        }
        else -> {
            ...
            contentType = ReplyContentType.LIST_ONLY
        }
    }
... 

接下来,您可以使用contentType 值在ReplyAppContent 组合函数中创建不同的分支布局。

  1. ReplyHomeScreen.kt 中,将contentType 作为参数添加到ReplyHomeScreen 组合函数中。

ReplyHomeScreen.kt

...
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ReplyHomeScreen(
    navigationType: ReplyNavigationType,
    contentType: ReplyContentType,
    replyUiState: ReplyUiState,
    onTabPressed: (MailboxType) -> Unit,
    onEmailCardPressed: (Email) -> Unit,
    onDetailScreenBackPressed: () -> Unit,
    modifier: Modifier = Modifier
) {
...
  1. contentType 值传递给ReplyHomeScreen 组合函数。

ReplyApp.kt

...
    ReplyHomeScreen(
        navigationType = navigationType,
        contentType = contentType,
        replyUiState = replyUiState,
        onTabPressed = { mailboxType: MailboxType ->
            viewModel.updateCurrentMailbox(mailboxType = mailboxType)
            viewModel.resetHomeScreenStates()
        },
        onEmailCardPressed = { email: Email ->
            viewModel.updateDetailsScreenStates(
                email = email
            )
        },
        onDetailScreenBackPressed = {
            viewModel.resetHomeScreenStates()
        },
        modifier = modifier
    )

... 
  1. contentType 添加为ReplyAppContent 组合函数的参数。

ReplyHomeScreen.kt

...
@Composable
private fun ReplyAppContent(
    navigationType: ReplyNavigationType,
    contentType: ReplyContentType,
    replyUiState: ReplyUiState,
    onTabPressed: ((MailboxType) -> Unit),
    onEmailCardPressed: (Email) -> Unit,
    navigationItemContentList: List<NavigationItemContent>,
    modifier: Modifier = Modifier
) {
... 
  1. contentType 值传递给两个ReplyAppContent 组合函数。

ReplyHomeScreen.kt

...
            ReplyAppContent(
                navigationType = navigationType,
                contentType = contentType,
                replyUiState = replyUiState,
                onTabPressed = onTabPressed,
                onEmailCardPressed = onEmailCardPressed,
                navigationItemContentList = navigationItemContentList,
                modifier = modifier
            )
        }
    } else {
        if (replyUiState.isShowingHomepage) {
            ReplyAppContent(
                navigationType = navigationType,
                contentType = contentType,
                replyUiState = replyUiState,
                onTabPressed = onTabPressed,
                onEmailCardPressed = onEmailCardPressed,
                navigationItemContentList = navigationItemContentList,
                modifier = modifier
            )
        } else {
            ReplyDetailsScreen(
                replyUiState = replyUiState,
                isFullScreen = true,
                onBackButtonClicked = onDetailScreenBackPressed,
                modifier = modifier
            )
        }
    }
... 

contentTypeLIST_AND_DETAIL 时,让我们显示完整的列表和详细信息屏幕;当contentTypeLIST_ONLY 时,仅显示列表电子邮件内容。

  1. ReplyHomeScreen.kt 中,在ReplyAppContent 组合函数上添加一个if/else 语句,当contentType 值为LIST_AND_DETAIL 时显示ReplyListAndDetailContent 组合函数,并在else 分支上显示ReplyListOnlyContent 组合函数。

ReplyHomeScreen.kt

...
        Column(
            modifier = modifier
                .fillMaxSize()
                .background(MaterialTheme.colorScheme.inverseOnSurface)
        ) {
            if (contentType == ReplyContentType.LIST_AND_DETAIL) {
                ReplyListAndDetailContent(
                    replyUiState = replyUiState,
                    onEmailCardPressed = onEmailCardPressed,
                    modifier = Modifier.weight(1f)
                )
            } else {
                ReplyListOnlyContent(
                    replyUiState = replyUiState,
                    onEmailCardPressed = onEmailCardPressed,
                    modifier = Modifier.weight(1f)
                        .padding(
                            horizontal = dimensionResource(R.dimen.email_list_only_horizontal_padding)
                        )
                )
            }
            AnimatedVisibility(visible = navigationType == ReplyNavigationType.BOTTOM_NAVIGATION) {
                ReplyBottomNavigationBar(
                    currentTab = replyUiState.currentMailbox,
                    onTabPressed = onTabPressed,
                    navigationItemContentList = navigationItemContentList
                )
            }
        }
... 
  1. 删除replyUiState.isShowingHomepage 条件以显示永久导航抽屉,因为如果用户使用扩展视图,则无需导航到详细信息视图。

ReplyHomeScreen.kt

...
    if (navigationType == ReplyNavigationType.PERMANENT_NAVIGATION_DRAWER) {
        PermanentNavigationDrawer(
            drawerContent = {
                PermanentDrawerSheet(Modifier.width(dimensionResource(R.dimen.drawer_width))) {
                    NavigationDrawerContent(
                        selectedDestination = replyUiState.currentMailbox,
                        onTabPressed = onTabPressed,
                        navigationItemContentList = navigationItemContentList,
                        modifier = Modifier
                            .wrapContentWidth()
                            .fillMaxHeight()
                            .background(MaterialTheme.colorScheme.inverseOnSurface)
                            .padding(dimensionResource(R.dimen.drawer_padding_content))
                    )
                }
            }
        ) {

... 
  1. 在平板电脑模式下运行您的应用以查看以下屏幕

fe811a212feefea5.png

改进列表详情视图的 UI 元素

目前,您的应用在扩展屏幕的主屏幕上显示详细信息窗格。

e7c540e41fe1c3d.png

但是,屏幕包含多余的元素,例如后退按钮、主题标题和额外的填充,因为它最初是为独立的详细信息屏幕设计的。您可以通过简单的调整来改进这一点。

要改进扩展视图的详细信息屏幕,请完成以下步骤

  1. ReplyDetailsScreen.kt 中,将isFullScreen 变量作为Boolean 参数添加到ReplyDetailsScreen 组合函数中。

此添加使您可以在将组合函数用作独立组合函数时以及在将其用作主屏幕内部组合函数时进行区分。

ReplyDetailsScreen.kt

...
@Composable
fun ReplyDetailsScreen(
    replyUiState: ReplyUiState,
    onBackPressed: () -> Unit,
    modifier: Modifier = Modifier,
    isFullScreen: Boolean = false
) {
... 
  1. ReplyDetailsScreen 组合函数内部,使用if 语句包装ReplyDetailsScreenTopBar 组合函数,以便它仅在应用处于全屏状态时显示。

ReplyDetailsScreen.kt

...
    LazyColumn(
        modifier = modifier
            .fillMaxSize()
            .background(color = MaterialTheme.colorScheme.inverseOnSurface)
            .padding(top = dimensionResource(R.dimen.detail_card_list_padding_top))
    ) {
        item {
            if (isFullScreen) {
                ReplyDetailsScreenTopBar(
                    onBackPressed,
                    replyUiState,
                    Modifier
                        .fillMaxWidth()
                        .padding(bottom = dimensionResource(R.dimen.detail_topbar_padding_bottom))
                    )
                )
            }

... 

您现在可以添加填充。所需的填充ReplyEmailDetailsCard 组合函数根据您是否将其用作全屏组合函数而有所不同。当您与其他组合函数一起使用ReplyEmailDetailsCard 在扩展屏幕上,来自其他组合函数的额外填充。

  1. isFullScreen 值传递给ReplyEmailDetailsCard 组合函数。如果屏幕是全屏的,则传递一个具有水平填充R.dimen.detail_card_outer_padding_horizontal 的修饰符;否则,传递一个具有结束填充R.dimen.detail_card_outer_padding_horizontal 的修饰符。

ReplyDetailsScreen.kt

...
        item {
            if (isFullScreen) {
                ReplyDetailsScreenTopBar(
                    onBackPressed,
                    replyUiState,
                    Modifier
                        .fillMaxWidth()
                        .padding(bottom = dimensionResource(R.dimen.detail_topbar_padding_bottom))
                    )
                )
            }
            ReplyEmailDetailsCard(
                email = replyUiState.currentSelectedEmail,
                mailboxType = replyUiState.currentMailbox,
                isFullScreen = isFullScreen,
                modifier = if (isFullScreen) {
                    Modifier.padding(horizontal = dimensionResource(R.dimen.detail_card_outer_padding_horizontal))
                } else {
                    Modifier.padding(end = dimensionResource(R.dimen.detail_card_outer_padding_horizontal))
                }
            )
        }
... 
  1. isFullScreen 值作为参数添加到ReplyEmailDetailsCard 组合函数中。

ReplyDetailsScreen.kt

...
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun ReplyEmailDetailsCard(
    email: Email,
    mailboxType: MailboxType,
    modifier: Modifier = Modifier,
    isFullScreen: Boolean = false
) {
... 
  1. ReplyEmailDetailsCard 组合函数内部,仅当应用未处于全屏状态时显示电子邮件主题文本,因为全屏布局已将电子邮件主题显示为标题。如果是全屏,则添加一个高度为R.dimen.detail_content_padding_top 的间隔符。

ReplyDetailsScreen.kt

...
Column(
    modifier = Modifier
        .fillMaxWidth()
        .padding(dimensionResource(R.dimen.detail_card_inner_padding))
) {
    DetailsScreenHeader(
        email,
        Modifier.fillMaxWidth()
    )
    if (isFullScreen) {
        Spacer(modifier = Modifier.height(dimensionResource(R.dimen.detail_content_padding_top)))
    } else {
        Text(
            text = stringResource(email.subject),
            style = MaterialTheme.typography.bodyMedium,
            color = MaterialTheme.colorScheme.outline,
            modifier = Modifier.padding(
                top = dimensionResource(R.dimen.detail_content_padding_top),
                bottom = dimensionResource(R.dimen.detail_expanded_subject_body_spacing)
            ),
        )
    }
    Text(
        text = stringResource(email.body),
        style = MaterialTheme.typography.bodyLarge,
        color = MaterialTheme.colorScheme.onSurfaceVariant,
    )
    DetailsScreenButtonBar(mailboxType, displayToast)
}

... 
  1. ReplyHomeScreen.kt 中,在ReplyHomeScreen 组合函数内部,在创建ReplyDetailsScreen 组合函数作为独立组合函数时,为isFullScreen 参数传递true 值。

ReplyHomeScreen.kt

...
        } else {
            ReplyDetailsScreen(
                replyUiState = replyUiState,
                isFullScreen = true,
                onBackPressed = onDetailScreenBackPressed,
                modifier = modifier
            )
        }
... 
  1. 在平板电脑模式下运行应用,查看以下布局

833b3986a71a0b67.png

调整列表详情视图的后退处理

使用扩展屏幕时,您根本不需要导航到ReplyDetailsScreen。相反,您希望应用在用户选择后退按钮时关闭。因此,我们应该调整后退处理程序。

通过将activity.finish() 函数作为onBackPressed 参数传递给ReplyDetailsScreen 组合函数(位于ReplyListAndDetailContent 组合函数内部)来修改后退处理程序。

ReplyHomeContent.kt

...
import android.app.Activity
import androidx.compose.ui.platform.LocalContext
...
        val activity = LocalContext.current as Activity
        ReplyDetailsScreen(
            replyUiState = replyUiState,
            modifier = Modifier.weight(1f),
            onBackPressed = { activity.finish() }
        )
... 

4. 验证不同的屏幕尺寸

大屏幕应用质量指南

为了为 Android 用户构建出色且一致的体验,务必牢记质量来构建和测试您的应用。您可以参考核心应用质量指南 以确定如何提高应用质量。

要为所有表单因素构建高质量的应用,请查看大屏幕应用质量指南。您的应用还必须满足第 3 层 - 大屏幕就绪要求

手动测试您的应用以确保其适用于大屏幕

应用质量指南提供了测试设备建议和程序,以检查您的应用质量。让我们来看一下与 Reply 应用相关的测试示例。

Large screen app quality description for configuration and continuity.

上述应用质量指南要求应用在配置更改后保留或恢复其状态。该指南还提供了有关如何测试应用的说明,如下面的图所示

The large screen app quality test steps for configuration and continuity.

要手动测试 Reply 应用的配置连续性,请完成以下步骤

  1. 在中等尺寸的设备上运行 Reply 应用,或者如果您使用的是可调整大小的模拟器,则在展开的折叠模式下运行。
  2. 确保模拟器上的**自动旋转**设置为**开启**。

5a1c3a4cb4fc0192.png

  1. 向下滚动电子邮件列表。

7ce0887b5b38a1f0.png

  1. 点击电子邮件卡片。例如,打开来自**Ali**的电子邮件。

16d7ca9c17206bf8.png

  1. 旋转设备以检查所选电子邮件是否仍然与纵向模式下所选电子邮件一致。在本例中,仍然显示来自 Ali 的电子邮件。

d078601f2cc50341.png

  1. 旋转回纵向模式以检查应用是否仍然显示相同的电子邮件。

16d7ca9c17206bf8.png

5. 为自适应应用添加自动化测试

配置紧凑屏幕尺寸的测试

测试 Cupcake 应用 代码实验室中,您学习了如何创建 UI 测试。现在让我们学习如何为不同的屏幕尺寸创建特定的测试。

在 Reply 应用中,您对不同的屏幕尺寸使用不同的导航元素。例如,您希望在用户看到展开的屏幕时看到永久导航抽屉。创建测试以验证各种导航元素(例如底部导航、导航栏和导航抽屉)在不同屏幕尺寸下的存在性非常有用。

要创建测试以验证紧凑屏幕中底部导航元素的存在性,请完成以下步骤

  1. 在测试目录中,创建一个名为ReplyAppTest.kt的新 Kotlin 类。
  2. ReplyAppTest类中,使用createAndroidComposeRule创建测试规则,并将ComponentActivity作为类型参数传递。ComponentActivity用于访问空活动而不是MainActivity

ReplyAppTest.kt

...
class ReplyAppTest {

    @get:Rule
    val composeTestRule = createAndroidComposeRule<ComponentActivity>()
...

为了区分屏幕中的导航元素,在ReplyBottomNavigationBar可组合项中添加testTag

  1. 为**导航底部**定义一个字符串资源。

strings.xml

...
<resources>
...
    <string name="navigation_bottom">Navigation Bottom</string>
...
</resources>
  1. 将字符串名称作为ModifiertestTag方法的testTag参数添加到ReplyBottomNavigationBar可组合项中。

ReplyHomeScreen.kt

...
val bottomNavigationContentDescription = stringResource(R.string.navigation_bottom)
ReplyBottomNavigationBar(
    ...
    modifier = Modifier
        .fillMaxWidth()
        .testTag(bottomNavigationContentDescription)
)
...
  1. ReplyAppTest类中,创建一个测试函数来测试紧凑尺寸屏幕。使用ReplyApp可组合项设置composeTestRule的内容,并将WindowWidthSizeClass.Compact作为windowSize参数传递。

ReplyAppTest.kt

...
    @Test
    fun compactDevice_verifyUsingBottomNavigation() {
        // Set up compact window
        composeTestRule.setContent {
            ReplyApp(
                windowSize = WindowWidthSizeClass.Compact
            )
        }
    }
  1. 断言底部导航元素是否存在且具有测试标签。在composeTestRule上调用扩展函数onNodeWithTagForStringId并传递导航底部字符串,然后调用assertExists()方法。

ReplyAppTest.kt

...
    @Test
    fun compactDevice_verifyUsingBottomNavigation() {
        // Set up compact window
        composeTestRule.setContent {
            ReplyApp(
                windowSize = WindowWidthSizeClass.Compact
            )
        }
        // Bottom navigation is displayed
        composeTestRule.onNodeWithTagForStringId(
            R.string.navigation_bottom
        ).assertExists()
    }
  1. 运行测试并验证其是否通过。

配置中等和展开屏幕尺寸的测试

既然您已成功为紧凑屏幕创建了测试,那么让我们为中等和展开屏幕创建相应的测试。

要创建测试以验证中等和展开屏幕的导航栏和永久导航抽屉的存在性,请完成以下步骤

  1. 为**导航栏**定义一个字符串资源,以便稍后用作测试标签。

strings.xml

...
<resources>
...
    <string name="navigation_rail">Navigation Rail</string>
...
</resources>
  1. 通过PermanentNavigationDrawer可组合项中的Modifier将字符串作为测试标签传递。

ReplyHomeScreen.kt

...
    val navigationDrawerContentDescription = stringResource(R.string.navigation_drawer)
        PermanentNavigationDrawer(
...
modifier = Modifier.testTag(navigationDrawerContentDescription)
)
...
  1. 通过ReplyNavigationRail可组合项中的Modifier将字符串作为测试标签传递。

ReplyHomeScreen.kt

...
val navigationRailContentDescription = stringResource(R.string.navigation_rail)
ReplyNavigationRail(
    ...
    modifier = Modifier
        .testTag(navigationRailContentDescription)
)
...
  1. 添加一个测试以验证导航栏元素在中等屏幕中是否存在。

ReplyAppTest.kt

...
@Test
fun mediumDevice_verifyUsingNavigationRail() {
    // Set up medium window
    composeTestRule.setContent {
        ReplyApp(
            windowSize = WindowWidthSizeClass.Medium
        )
    }
    // Navigation rail is displayed
    composeTestRule.onNodeWithTagForStringId(
        R.string.navigation_rail
    ).assertExists()
}
  1. 添加一个测试以验证导航抽屉元素在展开屏幕中是否存在。

ReplyAppTest.kt

...
@Test
fun expandedDevice_verifyUsingNavigationDrawer() {
    // Set up expanded window
    composeTestRule.setContent {
        ReplyApp(
            windowSize = WindowWidthSizeClass.Expanded
        )
    }
    // Navigation drawer is displayed
    composeTestRule.onNodeWithTagForStringId(
        R.string.navigation_drawer
    ).assertExists()
}
  1. 使用平板电脑模拟器或在平板电脑模式下使用可调整大小的模拟器来运行测试。
  2. 运行所有测试并验证其是否通过。

测试紧凑屏幕中的配置更改

配置更改是应用生命周期中常见的事件。例如,当您将方向从纵向更改为横向时,就会发生配置更改。当发生配置更改时,务必测试您的应用是否保留其状态。接下来,您将创建模拟配置更改的测试,以测试您的应用在紧凑屏幕中是否保留其状态。

要测试紧凑屏幕中的配置更改

  1. 在测试目录中,创建一个名为ReplyAppStateRestorationTest.kt的新 Kotlin 类。
  2. ReplyAppStateRestorationTest类中,使用createAndroidComposeRule创建测试规则,并将ComponentActivity作为类型参数传递。

ReplyAppStateRestorationTest.kt

...
class ReplyAppStateRestorationTest {

    /**
     * Note: To access to an empty activity, the code uses ComponentActivity instead of
     * MainActivity.
     */
    @get:Rule
    val composeTestRule = createAndroidComposeRule<ComponentActivity>()
}
...
  1. 创建一个测试函数以验证配置更改后紧凑屏幕中是否仍选中电子邮件。

ReplyAppStateRestorationTest.kt

...
@Test
fun compactDevice_selectedEmailEmailRetained_afterConfigChange() {
    
}
...

要测试配置更改,您需要使用StateRestorationTester

  1. 通过将composeTestRule作为参数传递给StateRestorationTester来设置stateRestorationTester
  2. 使用setContent()ReplyApp可组合项,并将WindowWidthSizeClass.Compact作为windowSize参数传递。

ReplyAppStateRestorationTest.kt

...
@Test
fun compactDevice_selectedEmailEmailRetained_afterConfigChange() {
    // Setup compact window
    val stateRestorationTester = StateRestorationTester(composeTestRule)
    stateRestorationTester.setContent { ReplyApp(windowSize = WindowWidthSizeClass.Compact) }

}
...
  1. 验证应用中是否显示了第三封电子邮件。在composeTestRule上使用assertIsDisplayed()方法,该方法查找第三封电子邮件的文本。

ReplyAppStateRestorationTest.kt

...
@Test
fun compactDevice_selectedEmailEmailRetained_afterConfigChange() {
    // Setup compact window
    val stateRestorationTester = StateRestorationTester(composeTestRule)
    stateRestorationTester.setContent { ReplyApp(windowSize = WindowWidthSizeClass.Compact) }

    // Given third email is displayed
    composeTestRule.onNodeWithText(
        composeTestRule.activity.getString(LocalEmailsDataProvider.allEmails[2].body)
    ).assertIsDisplayed()
}
...
  1. 通过点击电子邮件主题导航到电子邮件的详细信息屏幕。使用performClick()方法进行导航。

ReplyAppStateRestorationTest.kt

...
@Test
fun compactDevice_selectedEmailEmailRetained_afterConfigChange() {
    // Setup compact window
    val stateRestorationTester = StateRestorationTester(composeTestRule)
    stateRestorationTester.setContent { ReplyApp(windowSize = WindowWidthSizeClass.Compact) }

    // Given third email is displayed
    composeTestRule.onNodeWithText(
        composeTestRule.activity.getString(LocalEmailsDataProvider.allEmails[2].body)
    ).assertIsDisplayed()

    // Open detailed page
    composeTestRule.onNodeWithText(
        composeTestRule.activity.getString(LocalEmailsDataProvider.allEmails[2].subject)
    ).performClick()
}
...
  1. 验证详细信息屏幕中是否显示了第三封电子邮件。断言后退按钮的存在以确认应用位于详细信息屏幕,并验证是否显示了第三封电子邮件的文本。

ReplyAppStateRestorationTest.kt

...
@Test
fun compactDevice_selectedEmailEmailRetained_afterConfigChange() {
    ...
    // Open detailed page
    composeTestRule.onNodeWithText(
        composeTestRule.activity.getString(LocalEmailsDataProvider.allEmails[2].subject)
    ).performClick()

    // Verify that it shows the detailed screen for the correct email
    composeTestRule.onNodeWithContentDescriptionForStringId(
        R.string.navigation_back
    ).assertExists()
    composeTestRule.onNodeWithText(
}
...
  1. 使用stateRestorationTester.emulateSavedInstanceStateRestore()模拟配置更改。

ReplyAppStateRestorationTest.kt

...
@Test
fun compactDevice_selectedEmailEmailRetained_afterConfigChange() {
    ...
    // Verify that it shows the detailed screen for the correct email
    composeTestRule.onNodeWithContentDescriptionForStringId(
        R.string.navigation_back
    ).assertExists()
    composeTestRule.onNodeWithText(
        composeTestRule.activity.getString(LocalEmailsDataProvider.allEmails[2].body)
    ).assertExists()

    // Simulate a config change
    stateRestorationTester.emulateSavedInstanceStateRestore()
}
...
  1. 再次验证详细信息屏幕中是否显示了第三封电子邮件。断言后退按钮的存在以确认应用位于详细信息屏幕,并验证是否显示了第三封电子邮件的文本。

ReplyAppStateRestorationTest.kt

...
@Test
fun compactDevice_selectedEmailEmailRetained_afterConfigChange() {
    // Setup compact window
    val stateRestorationTester = StateRestorationTester(composeTestRule)
    stateRestorationTester.setContent { ReplyApp(windowSize = WindowWidthSizeClass.Compact) }

    // Given third email is displayed
    composeTestRule.onNodeWithText(
        composeTestRule.activity.getString(LocalEmailsDataProvider.allEmails[2].body)
    ).assertIsDisplayed()

    // Open detailed page
    composeTestRule.onNodeWithText(
        composeTestRule.activity.getString(LocalEmailsDataProvider.allEmails[2].subject)
    ).performClick()

    // Verify that it shows the detailed screen for the correct email
    composeTestRule.onNodeWithContentDescriptionForStringId(
        R.string.navigation_back
    ).assertExists()
    composeTestRule.onNodeWithText(
        composeTestRule.activity.getString(LocalEmailsDataProvider.allEmails[2].body)
    ).assertExists()

    // Simulate a config change
    stateRestorationTester.emulateSavedInstanceStateRestore()

    // Verify that it still shows the detailed screen for the same email
    composeTestRule.onNodeWithContentDescriptionForStringId(
        R.string.navigation_back
    ).assertExists()
    composeTestRule.onNodeWithText(
        composeTestRule.activity.getString(LocalEmailsDataProvider.allEmails[2].body)
    ).assertExists()
}

...
  1. 使用手机模拟器或在手机模式下使用可调整大小的模拟器运行测试。
  2. 验证测试是否通过。

测试展开屏幕中的配置更改

要通过模拟配置更改并传递适当的 WindowWidthSizeClass 来测试展开屏幕中的配置更改,请完成以下步骤

  1. 创建一个测试函数以验证配置更改后详细信息屏幕中是否仍选中电子邮件。

ReplyAppStateRestorationTest.kt

...
@Test
fun expandedDevice_selectedEmailEmailRetained_afterConfigChange() {

}
...

要测试配置更改,您需要使用StateRestorationTester

  1. 通过将composeTestRule作为参数传递给StateRestorationTester来设置stateRestorationTester
  2. 使用setContent()ReplyApp可组合项,并将WindowWidthSizeClass.Expanded作为windowSize参数传递。

ReplyAppStateRestorationTest.kt

...
@Test
fun expandedDevice_selectedEmailEmailRetained_afterConfigChange() {
    // Setup expanded window
    val stateRestorationTester = StateRestorationTester(composeTestRule)
    stateRestorationTester.setContent { ReplyApp(windowSize = WindowWidthSizeClass.Expanded) }
}
...
  1. 验证应用中是否显示了第三封电子邮件。在composeTestRule上使用assertIsDisplayed()方法,该方法查找第三封电子邮件的文本。

ReplyAppStateRestorationTest.kt

...
@Test
fun expandedDevice_selectedEmailEmailRetained_afterConfigChange() {
    // Setup expanded window
    val stateRestorationTester = StateRestorationTester(composeTestRule)
    stateRestorationTester.setContent { ReplyApp(windowSize = WindowWidthSizeClass.Expanded) }

    // Given third email is displayed
    composeTestRule.onNodeWithText(
        composeTestRule.activity.getString(LocalEmailsDataProvider.allEmails[2].body)
    ).assertIsDisplayed()
}
...
  1. 在详细信息屏幕上选择第三封电子邮件。使用performClick()方法选择电子邮件。

ReplyAppStateRestorationTest.kt

...
@Test
fun expandedDevice_selectedEmailEmailRetained_afterConfigChange() {
    // Setup expanded window
    val stateRestorationTester = StateRestorationTester(composeTestRule)
    stateRestorationTester.setContent { ReplyApp(windowSize = WindowWidthSizeClass.Expanded) }

    // Given third email is displayed
    composeTestRule.onNodeWithText(
        composeTestRule.activity.getString(LocalEmailsDataProvider.allEmails[2].body)
    ).assertIsDisplayed()

    // Select third email
    composeTestRule.onNodeWithText(
        composeTestRule.activity.getString(LocalEmailsDataProvider.allEmails[2].subject)
    ).performClick()
    ...
}

...
  1. 通过在详细信息屏幕上使用testTag并查找其子元素中的文本来验证详细信息屏幕是否显示了第三封电子邮件。这种方法确保您可以在详细信息部分查找文本,而不是在电子邮件列表中查找。

ReplyAppStateRestorationTest.kt

...

@Test
fun expandedDevice_selectedEmailEmailRetained_afterConfigChange() {
    ...
    // Select third email
    composeTestRule.onNodeWithText(
        composeTestRule.activity.getString(LocalEmailsDataProvider.allEmails[2].subject)
    ).performClick()

    // Verify that third email is displayed on the details screen
    composeTestRule.onNodeWithTagForStringId(R.string.details_screen).onChildren()
        .assertAny(hasAnyDescendant(hasText(
            composeTestRule.activity.getString(LocalEmailsDataProvider.allEmails[2].body)))
        )
...
}

...
  1. 使用stateRestorationTester.emulateSavedInstanceStateRestore()模拟配置更改。

ReplyAppStateRestorationTest.kt

...
@Test
fun expandedDevice_selectedEmailEmailRetained_afterConfigChange() {
    ...
    // Verify that third email is displayed on the details screen
    composeTestRule.onNodeWithTagForStringId(R.string.details_screen).onChildren()
        .assertAny(hasAnyDescendant(hasText(
            composeTestRule.activity.getString(LocalEmailsDataProvider.allEmails[2].body)))
        )

    // Simulate a config change
    stateRestorationTester.emulateSavedInstanceStateRestore()
    ...
}
...
  1. 再次验证配置更改后详细信息屏幕是否显示了第三封电子邮件。

ReplyAppStateRestorationTest.kt

...
@Test
fun expandedDevice_selectedEmailEmailRetained_afterConfigChange() {
    // Setup expanded window
    val stateRestorationTester = StateRestorationTester(composeTestRule)
    stateRestorationTester.setContent { ReplyApp(windowSize = WindowWidthSizeClass.Expanded) }

    // Given third email is displayed
    composeTestRule.onNodeWithText(
        composeTestRule.activity.getString(LocalEmailsDataProvider.allEmails[2].body)
    ).assertIsDisplayed()

    // Select third email
    composeTestRule.onNodeWithText(
        composeTestRule.activity.getString(LocalEmailsDataProvider.allEmails[2].subject)
    ).performClick()

    // Verify that third email is displayed on the details screen
    composeTestRule.onNodeWithTagForStringId(R.string.details_screen).onChildren()
        .assertAny(hasAnyDescendant(hasText(
            composeTestRule.activity.getString(LocalEmailsDataProvider.allEmails[2].body)))
        )

    // Simulate a config change
    stateRestorationTester.emulateSavedInstanceStateRestore()

    // Verify that third email is still displayed on the details screen
    composeTestRule.onNodeWithTagForStringId(R.string.details_screen).onChildren()
        .assertAny(hasAnyDescendant(hasText(
            composeTestRule.activity.getString(LocalEmailsDataProvider.allEmails[2].body)))
        )
}
...
  1. 使用平板电脑模拟器或在平板电脑模式下使用可调整大小的模拟器运行测试。
  2. 验证测试是否通过。

使用注解对不同屏幕尺寸的测试进行分组

您可能从之前的测试中意识到,某些测试在与屏幕尺寸不兼容的设备上运行时会失败。虽然您可以使用适当的设备逐个运行测试,但当您有很多测试用例时,这种方法可能无法扩展。

为了解决此问题,您可以创建注解来表示测试可以运行的屏幕尺寸,并为相应的设备配置带注解的测试。

要根据屏幕尺寸运行测试,请完成以下步骤

  1. 在测试目录中,创建TestAnnotations.kt,其中包含三个注解类:TestCompactWidthTestMediumWidthTestExpandedWidth

TestAnnotations.kt

...
annotation class TestCompactWidth
annotation class TestMediumWidth
annotation class TestExpandedWidth
...
  1. 在紧凑测试的测试函数上使用注解,方法是在ReplyAppTestReplyAppStateRestorationTest中紧凑测试的测试注解之后放置TestCompactWidth注解。

ReplyAppTest.kt

...
    @Test
    @TestCompactWidth
    fun compactDevice_verifyUsingBottomNavigation() {
...

ReplyAppStateRestorationTest.kt

...
    @Test
    @TestCompactWidth
    fun compactDevice_selectedEmailEmailRetained_afterConfigChange() {

...
  1. 在中等测试的测试函数上使用注解,方法是在ReplyAppTest中中等测试的测试注解之后放置TestMediumWidth注解。

ReplyAppTest.kt

...
    @Test
    @TestMediumWidth
    fun mediumDevice_verifyUsingNavigationRail() {
...
  1. 在展开测试的测试函数上使用注解,方法是在ReplyAppTestReplyAppStateRestorationTest中展开测试的测试注解之后放置TestExpandedWidth注解。

ReplyAppTest.kt

...
    @Test
    @TestExpandedWidth
    fun expandedDevice_verifyUsingNavigationDrawer() {
...

ReplyAppStateRestorationTest.kt

...
    @Test
    @TestExpandedWidth
    fun expandedDevice_selectedEmailEmailRetained_afterConfigChange() {
...

为了确保成功,请将测试配置为仅运行带有TestCompactWidth注解的测试。

  1. 在 Android Studio 中,选择**运行** > **编辑配置...**7be537f5faa1a61a.png
  2. 将测试重命名为**紧凑测试**,并选择运行**包中的所有**测试。

f70b74bc2e6674f1.png

  1. 点击**Instrumentation arguments**字段右侧的三个点(**...**)。
  2. 点击加号(+)按钮并添加额外的参数:值为com.example.reply.test.TestCompactWidth的**annotation**。

cf1ef9b80a1df8aa.png

  1. 使用紧凑模拟器运行测试。
  2. 检查是否只运行了紧凑测试。

204ed40031f8615a.png

  1. 对中等和展开屏幕重复上述步骤。

6. 获取解决方案代码

要下载完成的代码实验室的代码,请使用以下 git 命令

$ git clone https://github.com/google-developer-training/basic-android-kotlin-compose-training-reply-app.git

或者,您可以将存储库下载为 zip 文件,解压缩并将其在 Android Studio 中打开。

如果您想查看解决方案代码,请在 GitHub 上查看

7. 结论

恭喜!您通过实现自适应布局,使 Reply 应用能够适应所有屏幕尺寸。您还学习了如何使用预览加速开发,以及使用各种测试方法维护应用质量。

不要忘记在社交媒体上分享您的作品,并使用 #AndroidBasics 标签!

了解更多