构建车载媒体应用

Android Auto 和 Android Automotive OS 可帮助您将媒体应用内容带给车上的用户。车载媒体应用必须提供媒体浏览器服务,Android Auto 和 Android Automotive OS 或其他带有媒体浏览器的应用才能发现和展示您的内容。

本指南假定您已经拥有可在手机上播放音频的媒体应用,并且您的媒体应用遵循 Android 媒体应用架构

本指南介绍了您的应用在 Android Auto 或 Android Automotive OS 上运行所需的 MediaBrowserServiceMediaSession 组件。完成核心媒体基础架构的搭建后,您可以向媒体应用添加对 Android Auto 的支持添加对 Android Automotive OS 的支持

准备工作

  1. 查看 Android 媒体 API 文档
  2. 查看 Android Automotive OS 应用设计准则Android Auto 应用设计准则
  3. 查看本部分列出的关键术语和概念。

关键术语和概念

媒体浏览器服务
由您的媒体应用实现且符合 MediaBrowserServiceCompat API 要求的 Android 服务。应用使用此服务来公开其内容。
媒体浏览器
媒体应用用于发现媒体浏览器服务并展示其内容的 API。Android Auto 和 Android Automotive OS 使用媒体浏览器查找应用的媒体浏览器服务。
媒体项

媒体浏览器将其内容整理成 MediaItem 对象树。媒体项可以包含以下两个标记之一,也可以同时包含这两个标记:

  • 可播放:此标记表示媒体项为内容树上的一个叶项。这类媒体项表示单个声音流,如专辑里的一首歌曲、有声读物中的一章或一集播客。
  • 可浏览:此标记表示媒体项为内容树上的一个节点,它有子项。例如,媒体项表示一个专辑,其子项为专辑里的歌曲。

既可浏览又可播放的媒体项相当于播放列表。您可以选择媒体项本身来播放其中的所有子项,也可以浏览其子项。

车辆优化

遵循 Android Automotive OS 设计准则的 Android Automotive OS 应用的 activity。这些 activity 的界面并非由 Android Automotive OS 绘制,因此您必须确保应用遵循设计准则。通常情况下,这包括使用较大的点按目标和字体大小、支持日间模式和夜间模式以及提高对比度。

只有在车载用户体验限制 (CUXR) 未生效时才允许显示车辆优化界面,因为这些界面可能需要用户长时间关注或互动。汽车处于停车状态时 CUXR 不起作用,但在汽车行驶时始终有效。

您无需针对 Android Auto 设计 activity,因为 Android Auto 会使用媒体浏览器服务中的信息自行绘制车辆优化界面。

配置应用的清单文件

您需要先配置应用的清单文件,然后才能创建媒体浏览器服务。

声明媒体浏览器服务

Android Auto 和 Android Automotive OS 都通过媒体浏览器服务连接到您的应用,以便浏览媒体项。您需要在清单中声明媒体浏览器服务,以便 Android Auto 和 Android Automotive OS 发现该服务并连接到您的应用。

以下代码段展示了如何在清单中声明媒体浏览器服务。您应将此代码包含在 Android Automotive OS 模块的清单文件和手机应用的清单文件中。

<application>
    ...
    <service android:name=".MyMediaBrowserService"
             android:exported="true">
        <intent-filter>
            <action android:name="android.media.browse.MediaBrowserService"/>
        </intent-filter>
    </service>
    ...
<application>

指定应用图标

您需要指定一个应用图标,供 Android Auto 和 Android Automotive OS 用来在系统界面中表示您的应用。

您可以通过以下清单声明指定用于表示应用的图标:

<!--The android:icon attribute is used by Android Automotive OS-->
<application
    ...
    android:icon="@mipmap/ic_launcher">
    ...
    <!--Used by Android Auto-->
    <meta-data android:name="com.google.android.gms.car.notification.SmallIcon"
               android:resource="@drawable/ic_auto_icon" />
    ...
<application>

创建媒体浏览器服务

请通过扩展 MediaBrowserServiceCompat 类来创建媒体浏览器服务。然后,Android Auto 和 Android Automotive OS 即可使用您的服务执行以下操作:

  • 浏览应用的内容层次结构,以向用户展示菜单。
  • 获取应用的 MediaSessionCompat 对象的令牌,以便控制音频播放。

媒体浏览器服务工作流

本部分介绍在典型的用户工作流中,Android Automotive OS 和 Android Auto 如何与您的媒体浏览器服务互动。

  1. 用户在 Android Automotive OS 或 Android Auto 中启动您的应用。
  2. Android Automotive OS 或 Android Auto 使用 onCreate() 方法与应用的媒体浏览器服务通讯。在 onCreate() 方法的实现中,您必须创建并注册 MediaSessionCompat 对象及其回调对象。
  3. Android Automotive OS 或 Android Auto 调用您的服务的 onGetRoot() 方法,以获取内容层次结构中的根媒体项。根媒体项不会显示出来,而是用于从您的应用中检索更多内容。
  4. Android Automotive OS 或 Android Auto 调用服务的 onLoadChildren() 方法,以获取根媒体项的子项。Android Automotive OS 和 Android Auto 会将这些媒体项显示为顶级内容项。如需详细了解系统期望在此级别存在哪些内容,请参阅构造根菜单
  5. 如果用户选择了可浏览媒体项,系统会再次调用服务的 onLoadChildren() 方法,以检索所选菜单项的子项。
  6. 如果用户选择了可播放媒体项,Android Automotive OS 或 Android Auto 会调用相应的媒体会话回调方法来执行该操作。
  7. 如果您的应用支持,用户还可以搜索您的内容。在这种情况下,Android Automotive OS 或 Android Auto 会调用服务的 onSearch() 方法。

构建内容层次结构

Android Auto 和 Android Automotive OS 调用应用的媒体浏览器服务,以查找可用内容。为了支持这一点,您需要在媒体浏览器服务中实现 onGetRoot()onLoadChildren() 这两种方法。

实现 onGetRoot

服务的 onGetRoot() 方法返回有关内容层次结构根节点的信息。Android Auto 和 Android Automotive OS 使用此根节点,通过 onLoadChildren() 方法请求您的其余内容。

