浅谈 Media3 (一)

一、Media3 的前世今生

Jetpack Media3 是媒体库的新家,为 Android 应用程序带来丰富的音频和视觉体验。 Media3 提供了一个具有强大的定制能力、可靠性极高和基于设备优化的简单架构,以消除碎片带来的复杂性。

Android Developers 文档

今年谷歌为 Media3, 也就是安卓最新的媒体播放实现库推出了第一个稳定版本。Media3 比起先前的 Media2, Exoplayer 有着极高的可定制性。

在 Media3 这样的库发布之前之前写一个播放器是非常困难的@w@ 笔者之前的项目使用的是远古库 MediaPlayer, 不仅要手动处理播放清单、随机等逻辑, 和系统交互的 MediaSession 通知也需要自己构建。更不用提 MediaPlayer 还会因为各种奇奇怪怪的理由罢工 2333

示例代码

感兴趣的读者可以点开上面的链接看看笔者之前的实现。到这个项目 Archive 为止,实际上部分关于媒体服务的 Impl 还没有写完 (蓝牙控制,播放恢复等)

Media3 为了规范播放器的架构将 Playback Notification / Resumption 的实现都包装在了一起, 让实现一个高效且标准的播放器非常容易。

二、Media3 的美好愿景

首先让我们来简单了解一下现在市面上大部分音乐/视频播放器所使用的软件结构:

服务层                              活动(Activity)层
Player <-> MediaSession <--------> 媒体控制器 <-> UI

如上面的简例所示,服务层包含了 Player 本体、与 Android 系统 / 活动层 对接的 MediaSession。而活动层包含了与服务对接的媒体控制器还有呈现给用户看的UI界面。

但有些没有实战过的读者可能会问了:
这不是很简单吗???

实际上上面的示意图省去了很多部分,比如 MediaSession 和 Player 之间对接的控制器,媒体控制器还有自己对于播放的接口,需要UI去实现。这导致了一个非常大的问题,结构太过于复杂。代码的复杂性只会导致更多的Bug。

甚至在实现控制器的时候还有多个库可以使用 (ExoPlayer, Media2),这往往会给开发者造成困惑。

Media3 的处理是这样的:

服务层                                 活动(Activity)层
ExoPlayer <-> MediaSession <--------> 媒体控制器 <-> UI
   ^                                    ^
   |                                    |
   ----------------------> Player <------

在 Media3 的理想结构下, 四个部件都不用做连接器处理。它们都直接实现通用播放器的接口。这不仅省去了写一大堆连接器的麻烦, 而且让代码更加易于维护。可能之前我们用 MediaPlayer 实例的时候需要自己处理 Activity 与 Service 之间的通信, 但在 Media3 中, 这些都已经被预制的通用播放器解决了。

你还会发现, 当你采用了 Media3 的架构来写播放器的时候, UI 层和服务层是完全分离的。这意味着更少的错误更好的独立性。对于一个存活的 Service, 可以有多个播放器界面存在。

同时, Media3 还整合了 ExoPlayer2 作为它可选的播放器实现。

在 Android 13 中, 安卓推送了一项新功能: Playback Resumption (也就是前文所说的播放恢复)。当使用 Playback Resumption 的时候, 应用程序生成的 Media Control 通知 应该常驻于快速设置的面板上, 这样即使播放器根本不在运行, 使用 Media Control 也能迅速开始播放退出时的播放清单。

一些比较知名的播放器,比如自称安卓第一的 Retro Music Player, 还有 Oto Music 都没有把 Playback Resumption 做好。事实上,根据笔者的观察, 市面上除了笔者自己的 GramophoneSpotify 之外似乎没有任何一个播放器正确的植入了 Playback Resumption。这导致 Playback Resumption 这一功能就等于作废, 停驻的 Media Control 通知并不会迅速恢复播放, 只会 **”假死”**。

幸运的是, Media3 发现了这一现状, 并且提供了相应的接口方便的供开发者来实现 Playback Resumption。

三、实现

服务端

import androidx.media3.common.AudioAttributes
import androidx.media3.common.C
import androidx.media3.common.Player
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.session.MediaSession
import androidx.media3.session.MediaSessionService

class PlaybackService : MediaSessionService() {

    private var mediaSession: MediaSession? = null

    override fun onCreate() {
        val player = ExoPlayer.Builder(this).build()
        val audioAttributes: AudioAttributes = AudioAttributes.Builder()
            .setUsage(C.USAGE_MEDIA)
            .setContentType(C.AUDIO_CONTENT_TYPE_MUSIC)
            .build()
        player.setAudioAttributes(audioAttributes, true)
        super.onCreate()
    }

    override fun onDestroy() {
        mediaSession?.run {
            player.release()
            release()
            mediaSession = null
        }
        super.onDestroy()
    }

    override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaSession? = mediaSession

}

如上面的代码段, 这便是一个最简单的 Media3 播放器实例了。我们这里使用的是实现播放功能最简单的 MediaSessionService, 但通常情况下最好使用 MediaLibraryService。MediaLibraryService 能够为其他的使用暴露播放器的媒体库 (Android Auto, 语音助手)。

让我们逐行分解。

  1. MediaSession
private var mediaSession: MediaSession? = null
/*
...
*/
override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaSession? = mediaSession

我们在这里定义了一个 mediaSession。这个 session 将作为 Activity 的 UI 层与 Service 层交互的凭证。 Activity 层可以凭借这个 Session 来与播放器通讯, 添加播放清单等。

  1. Player
    val player = ExoPlayer.Builder(this).build()
    
    // 处理 Audio Focus
    val audioAttributes: AudioAttributes = AudioAttributes.Builder()
    	.setUsage(C.USAGE_MEDIA)
    	.setContentType(C.AUDIO_CONTENT_TYPE_MUSIC)
    	.build()
    player.setAudioAttributes(audioAttributes, true)

我们在这里创建了一个 ExoPlayer 的实例。请注意,此处的 ExoPlayer 非彼 ExoPlayer。 ExoPlayer 作为 Media3 的一部分被包含在了 Media3 当中。
而 AudioAttributes 是我们加给 ExoPlayer 的媒体信息,这样当其他的应用需要播放媒体的时候, ExoPlayer 便会自动暂停,当别的应用释放媒体焦点后 ExoPlayer 再自动获取。之前需要用到 AudioManager 来手动处理这一点,现在可以直接通过 AudioAttributes 直接解决。

活动端

活动端的实现很简单,我们只需要在 onStart 中添加以下获取 session 的方法即可。

sessionToken = SessionToken(this, ComponentName(this,GramophonePlaybackService::class.java))
controllerFuture = MediaController.Builder(this, sessionToken).buildAsync()

在这里我们获取了一个 controllerFuture,而我们可以通过它来获取 Player 本体。

val player = controllerFuture.get()

举个例子,假如我们想要暂停播放的话,就可以用 player.pause() 来实现。如果以前我们想要更新 UI 内容的话可能会要用到 Handler 或者 Thread。现在我们只需要给 Player 新增一个回调即可:

val playerListener = object : Player.Listener {
	override fun onIsPlayingChanged(isPlaying: Boolean) {
		super.onIsPlayingChanged(isPlaying)
	}
}
controllerFuture.addListener(
	{ controllerFuture.get().addListener(playerListener) },
	MoreExecutors.directExecutor()
)