为 Android Automotive OS 构建和测试停泊应用

1. 在您开始之前

这不是什么

  • 有关如何为 Android Auto 和 Android Automotive OS 创建媒体(音频 - 例如音乐、广播、播客)应用的指南。有关如何构建此类应用的详细信息,请参阅 为汽车构建媒体应用

您需要什么

您将构建什么

在此代码实验室中,您将学习如何将现有的视频流移动应用 Road Reels 迁移到 Android Automotive OS。

The starting point version of the app running on a phone

The completed version of the app running on an Android Automotive OS emulator with a display cutout.

在手机上运行的应用的起始版本

在带有显示切口的 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 模拟器中运行应用

安装带有 Play 商店的 Automotive 系统映像

  1. 首先,在 Android Studio 中打开 SDK 管理器,如果尚未选择,则选择“SDK 平台”选项卡。在 SDK 管理器窗口的右下角,确保选中“显示包详细信息”框。
  2. 安装添加通用系统映像中列出的带有 Play 商店的 Automotive模拟器映像之一。映像只能在与自身具有相同架构(x86/ARM)的机器上运行。

创建 Android Automotive OS Android 虚拟设备

  1. 打开设备管理器,在窗口左侧的“类别”列下选择“Automotive”。然后,从列表中选择Automotive (1024p 横向)捆绑的硬件配置文件,然后点击“下一步”。

The Virtual Device Configuration wizard showing the 'Automotive (1024p landscape)' hardware profile selected.

  1. 在下一页上,从上一步中选择系统映像。点击“下一步”,并选择所需的任何高级选项,然后最终通过点击“完成”创建 AVD。注意:如果您选择了 API 30 映像,它可能位于“推荐”选项卡以外的其他选项卡下。

运行应用

使用现有的app运行配置在您刚刚创建的模拟器上运行应用。试用该应用以浏览不同的屏幕,并比较它与在手机或平板电脑模拟器上运行时的行为。

301e6c0d3675e937.png

5. 创建 Android Automotive OS 构建

尽管应用“正常工作”,但仍需要进行一些小的更改才能使其在 Android Automotive OS 上正常工作,并满足在 Play 商店上发布的要求。并非所有这些更改都有意义包含在应用的移动版本中,因此您将首先创建一个 Android Automotive OS 构建变体。

添加外形尺寸风格维度

首先,通过修改build.gradle.kts文件中的flavorDimensions,为构建目标的外形尺寸添加风格维度。然后,添加一个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 文件已更改。IDE 正常工作可能需要项目同步”。点击该横幅中的“立即同步”按钮,以便 Android Studio 导入这些构建配置更改。

8685bcde6b21901f.png

接下来,从构建>选择构建变体...菜单项打开构建变体工具窗口,并选择automotiveDebug变体。这将确保您在项目窗口中看到automotive源集的文件,以及在通过 Android Studio 运行应用时使用此构建变体。

19e4aa8135553f62.png

创建 Android Automotive OS 清单

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

  1. 项目窗口中,右键点击app模块。从出现的下拉列表中,选择新建>其他>Android 清单文件
  2. 在新打开的“新建 Android 组件”窗口中,选择automotive作为新文件的“目标源集”。点击“完成”以创建文件。

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 Console 上的 Android Automotive OS 轨道所必需的。Google Play 使用此功能的存在来仅将应用分发到具有android.hardware.type.automotive功能(即汽车)的设备。

其他声明是为了确保应用能够安装在汽车中存在的各种硬件配置上。有关更多详细信息,请参阅必需的 Android Automotive OS 功能

将应用标记为视频应用

需要添加的最后一个元数据是automotive_app_desc.xml文件。它用于在 Android for Cars 上下文中声明应用的类别,并且独立于您在 Play Console 中为应用选择的类别

  1. 右键点击app模块,然后选择新建>Android 资源文件选项,并在点击“确定”之前输入以下值
  • 文件名: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>元素。在其中,添加以下引用您刚刚创建的automotive_app_desc.xml文件的<meta-data>元素。

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

@Composable
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
    }

    ...
}

The player screen enters a flickering loop on the Polestar 2 emulator before adding the check (when the activity does not handle orientation configuration changes)

The player screen is letterboxed on the Polestar 2 emulator before adding the check (when the activity handles orientation configuration changes)

The player screen is not letterboxed on the Polestar 2 emulator after adding the check.

在添加检查之前,播放器屏幕在 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(...)
}

The content doesn’t jump around when the system bars can be hidden

The content remains visible when the system bars can’t be hidden

当系统栏可以隐藏时,内容不会跳动

当系统栏无法隐藏时,内容仍然可见