以下代码段展示了 onGetRoot() 方法的简单实现:

Kotlin

override fun onGetRoot(
    clientPackageName: String,
    clientUid: Int,
    rootHints: Bundle?
): BrowserRoot? =
    // Verify that the specified package is allowed to access your
    // content! You'll need to write your own logic to do this.
    if (!isValid(clientPackageName, clientUid)) {
        // If the request comes from an untrusted package, return null.
        // No further calls will be made to other media browsing methods.

        null
    } else MediaBrowserServiceCompat.BrowserRoot(MY_MEDIA_ROOT_ID, null)

Java

@Override
public BrowserRoot onGetRoot(String clientPackageName, int clientUid,
    Bundle rootHints) {

    // Verify that the specified package is allowed to access your
    // content! You'll need to write your own logic to do this.
    if (!isValid(clientPackageName, clientUid)) {
        // If the request comes from an untrusted package, return null.
        // No further calls will be made to other media browsing methods.

        return null;
    }

    return new MediaBrowserServiceCompat.BrowserRoot(MY_MEDIA_ROOT_ID, null);
}

如需查看此方法更为详细的示例,请参阅 GitHub 上 Universal Android Music Player 示例应用中的 onGetRoot() 方法。

为 onGetRoot() 添加软件包验证

调用服务的 onGetRoot() 方法时,发出调用的软件包会将标识信息传递给您的服务。您的服务可以使用此信息来决定该软件包能否访问您的内容。例如,您可以将 clientPackageName 与许可名单进行比较,并验证用于为软件包的 APK 签名的证书,从而限制只有获批的软件包有权访问应用的内容。如果无法验证软件包,则返回 null 以拒绝它访问您的内容。

为了允许系统应用(例如 Android Auto 和 Android Automotive OS)访问您的内容,当这些系统应用调用 onGetRoot() 方法时,您的服务应始终返回一个非 null BrowserRoot。Android Automotive OS 系统应用的签名可能会因汽车的品牌和型号而异,因此您应允许来自所有系统应用的连接以可靠地支持 Android Automotive OS。

以下代码段展示了您的服务如何验证发出调用的软件包是否为系统应用:

fun isKnownCaller(
    callingPackage: String,
    callingUid: Int
): Boolean {
    ...
    val isCallerKnown = when {
       // If the system is making the call, allow it.
       callingUid == Process.SYSTEM_UID -> true
       // If the app was signed by the same certificate as the platform
       // itself, also allow it.
       callerSignature == platformSignature -> true
       // ... more cases
    }
    return isCallerKnown
}

此代码段摘录自 GitHub 上 Universal Android Music Player 示例应用中的 PackageValidator 类。请参阅该类,通过更详细的示例来了解如何为服务的 onGetRoot() 方法实现软件包验证。

除了允许系统应用之外,您还必须允许 Google 助理连接到您的 MediaBrowserService。请注意,在手机(包括 Android Auto)和 Android Automotive OS 上,Google 助理具有单独的软件包名称

实现 onLoadChildren()

收到根节点对象后,Android Auto 和 Android Automotive OS 会对根节点对象调用 onLoadChildren() 来获取其子节点,以构建顶级菜单。客户端应用使用子节点对象调用同一方法来构建子菜单。

内容层次结构中的每个节点都由一个 MediaBrowserCompat.MediaItem 对象表示。其中每一个媒体项都由一个唯一的 ID 字符串标识。客户端应用将这些 ID 字符串视为不透明令牌。当客户端应用想要浏览子菜单或播放媒体项时,它会传递这个令牌。您的应用负责将令牌与相应的媒体项关联。

注意:Android Auto 和 Android Automotive OS 对菜单中各层级可显示的媒体项数量有严格的限制。这些限制可最大限度地减少对驾驶员的干扰,同时帮助他们通过语音指令操作应用。如需了解详情,请参阅浏览内容详情浏览视图

以下代码段展示了 onLoadChildren() 方法的简单实现:

Kotlin

override fun onLoadChildren(
    parentMediaId: String,
    result: Result<List<MediaBrowserCompat.MediaItem>>
) {
    // Assume for example that the music catalog is already loaded/cached.

    val mediaItems: MutableList<MediaBrowserCompat.MediaItem> = mutableListOf()

    // Check if this is the root menu:
    if (MY_MEDIA_ROOT_ID == parentMediaId) {

        // build the MediaItem objects for the top level,
        // and put them in the mediaItems list
    } else {

        // examine the passed parentMediaId to see which submenu we're at,
        // and put the children of that menu in the mediaItems list
    }
    result.sendResult(mediaItems)
}

Java

@Override
public void onLoadChildren(final String parentMediaId,
    final Result<List<MediaBrowserCompat.MediaItem>> result) {

    // Assume for example that the music catalog is already loaded/cached.

    List<MediaBrowserCompat.MediaItem> mediaItems = new ArrayList<>();

    // Check if this is the root menu:
    if (MY_MEDIA_ROOT_ID.equals(parentMediaId)) {

        // build the MediaItem objects for the top level,
        // and put them in the mediaItems list
    } else {

        // examine the passed parentMediaId to see which submenu we're at,
        // and put the children of that menu in the mediaItems list
    }
    result.sendResult(mediaItems);
}

如需查看此方法的完整示例,请参阅 GitHub 上 Universal Android Music Player 示例应用中的 onLoadChildren() 方法。

构造根菜单

图 1. 根内容显示为导航标签页

Android Auto 和 Android Automotive OS 对根菜单的结构有特定的约束条件。这些约束条件通过根提示传达给 MediaBrowserService,而根提示可通过传入 onGetRoot()Bundle 参数来读取。遵循这些提示可让系统以最佳方式将根内容显示为导航标签页(不过,默认情况下,Android Auto 不会这样显示内容。请参阅下面的选择启用期限)。如果您不遵循这些提示,某些根内容可能会被系统丢弃或不太容易被系统发现。会发送两个提示:

  1. 根子级的数量限制:在大多数情况下,此数字应该为 4。这意味着,不能显示超过 4 个标签页。
  2. 根子级的受支持标志:此值应该为 MediaItem#FLAG_BROWSABLE。这意味着,只有可浏览项可以显示为标签页,而可播放项则不能。

