建構及測試適用於停車狀態的 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. 安裝「新增通用系統映像檔」中列出的 API 33「Android Automotive with Google APIs」模擬器映像檔。映像檔只能在與本身架構 (x86/ARM) 相同的機器上執行。

建立 Android Automotive OS Android 虛擬裝置

  1. 開啟裝置管理工具後,選取視窗左側「Category」欄下方的「Automotive」。接著,從清單中選取「Automotive (1408p landscape)」隨附的硬體設定檔,然後點選「Next」
  2. 在下一頁中,選取上一個步驟中的系統映像檔。按一下「Next」,選取所需進階選項,最後點選「Finish」建立 AVD。注意:如果您選擇 API 30 映像檔,可能位於「Recommended」分頁以外的分頁中。

執行應用程式

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

599922cd078f2589.png

5. 更新資訊清單,宣告支援 Android Automotive OS

雖然應用程式「可以運作」,但需要進行一些小幅調整,才能在 Android Automotive OS 上順暢運作,並符合 Play 商店上架規定。您可以採取特定方式進行這類變更,讓同一個 APK 或應用程式套件同時支援行動裝置和 Android Automotive OS 裝置。第一組變更是更新 AndroidManifest.xml 檔案,指出應用程式支援 Android Automotive OS 裝置,且為影片應用程式。

宣告車用硬體功能

如要指出應用程式支援 Android Automotive OS 裝置,請在 AndroidManifest.xml 檔案中加入以下 <uses-feature> 元素:

AndroidManifest.xml

<manifest xmlns:android="http://schemas.android.com/apk/res/android">
    ...
    <uses-feature
        android:name="android.hardware.type.automotive"
        android:required="false" />
    ...
</manifest>

android:required 屬性值設為 false,即可將產生的 APK 或應用程式套件分發至 Android Automotive OS 裝置和行動裝置。詳情請參閱「選擇 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:main
  • Directory name:xml

9fc697aec93d9d09.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. 在現有的 <application> 元素中,新增下列 <meta-data> 元素,參照您剛建立的 automotive_app_desc.xml 檔案。

AndroidManifest.xml

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

完成上述步驟後,您已完成所有必要變更,可宣告支援 Android Automotive OS。

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

雖然宣告 Android Automotive OS 支援功能是將應用程式提供給車輛使用的其中一個環節,但仍須確保應用程式可用且安全無虞。

新增 Navigation 功能提示

在 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

...

} else if (route?.startsWith(Screen.Detail.name) == true) {
    CenterAlignedTopAppBar(
        title = { Text(stringResource(R.string.bbb_title)) },
        navigationIcon = {
            IconButton(onClick = { navController.popBackStack() }) {
                Icon(
                    Icons.AutoMirrored.Filled.ArrowBack,
                    contentDescription = null
                )
            }
        }
    )
}

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

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

PlayerControls.kt

import androidx.compose.material.icons.twotone.Close

...

@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(
    uiState: PlayerUiState,
    onClose: () -> Unit,
    onPlayPause: () -> Unit,
    onSeek: (seekToMillis: Long) -> Unit,
    modifier: Modifier = Modifier,
) {
    AnimatedVisibility(
        visible = uiState.isShowingControls,
        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 = uiState.mediaMetadata.title?.toString(),
                onClose = onClose
            )
            ...
        }
    }
}
  1. 接著,更新 PlayerScreen 可組合函式以接收同一參數,並向下傳遞給 PlayerControls

PlayerScreen.kt

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

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

RoadReelsNavHost.kt

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

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

46cf7ec051b32ddf.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

...

DisposableEffect(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 (activity.supportedOrientations().contains(SupportedOrientation.Landscape)
        && !activity.isInMultiWindowMode
    ) {
        activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE
    }

    ...
}

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

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

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

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

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

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

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

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

