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

1. 简介

在上一个 Codelab 中,您学习了如何通过运用窗口大小类以及实现动态导航来将 Reply 应用转变为自适应应用。这些功能是构建适合所有屏幕尺寸的应用的重要基础,也是第一步。如果您尚未学习构建具有动态导航栏的自适应应用 Codelab,强烈建议您先回去学习该 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 值,并将 WindowWidthSizeClass.Medium 值指定为 ReplyApp 可组合项的形参。

MainActivity.kt

...
@Preview(showBackground = true, widthDp = 700)
@Composable
fun ReplyAppMediumPreview() {
    ReplyTheme {
        Surface {
            ReplyApp(windowSize = WindowWidthSizeClass.Medium)
        }
    }
}
... 
  1. 如需再为较大屏幕添加一个预览,请在 Preview 注解形参中设置较大的 widthDp 值,并将 WindowWidthSizeClass.Expanded 值指定为 ReplyApp 可组合项的形参。

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. ReplyAppContent 可组合项添加 contentType 形参。

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

改进列表-详情视图的界面元素

目前,应用会在较大屏幕的主屏幕上显示“详细信息”窗格。

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 中,当以独立形式创建 ReplyDetailsScreen 可组合项时,在 ReplyHomeScreen 可组合项内为 isFullScreen 形参传递 true 值。

ReplyHomeScreen.kt

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

833b3986a71a0b67.png

针对列表-详情视图调整返回处理

使用较大屏幕时,您不需要导航至 ReplyDetailsScreen,而是希望应用在用户选择返回按钮时关闭。因此,我们应调整返回处理程序。

对返回处理程序做以下调整:将 activity.finish() 函数作为 ReplyListAndDetailContent 可组合项中的 ReplyDetailsScreen 可组合项的 onBackPressed 形参传递。

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 应用相关的测试示例。

大屏设备应用配置和连续性质量说明。

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

针对配置和连续性的大屏设备应用质量测试步骤。

如需手动测试 Reply 应用以确保配置连续性,请完成以下步骤:

  1. 在中型设备上运行 Reply 应用,或者如果您使用的是可调整大小的模拟器,请在展开的可折叠模式下运行 Reply 应用。
  2. 确保模拟器上的 Auto-rotate 设置为 On

5a1c3a4cb4fc0192.png

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

7ce0887b5b38a1f0.png

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

16d7ca9c17206bf8.png

  1. 旋转设备,检查所选电子邮件是否仍与竖屏模式时选择的电子邮件相一致。在此示例中,屏幕中仍会显示来自 Ali 的电子邮件。

d078601f2cc50341.png

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

16d7ca9c17206bf8.png

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

为紧凑屏幕尺寸配置测试

测试 Cupcake 应用 Codelab 中,您学习了如何创建界面测试。现在,我们将学习如何针对不同屏幕尺寸创建特定测试。

在 Reply 应用中,您可以针对不同屏幕尺寸使用不同的导航元素。例如,您希望用户在看到较大屏幕时也会看到一个永久性抽屉式导航栏。一种实用方法是创建测试来验证各种导航元素(例如,针对不同屏幕尺寸的底部导航栏、导航栏和抽屉式导航栏)是否存在。

如需创建测试来验证紧凑屏幕中是否存在底部导航元素,请完成以下步骤:

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

ReplyAppTest.kt

...
class ReplyAppTest {

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

如需区分屏幕上的导航元素,请在 ReplyBottomNavigationBar 可组合项中添加 testTag

  1. Navigation Bottom 定义一个字符串资源。

strings.xml

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

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. 设置 stateRestorationTester,具体方法是将 composeTestRule 作为实参传递给 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. 设置 stateRestorationTester,具体方法是将 composeTestRule 作为实参传递给 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.ktTestCompactWidthTestMediumWidthTestExpandedWidth

TestAnnotations.kt

...
annotation class TestCompactWidth
annotation class TestMediumWidth
annotation class TestExpandedWidth
...
  1. TestCompactWidth 注解放在 ReplyAppTestReplyAppStateRestorationTest 中的紧凑测试的测试注解后面,以便在紧凑测试函数中使用注解。

ReplyAppTest.kt

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

ReplyAppStateRestorationTest.kt

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

...
  1. TestMediumWidth 注解放在 ReplyAppTest 中的中等测试的测试注解后面,以便在中等测试的测试函数中使用注解。

ReplyAppTest.kt

...
    @Test
    @TestMediumWidth
    fun mediumDevice_verifyUsingNavigationRail() {
...
  1. TestExpandedWidth 注解放在 ReplyAppTestReplyAppStateRestorationTest 中的展开测试的测试注解后面,以便在展开测试的测试函数中使用注解。

ReplyAppTest.kt

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

ReplyAppStateRestorationTest.kt

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

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

  1. 在 Android Studio 中,依次选择 Run > Edit Configurations…7be537f5faa1a61a.png
  2. 将测试重命名为 Compact tests,然后选择以 All in Package 方式运行测试。

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 标签在社交媒体上分享您的作品!

了解更多内容