使用以下代码来读取相关的根提示:

Kotlin

import androidx.media.utils.MediaConstants

// Later, in your MediaBrowserServiceCompat
override fun onGetRoot(
    clientPackageName: String,
    clientUid: Int,
    rootHints: Bundle
): BrowserRoot {

  val maximumRootChildLimit = rootHints.getInt(
      MediaConstants.BROWSER_ROOT_HINTS_KEY_ROOT_CHILDREN_LIMIT,
      /* defaultValue= */ 4)
  val supportedRootChildFlags = rootHints.getInt(
      MediaConstants.BROWSER_ROOT_HINTS_KEY_ROOT_CHILDREN_SUPPORTED_FLAGS,
      /* defaultValue= */ MediaItem.FLAG_BROWSABLE)

  // rest of method..
}

Java

import androidx.media.utils.MediaConstants;

// Later, in your MediaBrowserServiceCompat
@Override
public BrowserRoot onGetRoot(
    String clientPackageName, int clientUid, Bundle rootHints) {

    int maximumRootChildLimit = rootHints.getInt(
        MediaConstants.BROWSER_ROOT_HINTS_KEY_ROOT_CHILDREN_LIMIT,
        /* defaultValue= */ 4);
    int supportedRootChildFlags = rootHints.getInt(
        MediaConstants.BROWSER_ROOT_HINTS_KEY_ROOT_CHILDREN_SUPPORTED_FLAGS,
        /* defaultValue= */ MediaItem.FLAG_BROWSABLE);

    // rest of method...
}

您可以根据这些提示的值选择为内容层次结构的逻辑创建分支,尤其是当您的层次结构因 Android Auto 和 Android Automotive OS 之外的 MediaBrowser 集成而异时。例如,如果您通常显示一个根可播放项,根据受支持标志提示的值,您可能希望将其嵌套在一个根可浏览项下。

除了根提示之外,还应遵循一些其他准则,以确保标签页以最佳方式呈现:

  1. 为每个标签页项提供单色(最好是白色)图标。
  2. 为每个标签页项提供简短而有意义的标签。使标签保持简洁可降低字符串被截断的可能性。

在 Android Auto 上选择启用标签页

对导航标签页的支持是 Android Auto 的新增功能。以前,Android Auto 将根内容显示为列表。由于标签页会使用户的内容访问行为发生显著变化,并且标签页在默认情况下可能并不是某些现有应用(即,那些具有根可播放项或多个根可浏览项的应用)的最佳选择,因此 Android Auto 目前允许开发者选择启用标签页,直到 2021 年 5 月,届时所有应用都会转换为标签页式界面。在此之前,根内容在默认情况下将继续以列表的形式呈现,但您可以使用 onGetRoot() 方法,在服务的 BrowserRootBundle 中发送 extra,从而为用户启用标签页。

以下代码段展示了如何启用标签页:

Kotlin

val TABS_OPT_IN_HINT = "android.media.browse.AUTO_TABS_OPT_IN_HINT"

override fun onGetRoot(
    @NonNull clientPackageName: String,
    clientUid: Int,
    @Nullable rootHints: Bundle
): BrowserRoot {
    val extras = Bundle()
    extras.putBoolean(TABS_OPT_IN_HINT, true)
    return BrowserRoot(ROOT_ID, extras)
}

Java

static final String TABS_OPT_IN_HINT =
  "android.media.browse.AUTO_TABS_OPT_IN_HINT";

@Override
public BrowserRoot onGetRoot(@NonNull String clientPackageName, int clientUid,
   @Nullable Bundle rootHints) {
   Bundle extras = new Bundle();
   extras.putBoolean(TABS_OPT_IN_HINT, true);
   return new BrowserRoot(ROOT_ID, extras);
}

我们建议开发者让支持 Android Auto 的应用保持不变,先在团队中测试标签页,然后再为用户启用该功能。

显示媒体图片

必须使用 ContentResolver.SCHEME_CONTENTContentResolver.SCHEME_ANDROID_RESOURCE 将媒体项的图片作为本地 URI 进行传递。此本地 URI 必须解析为应用资源中的位图或矢量可绘制对象。对于表示内容层次结构中的项的 MediaDescription 对象,通过 setIconUri() 传递 URI。对于表示当前播放项的 MediaMetadata 对象,使用以下任意键,通过 putString() 传递 URI:

以下示例说明了如何通过网页 URI 下载图片并通过本地 URI 公开图片:

  1. 下载图片文件(以下代码段使用 Glide)。

    Kotlin

    val artFile = Glide.with(context)
      .downloadOnly()
      .load(imageUri)
      .submit()
      .get()
    

    Java

    File artFile = Glide.with(context)
      .downloadOnly()
      .load(imageUri)
      .submit()
      .get();
    
  2. 为该文件构建 content:// URI。媒体浏览器服务和媒体会话应将此 URI 传递给 Android Auto 和 Android Automotive OS。

    Kotlin

    fun File.asAlbumArtContentURI(): Uri {
      return Uri.Builder()
        .scheme(ContentResolver.SCHEME_CONTENT)
        .authority(AUTHORITY)
        .appendPath(this.path)
        .build()
    }
    

    Java

    public static Uri asAlbumArtContentURI(File file) {
      return Uri.Builder()
        .scheme(ContentResolver.SCHEME_CONTENT)
        .authority(AUTHORITY)
        .appendPath(file.getPath())
        .build();
    }
    
  3. 使该文件在 ContentProvider.openFile() 方法中可访问。

    Kotlin

    override fun openFile(uri: Uri, mode: String): ParcelFileDescriptor? {
      val context = this.context ?: return null
      val file = File(uri.path)
      if (!file.exists()) {
        throw FileNotFoundException(uri.path)
      }
      // Only allow access to files under cache path
      val cachePath = context.cacheDir.path
      if (!file.path.startsWith(cachePath)) {
        throw FileNotFoundException()
      }
      return ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_ONLY)
    }
    

    Java

    @Nullable
    @Override
    public ParcelFileDescriptor openFile(@NonNull Uri uri, @NonNull String mode)
        throws FileNotFoundException {
      Context context = this.getContext();
      File file = new File(uri.getPath());
      if (!file.exists()) {
        throw new FileNotFoundException(uri.getPath());
      }
      // Only allow access to files under cache path
      String cachePath = context.getCacheDir().getPath();
      if (!file.getPath().startsWith(cachePath)) {
        throw new FileNotFoundException();
      }
      return ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_ONLY);
    }
    

