构建和测试适用于 Android Automotive OS 的停车状态下使用的应用

1. 准备工作

这并非是

  • 介绍如何创建适用于 Android Auto 和 Android Automotive OS 的媒体(音频,例如音乐、电台、播客)应用的指南。如需详细了解如何构建此类应用,请参阅构建车载媒体应用

所需条件

构建内容

在此 Codelab 中,您将学习如何将现有的视频在线播放移动应用 Road Reels 迁移到 Android Automotive OS。

在手机上运行的应用的最初版本

在具有刘海屏的 Android Automotive OS 模拟器上运行的应用的完成版本。

在手机上运行的应用的最初版本

在具有刘海屏的 Android Automotive OS 模拟器上运行的应用的完成版本。

学习内容

  • 如何使用 Android Automotive OS 模拟器。
  • 如何进行创建 Android Automotive OS build 所需的更改
  • 当应用在 Android Automotive OS 上运行时,在为移动设备开发应用时做出的哪些常见假设可能会不成立
  • 车载应用的不同质量层级
  • 如何使用媒体会话让其他应用能够控制您的应用的播放
  • 与在移动设备上相比,Android Automotive OS 设备上的系统界面和窗口边衬区有何不同

2. 进行设置

获取代码

  1. 此 Codelab 的代码可以在 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 管理器,然后选择 SDK Platforms 标签页(如果尚未选择)。在 SDK 管理器窗口的右下角,确保选中 Show package details 复选框。
  2. 安装添加通用系统映像中列出的 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 build

虽然应用“可以运行”,但需要进行一些小更改,以便应用在 Android Automotive OS 上顺畅运行,并满足在 Play 商店中发布的相关要求。并非所有这些更改都适合添加到应用的移动版中,因此您需要先创建 Android Automotive OS build 变体。

添加设备规格变种维度

首先,通过修改 build.gradle.kts 文件中的 flavorDimensions,为 build 的目标设备规格添加一个变种维度。然后,为每种类型的设备添加 productFlavors 代码块和变种(mobileautomotive)。

如需了解详情,请参阅配置产品变种

