建構及測試可在車輛停妥時使用的 Android Automotive OS 應用程式

1. 事前準備

這不是:

  • 如何打造適用於 Android Auto 和 Android Automotive OS 的媒體 (音訊 - 例如音樂、無線電、Podcast) 應用程式相關指南。如要進一步瞭解如何建構這類應用程式,請參閱「打造車用媒體應用程式」一文。

軟硬體需求

建構項目

在本程式碼研究室中,您將瞭解如何將現有的影片串流行動應用程式 Road Reels 遷移至 Android Automotive OS。

在手機上執行的應用程式起始版本

應用程式的完成版本,在具有螢幕凹口的 Android Automotive OS 模擬器上執行。

在手機上執行的應用程式起始版本

應用程式的完成版本,在具有螢幕凹口的 Android Automotive OS 模擬器上執行。

課程內容

  • 如何使用 Android Automotive OS 模擬器
  • 如何進行必要變更,建立 Android Automotive OS 版本
  • 在行動裝置專用應用程式開發過程中做出的常見假設,在應用程式於 Android Automotive OS 上執行時可能不會成立
  • 車用應用程式的不同品質等級
  • 如何使用媒體工作階段,讓其他應用程式控制應用程式的播放作業
  • 與行動裝置相較,Android Automotive OS 裝置的系統 UI 和視窗插邊可能會有哪些差異

2. 做好準備

取得程式碼

  1. 您可以在 car-codelabs GitHub 存放區的 build-a-parked-app 目錄中找到本程式碼研究室的程式碼。如要複製這個存放區,請執行下列指令:
git clone https://github.com/android/car-codelabs.git
  1. 或者,您也可以將存放區下載為 ZIP 檔案:

開啟專案

  • 啟動 Android Studio 後,匯入專案並只選取 build-a-parked-app/start 目錄。build-a-parked-app/end 目錄內含解決方案程式碼;如果遇到困難,或只是想查看完整專案,都可以隨時參考。

熟悉程式碼

  • 在 Android Studio 中開啟專案後,請花點時間瀏覽起始程式碼。

3. 瞭解可在車輛停妥時使用的 Android Automotive OS 應用程式

可在車輛停妥時使用的應用程式成為 Android Automotive OS 支援的部分應用程式類別。截至本文撰寫時為止,這些應用程式包含影片串流應用程式、網頁瀏覽器和遊戲等類別。這些應用程式非常適合在車上使用,因為車輛硬體內建 Google 服務且電動車的普及率不斷提高,駕駛和乘客可在充電期間與這類應用程式互動。

車輛在許多方面都類似於平板電腦和摺疊式裝置等其他大螢幕裝置。配備的觸控螢幕大小、解析度和顯示比例相似,也可能為直向或橫向 (但這類裝置的螢幕方向是固定的,與平板電腦不同)。此外,這類連結裝置隨時可能連上或中斷網路連線。考量上述所有因素後,自然會得出以下結論:已針對大螢幕完成最佳化的應用程式,通常還須投入最低限度的工作量,才能為車輛打造絕佳使用者體驗。

與大螢幕應用程式相似,車用應用程式也有應用程式品質等級

  • 第 3 級 - 可供車輛使用:應用程式與大螢幕相容,且可在車輛停妥後使用。雖然這類應用程式可能沒有任何針對車輛進行最佳化調整的功能,但使用者可以像在其他大螢幕 Android 裝置上一樣使用應用程式。符合上述規定的行動應用程式,可透過「車用行動應用程式」計畫,直接發布到車輛。
  • 第 2 級 - 針對車輛完成最佳化調整:應用程式可在車輛中控台螢幕上提供絕佳體驗。為達成這個目的,應用程式需要一些車輛專屬的工程,根據應用程式的類別,提供可用於行車或停車模式的功能。
  • 第 1 級 - 針對車輛提供差異化設計:應用程式可在各種車輛硬體上運作,並配合行車和停車模式分別提供相應的體驗。對於車輛中的不同螢幕 (例如中控台、儀表板,以及許多高級車會配備的全景螢幕等其他螢幕),應用程式都會設計最佳使用者體驗。

4. 在 Android Automotive OS 模擬器中執行應用程式

安裝 Automotive with Play Store 系統映像檔

  1. 首先,在 Android Studio 中開啟 SDK Manager,然後選取「SDK Platforms」分頁標籤 (如果尚未選取)。在 SDK Manager 視窗的右下角,確認已勾選「Show package details」方塊。
  2. 安裝「Add generic system images」中列出的其中一個「Automotive with Play Store」模擬器映像檔。映像檔只能在與本身架構 (x86/ARM) 相同的機器上執行。

建立 Android Automotive OS Android 虛擬裝置

  1. 開啟裝置管理工具後,選取視窗左側「Category」欄下方的「Automotive」。接著,從清單中選取「Automotive (1024p landscape)」隨附的硬體設定檔,然後點選「Next」

「Virtual Device Configuration」精靈顯示選取的硬體設定檔「Automotive (1024p landscape)」。

  1. 在下一頁中,選取上一個步驟中的系統映像檔。按一下「Next」,選取所需進階選項,最後點選「Finish」建立 AVD。注意:如果您選擇 API 30 映像檔,可能位於「Recommended」分頁以外的分頁中。

執行應用程式

