17、MediaSession 与媒体控制:媒体按键、锁屏控制、通知栏集成
媒体控制这件事,说白了就是让用户能「隔空操作」你的播放器。不管是按蓝牙耳机上的下一曲,还是在锁屏界面点暂停,又或者在下拉通知栏里拖动进度条——这些操作的背后,都是 MediaSession 在撑腰。
我个人习惯把 MediaSession 理解成一个「遥控器接收器」。你的播放器是电视机,MediaSession 就是那个红外接收头。用户按遥控器(媒体按键、锁屏控件、通知栏),信号传到接收头,再转成播放器的具体动作。
17.1 MediaSession 的核心角色
先看一张图,把整个控制链路理清楚。
从图上可以看得很清楚:用户操作走左边进来,经过 MediaSession 中转,最终落到 Callback 里。播放器状态变化后,又通过 MediaMetadata 和 PlaybackState 反向同步到 UI 层。
17.2 创建 MediaSession
创建 MediaSession 本身不复杂,但有几个细节容易踩坑。我直接给一个我项目里常用的模板。
// 创建 MediaSession
MediaSessionCompat mediaSession = new MediaSessionCompat(context, "MusicPlayerSession");
mediaSession.setFlags(
MediaSessionCompat.FLAG_HANDLES_MEDIA_BUTTONS |
MediaSessionCompat.FLAG_HANDLES_TRANSPORT_CONTROLS
);
// 设置回调
mediaSession.setCallback(new MediaSessionCompat.Callback() {
@Override
public void onPlay() {
// 开始播放
player.play();
updatePlaybackState();
}
@Override
public void onPause() {
// 暂停播放
player.pause();
updatePlaybackState();
}
@Override
public void onSkipToNext() {
// 下一曲
player.next();
updateMetadata();
updatePlaybackState();
}
@Override
public void onSkipToPrevious() {
// 上一曲
player.previous();
updateMetadata();
updatePlaybackState();
}
@Override
public void onSeekTo(long pos) {
// 拖动进度
player.seekTo(pos);
updatePlaybackState();
}
});
// 激活 Session
mediaSession.setActive(true);
setActive(true),结果媒体按键死活没反应。Session 默认是未激活状态,必须显式激活才能接收控制指令。
17.3 媒体按键处理
媒体按键包括蓝牙耳机上的播放/暂停、上一曲、下一曲,还有有线耳机上的线控。Android 系统会把按键事件转成对应的 KeyEvent,然后交给当前活跃的 MediaSession 处理。
这里有个关键点:MediaSession 必须注册 MediaButtonReceiver。否则系统不知道把按键事件发给谁。
// 在 AndroidManifest.xml 中注册
<receiver android:name=".MediaButtonReceiver">
<intent-filter>
<action android:name="android.intent.action.MEDIA_BUTTON" />
</intent-filter>
</receiver>
// MediaButtonReceiver 实现
public class MediaButtonReceiver extends BroadcastReceiver {
@Override
public void onReceive(Context context, Intent intent) {
String action = intent.getAction();
if (Intent.ACTION_MEDIA_BUTTON.equals(action)) {
KeyEvent event = intent.getParcelableExtra(Intent.EXTRA_KEY_EVENT);
if (event != null && event.getAction() == KeyEvent.ACTION_DOWN) {
// 交给 MediaSession 处理
MediaSessionCompat session =
MediaSessionManager.getInstance().getSession();
if (session != null) {
session.getController().dispatchMediaButtonEvent(event);
}
}
}
}
}
onReceive 里只做分发,不要做任何 I/O 或网络请求。
17.4 锁屏控制
锁屏控制其实分两种情况:
- Android 5.0 及以上: 系统自带锁屏媒体控件,只要 MediaSession 处于活跃状态,锁屏界面会自动显示。
- Android 4.4 及以下: 需要自己实现锁屏界面,通过
setLockScreenArtwork()设置封面。
对于现代 Android 版本,你只需要做好两件事:
- 设置
MediaMetadata,包含标题、艺术家、封面等。 - 设置
PlaybackState,包含播放状态、进度、可用操作。
// 设置元数据
MediaMetadataCompat metadata = new MediaMetadataCompat.Builder()
.putString(MediaMetadataCompat.METADATA_KEY_TITLE, "晴天")
.putString(MediaMetadataCompat.METADATA_KEY_ARTIST, "周杰伦")
.putString(MediaMetadataCompat.METADATA_KEY_ALBUM, "叶惠美")
.putLong(MediaMetadataCompat.METADATA_KEY_DURATION, 280000L) // 毫秒
.putBitmap(MediaMetadataCompat.METADATA_KEY_ALBUM_ART, albumArtBitmap)
.build();
mediaSession.setMetadata(metadata);
// 设置播放状态
PlaybackStateCompat state = new PlaybackStateCompat.Builder()
.setState(
PlaybackStateCompat.STATE_PLAYING,
player.getCurrentPosition(),
1.0f // 播放速度
)
.setActions(
PlaybackStateCompat.ACTION_PLAY |
PlaybackStateCompat.ACTION_PAUSE |
PlaybackStateCompat.ACTION_SKIP_TO_NEXT |
PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS |
PlaybackStateCompat.ACTION_SEEK_TO
)
.build();
mediaSession.setPlaybackState(state);
🔑 关键点: 锁屏控件显示什么操作按钮,完全取决于 setActions() 里传了哪些 flag。如果你没传 ACTION_SKIP_TO_NEXT,锁屏界面上就不会出现下一曲按钮。
17.5 通知栏集成
通知栏媒体控件是用户最常接触的入口。Android 官方推荐使用 MediaStyle 来构建通知。
// 构建媒体通知
NotificationCompat.Builder builder = new NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_notification)
.setContentTitle("晴天")
.setContentText("周杰伦")
.setSubText("叶惠美")
.setLargeIcon(albumArtBitmap)
.setContentIntent(pendingIntent) // 点击通知打开 App
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC) // 锁屏可见
.setOngoing(true) // 不可滑动清除
.setStyle(new androidx.media.app.NotificationCompat.MediaStyle()
.setMediaSession(mediaSession.getSessionToken())
.setShowActionsInCompactView(0, 1, 2) // 在紧凑模式下显示哪些按钮
.setShowCancelButton(true)
.setCancelButtonIntent(cancelPendingIntent)
)
.addAction(R.drawable.ic_previous, "上一曲", previousPendingIntent)
.addAction(R.drawable.ic_play, "播放", playPendingIntent)
.addAction(R.drawable.ic_next, "下一曲", nextPendingIntent);
// 注意:播放/暂停按钮的图标需要根据状态动态切换
if (isPlaying) {
// 把第二个按钮换成暂停
builder.setStyle(new NotificationCompat.MediaStyle()
.setShowActionsInCompactView(0, 1, 2));
builder.mActions.set(1,
new NotificationCompat.Action(R.drawable.ic_pause, "暂停", pausePendingIntent));
} else {
builder.mActions.set(1,
new NotificationCompat.Action(R.drawable.ic_play, "播放", playPendingIntent));
}
NotificationManagerCompat.from(context).notify(NOTIFICATION_ID, builder.build());
setShowActionsInCompactView 里传了 3 个索引,但只加了 2 个 Action,结果通知栏直接崩溃。这个方法的参数是「在紧凑模式下显示哪些位置的按钮」,索引不能超出实际添加的 Action 数量。
17.6 状态同步的最佳实践
通知栏、锁屏、MediaSession 三者的状态必须保持一致。我见过太多 App 出现「通知栏显示暂停,但音乐还在播放」的诡异现象。
我的做法是:统一状态管理。所有状态变化都通过一个方法触发。
private void updatePlaybackState() {
boolean isPlaying = player.isPlaying();
long position = player.getCurrentPosition();
float speed = isPlaying ? 1.0f : 0f;
int state = isPlaying ?
PlaybackStateCompat.STATE_PLAYING :
PlaybackStateCompat.STATE_PAUSED;
// 1. 更新 PlaybackState
PlaybackStateCompat playbackState = new PlaybackStateCompat.Builder()
.setState(state, position, speed)
.setActions(availableActions)
.build();
mediaSession.setPlaybackState(playbackState);
// 2. 更新通知栏
updateNotification(isPlaying);
// 3. 如果需要在锁屏显示特殊内容,也在这里处理
// ...
}
这样做的好处是:不管用户通过什么方式操作(蓝牙按键、锁屏、通知栏、App 内按钮),最终都走到同一个状态更新方法里,不会出现状态不一致。
17.7 生命周期管理
MediaSession 的生命周期要跟播放服务绑定。我建议在 MediaBrowserService 或 Service 中创建和销毁 Session。
public class MusicPlaybackService extends Service {
private MediaSessionCompat mediaSession;
@Override
public void onCreate() {
super.onCreate();
mediaSession = new MediaSessionCompat(this, "MusicService");
mediaSession.setFlags(
MediaSessionCompat.FLAG_HANDLES_MEDIA_BUTTONS |
MediaSessionCompat.FLAG_HANDLES_TRANSPORT_CONTROLS
);
mediaSession.setCallback(callback);
mediaSession.setActive(true);
}
@Override
public void onDestroy() {
mediaSession.setActive(false);
mediaSession.release();
super.onDestroy();
}
}
onDestroy 里先 setActive(false) 再 release()。直接 release 有时候会导致系统残留引用,下次启动时出现奇怪的异常。
17.8 兼容性注意事项
| 场景 | 注意事项 |
|---|---|
| Android 5.0 以下 | 锁屏控件需要自己实现,MediaSession 只负责提供数据 |
| Android 8.0 以上 | 必须创建通知渠道(NotificationChannel),否则通知不显示 |
| Android 12 以上 | 媒体通知默认显示在「媒体播放器」区域,需要适配新样式 |
| 多应用同时播放 | 系统会优先显示最近活跃的 MediaSession,但用户可以在通知栏切换 |
嗯,这里要注意一点:Android 12 开始,系统对媒体通知的样式做了统一管理。你的通知可能不会完全按照你设置的样式显示,系统会提取关键信息(标题、艺术家、封面)重新排版。所以别在通知样式上花太多心思,把元数据填对才是正经事。
MediaSession 这套体系,说白了就是「一次接入,处处控制」。你只要把 Session 和 Callback 搭好,蓝牙耳机、车载系统、智能手表、通知栏、锁屏——所有这些入口都能自动工作。我早期做播放器时,每个入口单独写一套控制逻辑,维护起来简直噩梦。后来全部统一到 MediaSession,代码量直接砍半。
最后提醒一句:测试的时候别光在 App 里点按钮。把手机锁屏试试,戴上蓝牙耳机试试,下拉通知栏试试。用户真正用的场景,往往是你开发时最容易忽略的。
公众号:蓝海资料掘金营,微信deep3321