https://mp.weixin.qq.com/s/AngSHW1qUBsl_acFhlWGDA?
概要
下面列出构建一个典型的音乐 App 需要注意的点,然后我们将一一展开。
使用 MediaSession 进行 UI 与后台播放状态的同步
音乐的后台播放
AudioFocus 的处理
ACTION_AUDIO_BECOMING_NOISY
MediaButton 事件处理
通知栏更新
Android 旧版本的兼容
WifiLock && WakeLock
播放进度更新
MediaSession 框架简介
MediaSession 是 Android 5.0 推出的媒体播放框架,负责 UI 和后台播放之间的状态同步,支持了绝大部分音频播放的可能会遇到的操作,而且支持自定义操作。主要由 MediaSession (受控端) 和 MediaController (控制端) 构成:
- MediaSession
MediaSession.Token: 用于保持与 MediaController 的正常配对
MediaSession.Callback:用于监听 MediaController 的各种播放指令
- MediaController
MediaController.Callback:用于监听播放状态/信息更新
MediaController.TransportControls:用于向 MediaSession 发送各种播放指令
基本流程就是,UI 通过使用 MediaController.TransportControls 发送播放相关的控制指令(play, pause, stop 等等),MediaSession.Callback 在接收到相关指令后,对 Player 进行对应的操作,然后状态更新通过 MediaSession 同步给 MediaController.Callback, 最后更新 UI。
后台播放
显然,后台播放需要通过 Service 实现,而且后台 Service 需要继承自 MediaSession 框架中的 MediaBrowserService,同时需要在 AndroidManifest.xml 中加入 IntentFiliter。
<intent-filter>
<action android:name="android.media.browse.MediaBrowserService" />
</intent-filter>
典型的初始化工作如下:
public class MediaPlaybackService extends MediaBrowserService {
@Override
public void onCreate() {
super.onCreate();
// 1\. 初始化 MediaSession
// 2\. 设置 MedisSessionCallback
// 3\. 开启 MediaButton 和 TransportControls 的支持
// 4\. 初始化 PlaybackState
// 5\. 关联 SessionToken
}
@Override
public BrowserRoot onGetRoot(String clientPackageName, int clientUid, Bundle rootHints) {
// 对每个访问端做一些访问权限判断等
}
@Override
public void onLoadChildren(final String parentMediaId,
final Result<List<MediaItem>> result) {
// 根据访问权限返回播放列表相关信息
}
}
而关联 UI 和 Service 的工作主要封装在了 MediaBrowser 里。MediaBrowser 主要的工作就是使用 Bind 的启动方式启动 Service,然后将 MediaSession 的 Token 回调,用于创建 MediaController。
public class MediaPlayerActivity extends AppCompatActivity {
private MediaBrowserCompat mMediaBrowser;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
mMediaBrowser = new MediaBrowserCompat(this,
new ComponentName(this, MediaPlaybackService.class),
mConnectionCallbacks, null);
}
@Override
public void onStart() {
super.onStart();
mMediaBrowser.connect();
}
@Override
public void onStop() {
super.onStop();
if (MediaControllerCompat.getMediaController(MediaPlayerActivity.this) != null) {
MediaControllerCompat.getMediaController(MediaPlayerActivity.this).unregisterCallback(controllerCallback);
}
mMediaBrowser.disconnect();
}
}
需要注意的是,为了保证音频在后台能够正常的持续播放和停止,需要结合 Service 的 start 和 bind 两种启动方式。每当 UI 需要获取后台播放状态时,都需要 bind 后台 Service 以保证存活,即 MediaBrowser 就是用这种方法启动 Service。而为了让 UI 都 unbind 了之后,后台的音乐不会因此停止播放,需要在音乐播放(onPlay()
)时,通过 start 的方式启动 Service,而音乐停止播放(onStop()
)后,因为不在需要 Service 的存活了, 所以可以调用 Service.stopSelf()
来停止 Service。因为只有两个启动方式都不存在的情况下,Service 才会立即销毁。生命周期下图所示,图中 counter 表示 bind 的数量:
另外,如果有多进程的需要的话,直接将 Service 放到单独的进程就好了,因为 MediaController 和 MediaSession 的交互底层是通过 Binder 通信的,已经很好的支持了进程间通信。
MediaButton
当音乐处于后台播放的情况下,需要支持 MediaButton 的按键事件(KEYCODE_MEDIA_XXX
),比如说线控耳机上的播放/暂停按钮。
首先需要注册 MediaButtonReceiver 到 AndroidManifest.xml。
<receiver android:name="android.support.v4.media.session.MediaButtonReceiver" >
<intent-filter>
<action android:name="android.intent.action.MEDIA_BUTTON" />
</intent-filter>
</receiver>
有两个原因:
- 如果 MediaSession 是 Active 状态。 在Android 5.0 之后, Android MediaButton 的按键事件会直接分发到 MediaSession 的 onMediaButtonEvent(...),默认情况下回调用 MediaSession.Callback 的对应回调,如果有特别需要可以重写这个方法。Android 5.0之前,则需要监听事件广播,典型的代码是在 Service 中加入如下代码。因为,一方面 MediaButtonReceiver 在接收到事件后会直接 startService,另一方面,MediaButtonReceiver.handleIntent(...) 自动映射事件到 MediaSession.Callback 中的对应回调。
@Override
public int onStartCommand(Intent intent, int flags, int startId) {
MediaButtonReceiver.handleIntent(mMediaSessionCompat, intent);
return super.onStartCommand(intent, flags, startId);
}
- 如果 Android 识别出这个 MediaSession 是上一个变为 Inactive 状态的,那么 MediaButtonReceiver 就可以接受到这个广播,然后可以重新启动 MediaSession。(主要注意的是这个行为在只有在 Android 5.0 之后可以关闭)
BecomingNoisy
在耳机插入时播放音乐,然后将耳机突然拔出,可能造成音乐外放的尴尬情况。这种情况下系统会发送 AudioManager.ACTION_AUDIO_BECOMING_NOISY
广播,我们只需要在音乐播放的时候注册一个 BroadcastReceiver,在收到广播时暂停播放就好了。
private IntentFilter intentFilter = new IntentFilter(AudioManager.ACTION_AUDIO_BECOMING_NOISY);
private BecomingNoisyReceiver myNoisyAudioStreamReceiver = new BecomingNoisyReceiver();
MediaSessionCompat.Callback callback = new MediaSessionCompat.Callback() {
@Override
public void onPlay() {
registerReceiver(myNoisyAudioStreamReceiver, intentFilter);
}
@Override
public void onStop() {
unregisterReceiver(myNoisyAudioStreamReceiver);
}
}
WifiLock && WakeLock
通常情况下,当用户没有使用设备一段时间,Android 系统出于省电的考虑,可能会关闭Wifi 网络和 CPU。
为了让后台播放的音乐不会因为网络关闭而获取不到音频数据等,需要获取 WifiLock,使音乐播放过程中,Wifi 保持唤醒。
WifiLock wifiLock = ((WifiManager) getSystemService(Context.WIFI_SERVICE))
.createWifiLock(WifiManager.WIFI_MODE_FULL, "mylock");
MediaSessionCompat.Callback callback = new MediaSessionCompat.Callback() {
@Override
public void onPlay() {
wifiLock.acquire();
}
@Override
public void onPause() {
wifiLock.release(); // 或者在 onStop 中, 根据是否对网络有具体需求而定, 权衡需求和省电
}
@Override
public void onStop() {
// wifiLock.release();
}
}
可以通过设置 MediaPlayer 的 WakeMode 来保持 CPU 的状态。
MediaPlayer mediaPlayer = new MediaPlayer();
mediaPlayer.setWakeMode(getApplicationContext(), PowerManager.PARTIAL_WAKE_LOCK);
其内部实现是通过 PowerManager 来实现的。WakeLock 会在播放结束之后(播放完成,播放错误,和重置/释放播放器)释放。所以如果播放器不是 MediaPlayer 则需要自行处理。
PowerManager pm = (PowerManager)context.getSystemService(Context.POWER_SERVICE);
mWakeLock = pm.newWakeLock(mode|PowerManager.ON_AFTER_RELEASE, MediaPlayer.class.getName());
mWakeLock.setReferenceCounted(false);
mWakeLock.acquire();
Notification
为了减少 Service 在后台运行的时候,被系统回收的情况,通常需要将 Service 设置为 foreground。当 Service 被设置为 foreground 的时候,系统会显示一个不可移除的 Notification(引导用户强制停止 App) 提醒用户有个正在运行的高优先级后台 Service。这种体验显然是不好的,不过好在这个 Notification 是可以自定义的, 有两种方式可以实现。
- 使用 Android 5.0 上引入的 MediaStyle,支持 Expanded Notification, 收缩是最多显示3个按钮,展开是最多显示5个按钮。
典型的初始化工作如下
MediaControllerCompat controller = mediaSession.getController();
MediaMetadataCompat mediaMetadata = controller.getMetadata();
MediaDescriptionCompat description = mediaMetadata.getDescription();
NotificationCompat.Builder builder = new NotificationCompat.Builder(context);
builder.setContentTitle(description.getTitle())
.setContentText(description.getSubtitle())
.setSubText(description.getDescription())
.setLargeIcon(description.getIconBitmap())
// 点击通知栏后直接跳转至播放页面
.setContentIntent(controller.getSessionActivity())
// 左滑掉通知栏后自动停止
.setDeleteIntent(MediaButtonReceiver.buildMediaButtonPendingIntent(this, PlaybackStateCompat.ACTION_STOP))
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC) // 在锁屏状态下显示控制按钮
.addAction(new NotificationCompat.Action(R.drawable.pause, getString(R.string.pause),
MediaButtonReceiver.buildMediaButtonPendingIntent(this, PlaybackStateCompat.ACTION_PLAY_PAUSE)))
.setStyle(new NotificationCompat.MediaStyle() // 使用系统提供的样式
.setMediaSession(mediaSession.getSessionToken())
.setShowActionsInCompactView(0) // 根据索引配置在简介的模式下应该显示哪些操作按钮
.setShowCancelButton(true) // 显示关闭按钮,需要特别注意的是,5.0 之前显示在右上角,5.0 之后将不再显示
.setCancelButtonIntent(MediaButtonReceiver.buildMediaButtonPendingIntent(this,
PlaybackStateCompat.ACTION_STOP));
startForeground(id, builder.build());
- 使用自己自定义的布局, MediaStyle 能做到的都能做到,只是需要自己处理的事情比较多。由于国内存在众多 ROM,且都定制了自己的 UI, MediaStyle 在不同的 ROM 上可能表现不一致,为了统一的体验和 避免不必要的 Bug 产生,我们英语流利说中采用的就是自定义自己的 Notification。
AudioFocus
Android 的 AudioFocus 用于处理多个音频同时播放时,如何协调它们之间的竞争关系的机制。当音乐开始播放的时候,需要和其他的音频播放竞争 AudioFocus, 当获取到 AudioFocus 时,才开始播放。 1. streamType
基本上就是用 AudioManager.STREAM_MUSIC
, 代表播放音乐。
durationHint
申请 AudioFocus 时,告诉被竞争的播放器竞争者需要播放的时间。AUDIOFOCUS_GAIN
/AUDIOFOCUS_LOSS
: 没有具体时长,长期持有,比如音乐播放。当音乐要开始播放的时候就需要申请AUDIOFOCUS_GAIN
。而当其他的音乐需要播放的时候,我们会收到AUDIOFOCUS_LOSS
的状态改变通知,处理方法可以是直接停止,或者暂停,然后过一段时间停止。AUDIOFOCUS_GAIN_TRANSIENT
/AUDIOFOCUS_LOSS_TRANSIENT
:一段很短时长,比如 流利说中单词读音的播放。当我们收到AUDIOFOCUS_LOSS_TRANSIENT
时,通常的做法是暂停音乐的播放。AUDIOFOCUS_GAIN_TRANSIENT_MAY_DUCK
/AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK
:非常短的时长,比如音效。当我们收到AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK
的状态改变通知是,通常做法是降低音量,所以音乐和其他音效都会在同一个streamType
中播放出来。
注意:这个机制不是强制的,是一个规范,只有大家都遵守的时候,机制才能运作的很好,所以为了平台统一的体验,还是需要处理的。
播放进度更新
由于 MediaSession 很好的解耦了播放器,如果使用 MediaPlayer,UI 是拿不到 MediaPlayer 对象的,所以无法直接通过 MediaPlayer 的 getCurrentPosition()
拿到进度的。而 MediaSession 框架都是通过 PlaybackState 来同步状态的, PlaybackState.Builder
有个如下的方法:
setState(int state, long position, float playbackSpeed, long updateTime)
当 UI 拿到上述参数后,可以通过如下代码计算得出当前的播放进度:
//(当前开机时间(无法更改的) – 上次更新状态的时间)* 播放速度 + 上次更新状态时的播放进度
long currentPosition = ((SystemClock.elapsedRealtime() – playbackState.getLastPositionUpdateTime() ) * playbackState. getPlaybackSpeed() ) + playbackState.getPosition();
兼容
关于两者之间的兼容性,他们是交叉兼容的,比如 MediaBrowser 和 MediaBrowserServiceCompat 搭配,或者 MediaBrowserCompat 和 MediaBrowserService 搭配,都是可以正常工作的。不会遇到类似 PreferenceFragmentCompat 没有加到 support 库前, PreferenceFragment 与 SupportFragmentManager 不兼容的尴尬情况。
虽然 MediaSession 框架在 Api 21 的时候引入,但是 support library 在 23.2.0 的时候添加 MediaSession 的兼容。支持到 v4 版本。而且 support library 在 24.2.0 的时候拆分了 media 模块 为单独的包。
com.android.support:support-media-compat:24.2.0