使用現有 app 執行設定,在剛建立的模擬器上執行應用程式。盡情試用應用程式,瀏覽不同的畫面,並與在手機或平板電腦模擬器上執行應用程式時的行為比較。

301e6c0d3675e937.png

5. 建立 Android Automotive OS 版本

雖然應用程式「可以運作」,但需要進行一些小幅調整,才能在 Android Automotive OS 上順暢運作,並符合 Play 商店上架規定。部分變更項目不一定適合納入行動版應用程式,因此要先建立 Android Automotive OS 建構變化版本。

新增板型規格版本維度

首先,在 build.gradle.kts 檔案中修改 flavorDimensions,為版本指定的板型規格新增版本維度。接著,為每個板型規格 (mobileautomotive) 新增 productFlavors 區塊和變種版本。

詳情請參閱「設定變種版本」。

build.gradle.kts (Module :app)

android {
    ...
    flavorDimensions += "formFactor"
    productFlavors {
        create("mobile") {
            // Inform Android Studio to use this flavor as the default (e.g. in the Build Variants tool window)
            isDefault = true
            // Since there is only one flavor dimension, this is optional
            dimension = "formFactor"
        }
        create("automotive") {
            // Since there is only one flavor dimension, this is optional
            dimension = "formFactor"
            // Adding a suffix makes it easier to differentiate builds (e.g. in the Play Console)
            versionNameSuffix = "-automotive"
        }
    }
    ...
}

更新 build.gradle.kts 檔案後,您應該會在檔案頂端看到橫幅,告知「Gradle files have changed since last project sync. A project sync may be necessary for the IDE to work properly.」。按一下該橫幅中的「Sync Now」按鈕,讓 Android Studio 匯入這些建構設定變更。

8685bcde6b21901f.png

接著,依序前往「Build」>「Select Build Variant...」選單項目開啟「Build Variants」工具視窗,然後選取 automotiveDebug 變化版本。這可確保您會在「Project」視窗中看到 automotive 來源集的檔案,而且透過 Android Studio 執行應用程式時,會使用此建構變化版本。

19e4aa8135553f62.png

建立 Android Automotive OS 資訊清單

接下來,您將為 automotive 來源集建立 AndroidManifest.xml 檔案。這個檔案包含 Android Automotive OS 應用程式所需的必要元素。

  1. 在「Project視窗中的 app 模組上按一下滑鼠右鍵。在隨即顯示的下拉式選單中,依序選取「New」>「Other」>「Android Manifest File」
  2. 在隨即開啟的「New Android Component」視窗中,選取 automotive 做為新檔案的「Target Source Set」。按一下「Finish」建立檔案。

3fe290685a1026f5.png

  1. 在剛剛建立的 AndroidManifest.xml 檔案 (位於 app/src/automotive/AndroidManifest.xml 路徑下) 中新增以下內容:

AndroidManifest.xml (automotive)

<manifest xmlns:android="http://schemas.android.com/apk/res/android">
    <!--  https://developer.android.com/training/cars/parked#required-features  -->
    <uses-feature
        android:name="android.hardware.type.automotive"
        android:required="true" />
    <uses-feature
        android:name="android.hardware.wifi"
        android:required="false" />
    <uses-feature
        android:name="android.hardware.screen.portrait"
        android:required="false" />
    <uses-feature
        android:name="android.hardware.screen.landscape"
        android:required="false" />
</manifest>

您必須提供第一個宣告,才能將建構構件上傳至 Play 管理中心的 Android Automotive OS 測試群組。存在這項功能,Google Play 就只會將應用程式發布到具備 android.hardware.type.automotive 功能的裝置 (例如汽車)。

為了確保應用程式能安裝在車輛的各種硬體設定中,也必須提供其他宣告。詳情請參閱「必要的 Android Automotive OS 功能」。

將應用程式標示為影片應用程式

最後需要加入的中繼資料是 automotive_app_desc.xml 檔案。這個檔案可用於在車輛專用 Android 的環境中宣告應用程式類別,與您在 Play 管理中心選取的應用程式類別無關。

  1. app 模組上按一下滑鼠右鍵,依序選取「New」>「Android Resource File」選項,輸入下列各值,最後按一下「OK」
  • File name:automotive_app_desc.xml
  • Resource type:XML
  • Root element:automotiveApp
  • Source set:automotive
  • Directory name:xml

47ac6bf76ef8ad45.png

  1. 在該檔案中加入以下 <uses> 元素,宣告應用程式是影片應用程式。

automotive_app_desc.xml

<?xml version="1.0" encoding="utf-8"?>
<automotiveApp xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools">
    <uses
        name="video"
        tools:ignore="InvalidUsesTagAttribute" />
</automotiveApp>
  1. automotive 來源集的 AndroidManifest.xml 檔案 (就是您剛加入 <uses-feature> 元素的檔案) 中,新增空白的 <application> 元素。在其中加入以下 <meta-data> 元素,參照您剛建立的 automotive_app_desc.xml 檔案。

AndroidManifest.xml (automotive)

<manifest xmlns:android="http://schemas.android.com/apk/res/android">
    ...

    <application>
        <meta-data
            android:name="com.android.automotive"
            android:resource="@xml/automotive_app_desc" />
    </application>
</manifest>

