調整 Android 應用程式大小

1. 簡介

Android 裝置生態系統不斷與時俱進。從早期內建硬體鍵盤的裝置,到現代的翻蓋式裝置、摺疊式裝置、平板電腦,甚至是可調整大小的任意形式視窗,今日支援 Android 應用程式的裝置種類可說是五花八門。

雖然這對開發人員來說是好消息,但如要滿足使用者對可用性的期望,在各種大小的裝置上提供優異的使用者體驗,就勢必得將應用程式最佳化。不過,你不必一次針對一種新裝置進行調整,而可採用回應式/自動調整式 UI 和彈性架構,讓應用程式在任何大小/形狀的現有/未來裝置上,都能保持美觀並順利運作。

藉由初步探索可調整大小的任意形式 Android 環境,你可以對自己的回應式/自動調整式 UI 進行壓力測試,確保 UI 在任何裝置上都能正常顯示。本程式碼研究室將引導你瞭解調整大小的重要性,以及如何採用最佳做法,確保應用程式可輕鬆妥善地調整大小。

建構目標

你將瞭解任意形式大小調整的重要性,並採用調整大小的最佳做法將 Android 應用程式最佳化。你的應用程式將會:

有相容的資訊清單

  • 移除相關限制,讓應用程式能任意調整大小

在調整大小時維持 UI 狀態

  • 使用 rememberSaveable,在調整大小時維持 UI 狀態
  • 避免在初始化 UI 時進行不必要的重複背景作業

課程需求

  1. 建立基本 Android 應用程式的相關知識
  2. Compose 中 ViewModel 和狀態的相關知識
  3. 支援任意形式視窗大小調整的測試裝置,例如:

進行本程式碼研究室時,如果你遇到任何問題 (例如程式碼錯誤、文法錯誤或用詞不明確等),請透過程式碼研究室左下角的「回報錯誤」連結回報問題。

2. 踏出第一步

GitHub 複製存放區。

git clone https://github.com/android/large-screen-codelabs/

你也可以下載存放區的 ZIP 檔案並解壓縮

匯入專案

  • 開啟 Android Studio
  • 選擇「Import Project」,或依序選取「File」->「New」->「Import Project」
  • 前往你複製或解壓縮專案的位置
  • 開啟「resizing」資料夾
  • 開啟「start」資料夾中的專案,當中包含範例程式碼

嘗試調整應用程式大小

  • 建構並執行應用程式
  • 嘗試調整應用程式大小

結果如何?

視測試裝置的相容性而定,你可能會發現使用者體驗不甚理想。應用程式無法調整大小,一直維持初始顯示比例。為什麼會這樣呢?

資訊清單限制

如果你查看應用程式的 AndroidManifest.xml 檔案,可能會發現當中加入了幾項限制,導致應用程式無法在任意形式視窗大小調整環境中正常運作。

AndroidManifest.xml

            android:maxAspectRatio="1.4"
            android:resizeableActivity="false"
            android:screenOrientation="portrait">

請從資訊清單中移除造成問題的這幾行程式碼,重新建構應用程式,然後在測試裝置上再試一次。你會發現應用程式可以進行任意形式大小調整了。從資訊清單中移除這類限制,是讓應用程式能夠進行任意形式視窗大小調整的重要步驟。

3. 大小調整設定變更

應用程式視窗調整大小時,應用程式的設定會隨之更新,進而影響應用程式。建議你瞭解這些影響並做好準備,以便提供優異的使用者體驗。最明顯的變化是應用程式視窗的寬度和高度,但顯示比例和螢幕方向也會受到影響。

觀察設定變化

如要自行在透過 Android View 系統建構的應用程式中觀察相關變化,你可以覆寫 View.onConfigurationChanged。在 Jetpack Compose 中,我們可以存取 LocalConfiguration.current,其會在系統呼叫 View.onConfigurationChanged 時自動更新。

如要在範例應用程式中觀察這些設定變化,請在應用程式中新增會顯示 LocalConfiguration.current 值的可組合函式,或是建立包含這類可組合函式的新範例專案。可觀察相關變化的範例 UI 看起來如下所示:

val configuration = LocalConfiguration.current
val isPortrait = configuration.orientation ==
    Configuration.ORIENTATION_PORTRAIT
