建構含有自動調整式版面配置的應用程式

1. 簡介

在先前的程式碼研究室中,您曾使用視窗大小類別並實作動態導覽,將 Reply 應用程式轉換為自動調整式應用程式。如想建構適合所有螢幕大小的應用程式,以上功能是第一步,也是十分重要的基礎。如果您未學習「使用動態導覽建構自動調整式應用程式」程式碼研究室,強烈建議您返回並從該程式碼研究室開始學習。

在本程式碼研究室中,您將以所學概念為基礎,進一步在應用程式中實作自動調整式版面配置。這類版面配置是標準版面配置的一部分,而標準版面配置是適合大螢幕的常用版面配置模式。您也會學到更多工具和測試技巧,可用來快速建構強大的應用程式。

必要條件

  • 完成「使用動態導覽建構自動調整式應用程式」程式碼研究室
  • 熟悉 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. 不同螢幕大小的預覽畫面

針對不同螢幕大小建立預覽畫面

在「使用動態導覽建構自動調整式應用程式」程式碼研究室中,您學到如何使用預覽可組合項協助開發程序。如果是自動調整式應用程式,最好的做法是建立多個預覽畫面,以便在不同大小的螢幕上顯示應用程式。您可以透過多種預覽功能,一次查看所有螢幕大小的變更。此外,其他開發人員也可以查看預覽畫面,這些程式碼會用來檢查您的應用程式是否與不同螢幕大小相容。

之前,您只有一個支援小型螢幕的單一預覽畫面。接下來,您要新增更多預覽畫面。

請完成下列步驟,為中型和展開式螢幕新增預覽畫面:

  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. 建立預覽畫面,用來查看以下項目:

a60105aedfbd8172.png

2e9e6b444a676f3a.png

3. 實作自動調整內容的版面配置

清單/詳細資料檢視畫面簡介

您可能會發現,在展開式螢幕中,內容看起來只是向外延伸,並未充分運用可用的螢幕空間。

46cbd7374bfcc4a9.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. 在平板電腦模式中執行應用程式,即可看到以下畫面:

3a545c9f30ccae08.png

改善清單/詳細資料檢視畫面的 UI 元素

目前,您的應用程式會在展開式螢幕的主畫面中顯示詳細資料窗格。

6292c7a61485e112.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. 當以獨立方式建立 ReplyDetailsScreen 時,在 ReplyHomeScreen.kt 中的 ReplyHomeScreen 可組合項內,為 isFullScreen 參數傳遞 true 值。

ReplyHomeScreen.kt

...
        } else {
            ReplyDetailsScreen(
                replyUiState = replyUiState,
                isFullScreen = true,
                onBackPressed = onDetailScreenBackPressed,
                modifier = modifier
            )
        }
...
  1. 在平板電腦模式下執行應用程式,並查看以下版面配置:

a3c92a6b810cb9d1.png

針對清單/詳細資料檢視畫面調整返回功能的處理方式

使用展開式螢幕時,您完全不必前往 ReplyDetailsScreen。相對的,應用程式應在使用者點選返回按鈕時關閉。因此,我們應調整返回處理常式。

修改返回處理常式的方法是在 ReplyListAndDetailContent 可組合項中,將 activity.finish() 函式做為 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 應用程式。此外,如果您使用的是可調整螢幕大小的模擬器,可以在未摺疊螢幕的摺疊模式中執行。
  2. 確認模擬器上的「Auto rotate」已設為「on」

9f10bf0c49070e8d.png

  1. 向下捲動電子郵件清單。

9c069c37c5591316.png

  1. 按一下電子郵件資訊卡。例如,開啟來自 Stef 的電子郵件。

40ded263c2342432.png

  1. 旋轉裝置,檢查所選電子郵件是否仍與螢幕方向為直向時選取的電子郵件一致。在這個範例中,畫面上仍會顯示來自 Stef 的電子郵件。

98d9e0b7ff3c4081.png

  1. 將裝置轉回直向,檢查應用程式是否仍會顯示相同的電子郵件。

40ded263c2342432.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. 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. 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. 如要對小型螢幕測試的測試函式使用註解,請將 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 測試執行器上,按一下扳手圖示來設定測試。
  2. 將測試重新命名為 Compact Test,然後選取「All in Package」來執行測試的全套檔案包

a277b820697a102f.png

  1. 按一下「Instrumentation arguments」欄位右側的三點圖示 (...)。
  2. 按一下加號 (+) 按鈕,然後加入其他參數:annotation,值為 com.example.reply.test.TestCompactWidth

944778497cfa41d4.png

  1. 使用小型螢幕模擬器執行測試。
  2. 檢查是否只執行了小型螢幕測試。

626e73e8325acb0c.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,透過社群媒體分享您的作品!

瞭解詳情