加入上述元素後,您已完成所有必要變更,可為應用程式建立 Android Automotive OS 版本!

6. 符合 Android Automotive OS 品質規範:操作容易度

雖然製作 Android Automotive OS 建構變化版本是將應用程式提供給車輛使用的其中一個環節,但仍須確保應用程式可用且安全無虞。

新增導覽功能提示

在 Android Automotive OS 模擬器中執行應用程式時,您可能已注意到,無法從詳細資料畫面返回主畫面,也無法從播放器畫面返回詳細資料畫面。其他板型規格可能需要返回按鈕或觸控手勢才能啟用返回導覽功能,但 Android Automotive OS 裝置並沒有這類需求。因此,應用程式必須在 UI 中提供導覽功能提示,確保使用者能夠順利瀏覽應用程式,不會卡在某個畫面上。這項規定已編纂為 AN-1 品質指南。

如要支援從詳細資料畫面到主畫面的返回導覽功能,請為詳細資料畫面的 CenterAlignedTopAppBar 額外新增 navigationIcon 參數,如下所示:

RoadReelsApp.kt

import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton

...

navigationIcon = {
    IconButton(onClick = { navController.popBackStack() }) {
        Icon(
            Icons.AutoMirrored.Filled.ArrowBack,
            contentDescription = null
        )
    }
}

如何支援從播放器畫面到主畫面的返回瀏覽功能:

  1. 更新 TopControls 可組合函式以接收名為 onClose 的回呼參數,並新增 IconButton,在使用者點選時呼叫該參數。

PlayerControls.kt

@Composable
fun TopControls(
    title: String?,
    onClose: () -> Unit,
    modifier: Modifier = Modifier
) {
    Box(modifier) {
        IconButton(
            modifier = Modifier
                .align(Alignment.TopStart),
            onClick = onClose
        ) {
            Icon(
                Icons.TwoTone.Close,
                contentDescription = "Close player",
                tint = Color.White
            )
        }

        if (title != null) { ... }
    }
}
  1. 更新 PlayerControls 可組合函式,也接收 onClose 回呼參數,並將其傳遞至 TopControls

PlayerControls.kt

fun PlayerControls(
    visible: Boolean,
    playerState: PlayerState,
    onClose: () -> Unit,
    onPlayPause: () -> Unit,
    onSeek: (seekToMillis: Long) -> Unit,
    modifier: Modifier = Modifier,
) {
    AnimatedVisibility(
        visible = visible,
        enter = fadeIn(),
        exit = fadeOut()
    ) {
        Box(modifier = modifier.background(Color.Black.copy(alpha = .5f))) {
            TopControls(
                modifier = Modifier
                    .fillMaxWidth()
                    .padding(dimensionResource(R.dimen.screen_edge_padding))
                    .align(Alignment.TopCenter),
                title = playerState.mediaMetadata.title?.toString(),
                onClose = onClose
            )
            ...
        }
    }
}
  1. 接著,更新 PlayerScreen 可組合函式以接收同一參數,並向下傳遞給 PlayerControls

PlayerScreen.kt

@Compsable
fun PlayerScreen(
    onClose: () -> Unit,
    modifier: Modifier = Modifier,
) {
    ...

    PlayerControls(
        modifier = Modifier
            .fillMaxSize(),
        visible = isShowingControls,
        playerState = playerState,
        onClose = onClose,
        onPlayPause = { if (playerState.isPlaying) player.pause() else player.play() },
        onSeek = { player.seekTo(it) }
    )
}
  1. 最後,在 RoadReelsNavHost 中提供傳遞至 PlayerScreen 的實作內容:

RoadReelsNavHost.kt

composable(route = Screen.Player.name) {
    PlayerScreen(onClose = { navController.popBackStack() })
}

太棒了,使用者現在可以順利切換畫面,不會遇到任何死路!此外,在其他板型規格上,使用者體驗也可能更臻完善。舉例來說,在長型手機上,當使用者的手已經靠近螢幕頂端時,可以輕鬆瀏覽應用程式,不需在手中移動裝置。

43122e716eeeeb20.gif

配合支援的螢幕方向進行調整

與絕大多數行動裝置不同,多數車用裝置的螢幕方向是固定的。也就是說,由於這類裝置的螢幕無法旋轉,只能支援橫向或直向,因此,應用程式應避免假設裝置能同時支援這兩種螢幕方向。

在「建立 Android Automotive OS 資訊清單」一節中,您為 android.hardware.screen.portraitandroid.hardware.screen.landscape 功能加入了兩個 <uses-feature> 元素,並將 required 屬性設為 false。這種做法可確保這兩種螢幕方向都沒有隱含的功能依附元件,會導致應用程式無法發布至車輛。不過,這些資訊清單元素不會變更應用程式的行為,只會影響其發布方式。

這個應用程式目前提供一項實用功能,會在影片播放器開啟時,自動將活動的螢幕方向設為橫向,讓手機使用者不必調整裝置方向 (如果原本並非橫向)。

不幸的是,在螢幕方向固定為直向的裝置上 (包括現今路上的許多車輛在內),這樣的行為可能會導致閃爍迴圈或出現上下黑邊。

如要解決這個問題,可以新增檢查項目,確認目前裝置支援的螢幕方向。

  1. 為簡化實作程序,請先在 Extensions.kt 中加入下列內容:

Extensions.kt

import android.content.Context
import android.content.pm.PackageManager

...

enum class SupportedOrientation {
    Landscape,
    Portrait,
}

fun Context.supportedOrientations(): List<SupportedOrientation> {
    return when (Pair(
        packageManager.hasSystemFeature(PackageManager.FEATURE_SCREEN_LANDSCAPE),
        packageManager.hasSystemFeature(PackageManager.FEATURE_SCREEN_PORTRAIT)
    )) {
        Pair(true, false) -> listOf(SupportedOrientation.Landscape)
        Pair(false, true) -> listOf(SupportedOrientation.Portrait)
        // For backwards compat, if neither feature is declared, both can be assumed to be supported
        else -> listOf(SupportedOrientation.Landscape, SupportedOrientation.Portrait)
    }
}
  1. 接著,監看呼叫結果,據以設定要求的螢幕方向。在行動裝置的多視窗模式下,應用程式可能會遇到類似的問題,因此您也可以新增檢查項目,不要動態設定螢幕方向。

PlayerScreen.kt

import com.example.android.cars.roadreels.SupportedOrientation
import com.example.android.cars.roadreels.supportedOrientations

...

LaunchedEffect(Unit) {
    ...

    // Only automatically set the orientation to landscape if the device supports landscape.
    // On devices that are portrait only, the activity may enter a compat mode and won't get to
    // use the full window available if so. The same applies if the app's window is portrait
    // in multi-window mode.
    if (context.supportedOrientations().contains(SupportedOrientation.Landscape)
        && !context.isInMultiWindowMode
    ) {
        context.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE
    }

    ...
}

新增檢查項目前,Polestar 2 模擬器上的播放器畫面會進入閃爍迴圈 (這時活動未處理螢幕方向設定變更)

新增檢查項目前,Polestar 2 模擬器上的播放器畫面會顯示上下黑邊 (這時活動會處理螢幕方向設定變更)

新增檢查項目後,Polestar 2 模擬器上的播放器畫面不會顯示上下黑邊

新增檢查項目前,Polestar 2 模擬器上的播放器畫面會進入閃爍迴圈 (這時活動未處理 orientation 設定變更)

新增檢查項目前,Polestar 2 模擬器上的播放器畫面會顯示上下黑邊 (這時活動會處理 orientation 設定變更)

新增檢查項目後,Polestar 2 模擬器上的播放器畫面不會顯示上下黑邊

由於這是應用程式中唯一設定螢幕方向的位置,應用程式現已不會出現上下黑邊!在您自己的應用程式中,檢查是否有任何僅限橫向或直向螢幕設定的 screenOrientation 屬性或 setRequestedOrientation 呼叫 (包括個別感應器、反向和使用者變化版本),並視需要加以移除或監看,以免出現上下黑邊。詳情請參閱「大螢幕相容模式」。

配合系統資訊列可控性進行調整

很遺憾,雖然先前的變更可確保應用程式不會進入閃爍迴圈或出現上下黑邊,卻也曝露了另一個不成立的假設,也就是可能會一律隱藏系統資訊列!相較於使用手機或平板電腦,使用者在使用汽車時會有不同需求,因此原始設備製造商 (OEM) 可以選擇讓應用程式不隱藏系統資訊列,確保車輛控制項 (例如溫度控制項) 一律會顯示在螢幕上供隨時存取。

因此,當應用程式在沈浸模式下轉譯並假設系統資訊列可隱藏時,就可能會顯示在系統資訊列後方。您可以在上一個步驟中看到這個現象,因為應用程式在沒有上下黑邊時,也無法再顯示畫面頂端和底部的播放器控制項!在此特定情況下,由於關閉播放器的按鈕遭到遮蔽,且因無法使用跳轉滑桿而無法執行功能,應用程式就無法再進行導覽。

最簡單的修正方式,就是在播放器中套用 systemBars 視窗插邊邊框間距,如下所示:

PlayerScreen.kt

import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.systemBars
import androidx.compose.foundation.layout.windowInsetsPadding

...

Box(
    modifier = Modifier
        .fillMaxSize()
        .windowInsetsPadding(WindowInsets.systemBars)
) {
    PlayerView(...)
    PlayerControls(...)
}

但是,這個解決方案並不理想,因為這樣會導致 UI 元素在系統資訊列動畫消失時,在畫面上跳動。

9c51956e2093820a.gif

如要改善使用者體驗,您可以更新應用程式,持續追蹤哪些是可控制的插邊,並且只為無法控制的插邊套用邊框間距。

  1. 由於應用程式中的其他畫面可能會想控制視窗插邊,建議您將可控制的插邊以 CompositionLocal 形式傳遞。在 com.example.android.cars.roadreels 套件中建立新檔案 LocalControllableInsets.kt,並加入下列內容:

LocalControllableInsets.kt

import androidx.compose.runtime.compositionLocalOf

// Assume that no insets can be controlled by default
const val DEFAULT_CONTROLLABLE_INSETS = 0
val LocalControllableInsets = compositionLocalOf { DEFAULT_CONTROLLABLE_INSETS }
  1. 設定 OnControllableInsetsChangedListener 來監聽變更。

MainActivity.kt

import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsControllerCompat.OnControllableInsetsChangedListener

...

