构建媒体浏览器服务

您的应用必须在清单中使用意图过滤器声明 MediaBrowserService。您可以选择自己的服务名称;在以下示例中,它是“MediaPlaybackService”。

<service android:name=".MediaPlaybackService">
  <intent-filter>
    <action android:name="android.media.browse.MediaBrowserService" />
  </intent-filter>
</service>

注意: 建议实现 MediaBrowserService 的方式是使用 MediaBrowserServiceCompat,它定义在 media-compat 支持库 中。在本页面中,“MediaBrowserService” 指的是 MediaBrowserServiceCompat 的一个实例。

初始化媒体会话

当服务接收到 onCreate() 生命周期回调方法时,它应该执行以下步骤

下面的 onCreate() 代码演示了这些步骤

Kotlin

private const val MY_MEDIA_ROOT_ID = "media_root_id"
private const val MY_EMPTY_MEDIA_ROOT_ID = "empty_root_id"

class MediaPlaybackService : MediaBrowserServiceCompat() {

    private var mediaSession: MediaSessionCompat? = null
    private lateinit var stateBuilder: PlaybackStateCompat.Builder

    override fun onCreate() {
        super.onCreate()

        // Create a MediaSessionCompat
        mediaSession = MediaSessionCompat(baseContext, LOG_TAG).apply {

            // Enable callbacks from MediaButtons and TransportControls
            setFlags(MediaSessionCompat.FLAG_HANDLES_MEDIA_BUTTONS
                    or MediaSessionCompat.FLAG_HANDLES_TRANSPORT_CONTROLS
            )

            // Set an initial PlaybackState with ACTION_PLAY, so media buttons can start the player
            stateBuilder = PlaybackStateCompat.Builder()
                    .setActions(PlaybackStateCompat.ACTION_PLAY
                                    or PlaybackStateCompat.ACTION_PLAY_PAUSE
                    )
            setPlaybackState(stateBuilder.build())

            // MySessionCallback() has methods that handle callbacks from a media controller
            setCallback(MySessionCallback())

            // Set the session's token so that client activities can communicate with it.
            setSessionToken(sessionToken)
        }
    }
}

Java

public class MediaPlaybackService extends MediaBrowserServiceCompat {
    private static final String MY_MEDIA_ROOT_ID = "media_root_id";
    private static final String MY_EMPTY_MEDIA_ROOT_ID = "empty_root_id";

    private MediaSessionCompat mediaSession;
    private PlaybackStateCompat.Builder stateBuilder;

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

        // Create a MediaSessionCompat
        mediaSession = new MediaSessionCompat(context, LOG_TAG);

        // Enable callbacks from MediaButtons and TransportControls
        mediaSession.setFlags(
              MediaSessionCompat.FLAG_HANDLES_MEDIA_BUTTONS |
              MediaSessionCompat.FLAG_HANDLES_TRANSPORT_CONTROLS);

        // Set an initial PlaybackState with ACTION_PLAY, so media buttons can start the player
        stateBuilder = new PlaybackStateCompat.Builder()
                            .setActions(
                                PlaybackStateCompat.ACTION_PLAY |
                                PlaybackStateCompat.ACTION_PLAY_PAUSE);
        mediaSession.setPlaybackState(stateBuilder.build());

        // MySessionCallback() has methods that handle callbacks from a media controller
        mediaSession.setCallback(new MySessionCallback());

        // Set the session's token so that client activities can communicate with it.
        setSessionToken(mediaSession.getSessionToken());
    }
}

管理客户端连接

MediaBrowserService 具有两种处理客户端连接的方法:onGetRoot() 控制对服务的访问,而 onLoadChildren() 提供了一种能力,允许客户端构建和显示 MediaBrowserService 内容层次结构的菜单。

使用 onGetRoot() 控制客户端连接

onGetRoot() 方法返回内容层次结构的根节点。如果该方法返回 null,则拒绝连接。

要允许客户端连接到您的服务并浏览其媒体内容,onGetRoot() 必须返回一个非 null 的 BrowserRoot,它是一个表示您的内容层次结构的根 ID。

要允许客户端连接到您的 MediaSession 而无需浏览,onGetRoot() 仍然必须返回一个非 null 的 BrowserRoot,但根 ID 应表示一个空的内容层次结构。

onGetRoot() 的典型实现可能如下所示