build.gradle.kts(模块 :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 the last project sync.A project sync may be necessary for the IDE to work properly”。点击该横幅中的 Sync Now 按钮,以便 Android Studio 可以导入这些 build 配置更改。

8685bcde6b21901f.png

接下来,依次选择 Build > Select Build Variant... 菜单项以打开 Build Variants 工具窗口,然后选择 automotiveDebug 变体。这样可确保您在 Project 窗口中看到 automotive 源代码集的文件,并在通过 Android Studio 运行应用时使用此 build 变体。

19e4aa8135553f62.png

创建 Android Automotive OS 清单

接下来,您将为 automotive 源代码集创建一个 AndroidManifest.xml 文件。此文件包含 Android Automotive OS 应用所需的必要元素。

  1. Project 窗口中,右键点击 app 模块。在显示的下拉菜单中,依次选择 New > Other > Android Manifest File
  2. 在随即打开的 New Android Component 窗口中,为新文件的 Target Source Set 选择 automotive。点击 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>

必须提供第一个声明,才能将 build 工件上传到 Play 管理中心的 Android Automotive OS 轨道。Google Play 会根据此功能的存在情况来确保仅向具备 android.hardware.type.automotive 功能的设备(即汽车)分发应用。

其他声明是必需的,以确保可以在汽车中的各种硬件配置上安装应用。如需了解详情,请参阅必需的 Android Automotive OS 功能

将应用标记为视频应用

最后需要添加的元数据是 automotive_app_desc.xml 文件。此元数据用于在 Android for Cars 上下文中声明应用的类别,与您在 Play 管理中心为应用选择的类别无关。

  1. 右键点击 app 模块并依次选择 New > Android Resource File 选项,然后输入以下值并点击 OK
  • 文件名:automotive_app_desc.xml
  • 资源类型:XML
  • 根元素:automotiveApp
  • 源代码集:automotive
  • 目录名称: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 build 所需的所有更改!

6. 满足 Android Automotive OS 质量要求:可导航性

虽然创建 Android Automotive OS build 变体是将应用引入汽车的一部分,但确保应用可用且可安全使用仍然是必要的。

添加导航可供性 (affordance)

在 Android Automotive OS 模拟器中运行应用时,您可能已经注意到,无法从详情屏幕返回到主屏幕,或者无法从播放器屏幕返回到详情屏幕。与可能需要返回按钮或触摸手势才能实现返回导航的其他设备不同,Android Automotive OS 设备没有此类要求。因此,应用必须在界面中提供导航可供性 (affordance),以确保用户能够在应用中导航,而不会卡在应用中的某个屏幕上。此要求已编入 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。这样做可确保任何与屏幕方向相关的隐式功能依赖项都不会阻止应用分发到汽车。不过,这些清单元素不会更改应用的行为,而只会更改应用的分发方式。

目前,该应用具有一项实用功能,即在视频播放器打开时自动将 activity 的屏幕方向设置为横向,这样手机用户就无需在设备未处于横屏模式时费力更改屏幕方向。

不幸的是,这种行为可能会导致固定竖屏设备(包括当今道路上行驶的许多汽车)出现闪烁循环或信箱模式。

为了解决此问题,您可以根据当前设备支持的屏幕方向添加检查功能。

  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 模拟器上进入闪烁循环(当 activity 不处理屏幕方向配置更改时)

在添加检查之前,Polestar 2 模拟器上的播放器屏幕处于信箱模式(当 activity 处理屏幕方向配置更改时)

添加检查后,Polestar 2 模拟器上的播放器屏幕不会处于信箱模式。

在添加检查之前,Polestar 2 模拟器上的播放器屏幕进入闪烁循环(当 activity 不处理 orientation 配置更改时)

在添加检查之前,Polestar 2 模拟器上的播放器屏幕处于信箱模式(当 activity 处理 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(...)
}

不过,这种解决方案并不理想,因为它会导致界面元素在系统栏动画消失时四处跳动。

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 生命周期事件。应用应在此调用期间暂停播放。

模拟驾驶

在模拟器中转到播放器视图,然后开始播放内容。接着,按照步骤模拟驾驶,并注意在应用的界面被系统遮挡时,播放不会暂停。这违反了 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(模块 :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 远程显示屏映像

  1. 首先,在 Android Studio 中打开 SDK 管理器,然后选择 SDK Platforms 标签页(如果尚未选择)。在 SDK 管理器窗口的右下角,确保选中 Show package details 复选框。
  2. 根据计算机架构 (x86/ARM) 安装 Automotive Distant Display with Google APIs 模拟器映像。

创建 Android Automotive OS Android 虚拟设备

  1. 打开设备管理器后,选择窗口左侧 Category 列下的 Automotive。从列表中选择“Automotive Distant Display”捆绑硬件配置文件,然后点击 Next
  2. 在下一页上,选择上一步中的系统映像。点击 Next,并选择所需的任何高级选项,最后点击 Finish 以创建 AVD。

运行应用

使用现有 app 运行配置在您刚刚创建的模拟器上运行应用。按照使用远程显示屏模拟器中的说明将应用移入和移出远程显示屏。在应用位于主屏幕/详情屏幕和播放器屏幕上时,测试应用移动情况,并尝试在这两个屏幕上与应用互动。

b277bd18a94e9c1b.png

9. 提升远程显示屏上的应用体验

在远程显示屏上使用应用时,您可能会注意到以下两点:

  1. 当应用移入或移出远程显示屏时,播放会重新开始
  2. 当应用位于远程显示屏上时,您无法与其互动,包括更改播放状态。

提升应用连续性

播放重新开始这一问题是由于配置更改而重新创建 activity 所导致的。由于应用是使用 Compose 编写的,并且更改的配置与尺寸相关,因此您可以轻松地通过限制为基于尺寸的配置更改重新创建 activity,让 Compose 为您处理配置更改。这样可以在不同显示屏之间实现顺畅过渡,不会因 activity 重新创建而导致播放停止或重新加载。

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(模块 :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 管理器,然后选择 SDK Platforms 标签页(如果尚未选择)。在 SDK 管理器窗口的右下角,确保选中 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。

配置侧边系统栏

使用可配置的模拟器进行测试中所述,您可以使用各种选项来模拟汽车中的不同系统配置。

在此 Codelab 中,com.android.systemui.rro.left 可用于测试不同的系统栏配置。若要启用 com.android.systemui.rro.left,请使用以下命令:

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

b642703a7278b219.png

由于应用在 Scaffold 中使用 systemBars 修饰符作为 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 也使用该名称)。此外,由于 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_DEFAULT。此整数值已在 MainActivity.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 更新为始终为 0dp(而不仅仅针对 PlayerScreen 为 0dp)。这样,屏幕中的每个屏幕和/或组件都可以确定其在边衬区方面的行为。

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 的开头和结尾添加 Spacer,使其具有相应 safeDrawing 组件的宽度,以确保所有缩略图都能完全显示。使用 widthIn 修饰符确保这些分隔器的宽度至少与内容内边距一样大。如果没有这些元素,行首和行尾的项可能会被系统栏和/或刘海屏遮挡,即使完全滑动到行首/行尾也是如此。

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 顶部添加等效的分隔器,因为顶部应用栏会处理这些边衬区。如果应用使用的是底部应用栏,而不是顶部应用栏,您可以使用 windowInsetsTopHeight 修饰符在列表开头添加 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. 恭喜

您已经成功迁移和优化了您的第一个停车状态下使用的应用。现在是时候将您学到的知识应用到您自己的应用了!

尝试以下任务

深入阅读