class MainActivity : ComponentActivity() {
    private lateinit var onControllableInsetsChangedListener: OnControllableInsetsChangedListener

    @OptIn(ExperimentalMaterial3WindowSizeClassApi::class)
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        enableEdgeToEdge()

        setContent {
            var controllableInsetsTypeMask by remember { mutableIntStateOf(DEFAULT_CONTROLLABLE_INSETS) }

            onControllableInsetsChangedListener =
                OnControllableInsetsChangedListener { _, typeMask ->
                    if (controllableInsetsTypeMask != typeMask) {
                        controllableInsetsTypeMask = typeMask
                    }
                }

            WindowCompat.getInsetsController(window, window.decorView)
                .addOnControllableInsetsChangedListener(onControllableInsetsChangedListener)

            RoadReelsTheme {
                RoadReelsApp(calculateWindowSizeClass(this))
            }
        }
    }

    override fun onDestroy() {
        super.onDestroy()

        WindowCompat.getInsetsController(window, window.decorView)
            .removeOnControllableInsetsChangedListener(onControllableInsetsChangedListener)
    }
}
  1. 新增包含主題和應用程式可組合函式的頂層 CompositionLocalProvider,並將值繫結至 LocalControllableInsets

MainActivity.kt

import androidx.compose.runtime.CompositionLocalProvider

...

CompositionLocalProvider(LocalControllableInsets provides controllableInsetsTypeMask) {
    RoadReelsTheme {
        RoadReelsApp(calculateWindowSizeClass(this))
    }
}
  1. 在播放器中讀取目前的值,據以判斷要用來加上邊框間距的插邊。

PlayerScreen.kt

import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.union
import androidx.compose.ui.unit.dp
import com.example.android.cars.roadreels.LocalControllableInsets

...

val controllableInsetsTypeMask = LocalControllableInsets.current

// When the system bars can be hidden, ignore them when applying padding to the player and
// controls so they don't jump around as the system bars disappear. If they can't be hidden
// include them so nothing renders behind the system bars
var windowInsetsForPadding = WindowInsets(0.dp)
if (controllableInsetsTypeMask.and(WindowInsetsCompat.Type.statusBars()) == 0) {
    windowInsetsForPadding = windowInsetsForPadding.union(WindowInsets.statusBars)
}
if (controllableInsetsTypeMask.and(WindowInsetsCompat.Type.navigationBars()) == 0) {
    windowInsetsForPadding = windowInsetsForPadding.union(WindowInsets.navigationBars)
}

Box(
    modifier = Modifier
        .fillMaxSize()
        .windowInsetsPadding(windowInsetsForPadding)
) {
    PlayerView(...)
    PlayerControls(...)
}

可以隱藏系統資訊列時,內容不會跳動

無法隱藏系統資訊列時,內容仍會顯示

可以隱藏系統資訊列時,內容不會跳動

無法隱藏系統資訊列時,內容仍會顯示

好多了!內容不會跳動,同時控制項可完整顯示,即使在無法控制系統資訊列的車輛上也是如此。

7. 符合 Android Automotive OS 品質規範:駕駛人分心等級

最後,車輛與其他板型規格間有一個主要差異,那就是用於駕駛!因此,減少行車時的干擾非常重要。所有可在車輛停妥時使用的 Android Automotive OS 應用程式,都必須在行車時暫停播放。系統疊加層會在開始行車時顯示,並反過來為被重疊的應用程式呼叫 onPause 生命週期事件。應用程式應在此呼叫期間暫停播放。

模擬行車狀態

前往模擬器中的播放器檢視畫面,開始播放內容。接著,按照模擬行車狀態的步驟操作,並請留意,當應用程式的 UI 遭到系統遮蔽時不會暫停播放。這項行為已違反 DD-2 車用應用程式品質指南。

839af1382c1f10ca.png

開始行車時暫停播放

  1. 新增 androidx.lifecycle:lifecycle-runtime-compose 構件的依附元件,其中包含 LifecycleEventEffect,可協助在生命週期事件上執行程式碼

libs.version.toml

androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycle" }

Build.gradle.kts (Module :app)

implementation(libs.androidx.lifecycle.runtime.compose)
  1. 同步處理專案以下載依附元件後,新增在 ON_PAUSE 事件上執行的 LifecycleEventEffect,以便暫停播放。

PlayerScreen.kt

import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.LifecycleEventEffect

...

@Composable
fun PlayerScreen(...) {
    ...
    LifecycleEventEffect(Lifecycle.Event.ON_PAUSE) {
        player.pause()
    }

    LifecycleEventEffect(Lifecycle.Event.ON_RESUME) {
        player.play()
    }
    ...
}

實作這項修正後,請按照先前在主動播放期間模擬行車狀態時採取的步驟操作,並留意應用程式會停止播放,符合 DD-2 的規範!

8. 在遠距螢幕模擬器中測試應用程式

汽車開始會採用兩個螢幕的新配置,也就是中央主控台設有主要螢幕,儀表板上方靠近擋風玻璃處則設有次要螢幕。應用程式可以從中央螢幕移到次要螢幕,再往回移,為駕駛人和乘客提供更多選擇。