Kotlin

override fun onGetRoot(
        clientPackageName: String,
        clientUid: Int,
        rootHints: Bundle?
): MediaBrowserServiceCompat.BrowserRoot {

    // (Optional) Control the level of access for the specified package name.
    // You'll need to write your own logic to do this.
    return if (allowBrowsing(clientPackageName, clientUid)) {
        // Returns a root ID that clients can use with onLoadChildren() to retrieve
        // the content hierarchy.
        MediaBrowserServiceCompat.BrowserRoot(MY_MEDIA_ROOT_ID, null)
    } else {
        // Clients can connect, but this BrowserRoot is an empty hierarchy
        // so onLoadChildren returns nothing. This disables the ability to browse for content.
        MediaBrowserServiceCompat.BrowserRoot(MY_EMPTY_MEDIA_ROOT_ID, null)
    }
}

Java

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

    // (Optional) Control the level of access for the specified package name.
    // You'll need to write your own logic to do this.
    if (allowBrowsing(clientPackageName, clientUid)) {
        // Returns a root ID that clients can use with onLoadChildren() to retrieve
        // the content hierarchy.
        return new BrowserRoot(MY_MEDIA_ROOT_ID, null);
    } else {
        // Clients can connect, but this BrowserRoot is an empty hierarchy
        // so onLoadChildren returns nothing. This disables the ability to browse for content.
        return new BrowserRoot(MY_EMPTY_MEDIA_ROOT_ID, null);
    }
}

在某些情况下,您可能希望控制谁可以连接到您的 MediaBrowserService。一种方法是使用访问控制列表 (ACL),该列表指定允许哪些连接,或者枚举哪些连接应被禁止。有关如何实现允许特定连接的 ACL 的示例,请参阅 PackageValidator 类,该类位于 通用 Android 音乐播放器 示例应用中。

您应该考虑根据发出查询的客户端类型提供不同的内容层次结构。特别是,Android Auto 限制了用户与音频应用交互的方式。有关更多信息,请参阅 为 Auto 播放音频。您可以在连接时查看 clientPackageName 以确定客户端类型,并根据客户端(或 rootHints,如果有)返回不同的 BrowserRoot

使用 onLoadChildren() 传递内容

客户端连接后,它可以通过重复调用 MediaBrowserCompat.subscribe() 来遍历内容层次结构,以构建 UI 的本地表示。 subscribe() 方法将回调 onLoadChildren() 发送到服务,该服务返回一个 MediaBrowser.MediaItem 对象列表。

每个 MediaItem 都有一个唯一的 ID 字符串,它是一个不透明的令牌。当客户端想要打开子菜单或播放项目时,它会传递 ID。您的服务负责将 ID 与相应的菜单节点或内容项目关联。

onLoadChildren() 的简单实现可能如下所示

Kotlin

override fun onLoadChildren(
        parentMediaId: String,
        result: MediaBrowserServiceCompat.Result<List<MediaBrowserCompat.MediaItem>>
) {
    //  Browsing not allowed
    if (MY_EMPTY_MEDIA_ROOT_ID == parentMediaId) {
        result.sendResult(null)
        return
    }

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

    val mediaItems = emptyList<MediaBrowserCompat.MediaItem>()

    // 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<MediaItem>> result) {

    //  Browsing not allowed
    if (TextUtils.equals(MY_EMPTY_MEDIA_ROOT_ID, parentMediaId)) {
        result.sendResult(null);
        return;
    }

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

    List<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);
}

注意: MediaItem 对象由 MediaBrowserService 提供,不应包含图标位图。而是通过在构建每个项目的 MediaDescription 时调用 setIconUri() 来使用 Uri

有关如何实现 onLoadChildren() 的示例,请参阅 通用 Android 音乐播放器 示例应用。

媒体浏览器服务的生命周期

Android 服务 的行为取决于它是启动还是绑定到一个或多个客户端。服务创建后,可以启动、绑定或同时启动和绑定。在所有这些状态下,它都完全可以运行,并且可以执行其设计要执行的工作。不同之处在于服务将存在多长时间。绑定服务在所有绑定客户端解除绑定之前都不会被销毁。启动的服务可以被显式停止和销毁(假设它不再绑定到任何客户端)。

