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

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

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

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

实现列表-详情视图

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

  1. 为了表示不同类型的 content 布局,在 WindowStateUtils.kt 上,为不同的 content 类型创建一个新的 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 参数传递给 ReplyListAndDetailContent 组合函数内的 ReplyDetailsScreen 组合函数来修改后退处理程序。

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. 将测试重命名为 **Compact tests**,并选择运行 **包中所有** 测试。

f70b74bc2e6674f1.png

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

cf1ef9b80a1df8aa.png

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

204ed40031f8615a.png

  1. 对中型和扩展屏幕重复上述步骤。

6. 获取解决方案代码

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

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

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

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

7. 结论

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

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

了解更多