安裝 Automotive Distant Display 映像檔

  1. 首先,在 Android Studio 中開啟 SDK Manager,然後選取「SDK Platforms」分頁標籤 (如果尚未選取)。在 SDK Manager 視窗的右下角,確認已勾選「Show package details」方塊。
  2. 安裝適用於電腦架構 (x86/ARM) 的「Automotive Distant Display with Google API」模擬器映像檔。

建立 Android Automotive OS Android 虛擬裝置

  1. 開啟裝置管理工具後,選取視窗左側「Category」欄下方的「Automotive」。接著,從清單中選取「Automotive Distant Display」隨附的硬體設定檔,然後點選「Next」
  2. 在下一頁中,選取上一個步驟中的系統映像檔。按一下「Next」,選取所需進階選項,最後點選「Finish」建立 AVD。

執行應用程式

使用現有 app 執行設定,在剛建立的模擬器上執行應用程式。按照「使用遠距螢幕模擬器」中的操作說明,將應用程式移到及移出遠距螢幕。請測試應用程式位於主畫面/詳細資料畫面和播放器畫面時移動應用程式,並嘗試在這兩個畫面上與應用程式互動。

b277bd18a94e9c1b.png

9. 提升遠距螢幕上的應用程式體驗

在遠距螢幕上使用應用程式時,您可能會注意到以下兩點:

  1. 應用程式移到及移出遠距螢幕時,會重新啟動播放作業。
  2. 應用程式在遠距螢幕上時,您無法與其互動,包括變更播放狀態。

提升應用程式連續性

當系統因為設定變更而重建活動,就會造成重新啟動播放作業的問題。由於應用程式是使用 Compose 編寫,且變更的設定與大小相關,您可以針對以大小為依據的設定變更限制活動重建功能,讓 Compose 替您處理設定變更。這樣一來,應用程式就能在螢幕間順暢轉換,不會因為活動重建而停止播放或重新載入。

AndroidManifest.xml

<activity
    android:name="com.example.android.cars.roadreels.MainActivity"
    ...
    android:configChanges="screenSize|smallestScreenSize|orientation|screenLayout|density">
        ...
</activity>

實作播放控制項

如要修正在遠距螢幕上無法控制應用程式的問題,可以實作 MediaSession。媒體工作階段提供與音訊或影片播放器互動的通用方式。詳情請參閱「使用 MediaSession 控制及通告播放功能」。

  1. 新增 androidx.media3:media3-session 構件的依附元件

libs.version.toml

androidx-media3-mediasession = { group = "androidx.media3", name = "media3-session", version.ref = "media3" }

build.gradle.kts (Module :app)

implementation(libs.androidx.media3.mediasession)
  1. 使用相關建構工具建立 MediaSession

PlayerScreen.kt

import androidx.media3.session.MediaSession

@Composable
fun PlayerScreen(...) {
    ...
    val mediaSession = remember(context, player) {
        MediaSession.Builder(context, player).build()
    }
    ...
}
  1. 接著,在 Player 可組合函式 DisposableEffectonDispose 區塊中新增一行,以便在 Player 離開組合樹狀結構時釋放 MediaSession

PlayerScreen.kt

DisposableEffect(Unit) {
    onDispose {
        mediaSession.release()
        player.release()
        ...
    }
}
  1. 最後,您可以使用 adb shell cmd media_session dispatch 指令,在播放器畫面上測試媒體控制項。
# To play content
adb shell cmd media_session dispatch play

# To pause content
adb shell cmd media_session dispatch pause

# To toggle the playing state
adb shell cmd media_session dispatch play-pause

這樣一來,在附有遠距螢幕的車輛上,應用程式的運作會順暢許多!但除此之外,這也能提升應用程式在其他板型規格上的運作效能!如果裝置可以旋轉螢幕或允許使用者調整應用程式視窗大小,應用程式現在也能配合這些情況順暢調整。

此外,由於整合了媒體工作階段,應用程式的播放功能不僅可透過車輛的硬體和軟體控制項進行控制,還可透過其他來源加以控制 (例如 Google 助理查詢或耳機上的暫停按鈕),為使用者提供更多選項,方便在不同板型規格中控制應用程式!

10. 在不同系統設定下測試應用程式

應用程式在主螢幕和遠距螢幕上都能正常運作後,最後要檢查的就是應用程式如何處理不同系統資訊列設定和螢幕凹口。如「使用視窗插邊和螢幕凹口」所述,Android Automotive OS 裝置採用的某些設定,可能會導致通常適用於行動裝置板型規格的假設不成立。

在本節中,您將下載可在執行階段設定的模擬器,設定模擬器以顯示左側系統資訊列,並使用該設定測試應用程式。

安裝 Android Automotive with Google APIs 映像檔

  1. 首先,在 Android Studio 中開啟 SDK Manager,然後選取「SDK Platforms」分頁標籤 (如果尚未選取)。在 SDK Manager 視窗的右下角,確認已勾選「Show package details」方塊。
  2. 安裝適用於電腦架構 (x86/ARM) 的 API 33「Android Automotive with Google APIs」模擬器映像檔。

建立 Android Automotive OS Android 虛擬裝置

  1. 開啟裝置管理工具後,選取視窗左側「Category」欄下方的「Automotive」。接著,從清單中選取「Automotive (1080p landscape)」隨附的硬體設定檔,然後點選「Next」
  2. 在下一頁中,選取上一個步驟中的系統映像檔。按一下「Next」,選取所需進階選項,最後點選「Finish」建立 AVD。