如需详细了解内容提供程序,请参阅创建内容提供程序

应用内容样式

使用可浏览项或可播放项构建内容层次结构后,您可以应用内容样式来确定这些项目在汽车中的显示方式。

您可以采用以下内容样式:

列表项

此内容样式重点显示标题和元数据,其次是图片。

网格项

在此内容样式下,图片的优先级高于标题和元数据。

设置默认内容样式

您可以通过在服务的 onGetRoot() 方法的 BrowserRoot extra 包中加入特定常量,设置媒体项的全局默认显示方式。Android Auto 和 Android Automotive OS 会读取此包并查找这些常量来确定相应的样式。

以下 extra 可用作包中的键:

这些键可映射到以下整数常量值,以影响相应项的呈现方式:

以下代码段展示了如何将可浏览项的默认内容样式设置为网格,并将可播放项的默认内容样式设置为列表:

Kotlin

import androidx.media.utils.MediaConstants

@Nullable
override fun onGetRoot(
    @NonNull clientPackageName: String,
    clientUid: Int,
    @Nullable rootHints: Bundle
): BrowserRoot {
    val extras = Bundle()
    extras.putInt(
        MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_BROWSABLE,
        MediaConstants.DESCRIPTION_EXTRAS_VALUE_CONTENT_STYLE_GRID_ITEM)
    extras.putInt(
        MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_PLAYABLE,
        MediaConstants.DESCRIPTION_EXTRAS_VALUE_CONTENT_STYLE_LIST_ITEM)
    return BrowserRoot(ROOT_ID, extras)
}

Java

import androidx.media.utils.MediaConstants;

@Nullable
@Override
public BrowserRoot onGetRoot(
    @NonNull String clientPackageName,
    int clientUid,
    @Nullable Bundle rootHints) {
    Bundle extras = new Bundle();
    extras.putInt(
        MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_BROWSABLE,
        MediaConstants.DESCRIPTION_EXTRAS_VALUE_CONTENT_STYLE_GRID_ITEM);
    extras.putInt(
        MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_PLAYABLE,
        MediaConstants.DESCRIPTION_EXTRAS_VALUE_CONTENT_STYLE_LIST_ITEM);
    return new BrowserRoot(ROOT_ID, extras);
}

按项设置内容样式

您可以通过 Content Style API,替换任何可浏览媒体项的子项的默认内容样式。如需替换默认设置,请在媒体项的 MediaDescription 中创建 extra 包,并添加上述的相同提示。

以下代码段展示了如何创建替换默认内容样式的可浏览 MediaItem

Kotlin

import androidx.media.utils.MediaConstants

private fun createBrowsableMediaItem(
    mediaId: String,
    folderName: String,
    iconUri: Uri
): MediaBrowser.MediaItem {
    val mediaDescriptionBuilder = MediaDescription.Builder()
    mediaDescriptionBuilder.setMediaId(mediaId)
    mediaDescriptionBuilder.setTitle(folderName)
    mediaDescriptionBuilder.setIconUri(iconUri)
    val extras = Bundle()
    extras.putInt(
        MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_BROWSABLE,
        MediaConstants.DESCRIPTION_EXTRAS_VALUE_CONTENT_STYLE_LIST_ITEM)
    extras.putInt(
        MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_PLAYABLE,
        MediaConstants.DESCRIPTION_EXTRAS_VALUE_CONTENT_STYLE_GRID_ITEM)
    mediaDescriptionBuilder.setExtras(extras)
    return MediaBrowser.MediaItem(
        mediaDescriptionBuilder.build(), MediaBrowser.MediaItem.FLAG_BROWSABLE)
}

Java

import androidx.media.utils.MediaConstants;

private MediaBrowser.MediaItem createBrowsableMediaItem(
    String mediaId,
    String folderName,
    Uri iconUri) {
    MediaDescription.Builder mediaDescriptionBuilder = new MediaDescription.Builder();
    mediaDescriptionBuilder.setMediaId(mediaId);
    mediaDescriptionBuilder.setTitle(folderName);
    mediaDescriptionBuilder.setIconUri(iconUri);
    Bundle extras = new Bundle();
    extras.putInt(
        MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_BROWSABLE,
        MediaConstants.DESCRIPTION_EXTRAS_VALUE_CONTENT_STYLE_LIST_ITEM);
    extras.putInt(
        MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_PLAYABLE,
        MediaConstants.DESCRIPTION_EXTRAS_VALUE_CONTENT_STYLE_GRID_ITEM);
    mediaDescriptionBuilder.setExtras(extras);
    return new MediaBrowser.MediaItem(
        mediaDescriptionBuilder.build(), MediaBrowser.MediaItem.FLAG_BROWSABLE);
}

使用标题提示对项进行分组

如需将相关媒体项归入一组,请使用按项提示。组中的每个媒体项都需要在其 MediaDescription 中声明一个 extra 包,其中包含与 DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_GROUP_TITLE 键的映射和一个相同的字符串值。此字符串将用作组标题,并且应本地化。

以下代码段展示了如何创建子组标题为 "Songs"MediaItem

Kotlin

import androidx.media.utils.MediaConstants

private fun createMediaItem(
    mediaId: String,
    folderName: String,
    iconUri: Uri
): MediaBrowser.MediaItem {
    val mediaDescriptionBuilder = MediaDescription.Builder()
    mediaDescriptionBuilder.setMediaId(mediaId)
    mediaDescriptionBuilder.setTitle(folderName)
    mediaDescriptionBuilder.setIconUri(iconUri)
    val extras = Bundle()
    extras.putString(
        MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_GROUP_TITLE,
        "Songs")
    mediaDescriptionBuilder.setExtras(extras)
    return MediaBrowser.MediaItem(
        mediaDescriptionBuilder.build(), /* playable or browsable flag*/)
}

