构建和测试 Android 汽车操作系统的停放应用

1. 开始之前

这不是什么

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

您需要什么

您将构建什么

在这个代码实验室中,您将学习如何将现有的视频流移动应用程序 Road Reels 迁移到 Android 汽车操作系统。

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 汽车操作系统模拟器上运行的应用程序的完成版本。

您将学到什么

  • 如何使用 Android 汽车操作系统模拟器。
  • 如何进行创建 Android 汽车操作系统构建所需的更改
  • 在应用程序在 Android 汽车操作系统上运行时,在为移动设备开发应用程序时做出的常见假设可能会失效
  • 汽车中应用程序的不同质量等级
  • 如何使用媒体会话让其他应用程序控制您应用程序的播放
  • 与移动设备相比,Android 汽车操作系统设备上的系统 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 汽车操作系统的停放应用程序

停放应用程序构成 Android 汽车操作系统支持的应用程序类别的一个子集。在撰写本文时,它们包括视频流应用程序、网络浏览器和游戏。这些应用程序非常适合汽车,因为车辆中装有内置 Google 的硬件,以及电动汽车的普及率不断提高,在这些汽车中,充电时间为驾驶员和乘客提供了与这些类型的应用程序互动的绝佳机会。

在许多方面,汽车类似于其他大屏幕设备,例如平板电脑和折叠屏手机。它们具有触摸屏,具有类似的大小、分辨率和纵横比,并且可以处于纵向或横向方向(尽管与平板电脑不同,它们的方位是固定的)。它们也是连接设备,可能会断开和重新连接网络连接。考虑到所有这些,应用程序已经 针对大屏幕进行了优化,通常只需要少量工作即可为汽车带来出色的用户体验,这一点也不足为奇。

与大屏幕类似,汽车中应用程序也有 应用程序质量等级

  • 等级 3 - 汽车就绪:您的应用程序是大屏幕兼容的,可以在汽车停放时使用。虽然它可能没有汽车优化的功能,但用户可以像在任何其他大屏幕 Android 设备上一样体验应用程序。符合这些要求的移动应用程序有资格通过 汽车就绪移动应用程序计划 分发到汽车。
  • 等级 2 - 汽车优化:您的应用程序在汽车的中控台显示屏上提供出色的体验。要实现这一点,您的应用程序将具有一些汽车特定的工程,以包含可以在驾驶模式或停放模式中使用的功能,具体取决于应用程序的类别。
  • 等级 1 - 汽车差异化:您的应用程序是为在汽车中各种硬件上运行而构建的,并且可以根据汽车中不同屏幕(例如中控台、仪表盘和附加屏幕 - 如许多高端汽车中看到的全景显示屏)调整其体验。它提供了专为汽车中不同屏幕设计的最佳用户体验。

4. 在 Android 汽车操作系统模拟器中运行应用程序

安装包含 Play 商店的汽车系统映像

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

创建 Android 汽车操作系统 Android 虚拟设备

  1. 打开设备管理器 后,选择左侧窗口 类别列下的 汽车。然后,从列表中选择汽车(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控制台的Android Automotive OS轨道所必需的。此功能的存在被Google Play用来仅将应用分发到具有android.hardware.type.automotive功能(即汽车)的设备。

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

将应用标记为视频应用

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

  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>元素。在其中,添加以下<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

@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 汽车操作系统质量要求:驾驶员分心

最后,汽车和其他外形尺寸之间存在一个主要区别 - 它们用于驾驶!因此,在驾驶时限制分心非常重要。所有针对 Android 汽车操作系统 停放的应用在驾驶开始时必须暂停播放。驾驶开始时会显示系统覆盖,反过来,将为被覆盖的应用调用 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. 在同步项目以下载依赖项后,添加一个 LifecycleEventEffect,它在 ON_PAUSE 事件上运行以暂停播放。

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. 在远端显示模拟器中测试应用

汽车中开始出现的一种新配置是双屏设置,在中控台上有一个主屏幕,在挡风玻璃附近仪表板上有一个辅助屏幕。应用可以从中心屏幕移动到辅助屏幕,然后移回,为驾驶员和乘客提供更多选择。

安装汽车远端显示镜像

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

创建 Android 汽车操作系统 Android 虚拟设备

  1. 打开设备管理器 后,在窗口左侧的类别列下选择汽车。然后,从列表中选择汽车远端显示 捆绑的硬件配置文件 并单击下一步
  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。媒体会话提供了一种与音频或视频播放器交互的通用方式。有关更多信息,请参阅 使用媒体会话控制和宣传播放

  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 汽车操作系统设备可能具有在移动外形尺寸上通常成立的假设失效的配置。

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

安装带有 Google API 的 Android 汽车镜像

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

创建 Android 汽车操作系统 Android 虚拟设备

  1. 打开设备管理器 后,在窗口左侧的类别列下选择汽车。然后,从列表中选择汽车(1080p 横向) 捆绑的硬件配置文件 并单击下一步
  2. 在下一页上,从上一步中选择系统镜像。单击下一步并选择您想要的任何高级选项,最后通过单击完成来创建 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 汽车操作系统车辆没有缺口或针孔摄像头切口,而是具有弯曲的屏幕,使屏幕不规则。

要查看应用程序在存在此类显示切口时的行为,请首先使用以下命令启用显示切口

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 文件来保存特定于汽车的覆盖。为此,请使用值为 Car DockUI 模式 限定符(该名称是当只有 Android Auto 存在时的遗留名称,但它也被 Android Automotive OS 使用)。此外,因为 LAYOUT_IN_DISPLAY_CUTOUT_MODE_ALWAYS 是在 Android R 中引入的,因此还需要添加值为 30 的 Android 版本 限定符。有关更多详细信息,请参阅 使用备用资源

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

修复顶部应用程序栏

要修复顶部应用程序栏,您可以在 CenterAlignedTopAppBar Composable 中添加以下 windowInsets 参数

RoadReelsApp.kt

import androidx.compose.foundation.layout.safeDrawing

...

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

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

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

7d59ebb63ada5f71.gif

修复主屏幕

修复主屏幕和详细信息屏幕上的内容的一个选项是使用 safeDrawing 而不是 systemBars 来表示 ScaffoldcontentWindowInsets。但是,使用该选项,应用程序看起来明显不太沉浸,内容在显示切口开始的地方突然被切断 - 这与应用程序根本不渲染到显示切口相比并没有好多少。

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 的顶部不需要等效的空格。如果应用程序使用的是底部应用程序栏而不是顶部应用程序栏,您将在列表的开头添加一个 Spacer,使用 windowInsetsTopHeight 修饰符。如果应用程序同时使用顶部和底部应用程序栏,则不需要任何空格。

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. 恭喜

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

尝试的事项

进一步阅读