設定側邊系統資訊列

如「使用可設定的模擬器進行測試」一文所述,您可以使用各種選項模擬車輛的不同系統設定。

在本程式碼研究室中,您可使用 com.android.systemui.rro.left 測試不同系統資訊列設定。如要啟用,請使用下列指令:

adb shell cmd overlay enable --user 0 com.android.systemui.rro.left

b642703a7278b219.png

由於應用程式使用 systemBars 修飾符做為 Scaffold 中的 contentWindowInsets,內容已繪製在系統資訊列的安全區域。如要查看應用程式假設系統資訊列只會顯示在畫面頂端和底部的情況,請將該參數變更如下:

RoadReelsApp.kt

contentWindowInsets = if (route?.equals(Screen.Player.name) == true) WindowInsets(0.dp) else WindowInsets.systemBars.only(WindowInsetsSides.Vertical)

糟糕!清單和詳細資料畫面會顯示在系統資訊列後方。由於先前的工作,即使無法控制系統資訊列,播放器畫面也能正常運作。

9898f7298a7dfb4.gif

繼續進行下一節的操作之前,請務必還原您剛剛對 windowContentPadding 參數所做的變更!

11. 處理螢幕凹口

最後,部分車輛的螢幕有螢幕凹口,與行動裝置上的螢幕截然不同。部分 Android Automotive OS 車輛的螢幕並非有凹槽或針孔相機凹口,而是使用非矩形的弧形螢幕。

如要查看應用程式在出現這類螢幕凹口時的行為,請先使用下列指令啟用螢幕凹口:

adb shell cmd overlay enable --user 0 com.android.internal.display.cutout.emulation.free_form

如要實際測試應用程式的運作情形,請一併啟用上一節所用的左側系統資訊列 (如果尚未啟用):

adb shell cmd overlay enable --user 0 com.android.systemui.rro.left

目前應用程式並未將內容顯示到螢幕凹口位置 (目前很難判斷凹口的確切形狀,但會在下一個步驟中清楚瞭解)。這完全沒問題,而且比起會將內容顯示到螢幕凹口位置,但未審慎配合調整的應用程式,這是更理想的體驗。

212628db84981025.gif

將內容顯示到螢幕凹口位置

為了盡可能為使用者提供沈浸式體驗,您可以將內容顯示到螢幕凹口位置,充分利用螢幕空間。

  1. 如要將內容顯示到螢幕凹口位置,請建立 integers.xml 檔案,用於保存車輛專用的覆寫值。做法如下:使用「UI mode」限定詞,並將值設為「Car Dock」(這個名稱是從只有 Android Auto 的時代沿用至今,但 Android Automotive OS 也會使用)。此外,由於 Android R 已導入 LAYOUT_IN_DISPLAY_CUTOUT_MODE_ALWAYS,請一併新增 Android「Version」限定詞,並將值設為「30」。詳情請參閱「使用替代資源」。

22b7f17657cac3fd.png

  1. 在剛建立的檔案 (res/values-car-v30/integers.xml) 中新增以下內容:

integers.xml

<?xml version="1.0" encoding="utf-8"?>
<resources>
    <integer name="windowLayoutInDisplayCutoutMode">3</integer>
</resources>

整數值 3 對應到 LAYOUT_IN_DISPLAY_CUTOUT_MODE_ALWAYS,並覆寫來自 res/values/integers.xml 的預設值 0,這會對應到 LAYOUT_IN_DISPLAY_CUTOUT_MODE_DEFAULTMainActivity.kt 中已參照這個整數值,以覆寫 enableEdgeToEdge() 設定的模式。如要進一步瞭解這項屬性,請參閱參考文件

現在,當您執行應用程式時,會發現內容延伸到凹口位置,有融入螢幕外觀的感覺!不過,螢幕凹口會遮蔽頂端應用程式列和部分內容,以致發生類似於應用程式假設系統資訊列只會顯示在畫面頂端和底部的情況。

f0eefa42dee6f7c7.gif

修正頂端應用程式列

如要修正頂端應用程式列,可以將下列 windowInsets 參數加入 CenterAlignedTopAppBar 可組合函式中:

RoadReelsApp.kt

import androidx.compose.foundation.layout.safeDrawing

...

windowInsets = WindowInsets.safeDrawing.only(WindowInsetsSides.Horizontal + WindowInsetsSides.Top)

由於 safeDrawing 包含 displayCutoutsystemBars 插邊,這改善了使用預設 windowInsets 參數 (此參數只會在定位頂端應用程式列時使用 systemBars) 的情形。

此外,由於頂端應用程式列是放置在視窗頂端,不應納入 safeDrawing 插邊的底部元件,否則可能會加上不必要的邊框間距。

7d59ebb63ada5f71.gif

修正主畫面

如要修正主畫面和詳細資料畫面上的內容,可以考慮在 ScaffoldcontentWindowInsets 中使用 safeDrawing 而非 systemBars。不過,採用這個選項後,應用程式顯然缺乏融入螢幕外觀的感覺,內容在螢幕凹口開始的位置突然遭到截斷,比應用程式完全不將內容顯示到螢幕凹口位置時更差。

6b3824ca3214cbfa.gif