Java

import androidx.media.utils.MediaConstants;

private MediaBrowser.MediaItem createMediaItem(String mediaId, String folderName, Uri iconUri) {
   MediaDescription.Builder mediaDescriptionBuilder = new MediaDescription.Builder();
   mediaDescriptionBuilder.setMediaId(mediaId);
   mediaDescriptionBuilder.setTitle(folderName);
   mediaDescriptionBuilder.setIconUri(iconUri);
   Bundle extras = new Bundle();
   extras.putString(
       MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_GROUP_TITLE,
       "Songs");
   mediaDescriptionBuilder.setExtras(extras);
   return new MediaBrowser.MediaItem(
       mediaDescriptionBuilder.build(), /* playable or browsable flag*/);
}

您的应用必须将您想要归入一组的所有媒体项传递为一个连续的块。例如,假设您希望显示“Songs”和“Albums”这两组媒体项(按此顺序),并且您的应用按以下顺序传递了五个媒体项:

  1. 媒体项 A,extras.putString(MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_GROUP_TITLE, "Songs")
  2. 媒体项 B,extras.putString(MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_GROUP_TITLE, "Albums")
  3. 媒体项 C,extras.putString(MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_GROUP_TITLE, "Songs")
  4. 媒体项 D,extras.putString(MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_GROUP_TITLE, "Songs")
  5. 媒体项 E,extras.putString(MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_GROUP_TITLE, "Albums")

由于“Songs”组和“Albums”组中的媒体项未归入连续的块中,因此 Android Auto 和 Android Automotive OS 会将其解读为以下四组:

  • 第 1 组名为“Songs”,包含媒体项 A
  • 第 2 组名为“Albums”,包含媒体项 B
  • 第 3 组名为“Songs”,包含媒体项 C 和 D
  • 第 4 组名为“Albums”,包含媒体项 E

如需按两组显示这些媒体项,您的应用应改为按以下顺序传递这些项:

  1. 媒体项 A,extras.putString(MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_GROUP_TITLE, "Songs")
  2. 媒体项 C,extras.putString(MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_GROUP_TITLE, "Songs")
  3. 媒体项 D,extras.putString(MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_GROUP_TITLE, "Songs")
  4. 媒体项 B,extras.putString(MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_GROUP_TITLE, "Albums")
  5. 媒体项 E,extras.putString(MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_GROUP_TITLE, "Albums")

显示其他元数据指示符

图 2. 一个播放视图,包含用于识别歌曲和艺人的元数据,以及用于指示露骨内容的图标

您可以添加其他元数据指示符,以便在媒体浏览树和播放过程中提供一目了然的内容信息。在浏览树内,Android Auto 和 Android Automotive OS 会读取与项关联的 extra,并查找特定常量来确定要显示的指示符。在媒体播放期间,Android Auto 和 Android Automotive OS 会读取媒体会话的元数据,并查找特定常量来确定要显示的指示符。

以下常量可同时用于 MediaItem 说明 extra 和 MediaMetadata extra:

以下常量只能在 MediaItem 说明 extra 中使用:

为了显示在用户浏览媒体浏览树时出现的指示符,可创建一个 extra 包,在其中加入一个或多个常量,然后将该包传递给 MediaDescription.Builder.setExtras() 方法。

以下代码段展示了如何为部分播放的露骨内容媒体项显示指示符:

Kotlin

import androidx.media.utils.MediaConstants

val extras = Bundle()
extras.putLong(
    MediaConstants.METADATA_KEY_IS_EXPLICIT,
    MediaConstants.METADATA_VALUE_ATTRIBUTE_PRESENT)
extras.putInt(
    MediaConstants.DESCRIPTION_EXTRAS_KEY_COMPLETION_STATUS,
    MediaConstants.DESCRIPTION_EXTRAS_VALUE_COMPLETION_STATUS_PARTIALLY_PLAYED)
val description =
    MediaDescriptionCompat.Builder()
        .setMediaId(/*...*/)
        .setTitle(resources.getString(/*...*/))
        .setExtras(extras)
        .build()
return MediaBrowserCompat.MediaItem(description, /* flags */)

Java

import androidx.media.utils.MediaConstants;

Bundle extras = new Bundle();
extras.putLong(
    MediaConstants.METADATA_KEY_IS_EXPLICIT,
    MediaConstants.METADATA_VALUE_ATTRIBUTE_PRESENT);
extras.putInt(
    MediaConstants.DESCRIPTION_EXTRAS_KEY_COMPLETION_STATUS,
    MediaConstants.DESCRIPTION_EXTRAS_VALUE_COMPLETION_STATUS_PARTIALLY_PLAYED);
MediaDescriptionCompat description =
    new MediaDescriptionCompat.Builder()
        .setMediaId(/*...*/)
        .setTitle(resources.getString(/*...*/))
        .setExtras(extras)
        .build();
return new MediaBrowserCompat.MediaItem(description, /* flags */);

如需为当前播放的媒体项显示指示符,您可以在 mediaSessionMediaMetadata.Builder() 方法中声明 METADATA_KEY_IS_EXPLICITEXTRA_DOWNLOAD_STATUSLong 值。您不能在播放视图上显示 DESCRIPTION_EXTRAS_KEY_COMPLETION_STATUS 指示符。

以下代码段展示了如何表明播放视图中的当前歌曲为露骨内容并已下载:

Kotlin

import androidx.media.utils.MediaConstants

mediaSession.setMetadata(
    MediaMetadata.Builder()
        .putString(
            MediaMetadata.METADATA_KEY_DISPLAY_TITLE, "Song Name")
        .putString(
            MediaMetadata.METADATA_KEY_DISPLAY_SUBTITLE, "Artist name")
        .putString(
            MediaMetadata.METADATA_KEY_ALBUM_ART_URI, albumArtUri.toString())
        .putLong(
            MediaConstants.METADATA_KEY_IS_EXPLICIT,
            MediaConstants.METADATA_VALUE_ATTRIBUTE_PRESENT)
        .putLong(
            MediaDescriptionCompat.EXTRA_DOWNLOAD_STATUS,
            MediaDescriptionCompat.STATUS_DOWNLOADED)
        .build())