很遺憾,雖然先前的變更可確保應用程式不會進入閃爍迴圈或出現上下黑邊,卻也曝露了另一個不成立的假設,也就是可能會一律隱藏系統資訊列!相較於使用手機或平板電腦,使用者在使用汽車時會有不同需求,因此原始設備製造商 (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 元素在系統資訊列動畫消失時,在畫面上跳動。

9fa1de6d2518340a.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.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.unit.dp
import com.example.android.cars.roadreels.LocalControllableInsets

...

val controllableInsetsTypeMask by rememberUpdatedState(LocalControllableInsets.current)

DisposableEffect(Unit) {
    ...
    windowInsetsController.hide(WindowInsetsCompat.Type.systemBars().and(controllableInsetsTypeMask))
    ...
}

...

// 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 車用應用程式品質指南。

c2eda16df688c102.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,以便暫停播放 (並視需要在 ON_RESUME 事件上執行,以便繼續播放)。

PlayerScreen.kt

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

...

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

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

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

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

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

安裝 Automotive Distant Display 映像檔

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

建立 Android Automotive OS Android 虛擬裝置

  1. 開啟裝置管理工具後,選取視窗左側「Category」欄下方的「Automotive」。接著,從清單中選取「Automotive Distant Display with Google Play」隨附的硬體設定檔,然後點選「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. PlayerViewModel 中,新增變數來保留媒體工作階段,並使用建構工具建立 MediaSession

PlayerViewModel.kt

import androidx.media3.session.MediaSession
...

class PlayerViewModel(...) {
    ...

    private var mediaSession: MediaSession? = null
    
    init {
        viewModelScope.launch {
            _player.onEach { player ->
                playerUiStateUpdateJob?.cancel()
                mediaSession?.release()

                if (player != null) {
                    initializePlayer(player)
                    mediaSession = MediaSession.Builder(application, player).build()
                    playerUiStateUpdateJob = viewModelScope.launch {... }
                }
            }.collect()
        }
    }
}
  1. 接著,在 onCleared 方法中新增一行,以便在不再需要 PlayerViewModel 時釋放 MediaSession

PlayerViewModel.kt

override fun onCleared() {
    super.onCleared()
    mediaSession?.release()
    _player.value?.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

限制繼續播放

雖然支援 MediaSession 可在應用程式位於遠距螢幕時控制播放作業,但這會引發一個新問題。也就是說,在使用者體驗限制生效時,這項功能卻會讓播放作業可以繼續執行,這違反了 DD-2 品質指南 (再次!)。如要自行測試這項功能,請按照下列步驟操作:

  1. 開始播放
  2. 模擬行車狀態
  3. 使用 media_session dispatch 指令。請注意,即使遮蔽了應用程式,播放作業仍會繼續。

如要修正這個問題,您可以監聽裝置的使用者體驗限制,並只允許在限制生效時繼續播放。甚至可以運用相同的邏輯,在行動裝置和 Android Automotive OS 上執行這項操作。

  1. app 模組的 build.gradle 檔案中新增以下內容,以納入 Android Automotive 程式庫,然後執行 Gradle 同步作業:

build.gradle.kts

android {
    ...
    useLibrary("android.car")
}
  1. com.example.android.cars.roadreels 套件上按一下滑鼠右鍵,然後依序選取「New」>「Kotlin Class/File」。輸入 RoadReelsPlayer 做為名稱,然後按一下「Class」類型。
  2. 在您剛剛建立的檔案中,新增該類別的以下初始實作項目。只要擴充 ForwardingSimpleBasePlayer,即可輕鬆覆寫 getState() 方法,為已包裝的播放器修改支援的指令和互動。

RoadReelsPlayer.kt

import android.content.Context
import androidx.media3.common.ForwardingSimpleBasePlayer
import androidx.media3.common.util.UnstableApi
import androidx.media3.exoplayer.ExoPlayer

@UnstableApi
class RoadReelsPlayer(context: Context) :
    ForwardingSimpleBasePlayer(ExoPlayer.Builder(context).build()) {
    private var shouldPreventPlay = false

    override fun getState(): State {
        val state = super.getState()

        return state.buildUpon()
            .setAvailableCommands(
                state.availableCommands.buildUpon().removeIf(COMMAND_PLAY_PAUSE, shouldPreventPlay)
                    .build()
            ).build()
    }
}
  1. PlayerViewModel.kt 中,更新播放器變數的宣告,使用 RoadReelsPlayer 例項而非 ExoPlayer。此時,由於 shouldPreventPlay 並未更新,仍是預設值 false,因此行為會與先前完全相同。

PlayerViewModel.kt

init {
    ...
    _player.update { RoadReelsPlayer(application) }
}
  1. 如要開始追蹤使用者體驗限制,請新增下列 init 區塊和 handleRelease 實作項目:

RoadReelsPlayer.kt

import android.car.Car
import android.car.drivingstate.CarUxRestrictions
import android.car.drivingstate.CarUxRestrictionsManager
import android.content.pm.PackageManager
import com.google.common.util.concurrent.ListenableFuture

...

@UnstableApi
class RoadReelsPlayer(context: Context) :
    ForwardingSimpleBasePlayer(ExoPlayer.Builder(context).build()) {
    ...
    private var pausedByUxRestrictions = false
    private lateinit var carUxRestrictionsManager: CarUxRestrictionsManager

   init {
        with(context) {
            // Only listen to UX restrictions if the device is running Android Automotive OS
            if (packageManager.hasSystemFeature(PackageManager.FEATURE_AUTOMOTIVE)) {
                val car = Car.createCar(context)
                carUxRestrictionsManager =
                    car.getCarManager(Car.CAR_UX_RESTRICTION_SERVICE) as CarUxRestrictionsManager

                // Get the initial UX restrictions and update the player state
                shouldPreventPlay =
                    carUxRestrictionsManager.currentCarUxRestrictions.isRequiresDistractionOptimization
                invalidateState()

                // Register a listener to update the player state as the UX restrictions change
                carUxRestrictionsManager.registerListener { carUxRestrictions: CarUxRestrictions ->
                    shouldPreventPlay = carUxRestrictions.isRequiresDistractionOptimization

                    if (!shouldPreventPlay && pausedByUxRestrictions) {
                        handleSetPlayWhenReady(true)
                        invalidateState()
                    } else if (shouldPreventPlay && isPlaying) {
                        pausedByUxRestrictions = true
                        handleSetPlayWhenReady(false)
                        invalidateState()
                    }
                }
            }

            addListener(object : Player.Listener {
                override fun onEvents(player: Player, events: Player.Events) {
                    if (events.contains(EVENT_IS_PLAYING_CHANGED) && isPlaying) {
                        pausedByUxRestrictions = false
                    }
                }
            })
        }
    }

    ...

    override fun handleRelease(): ListenableFuture<*> {
        if (::carUxRestrictionsManager.isInitialized) {
            carUxRestrictionsManager.unregisterListener()
        }
        return super.handleRelease()
    }
}

請注意以下幾點:

  • CarUxRestrictionsManager 會儲存為 lateinit 變數,因為它不會在非 Android Automotive OS 裝置上例項化或使用,但在播放器釋出時,應清除其事件監聽器。
  • 判斷使用者體驗限制狀態時,系統只會參照 isRequiresDistractionOptimization 值。雖然 CarUxRestrictions 類別包含哪些限制處於啟用狀態的詳細資訊,但您不必參照這些資訊,因為這些資訊僅供分心因素已排除的應用程式 (例如導航應用程式) 使用,這類應用程式會在限制處於啟用狀態時繼續顯示。
  • shouldPreventPlay 變數更新後,系統會呼叫 invalidateState(),通知消費者播放器的狀態變更。
  • 在事件監聽器本身中,可以使用適當的值呼叫 handleSetPlayWhenReady,自動暫停或繼續播放。
  1. 現在,請按照本節開頭所述,測試在模擬行車時繼續執行播放作業,您會發現系統不會繼續播放!
  2. 最後,由於在使用者體驗限制啟用時暫停播放作業是由 RoadReelsPlayer 處理,因此在 ON_PAUSE 期間,就不需要讓 LifecycleEventEffect 暫停播放器。您可以改為將其變更為 ON_STOP,這樣當使用者離開應用程式前往啟動器或開啟其他應用程式時,播放作業就會停止。

PlayerScreen.kt

LifecycleEventEffect(Lifecycle.Event.ON_START) {
    viewModel.play()
}
LifecycleEventEffect(Lifecycle.Event.ON_STOP) {
    viewModel.pause()
}

重點回顧

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

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

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

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

在本節中,您將瞭解如何設定模擬器,讓左側顯示系統資訊列,並使用該設定測試應用程式。

設定側邊系統資訊列

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

在本程式碼研究室中,您可使用 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.top_and_right

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

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

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

935aa1d4ee3eb72.png

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

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

  1. 如要將內容顯示到螢幕凹口位置,請建立 integers.xml 檔案,用於保存車輛專用的覆寫值。做法如下:使用「UI mode」限定詞,並將值設為「Car Dock」(這個名稱是從只有 Android Auto 的時代沿用至今,但 Android Automotive OS 也會使用)。此外,由於您要使用的值 LAYOUT_IN_DISPLAY_CUTOUT_MODE_ALWAYS 是在 Android R 中推出,請一併新增 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() 設定的模式。如要進一步瞭解這項屬性,請參閱參考文件

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

1d791b9e2ec91bda.png

修正頂端應用程式列

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

RoadReelsApp.kt

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

...

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

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

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

21d9a237572f85c2.png

修正主畫面

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

80bca44f0962a4a1.png

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

  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))
    }
}

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

b437a762e31abd02.png

修正詳細資料畫面

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)
) { ... }

adf17e27b576ec5a.png

修正播放器畫面

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

fc14798bc71110d3.png

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

PlayerScreen.kt

import androidx.compose.foundation.layout.displayCutout

...

var windowInsetsForPadding = WindowInsets(WindowInsets.displayCutout)

cce55d3f8129935d.png

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

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

dc7918499a33df31.png

12. 恭喜

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

體驗功能

其他資訊