如要進一步打造沈浸式的使用者介面,可以對畫面中每個元件的插邊進行處理。

  1. ScaffoldcontentWindowInsets 更新為持續保持 0 dp (而非只更新 PlayerScreen)。如此一來,每個畫面和/或每個畫面中的元件就能根據插邊決定自身的行為。

RoadReelsApp.kt

Scaffold(
    ...,
    contentWindowInsets = WindowInsets(0.dp)
) { ... }
  1. 設定列標頭 Text 可組合函式的 windowInsetsPadding,即可使用 safeDrawing 插邊的水平元件。頂端應用程式列會處理這些插邊的頂端元件,底部元件則將稍後處理。

MainScreen.kt

import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.windowInsetsPadding

...

LazyColumn(
    contentPadding = PaddingValues(bottom = dimensionResource(R.dimen.screen_edge_padding))
) {
    items(NUM_ROWS) { rowIndex: Int ->
        Text(
            "Row $rowIndex",
            style = MaterialTheme.typography.headlineSmall,
            modifier = Modifier
                .padding(
                    horizontal = dimensionResource(R.dimen.screen_edge_padding),
                    vertical = dimensionResource(R.dimen.row_header_vertical_padding)
                )
                .windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Horizontal))
        )
    ...
}
  1. 移除 LazyRowcontentPadding 參數。接著,在每個 LazyRow 的開頭和結尾,新增其對應 safeDrawing 元件寬度的 Spacer,確保所有縮圖都能完整顯示。使用 widthIn 修飾符,確保這些 Spacer 至少與內容邊框間距先前設定的寬度相同。如果沒有這些元素,列開頭和結尾的項目可能會被系統資訊列和/或螢幕凹口遮住,即使將列完全滑動至列開頭/結尾也一樣。

MainScreen.kt

import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.layout.windowInsetsEndWidth
import androidx.compose.foundation.layout.windowInsetsStartWidth

...

LazyRow(
    horizontalArrangement = Arrangement.spacedBy(dimensionResource(R.dimen.list_item_spacing)),
) {
    item {
        Spacer(
            Modifier
                .windowInsetsStartWidth(WindowInsets.safeDrawing)
                .widthIn(min = dimensionResource(R.dimen.screen_edge_padding))
        )
    }
    items(NUM_ITEMS_PER_ROW) { ... }
    item {
        Spacer(
            Modifier
                .windowInsetsEndWidth(WindowInsets.safeDrawing)
                .widthIn(min = dimensionResource(R.dimen.screen_edge_padding))
        )
    }
}
  1. 最後,在 LazyColumn 的結尾加入 Spacer,將畫面底部任何可能的系統資訊列或螢幕凹口插邊納入考量。頂端應用程式列會處理這些項目,因此不需要在 LazyColumn 頂端使用相同的 Spacer。如果應用程式使用的是底部應用程式列,而非頂端應用程式列,可以在清單開頭使用 windowInsetsTopHeight 修飾符加入 Spacer。如果應用程式同時使用頂端和底部應用程式列,則不需要使用任何 Spacer。

MainScreen.kt

import androidx.compose.foundation.layout.windowInsetsBottomHeight

...

LazyColumn(...){
    items(NUM_ROWS) { ... }
    item {
        Spacer(Modifier.windowInsetsBottomHeight(WindowInsets.safeDrawing))
    }
}

太棒了,頂端應用程式列可以完全顯示在畫面上,而捲動至列尾端時,也能看到完整的縮圖!

543706473398114a.gif

修正詳細資料畫面

f622958a8d0c16c8.png

詳細畫面雖然沒有那麼糟,但內容仍會遭到截斷。

由於詳細資料畫面沒有任何可捲動的內容,只要在頂層 Box 上新增 windowInsetsPadding 修飾符,即可修正問題。

DetailScreen.kt

import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.windowInsetsPadding

...

Box(
    modifier = modifier
        .padding(dimensionResource(R.dimen.screen_edge_padding))
        .windowInsetsPadding(WindowInsets.safeDrawing)
) { ... }

bdd6de6010fc139d.png

修正播放器畫面

雖然在前述「符合 Android Automotive OS 品質規範:操作容易度」中,PlayerScreen 已為部分或所有系統資訊列視窗插邊套用邊框間距,但現在應用程式已將內容顯示到螢幕凹口位置,因此不足以確保能完整顯示內容。在行動裝置上,螢幕凹口幾乎一律完全包含在系統資訊列中。但是在車輛中,螢幕凹口可能會遠遠超出系統資訊列,導致假設不成立。

427227df5e44f554.png

如要修正這個問題,只需將 windowInsetsForPadding 變數的初始值從零值變更為 displayCutout

PlayerScreen.kt

import androidx.compose.foundation.layout.displayCutout

...

var windowInsetsForPadding = WindowInsets(WindowInsets.displayCutout)

b523d8c1e1423757.gif

好極了,應用程式確實充分利用螢幕空間,同時保持可用性!

如果在行動裝置上執行應用程式,也會加深沈浸式體驗!清單項目會顯示至螢幕邊緣,包括導覽列後方。

dc7918499a33df31.png

12. 恭喜

您已成功遷移第一個可在車輛停妥時使用的應用程式,並完成最佳化調整。現在就運用所學,應用到自己的應用程式吧!

體驗功能

其他資訊