Java

import androidx.media.utils.MediaConstants;

mediaSession.setMetadata(
    new MediaMetadata.Builder()
        .putString(
            MediaMetadata.METADATA_KEY_DISPLAY_TITLE, "Song Name")
        .putString(
            MediaMetadata.METADATA_KEY_DISPLAY_SUBTITLE, "Artist name")
        .putString(
            MediaMetadata.METADATA_KEY_ALBUM_ART_URI, albumArtUri.toString())
        .putLong(
            MediaConstants.METADATA_KEY_IS_EXPLICIT,
            MediaConstants.METADATA_VALUE_ATTRIBUTE_PRESENT)
        .putLong(
            MediaDescriptionCompat.EXTRA_DOWNLOAD_STATUS,
            MediaDescriptionCompat.STATUS_DOWNLOADED)
        .build());

图 3. 一个播放视图,包含“搜索结果”选项,用于查看与用户的语音搜索相关的媒体项

您的应用可以提供在用户发起搜索查询时向其显示的上下文搜索结果。Android Auto 和 Android Automotive OS 将通过搜索查询接口或通过基于会话中之前进行的查询的功能显示这些结果。

如需显示可浏览的搜索结果,您应在服务的 onGetRoot() 方法的 extra 包中添加常量键 BROWSER_SERVICE_EXTRAS_KEY_SEARCH_SUPPORTED,并映射到布尔值 true

以下代码段展示了如何在 onGetRoot() 方法中启用支持:

Kotlin

import androidx.media.utils.MediaConstants

@Nullable
fun onGetRoot(
    @NonNull clientPackageName: String,
    clientUid: Int,
    @Nullable rootHints: Bundle
): BrowserRoot {
    val extras = Bundle()
    extras.putBoolean(
        MediaConstants.BROWSER_SERVICE_EXTRAS_KEY_SEARCH_SUPPORTED, true)
    return BrowserRoot(ROOT_ID, extras)
}

Java

import androidx.media.utils.MediaConstants;

@Nullable
@Override
public BrowserRoot onGetRoot(
    @NonNull String clientPackageName,
    int clientUid,
    @Nullable Bundle rootHints) {
    Bundle extras = new Bundle();
    extras.putBoolean(
        MediaConstants.BROWSER_SERVICE_EXTRAS_KEY_SEARCH_SUPPORTED, true);
    return new BrowserRoot(ROOT_ID, extras);
}

如需开始提供搜索结果,请在您的媒体浏览器服务中替换 onSearch() 方法。每当用户调用搜索查询接口或“搜索结果”功能时,Android Auto 和 Android Automotive OS 都会将用户的搜索字词转发给此方法。您可以使用标题项整理服务的 onSearch() 方法返回的搜索结果,使结果更易于浏览。例如,如果您的应用是用来播放音乐的,您可以按“Album”、“Artist”和“Songs”整理搜索结果。

以下代码段展示了 onSearch() 方法的简单实现:

Kotlin

fun onSearch(query: String, extras: Bundle) {
  // Detach from results to unblock the caller (if a search is expensive)
  result.detach()
  object:AsyncTask() {
    internal var searchResponse:ArrayList
    internal var succeeded = false
    protected fun doInBackground(vararg params:Void):Void {
      searchResponse = ArrayList()
      if (doSearch(query, extras, searchResponse))
      {
        succeeded = true
      }
      return null
    }
    protected fun onPostExecute(param:Void) {
      if (succeeded)
      {
        // Sending an empty List informs the caller that there were no results.
        result.sendResult(searchResponse)
      }
      else
      {
        // This invokes onError() on the search callback
        result.sendResult(null)
      }
      return null
    }
  }.execute()
}
// Populates resultsToFill with search results. Returns true on success or false on error
private fun doSearch(
    query: String,
    extras: Bundle,
    resultsToFill: ArrayList
): Boolean {
  // Implement this method
}

Java

@Override
public void onSearch(final String query, final Bundle extras,
                        Result<List<MediaItem>> result) {

  // Detach from results to unblock the caller (if a search is expensive)
  result.detach();

  new AsyncTask<Void, Void, Void>() {
    List<MediaItem> searchResponse;
    boolean succeeded = false;
    @Override
    protected Void doInBackground(Void... params) {
      searchResponse = new ArrayList<MediaItem>();
      if (doSearch(query, extras, searchResponse)) {
        succeeded = true;
      }
      return null;
    }

    @Override
    protected void onPostExecute(Void param) {
      if (succeeded) {
       // Sending an empty List informs the caller that there were no results.
       result.sendResult(searchResponse);
      } else {
        // This invokes onError() on the search callback
        result.sendResult(null);
      }
    }
  }.execute()
}

/** Populates resultsToFill with search results. Returns true on success or false on error */
private boolean doSearch(String query, Bundle extras, ArrayList<MediaItem> resultsToFill) {
    // Implement this method
}

启用播放控制

Android Auto 和 Android Automotive OS 通过服务的 MediaSessionCompat 发送播放控制命令。您必须注册会话并实现关联的回调方法。

注册媒体会话

在媒体浏览器服务的 onCreate() 方法中,创建 MediaSessionCompat,然后通过调用 setSessionToken() 注册媒体会话。

以下代码段展示了如何创建和注册媒体会话:

Kotlin

override fun onCreate() {
    super.onCreate()

    ...
    // Start a new MediaSession
    val session = MediaSessionCompat(this, "session tag").apply {
        // Set a callback object to handle play control requests, which
        // implements MediaSession.Callback
        setCallback(MyMediaSessionCallback())
    }
    sessionToken = session.sessionToken

    ...
}

Java

public void onCreate() {
    super.onCreate();

    ...
    // Start a new MediaSession
    MediaSessionCompat session = new MediaSessionCompat(this, "session tag");
    setSessionToken(session.getSessionToken());

    // Set a callback object to handle play control requests, which
    // implements MediaSession.Callback
    session.setCallback(new MyMediaSessionCallback());

    ...
}