val screenLayoutSize =
        when (configuration.screenLayout and
                Configuration.SCREENLAYOUT_SIZE_MASK) {
            SCREENLAYOUT_SIZE_SMALL -> "SCREENLAYOUT_SIZE_SMALL"
            SCREENLAYOUT_SIZE_NORMAL -> "SCREENLAYOUT_SIZE_NORMAL"
            SCREENLAYOUT_SIZE_LARGE -> "SCREENLAYOUT_SIZE_LARGE"
            SCREENLAYOUT_SIZE_XLARGE -> "SCREENLAYOUT_SIZE_XLARGE"
            else -> "undefined value"
        }
Column(
    horizontalAlignment = Alignment.CenterHorizontally,
    modifier = Modifier.fillMaxWidth()
) {
    Text("screenWidthDp: ${configuration.screenWidthDp}")
    Text("screenHeightDp: ${configuration.screenHeightDp}")
    Text("smallestScreenWidthDp: ${configuration.smallestScreenWidthDp}")
    Text("orientation: ${if (isPortrait) "portrait" else "landscape"}")
    Text("screenLayout SIZE: $screenLayoutSize")
}

「observing-configuration-changes」專案資料夾中包含實作範例。請將該範例加入應用程式的 UI,在測試裝置上執行,並觀察 UI 如何在應用程式設定變化時隨之更新。

應用程式調整大小時,應用程式的介面中會即時顯示發生變化的設定資訊

這些應用程式設定變化可讓你快速模擬各種情境,包括小型手機上的分割畫面模式等極端情況,或是平板電腦或桌上型電腦上的全螢幕模式。這不僅是在各種裝置上測試應用程式版面配置的好方法,還可讓你測試應用程式因應快速設定變化事件的情況。

4. 記錄活動生命週期事件

應用程式任意形式視窗大小調整的另一個影響,在於應用程式會發生各種 Activity 生命週期變化。如要即時觀察這類變化,請在 onCreate 方法中新增生命週期觀察器,並覆寫 onStateChanged 來記錄每項新的生命週期事件。

lifecycle.addObserver(object : LifecycleEventObserver {
        override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) {
        Log.d("resizing-codelab-lifecycle", "$event was called")
    }
})

完成記錄設定後,請再次在測試裝置上執行應用程式,然後將應用程式最小化並再次顯示於前景,同時查看 Logcat

你會看到應用程式在最小化時暫停運作,並在顯示於前景時繼續運作。這會對應用程式造成影響,詳情請參閱本程式碼研究室中後續關於連續性的部分。

Logcat 顯示應用程式調整大小時,系統叫用了活動生命週期方法

現在請查看 Logcat,瞭解當你將應用程式的大小從最小調整到最大時,系統呼叫了哪些活動生命週期回呼。

視測試裝置而定,實際觀察到的行為可能會有所不同,但你大概已注意到當應用程式視窗大小大幅度改變時,系統會刪除並重新建立活動,但如果視窗大小僅小幅度改變,則不會發生這類情形。這是因為在 API 24 以上版本中,只有在大小大幅度改變時,系統才會重新建立 Activity

你已瞭解任意形式視窗環境中的幾種常見設定變化,但還有其他要注意的變化。舉例來說,如果你將外接式螢幕連接至測試裝置,系統可能會根據設定變化 (例如顯示密度) 刪除並重新建立 Activity

如要簡化與設定變化相關的複雜作業,請使用 WindowSizeClass 等較高層級的 API 實作自動調整式 UI (另請參閱這個網頁)。

5. 連續性 - 在應用程式調整大小時維持可組合函式的內部狀態

在上一個部分中,你已瞭解在任意形式視窗大小調整環境中,應用程式可能會發生的設定變化。在這個部分中,你要讓應用程式的 UI 狀態在經歷這些變化時維持不變。

首先請讓 NavigationDrawerHeader 可組合函式 (位於 ReplyHomeScreen.kt 中) 展開,在點按時顯示電子郵件地址。

@Composable
private fun NavigationDrawerHeader(
    modifier: Modifier = Modifier
) {
    var showDetails by remember { mutableStateOf(false) }
    Column(
        modifier = modifier.clickable {
                showDetails = !showDetails
            }
    ) {


        Row(
            horizontalArrangement = Arrangement.SpaceBetween,
            verticalAlignment = Alignment.CenterVertically
        ) {
            ReplyLogo(
                modifier = Modifier
                    .size(dimensionResource(R.dimen.reply_logo_size))
            )
            ReplyProfileImage(
                drawableResource = LocalAccountsDataProvider
                    .userAccount.avatar,
                description = stringResource(id = R.string.profile),
                modifier = Modifier
                    .size(dimensionResource(R.dimen.profile_image_size))
            )
        }
        AnimatedVisibility (showDetails) {
            Text(
                text = stringResource(id = LocalAccountsDataProvider
                        .userAccount.email),
                style = MaterialTheme.typography.labelMedium,
                modifier = Modifier
                    .padding(
                        start = dimensionResource(
                            R.dimen.drawer_padding_header),
                        end = dimensionResource(
                            R.dimen.drawer_padding_header),
                        bottom = dimensionResource(
                            R.dimen.drawer_padding_header)
                ),


            )
        }
    }
}

