17、MediaSession 与媒体控制:媒体按键、锁屏控制、通知栏集成

媒体控制这件事,说白了就是让用户能「隔空操作」你的播放器。不管是按蓝牙耳机上的下一曲,还是在锁屏界面点暂停,又或者在下拉通知栏里拖动进度条——这些操作的背后,都是 MediaSession 在撑腰。

我个人习惯把 MediaSession 理解成一个「遥控器接收器」。你的播放器是电视机,MediaSession 就是那个红外接收头。用户按遥控器(媒体按键、锁屏控件、通知栏),信号传到接收头,再转成播放器的具体动作。

17.1 MediaSession 的核心角色

先看一张图,把整个控制链路理清楚。

MediaSession 媒体控制链路 用户输入 媒体按键 / 锁屏 / 通知栏 MediaSession 接收 & 分发控制指令 MediaSession.Callback onPlay / onPause / onSkipToNext 实际播放器 ExoPlayer / MediaPlayer MediaMetadata 标题 / 封面 / 进度 通知栏 / 锁屏 展示最新状态

从图上可以看得很清楚:用户操作走左边进来,经过 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);
                }
            }
        }
    }
}
💡 个人经验: 我曾经遇到一个 Bug——蓝牙耳机按一下播放/暂停,App 要延迟 2 秒才有反应。后来发现是 BroadcastReceiver 里做了太多耗时操作。记住:onReceive 里只做分发,不要做任何 I/O 或网络请求。

17.4 锁屏控制

锁屏控制其实分两种情况:

  • Android 5.0 及以上: 系统自带锁屏媒体控件,只要 MediaSession 处于活跃状态,锁屏界面会自动显示。
  • Android 4.4 及以下: 需要自己实现锁屏界面,通过 setLockScreenArtwork() 设置封面。

对于现代 Android 版本,你只需要做好两件事:

  1. 设置 MediaMetadata,包含标题、艺术家、封面等。
  2. 设置 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 的生命周期要跟播放服务绑定。我建议在 MediaBrowserServiceService 中创建和销毁 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