当在另一个活动中运行的 MediaBrowser 连接到 MediaBrowserService 时,它会将活动绑定到服务,使服务处于绑定状态(但未启动)。此默认行为内置于 MediaBrowserServiceCompat 类中。

仅绑定(未启动)的服务在其所有客户端解除绑定时将被销毁。如果您的 UI 活动此时断开连接,则服务将被销毁。如果您尚未播放任何音乐,则这不是问题。但是,当播放开始时,用户可能希望即使在切换应用后也能继续收听。当您解除 UI 绑定以使用另一个应用时,您不希望销毁播放器。

因此,您需要确保服务在开始播放时已启动,方法是调用 startService()。启动的服务必须被显式停止,无论它是否已绑定。这可确保即使控制 UI 活动解除绑定,您的播放器也能继续执行。

要停止启动的服务,请调用 Context.stopService()stopSelf()。系统会尽快停止并销毁服务。但是,如果一个或多个客户端仍绑定到服务,则停止服务的调用将延迟到所有客户端解除绑定。

MediaBrowserService 的生命周期由其创建方式、绑定到它的客户端数量以及它从媒体会话回调中接收到的调用来控制。概括地说

  • 当服务响应媒体按钮启动或活动绑定到它(通过其 MediaBrowser 连接后)时,它会被创建。
  • 媒体会话 onPlay() 回调应包含调用 startService() 的代码。这可确保服务启动并继续运行,即使所有绑定到它的 UI MediaBrowser 活动都解除绑定。
  • onStop() 回调应调用 stopSelf()。如果服务已启动,则会停止它。此外,如果没有任何活动绑定到它,则服务将被销毁。否则,服务将保持绑定状态,直到所有活动解除绑定。(如果在服务被销毁之前接收到后续的 startService() 调用,则挂起的停止操作将被取消。)

下图演示了如何管理服务的生命周期。变量计数器跟踪绑定客户端的数量

Service Lifecycle

在前景服务中使用 MediaStyle 通知

当服务正在播放时,它应该在前景中运行。这使系统知道服务正在执行有用的功能,并且如果系统内存不足,则不应将其杀死。前景服务必须显示通知,以便用户了解它并可以选择控制它。onPlay() 回调应将服务置于前景。(请注意,这是“前景”的特殊含义。虽然 Android 为了进程管理的目的将服务视为前景,但对于用户而言,播放器在后台播放,而某些其他应用在屏幕上的“前景”中可见。)

当服务在前景中运行时,它必须显示一个 通知,理想情况下包含一个或多个传输控件。通知还应包含会话元数据中的有用信息。

在播放器开始播放时构建并显示通知。执行此操作的最佳位置是在 MediaSessionCompat.Callback.onPlay() 方法内部。

以下示例使用 NotificationCompat.MediaStyle,该样式专为媒体应用而设计。它演示了如何构建一个显示元数据和传输控件的通知。便捷方法 getController() 允许您直接从媒体会话创建媒体控制器。

Kotlin

// Given a media session and its context (usually the component containing the session)
// Create a NotificationCompat.Builder

// Get the session's metadata
val controller = mediaSession.controller
val mediaMetadata = controller.metadata
val description = mediaMetadata.description

val builder = NotificationCompat.Builder(context, channelId).apply {
    // Add the metadata for the currently playing track
    setContentTitle(description.title)
    setContentText(description.subtitle)
    setSubText(description.description)
    setLargeIcon(description.iconBitmap)

    // Enable launching the player by clicking the notification
    setContentIntent(controller.sessionActivity)

    // Stop the service when the notification is swiped away
    setDeleteIntent(
            MediaButtonReceiver.buildMediaButtonPendingIntent(
                    context,
                    PlaybackStateCompat.ACTION_STOP
            )
    )

    // Make the transport controls visible on the lockscreen
    setVisibility(NotificationCompat.VISIBILITY_PUBLIC)

    // Add an app icon and set its accent color
    // Be careful about the color
    setSmallIcon(R.drawable.notification_icon)
    color = ContextCompat.getColor(context, R.color.primaryDark)

    // Add a pause button
    addAction(
            NotificationCompat.Action(
                    R.drawable.pause,
                    getString(R.string.pause),
                    MediaButtonReceiver.buildMediaButtonPendingIntent(
                            context,
                            PlaybackStateCompat.ACTION_PLAY_PAUSE
                    )
            )
    )

    // Take advantage of MediaStyle features
    setStyle(android.support.v4.media.app.NotificationCompat.MediaStyle()
            .setMediaSession(mediaSession.sessionToken)
            .setShowActionsInCompactView(0)

            // Add a cancel button
            .setShowCancelButton(true)
            .setCancelButtonIntent(
                    MediaButtonReceiver.buildMediaButtonPendingIntent(
                            context,
                            PlaybackStateCompat.ACTION_STOP
                    )
            )
    )
}