创建媒体会话对象时,您可以设置用于处理播放控制请求的回调对象。您可以为您的应用提供 MediaSessionCompat.Callback 类的实现来创建此回调对象。下一部分将介绍如何实现此对象。

实现播放命令

当用户从您的应用请求播放媒体项时,Android Automotive OS 和 Android Auto 会使用从应用的媒体浏览器服务获取的 MediaSessionCompat 对象的 MediaSessionCompat.Callback 类。当用户想要控制内容播放时,例如暂停播放或跳至下一曲目,Android Auto 和 Android Automotive OS 会调用其中一个回调对象的方法。

为了处理内容播放,您的应用必须扩展抽象 MediaSessionCompat.Callback 类,并实现应用支持的方法。

您应该实现以下所有回调方法,这些方法适用于应用提供的内容类型:

onPrepare()
在媒体来源改变时调用。Android Automotive OS 也会在启动后立即调用此方法。您的媒体应用必须实现此方法。
onPlay()
如果用户在未选择特定项的情况下选择播放,则调用此方法。您的应用应播放其默认内容。如果之前通过 onPause() 暂停了播放,您的应用应继续播放。

注意:当 Android Automotive OS 或 Android Auto 连接到您的媒体浏览器服务时,您的应用不应自动开始播放音乐。如需了解详情,请参阅设置初始播放状态

onPlayFromMediaId()
在用户选择播放特定项时调用。系统会将您的媒体浏览器服务为内容层次结构中的媒体项分配的 ID 传递给这个方法。
onPlayFromSearch()
在用户选择从搜索查询中播放时调用。应用应根据传入的搜索字符串做出适当的选择。
onPause()
在用户选择暂停播放时调用。
onSkipToNext()
在用户选择跳至下一项时调用。
onSkipToPrevious()
在用户选择跳至上一项时调用。
onStop()
在用户选择停止播放时调用。

您的应用应替换这些方法,以提供任何所需功能。如果应用不支持某一方法,则无需实现该方法。例如,如果您的应用用来直播节目(例如体育广播),则实现 onSkipToNext() 方法毫无意义,但您可以使用 onSkipToNext() 的默认实现。

您的应用无需任何特殊逻辑即可通过汽车扬声器播放内容。当应用收到播放内容的请求时,它应该像平常一样播放音频(例如,通过用户的手机扬声器或耳机播放内容)。Android Auto 和 Android Automotive OS 自动将音频内容发送到车载系统,以通过汽车扬声器播放。

如需详细了解播放音频内容,请参阅媒体播放管理音频播放ExoPlayer

设置标准播放操作

Android Auto 和 Android Automotive OS 根据 PlaybackStateCompat 对象中启用的操作显示播放控件。

默认情况下,您的应用必须支持以下操作:

您的应用可以另外支持以下操作(如果它们与应用的内容相关):

此外,您还可以创建可向用户显示的播放队列。为此,您需要调用 setQueue()setQueueTitle() 方法,启用 ACTION_SKIP_TO_QUEUE_ITEM 操作,并定义回调 onSkipToQueueItem()

Android Auto 和 Android Automotive OS 显示每个已启用操作的按钮;如果您选择创建播放队列,则也会显示播放队列。

保留未使用的空间

Android Auto 和 Android Automotive OS 会在界面中为 ACTION_SKIP_TO_PREVIOUSACTION_SKIP_TO_NEXT 操作预留空间。如果您的应用不支持其中某一功能,Android Auto 和 Android Automotive OS 会使用对应的空间显示您创建的任何自定义操作。

如果您不希望让自定义操作占据这些空间,则可以保留这些空间。这样,在您的应用不支持相应功能时,Android Auto 和 Android Automotive OS 会使对应空间留空。为此,请创建 extra 包以包含与保留功能对应的常量,然后使用这个 extra 包来调用 setExtras() 方法。SESSION_EXTRAS_KEY_SLOT_RESERVATION_SKIP_TO_NEXT 对应于 ACTION_SKIP_TO_NEXTSESSION_EXTRAS_KEY_SLOT_RESERVATION_SKIP_TO_PREV 对应于 ACTION_SKIP_TO_PREVIOUS。使用这些常量作为包中的键,并使用布尔值 true 作为它们的值。

设置初始播放状态

当 Android Auto 和 Android Automotive OS 与您的媒体浏览器服务通信时,媒体会话使用 PlaybackState 传达内容播放的状态。当 Android Automotive OS 或 Android Auto 连接到您的媒体浏览器服务时,您的应用不应自动开始播放音乐,而是依靠 Android Auto 和 Android Automotive OS 根据汽车的状态或用户操作恢复或开始播放。

为此,请将媒体会话的初始 PlaybackState 设置为 STATE_STOPPEDSTATE_PAUSEDSTATE_NONESTATE_ERROR

Android Auto 和 Android Automotive OS 中的媒体会话只在驾车期间持续有效,因此用户会频繁地开始和停止这些会话。为了提升前后两次驾车之间的无缝体验,请跟踪用户之前的会话状态(例如,上次播放的媒体项、PlaybackState 和队列),这样一来,当媒体应用收到恢复请求时,用户就可以自动从上次停下的地方继续播放。

添加自定义播放操作

您可以添加自定义播放操作来显示媒体应用支持的其他操作。如果空间允许(并且未被保留),Android 会将自定义操作添加到传输控件中。否则,自定义操作会显示在溢出菜单中。自定义操作会按添加到 PlaybackState 中的顺序进行显示。

自定义操作应提供与标准操作不同的行为,不应用于替换或重复标准操作。

您可以使用 PlaybackStateCompat.Builder 类中的 addCustomAction() 方法添加这些操作。

以下代码段展示了如何添加自定义的“启动电台频道”操作:

Kotlin

stateBuilder.addCustomAction(
    PlaybackStateCompat.CustomAction.Builder(
        CUSTOM_ACTION_START_RADIO_FROM_MEDIA,
        resources.getString(R.string.start_radio_from_media),
        startRadioFromMediaIcon
    ).run {
        setExtras(customActionExtras)
        build()
    }
)

Java

