构建媒体浏览器服务

您的应用必须在清单中通过 intent-filter 声明 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 的示例,请参阅 Universal Android Music Player 示例应用中的 PackageValidator 类。

您应该考虑根据发出查询的客户端类型提供不同的内容层级。特别是,Android 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);
}

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

有关如何实现 onLoadChildren() 的示例,请参阅 Universal Android Music Player 示例应用。

媒体浏览器服务生命周期

Android 服务的行为取决于它是已启动还是绑定到一个或多个客户端。服务创建后,可以启动、绑定或两者兼而有之。在所有这些状态下,它都功能齐全,可以执行其设计的工作。区别在于服务将存在多长时间。绑定服务在其所有绑定客户端解绑之前不会被销毁。已启动的服务可以明确停止和销毁(假设它不再绑定到任何客户端)。

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

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

因此,您需要确保服务在开始播放时通过调用 startService() 而启动。已启动的服务必须明确停止,无论它是否绑定。这可确保您的播放器即使在控制 UI activity 解绑后也能继续运行。

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

MediaBrowserService 的生命周期受其创建方式、绑定到它的客户端数量以及它从媒体会话回调接收的调用控制。总结如下

  • 当服务响应媒体按钮而启动时,或者当 activity 绑定到它时(通过其 MediaBrowser 连接后),服务就会被创建。
  • 媒体会话 onPlay() 回调应包含调用 startService() 的代码。这可确保服务启动并持续运行,即使所有绑定到它的 UI MediaBrowser activity 解绑后也是如此。
  • onStop() 回调应调用 stopSelf()。如果服务已启动,此操作会将其停止。此外,如果没有 activity 绑定到服务,服务就会被销毁。否则,服务会一直绑定,直到其所有 activity 解绑。(如果在服务销毁之前收到后续的 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 个操作以显示在通知的标准大小 contentView 中。(此处指定了暂停按钮。)
  • 在 Android 5.0(API 级别 21)及更高版本中,一旦服务不再在前台运行,您可以通过滑动通知来停止播放器。在早期版本中无法执行此操作。为了让用户在 Android 5.0(API 级别 21)之前移除通知并停止播放,您可以通过调用 setShowCancelButton(true)setCancelButtonIntent() 在通知的右上角添加取消按钮。

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