將可展開式標頭新增至應用程式後,請執行下列步驟:

  1. 在測試裝置上執行應用程式
  2. 輕觸展開標頭
  3. 調整視窗大小

大幅度調整視窗大小時,你會發現標頭遺失其狀態。

應用程式導覽匣標頭在輕觸後展開,但在應用程式調整大小後收合

之所以遺失 UI 狀態,是因為 remember 會在各次重組間保留狀態,但不會在重新建立活動或程序時保留。如要徹底避免這類問題,常見的做法是使用狀態提升,將狀態移至可組合函式的呼叫端,讓可組合函式變為無狀態。不過,你可能會使用 remember,將 UI 元素狀態保留在可組合函式內部。

如要解決這類問題,請將 remember 替換成 rememberSaveable。這樣做之所以可行,是因為 rememberSaveable 會儲存記下的值並還原至 savedInstanceState。請將 remember 變更為 rememberSaveable,在測試裝置上執行應用程式,然後再次調整應用程式大小。你會發現在調整大小時,可展開式標頭的狀態如期保留下來。

6. 避免不必要的重複背景作業

你已瞭解如何使用 rememberSaveable,在設定發生變化 (可能會因為任意形式視窗大小調整而經常發生) 時,保留可組合函式的內部 UI 狀態。不過,應用程式應經常提升狀態,將 UI 狀態和邏輯移出可組合函式。如要在調整大小時保留狀態,最好的方法之一是將狀態擁有權遷移至 ViewModel。將狀態提升至 ViewModel 時,長時間執行的背景作業可能會造成問題,例如初始化畫面所需的大量檔案系統存取和網路呼叫作業。

如要查看你可能會遇到的問題種類示例,請在 ReplyViewModelinitializeUIState 方法中新增記錄陳述式。

fun initializeUIState() {
    Log.d("resizing-codelab", "initializeUIState() called in the viewmodel")
    val mailboxes: Map<MailboxType, List<Email>> =
        LocalEmailsDataProvider.allEmails.groupBy { it.mailbox }
    _uiState.value =
        ReplyUiState(
            mailboxes = mailboxes,
            currentSelectedEmail = mailboxes[MailboxType.Inbox]?.get(0)
                ?: LocalEmailsDataProvider.defaultEmail
        )
}

現在請在測試裝置上執行應用程式,然後調整應用程式視窗大小數次。

在 Logcat 中,你會發現應用程式顯示初始化方法已執行多次。如果你只想執行特定作業一次來初始化 UI,這可能會造成問題。額外執行的網路呼叫、檔案 I/O 或其他作業可能會影響裝置效能,並導致其他非預期問題。

如要避免不必要的背景作業,請從活動的 onCreate() 方法中移除 initializeUIState() 呼叫,改為透過 ViewModelinit 方法初始化資料。這可確保 ReplyViewModel 首次例項化時,初始化方法只會執行一次:

init {
    initializeUIState()
}

再次執行應用程式,你會發現無論你調整應用程式視窗大小幾次,不必要的模擬初始化工作都只會執行一次。這是因為即使 Activity 的生命週期已結束,ViewModel 也會繼續存在。藉由只在 ViewModel 建立時執行初始化程式碼一次,我們就能將其與 Activity 重新建立作業分開,避免不必要的作業。如果這是為了初始化 UI 進行的高成本伺服器呼叫,或是會耗用大量資源的檔案 I/O 作業,你就能省下大量資源,同時改進使用者體驗。

7. 恭喜!

你成功了,做得好!你已採用最佳做法,讓 Android 應用程式在 ChromeOS 和其他多視窗、多螢幕環境中都能順利調整大小。

原始碼範例

從 GitHub 複製存放區

git clone https://github.com/android/large-screen-codelabs/

你也可以下載存放區的 ZIP 檔案並解壓縮