stateBuilder.addCustomAction(
    new PlaybackStateCompat.CustomAction.Builder(
        CUSTOM_ACTION_START_RADIO_FROM_MEDIA,
        resources.getString(R.string.start_radio_from_media),
        startRadioFromMediaIcon)
    .setExtras(customActionExtras)
    .build());

如需查看此方法的更为详细的示例,请参阅 GitHub 上 Universal Android Music Player 示例应用中的 setCustomAction() 方法。

创建自定义操作后,媒体会话可以通过替换 onCustomAction() 方法来响应该操作。

以下代码段展示了您的应用如何响应“启动电台频道”操作:

Kotlin

override fun onCustomAction(action: String, extras: Bundle?) {
    when(action) {
        CUSTOM_ACTION_START_RADIO_FROM_MEDIA -> {
            ...
        }
    }
}

Java

@Override
public void onCustomAction(@NonNull String action, Bundle extras) {
    if (CUSTOM_ACTION_START_RADIO_FROM_MEDIA.equals(action)) {
        ...
    }
}

如需查看此方法的更为详细的示例,请参阅 GitHub 上 Universal Android Music Player 示例应用中的 onCustomAction 方法。

自定义操作的图标

您创建的每个自定义操作都需要使用图标资源。汽车中的应用可能会以许多不同的屏幕尺寸和密度来运行,因此您提供的图标必须为矢量可绘制对象。矢量可绘制对象允许缩放资源,同时又不会丢失细节。矢量可绘制对象还能以较小的分辨率轻松地将边和角对准像素边界。

如果某项自定义操作是有状态的(例如,可打开或关闭播放设置),请为不同的状态提供不同的图标,这样一来,当用户选择该操作时,他们可以从视觉上察觉到变化。

为已停用的操作提供替代图标样式

如果自定义操作不适用于当前上下文,可将自定义操作图标替换为表明操作已被停用的替代图标。

图 4. 样式外自定义操作图标示例。

支持语音操作

您的媒体应用必须支持语音操作,以便为驾驶员提供安全、便利的体验,最大限度地减少干扰。例如,如果您的应用已在播放某一媒体项,则用户可以通过说“播放《波西米亚狂想曲》”让应用播放另一个项,而无需用户查看或轻触汽车的显示屏。

如需通过更详细的示例了解如何在应用中实现语音控制播放操作,请参阅 Google 助理和媒体应用

实现注意力分散预防措施

由于使用 Android Auto 时用户的手机会连接到汽车的扬声器,因此您必须采取额外的预防措施,防止驾驶员分散注意力。

检测车载模式

除非是用户有意识地开始播放(例如,在应用中按播放),否则 Android Auto 媒体应用不得通过汽车扬声器开始播放音频。即使用户在您的媒体应用中设定了闹钟,也不得通过汽车扬声器开始播放音乐。为了满足此要求,应用应在播放任何音频之前确定手机是否处于车载模式。应用可通过调用 getCurrentModeType() 方法检查手机是否处于车载模式。

如果用户的手机处于车载模式,支持闹钟的媒体应用必须执行以下任一操作:

  • 停用闹钟。
  • 通过 STREAM_ALARM 播放闹铃,并在手机屏幕上提供用于停用闹钟的界面。

以下代码段展示了如何检查应用是否在车载模式下运行:

Kotlin

fun isCarUiMode(c: Context): Boolean {
    val uiModeManager = c.getSystemService(Context.UI_MODE_SERVICE) as UiModeManager
    return if (uiModeManager.currentModeType == Configuration.UI_MODE_TYPE_CAR) {
        LogHelper.d(TAG, "Running in Car mode")
        true
    } else {
        LogHelper.d(TAG, "Running in a non-Car mode")
        false
    }
}

Java

 public static boolean isCarUiMode(Context c) {
      UiModeManager uiModeManager = (UiModeManager) c.getSystemService(Context.UI_MODE_SERVICE);
      if (uiModeManager.getCurrentModeType() == Configuration.UI_MODE_TYPE_CAR) {
            LogHelper.d(TAG, "Running in Car mode");
            return true;
      } else {
          LogHelper.d(TAG, "Running in a non-Car mode");
          return false;
        }
  }

处理媒体广告

默认情况下,当媒体元数据在音频播放会话期间发生更改时,Android Auto 会显示通知。当媒体应用从播放音乐切换到播放广告时,向用户显示通知会分散注意力(而且没有这个必要)。在这种情况下,为阻止 Android Auto 显示通知,您必须将媒体元数据键 METADATA_KEY_IS_ADVERTISEMENT 设置为 METADATA_VALUE_ATTRIBUTE_PRESENT,如以下代码段所示:

Kotlin

import androidx.media.utils.MediaConstants

override fun onPlayFromMediaId(mediaId: String, extras: Bundle?) {
    MediaMetadataCompat.Builder().apply {
        if (isAd(mediaId)) {
            putLong(
                MediaConstants.METADATA_KEY_IS_ADVERTISEMENT,
                MediaConstants.METADATA_VALUE_ATTRIBUTE_PRESENT)
        }
        // ...add any other properties as you normally would
        mediaSession.setMetadata(build())
    }
}

Java

import androidx.media.utils.MediaConstants;

@Override
public void onPlayFromMediaId(String mediaId, Bundle extras) {
    MediaMetadataCompat.Builder builder = new MediaMetadataCompat.Builder();
    if (isAd(mediaId)) {
        builder.putLong(
            MediaConstants.METADATA_KEY_IS_ADVERTISEMENT,
            MediaConstants.METADATA_VALUE_ATTRIBUTE_PRESENT);
    }
    // ...add any other properties as you normally would
    mediaSession.setMetadata(builder.build());
}

处理常规错误

当应用遇到错误时,您应将播放状态设置为 STATE_ERROR,并使用 setErrorMessage() 方法提供错误消息。错误消息必须面向用户,因此必须根据用户当前的语言区域进行本地化。然后,Android Auto 和 Android Automotive OS 就可以向用户显示错误消息了。

如需详细了解错误状态,请参见使用媒体会话:状态和错误

如果 Android Auto 用户需要打开您的手机应用以解决错误,您的消息应该向用户提供该信息。例如,您的错误消息应显示“登录 [应用名称]”,而不是“请登录”。

其他资源