1. 准备工作
这并非是
- 介绍如何创建适用于 Android Auto 和 Android Automotive OS 的媒体(音频,例如音乐、电台、播客)应用的指南。如需详细了解如何构建此类应用,请参阅构建车载媒体应用。
所需条件
- 最新版本的 Android Studio。
- 具备基本 Kotlin 使用经验。
- 具备创建 Android 虚拟设备以及在 Android 模拟器中运行 Android 虚拟设备的经验。
- 掌握了 Jetpack Compose 基础知识。
- 了解附带效应。
- 基本熟悉窗口边衬区。
构建内容
在此 Codelab 中,您将学习如何将现有的视频在线播放移动应用 Road Reels 迁移到 Android Automotive OS。
在手机上运行的应用的最初版本 | 在具有刘海屏的 Android Automotive OS 模拟器上运行的应用的完成版本。 |
学习内容
- 如何使用 Android Automotive OS 模拟器。
- 如何进行创建 Android Automotive OS build 所需的更改。
- 当应用在 Android Automotive OS 上运行时,在为移动设备开发应用时做出的哪些常见假设可能会不成立。
- 车载应用的不同质量层级。
- 如何使用媒体会话让其他应用能够控制您的应用的播放。
- 与在移动设备上相比,Android Automotive OS 设备上的系统界面和窗口边衬区有何不同。
2. 进行设置
获取代码
- 此 Codelab 的代码可以在
car-codelabs
GitHub 存储库的build-a-parked-app
目录中找到。若要克隆该代码,请运行以下命令:
git clone https://github.com/android/car-codelabs.git
- 或者,您也可以下载代码库 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 系统映像
- 首先,在 Android Studio 中打开 SDK 管理器,然后选择 SDK Platforms 标签页(如果尚未选择)。在 SDK 管理器窗口的右下角,确保选中 Show package details 复选框。
- 安装添加通用系统映像中列出的 API 33 Android Automotive with Google APIs 模拟器映像。映像只能在具有与其相同架构 (x86/ARM) 的机器上运行。
创建 Android Automotive OS Android 虚拟设备
- 打开设备管理器后,选择窗口左侧 Category 列下的 Automotive。从列表中选择“Automotive (1408p landscape)”捆绑硬件配置文件,然后点击 Next。
- 在下一页上,选择上一步中的系统映像。点击 Next,并选择所需的任何高级选项,最后点击 Finish 以创建 AVD。注意:如果您选择了 API 30 映像,则该映像可能位于 Recommended 标签页以外的其他标签页下。
运行应用
使用现有 app
运行配置在您刚刚创建的模拟器上运行应用。浏览应用的不同界面,并将其行为与在手机或平板电脑模拟器上运行应用的行为进行比较。
5. 更新清单,以声明对 Android Automotive OS 的支持
虽然应用“可以运行”,但需要进行一些小更改,以便应用在 Android Automotive OS 上顺畅运行,并满足在 Play 商店中发布的相关要求。您可以进行这些更改,以便相同的 APK 或 app bundle 可同时支持移动设备和 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 或 app bundle 便可同时分发到 Android Automotive OS 设备和移动设备。如需了解详情,请参阅为 Android Automotive OS 选择轨道类型。
将应用标记为视频应用
最后需要添加的元数据是 automotive_app_desc.xml
文件。此元数据用于在 Android for Cars 上下文中声明应用的类别,与您在 Play 管理中心为应用选择的类别无关。
- 右键点击
app
模块并依次选择 New > Android Resource File 选项,然后输入以下值并点击 OK:
- 文件名:
automotive_app_desc.xml
- 资源类型:
XML
- 根元素:
automotiveApp
- 源代码集:
main
- 目录名称:
xml
- 在该文件中,添加以下
<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>
- 在现有
<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 的支持是将应用引入汽车的一部分,但确保应用可用且可安全使用仍然是必要的。
添加导航可供性 (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
...
} 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
)
}
}
)
}
如需支持从播放器屏幕到主屏幕的返回导航,请执行以下操作:
- 更新
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) { ... }
}
}
- 更新
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
)
...
}
}
}
- 接下来,更新
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
)
}
- 最后,在
RoadReelsNavHost
中,提供传递给PlayerScreen
的实现:
RoadReelsNavHost.kt
composable(route = Screen.Player.name, ...) {
PlayerScreen(onClose = { navController.popBackStack() })
}
现在用户可以在屏幕之间移动,而不会遇到任何不通之处。对于其他类型的设备,用户体验甚至可能更好 - 例如,在屏幕较长的手机上,当用户的手已经靠近屏幕顶部时,他们可以更轻松地在应用中导航,而无需移动手中的设备。
适应屏幕方向支持
与绝大多数移动设备不同,大多数汽车的屏幕方向是固定的。也就是说,它们支持横屏或竖屏,但不能同时支持两者,因为屏幕无法旋转。因此,应用应避免假定同时支持两种屏幕方向。
在创建 Android Automotive OS 清单中,您为 android.hardware.screen.portrait
和 android.hardware.screen.landscape
功能添加了两个 <uses-feature>
元素,并将 required
属性设为 false
。这样做可确保任何与屏幕方向相关的隐式功能依赖项都不会阻止应用分发到汽车。不过,这些清单元素不会更改应用的行为,而只会更改应用的分发方式。
目前,该应用具有一项实用功能,即在视频播放器打开时自动将 activity 的屏幕方向设置为横向,这样手机用户就无需在设备未处于横屏模式时费力更改屏幕方向。
不幸的是,这种行为可能会导致固定竖屏设备(包括当今道路上行驶的许多汽车)出现闪烁循环或信箱模式。
为了解决此问题,您可以根据当前设备支持的屏幕方向添加检查功能。
- 为了简化实现,请先在
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)
}
}
- 然后,为调用添加保护性检查以设置所请求的屏幕方向。由于应用在移动设备上的多窗口模式下可能会遇到类似问题,因此您还可以添加检查,以确保在这种情况下也不会动态设置屏幕方向。
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 模拟器上的播放器屏幕进入闪烁循环(当 activity 不处理 | 在添加检查之前,Polestar 2 模拟器上的播放器屏幕处于信箱模式(当 activity 处理 | 添加检查后,Polestar 2 模拟器上的播放器屏幕不会处于信箱模式 |
由于这是应用中唯一设置屏幕方向的位置,因此应用现在可以避免进入信箱模式!在您自己的应用中,检查是否有任何仅适用于横屏或竖屏的 screenOrientation
属性或 setRequestedOrientation
调用(包括各自的 sensor
、reverse
和 user
变体),并根据需要移除这些属性或调用或者为它们添加保护性检查,以限制信箱模式。如需了解详情,请参阅设备兼容性模式。
适应系统栏可控性
遗憾的是,虽然之前的更改可确保应用不会进入闪烁循环或信箱模式,但它也暴露了另一个被打破的假设,即系统栏始终可以隐藏!与使用手机或平板电脑相比,用户在使用汽车时有不同的需求,因此原始设备制造商 (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(...)
}
不过,这种解决方案并不理想,因为它会导致界面元素在系统栏动画消失时四处跳动。
为了提升用户体验,您可以更新应用,以跟踪哪些边衬区可受控制,并仅针对无法控制的边衬区应用内边距。
- 由于应用中的其他界面可能需要控制窗口边衬区,因此最好传递可控边衬区作为
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 }
- 设置
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)
}
}
- 添加一个顶级
CompositionLocalProvider
,其中包含主题和应用可组合函数,并将值绑定到LocalControllableInsets
。
MainActivity.kt
import androidx.compose.runtime.CompositionLocalProvider
...
CompositionLocalProvider(LocalControllableInsets provides controllableInsetsTypeMask) {
RoadReelsTheme {
RoadReelsApp(calculateWindowSizeClass(this))
}
}
- 在播放器中,读取当前值并使用该值确定要隐藏的边衬区以及用于内边距的边衬区。
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
生命周期事件。应用应在此调用期间暂停播放。
模拟驾驶
在模拟器中转到播放器视图,然后开始播放内容。接着,按照步骤模拟驾驶,并注意在应用的界面被系统遮挡时,播放不会暂停。这违反了 DD-2 汽车应用质量指南。
开始驾驶时暂停播放
- 添加对
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)
- 同步项目以下载依赖项后,添加在发生
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 远程显示屏映像
- 首先,在 Android Studio 预览版中打开 SDK 管理器,然后选择 SDK Platforms 标签页(如果尚未选择)。在 SDK 管理器窗口的右下角,确保选中 Show package details 复选框。
- 根据计算机架构 (x86/ARM) 安装 API 33 Automotive Distant Display with Google Play 模拟器映像。
创建 Android Automotive OS Android 虚拟设备
- 打开设备管理器后,选择窗口左侧 Category 列下的 Automotive。然后,从列表中选择“Automotive Distant Display with Google Play”捆绑硬件配置文件,然后点击 Next。
- 在下一页上,选择上一步中的系统映像。点击 Next,并选择所需的任何高级选项,最后点击 Finish 以创建 AVD。
运行应用
使用现有 app
运行配置在您刚刚创建的模拟器上运行应用。按照使用远程显示屏模拟器中的说明将应用移入和移出远程显示屏。在应用位于主屏幕/详情屏幕和播放器屏幕上时,测试应用移动情况,并尝试在这两个屏幕上与应用互动。
9. 提升远程显示屏上的应用体验
在远程显示屏上使用应用时,您可能会注意到以下两点:
- 当应用移入或移出远程显示屏时,播放会卡顿。
- 当应用位于远程显示屏上时,您无法与其互动,包括更改播放状态。
提升应用连续性
播放卡顿是由于配置更改而重新创建 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 控制和通告播放。
- 添加对
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)
- 在
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()
}
}
}
- 然后,在
onCleared
方法中添加额外一行代码,以便在不再需要PlayerViewModel
时释放MediaSession
。
PlayerViewModel.kt
override fun onCleared() {
super.onCleared()
mediaSession?.release()
_player.value?.release()
}
- 最后,在播放器屏幕上(应用位于主显示屏或远程显示屏上),您可以使用
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 质量指南(再次违规!)。如需自行测试此功能,请执行以下操作:
- 开始播放
- 模拟驾驶
- 运行
media_session dispatch
命令。请注意,即使应用被遮挡,也会继续播放。
要解决此问题,您可以监听设备的用户体验限制,并仅在用户体验限制处于活跃状态时允许恢复播放。您甚至可以采用一种方法,让移动设备和 Android Automotive OS 使用相同的逻辑。
- 在
app
模块的build.gradle
文件中,添加以下内容以包含 Android Automotive 库,然后进行 Gradle 同步:
build.gradle.kts
android {
...
useLibrary("android.car")
}
- 右键点击
com.example.android.cars.roadreels
软件包,然后依次选择 New > Kotlin Class/File。输入RoadReelsPlayer
作为名称,然后点击 Class 类型。 - 在您刚刚创建的文件中,针对该类添加以下起始实现。通过扩展
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()
}
}
- 在
PlayerViewModel.kt
中,更新播放器变量的声明,以使用RoadReelsPlayer
的实例,而不是ExoPlayer
。此时,行为将与之前完全相同,因为shouldPreventPlay
永远不会从其默认值false
更新。
PlayerViewModel.kt
init {
...
_player.update { RoadReelsPlayer(application) }
}
- 如需开始跟踪用户体验限制,请添加以下
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
可自动暂停或恢复播放。
- 现在,按照本部分开头所述,测试在模拟驾驶时恢复播放,并注意它不会恢复播放!
- 最后,由于在用户体验限制生效时暂停播放由
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 设备的配置可能会打破在移动设备上通常成立的假设。
在本部分中,您将学习如何将模拟器配置为具有左侧系统栏,并在该配置下测试应用。
配置侧边系统栏
如使用可配置的模拟器进行测试中所述,您可以使用各种选项来模拟汽车中的不同系统配置。
在此 Codelab 中,com.android.systemui.rro.left
可用于测试不同的系统栏配置。若要启用 com.android.systemui.rro.left,请使用以下命令:
adb shell cmd overlay enable --user 0 com.android.systemui.rro.left
由于应用在 Scaffold
中使用 systemBars
修饰符作为 contentWindowInsets
,因此内容已在系统栏的安全区域内绘制。如需了解在应用假定系统栏仅显示在屏幕顶部和底部时会发生什么情况,请将该参数更改为以下内容:
RoadReelsApp.kt
contentWindowInsets = if (route?.equals(Screen.Player.name) == true) WindowInsets(0.dp) else WindowInsets.systemBars.only(WindowInsetsSides.Vertical)
糟糕!列表和详情屏幕渲染在系统栏后面。由于之前所做的改进,即使系统栏无法控制,播放器屏幕也会正常显示。
在继续学习下一部分之前,请务必还原您刚刚对 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
就目前而言,应用不会渲染到刘海屏中(目前很难判断刘海屏的确切形状,但在下一步中会变得清晰)。这完全没问题,这与渲染到刘海屏中但未仔细调整以适应刘海屏的应用相比,可以提供更好的体验。
渲染到刘海屏
为了尽可能为用户提供沉浸式体验,您可以通过渲染到刘海屏来利用更多屏幕空间。
- 如需渲染到刘海屏,请创建一个
integers.xml
文件来存储特定于汽车的替换项。为此,请使用 UI mode 限定符并将其值设为 Car Dock(该名称是从只有 Android Auto 存在的时代保留下来的,但 Android Automotive OS 也使用该名称)。此外,由于您将使用的值LAYOUT_IN_DISPLAY_CUTOUT_MODE_ALWAYS
是在 Android R 中引入的,因此还要添加 Android Version 限定符,并将其值设为 30。如需了解详情,请参阅使用备用资源。
- 在您刚刚创建的文件 (
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()
设置的模式。如需详细了解此属性,请参阅参考文档。
现在,当您运行应用时,请注意内容会延伸到刘海屏,看起来非常具有沉浸感!不过,顶部应用栏和部分内容会被刘海屏遮挡一部分,从而导致与应用假定系统栏只会显示在顶部和底部时出现的问题所类似的问题。
修正顶部应用栏
如需修正顶部应用栏,您可以将以下 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
包含 displayCutout
和 systemBars
边衬区,因此它优于默认的 windowInsets
参数,后者在放置顶部应用栏时仅使用 systemBars
。
此外,由于顶部应用栏位于窗口顶部,因此您不应添加 safeDrawing
边衬区的底部组件,否则可能会添加不必要的内边距。
修正主屏幕
要修正主屏幕和详情屏幕上的内容,一种方法是针对 Scaffold
的 contentWindowInsets
使用 safeDrawing
而不是 systemBars
。不过,使用该方法时,应用的沉浸感会明显降低,因为内容会在刘海屏开始处突然被切断 - 这与应用根本不渲染在刘海屏中的情况相差无几。
为了打造更具沉浸感的界面,您可以处理屏幕中每个组件上的边衬区。
- 将
Scaffold
的contentWindowInsets
更新为始终为 0dp(而不仅仅针对PlayerScreen
为 0dp)。这样,屏幕中的每个屏幕和/或组件都可以确定其在边衬区方面的行为。
RoadReelsApp.kt
Scaffold(
...,
contentWindowInsets = WindowInsets(0.dp)
) { ... }
- 将行标题
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))
)
...
}
- 移除
LazyRow
的contentPadding
参数。然后,在每个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))
)
}
}
- 最后,在
LazyColumn
末尾添加Spacer
,以考虑屏幕底部可能出现的系统栏或刘海屏边衬区。无需在LazyColumn
顶部添加等效的分隔器,因为顶部应用栏会处理这些边衬区。如果应用使用的是底部应用栏,而不是顶部应用栏,您可以使用windowInsetsTopHeight
修饰符在列表开头添加Spacer
。如果应用同时使用了顶部和底部应用栏,则不需要任何分隔器。
MainScreen.kt
import androidx.compose.foundation.layout.windowInsetsBottomHeight
...
LazyColumn(...){
items(NUM_ROWS) { ... }
item {
Spacer(Modifier.windowInsetsBottomHeight(WindowInsets.safeDrawing))
}
}
顶部的应用栏完全可见,当您滚动到行尾时,现在可以完整地看到所有缩略图。
修正详情屏幕
详情屏幕的效果没那么差,但仍有内容被截断。
由于详情屏幕没有任何可滚动内容,因此只需在顶级 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)
) { ... }
修正播放器屏幕
虽然先前在满足 Android Automotive OS 质量要求:可导航性中 PlayerScreen
已针对部分或全部系统栏窗口边衬区应用了内边距,但这还不足以确保应用在渲染到刘海屏时不会被遮挡。在移动设备上,刘海屏几乎总是完全包含在系统栏中。不过,在汽车中,刘海屏可能会远远超出系统栏,从而打破了假设。
如需修正此问题,只需将 windowInsetsForPadding
变量的初始值从零值更改为 displayCutout
:
PlayerScreen.kt
import androidx.compose.foundation.layout.displayCutout
...
var windowInsetsForPadding = WindowInsets(WindowInsets.displayCutout)
很好,应用在充分利用屏幕空间的同时还保持了可用性!
如果您在移动设备上运行应用,也更加具有沉浸感!列表项会一直渲染到屏幕边缘,包括导航栏后面。
12. 恭喜
您已经成功迁移和优化了您的第一个停车状态下使用的应用。现在是时候将您学到的知识应用到您自己的应用了!
尝试以下任务
深入阅读
- 构建适用于 Android Automotive OS 的停车状态下使用的应用
- 构建适用于 Android Automotive OS 的视频应用
- 构建适用于 Android Automotive OS 的游戏
- 构建适用于 Android Automotive OS 的浏览器
- Android 汽车应用质量页面描述了应用必须满足什么标准,才能打造出色的用户体验并通过 Play 商店审核。请务必过滤应用的类别。