好多了!内容不会跳动,同时,即使在无法控制系统栏的汽车上,控件也完全可见。

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 (模块: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 平台”选项卡。在 SDK 管理器窗口的右下角,确保选中“显示包详细信息”框。
  2. 为计算机的体系结构 (x86/ARM) 安装带 Google API 的 Automotive 远程显示模拟器映像。

创建 Android Automotive OS Android 虚拟设备

  1. 打开设备管理器,在窗口左侧的类别列下选择Automotive。然后,从列表中选择Automotive 远程显示捆绑的硬件配置文件,然后单击下一步
  2. 在下一页上,从上一步中选择系统映像。单击下一步,然后选择所需的任何高级选项,最后通过单击完成创建 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 (模块: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 设备可能采用一些配置,这些配置会破坏通常在移动外形尺寸上成立的假设。

在本节中,您将下载一个可以在运行时配置的模拟器,配置模拟器以具有左侧系统栏,并在该配置中测试应用程序。

安装带有 Google API 的 Android Automotive 镜像

  1. 首先,在 Android Studio 中打开 SDK 管理器,如果尚未选择,则选择“SDK 平台”选项卡。在 SDK 管理器窗口的右下角,确保选中“显示包详细信息”框。
  2. 为您的计算机体系结构 (x86/ARM) 安装 API 33 的带有 Google API 的 Android Automotive 模拟器镜像。

创建 Android Automotive OS Android 虚拟设备

  1. 打开设备管理器,在窗口左侧的类别列中选择汽车。然后,从列表中选择汽车 (1080p 横向) 捆绑的硬件配置文件,然后单击下一步
  2. 在下一页上,从上一步中选择系统映像。单击下一步,然后选择所需的任何高级选项,最后通过单击完成创建 AVD。

配置侧边系统栏

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

出于本 codelab 的目的,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 模式限定符,其值为汽车底座(名称保留自仅存在 Android Auto 时,但 Android Automotive OS 也使用它)。此外,因为LAYOUT_IN_DISPLAY_CUTOUT_MODE_ALWAYS是在 Android R 中引入的,所以还要添加 Android 版本限定符,其值为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 Composable 中

RoadReelsApp.kt

import androidx.compose.foundation.layout.safeDrawing

...

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

由于safeDrawing包含displayCutoutsystemBars内边距,因此改进了默认的windowInsets参数,后者在定位顶部应用栏时仅使用systemBars

此外,由于顶部应用栏位于窗口顶部,因此您不应包含safeDrawing内边距的底部组件——这样做可能会添加不必要的填充。

7d59ebb63ada5f71.gif

修复主屏幕

修复主屏幕和详细信息屏幕上的内容的一种方法是使用safeDrawing代替ScaffoldcontentWindowInsets中的systemBars。但是,使用该选项时,应用程序看起来明显不那么身临其境,内容在显示切口开始的地方突然被切断——与应用程序根本不渲染到显示切口中相比,并没有好多少。

6b3824ca3214cbfa.gif

为了获得更身临其境的界面,您可以在屏幕内的每个组件上处理内边距。

  1. 更新ScaffoldcontentWindowInsets,使其始终为 0dp(而不仅仅是为了PlayerScreen)。这允许屏幕或屏幕内的每个组件确定其相对于内边距的行为。

RoadReelsApp.kt

Scaffold(
    ...,
    contentWindowInsets = WindowInsets(0.dp)
) { ... }
  1. 设置行标题Text Composable 的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修饰符以确保这些间隔器至少与内容填充一样宽。如果没有这些元素,即使完全滑动到行的开头/结尾,行开头和结尾处的项目也可能被系统栏和/或显示切口遮挡。

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

修复播放器屏幕

虽然PlayerScreen已在满足 Android Automotive OS 质量要求:可导航性中为部分或全部系统栏窗口内边距应用了填充,但这不足以确保它不会被遮挡,因为应用程序现在正在渲染到显示切口中。在移动设备上,显示切口几乎总是完全包含在系统栏内。但是,在汽车中,显示切口可能会远远超出系统栏,从而破坏假设。

427227df5e44f554.png

要解决此问题,只需将windowInsetsForPadding变量的初始值从零值更改为displayCutout

PlayerScreen.kt

import androidx.compose.foundation.layout.displayCutout

...

var windowInsetsForPadding = WindowInsets(WindowInsets.displayCutout)

b523d8c1e1423757.gif

太棒了,该应用程序确实充分利用了屏幕,同时仍然可用!

而且,如果您在移动设备上运行应用程序,它在那里也会更身临其境!列表项渲染到屏幕边缘,包括导航栏后面。

dc7918499a33df31.png

12. 恭喜

您已成功迁移和优化了第一个停放的应用程序。现在是时候利用您学到的知识并将其应用到您自己的应用程序中了!

尝试的事情

进一步阅读