// Display the notification and place the service in the foreground
startForeground(id, builder.build())

Java

// Given a media session and its context (usually the component containing the session)
// Create a NotificationCompat.Builder

// Get the session's metadata
MediaControllerCompat controller = mediaSession.getController();
MediaMetadataCompat mediaMetadata = controller.getMetadata();
MediaDescriptionCompat description = mediaMetadata.getDescription();

NotificationCompat.Builder builder = new NotificationCompat.Builder(context, channelId);

builder
    // Add the metadata for the currently playing track
    .setContentTitle(description.getTitle())
    .setContentText(description.getSubtitle())
    .setSubText(description.getDescription())
    .setLargeIcon(description.getIconBitmap())

    // Enable launching the player by clicking the notification
    .setContentIntent(controller.getSessionActivity())

    // Stop the service when the notification is swiped away
    .setDeleteIntent(MediaButtonReceiver.buildMediaButtonPendingIntent(context,
       PlaybackStateCompat.ACTION_STOP))

    // Make the transport controls visible on the lockscreen
    .setVisibility(NotificationCompat.VISIBILITY_PUBLIC)

    // Add an app icon and set its accent color
    // Be careful about the color
    .setSmallIcon(R.drawable.notification_icon)
    .setColor(ContextCompat.getColor(context, R.color.primaryDark))

    // Add a pause button
    .addAction(new NotificationCompat.Action(
        R.drawable.pause, getString(R.string.pause),
        MediaButtonReceiver.buildMediaButtonPendingIntent(context,
            PlaybackStateCompat.ACTION_PLAY_PAUSE)))

    // Take advantage of MediaStyle features
    .setStyle(new MediaStyle()
        .setMediaSession(mediaSession.getSessionToken())
        .setShowActionsInCompactView(0)

        // Add a cancel button
       .setShowCancelButton(true)
       .setCancelButtonIntent(MediaButtonReceiver.buildMediaButtonPendingIntent(context,
           PlaybackStateCompat.ACTION_STOP)));

// Display the notification and place the service in the foreground
startForeground(id, builder.build());

在使用 MediaStyle 通知时,请注意以下 NotificationCompat 设置的行为

  • 当您使用 setContentIntent() 时,您的服务会在点击通知时自动启动,这是一个方便的功能。
  • 在锁定屏幕等“不受信任”的情况下,通知内容的默认可见性为 VISIBILITY_PRIVATE。您可能希望在锁定屏幕上看到传输控件,因此 VISIBILITY_PUBLIC 是最佳选择。
  • 设置背景颜色时要小心。在 Android 5.0 或更高版本中的普通通知中,颜色仅应用于小型应用图标的背景。但在 Android 7.0 之前的 MediaStyle 通知中,颜色用于整个通知背景。测试您的背景颜色。注意眼睛的舒适度,避免使用极其明亮或荧光色。

这些设置仅在使用 NotificationCompat.MediaStyle 时可用

  • 使用 setMediaSession() 将通知与您的会话关联。这允许第三方应用和配套设备访问和控制会话。
  • 使用 setShowActionsInCompactView() 将最多 3 个操作添加到将在通知的标准尺寸内容视图中显示的操作。(此处指定了暂停按钮。)

  • 在 Android 5.0(API 级别 21)及更高版本中,您可以滑动取消通知以停止播放器,前提是服务不再在前台运行。在早期版本中,您无法执行此操作。为了允许用户在 Android 5.0(API 级别 21)之前移除通知并停止播放,您可以在通知的右上角添加一个取消按钮,方法是调用 setShowCancelButton(true)setCancelButtonIntent()

当您添加暂停和取消按钮时,您需要一个 PendingIntent 附加到播放操作。方法 MediaButtonReceiver.buildMediaButtonPendingIntent() 将 PlaybackState 操作转换为 PendingIntent。