Android MediaSession 深度解析:从蓝牙耳机到通知栏的媒体控制链路 我之前写过一个本地音乐播放器音频解码、进度条、播放列表都弄好了结果拿蓝牙耳机一按暂停完全没反应锁屏界面上也没有媒体信息后来才发现播放器只活在自己的 Activity 里系统层面压根不知道你在播什么。要在 Android 上让媒体指令监听和外部控制真正打通绕不开原生 MediaSession。这篇笔记就把我从零搭通这套链路的过程讲清楚Session、Controller、Callback 怎么配合蓝牙耳机按键怎么映射通知栏媒体按钮怎么接以及我实际踩到的一堆坑。这篇东西适合两种人看一种是自己在写播放器想让通知栏、蓝牙耳机、智能手表都响应控制的 Android 开发另一种是已经在项目里用了第三方播放器库但对外部控制链路还停留在“能用就行”的阶段想知道背后到底发生了什么的同学。1. 为什么你的播放器进不了系统媒体栏1.1 系统媒体栏认识的是 Session不是 Activity先说我当时最困惑的事明明通知栏已经用 MediaStyle 样式显示了“上一条/播放/下一条”为什么锁屏上不出现为什么蓝牙耳机按了没反应因为 Android 的媒体控制链路核心不是通知栏那张卡片而是背后的 MediaSession。系统媒体栏、锁屏控件、蓝牙 AVRCP 控制、Wear 手表上的媒体卡片全部通过 MediaSession 找到“当前正在播放的 App”。它就像应用在系统媒体体系里的身份证。如果你只发了一个普通的通知哪怕样式长得像媒体通知系统也不会把蓝牙按键事件交给它。通知只是 UI 展示层MediaSession 才是控制中枢。很多初学者在这里第一次翻车我也是在这卡了很久。1.2 MediaSession 实际解决的三个问题说到底MediaSession 解决的是下面三个问题表象问题本质原因MediaSession 的对应能力锁屏和系统媒体栏不显示播放信息系统不知道当前播放状态和元数据PlaybackStateCompatMediaMetadataCompat持续上报蓝牙耳机按键没反应外部按键事件找不到处理者MediaSessionCompat.Callback接收并分发媒体指令通知栏按钮和 UI 状态不同步App 内外部控制状态没有统一出口MediaControllerCompat双向监听界面与控制状态同步所以标题里说的“媒体指令监听”和“外部控制”其实是同一套消息回环的两侧。外部设备发出指令通过 Session 进入你的播放引擎你的播放引擎状态变化又通过 Session 广播给所有外部 UI。理解了这一点后面写代码就有方向不是把一堆回调散落在各个 Activity 里而是围绕 Session 建立一个状态中枢。2. MediaSession 骨架Session、Controller、Callback 三者的三角关系2.1 用“后厨服务员”来理解这套机制我一般把 MediaSession 理解成餐厅的“前台服务员”。你的播放引擎是后厨真正干活解码、出声、暂停、跳转。MediaSession 是服务员站在大厅里接受客人点单。MediaController 是客人桌上的菜单系统 UI、通知栏、蓝牙设备都通过它“点菜”。Callback 是服务员和后厨之间的对讲机客人说“上一首”服务员通过对讲机告诉后厨。整套流程里App 需要做的核心事情只有两件第一把 Session 挂出去创建并 setActive第二在 Callback 里接住指令转给播放引擎。剩下的事情——通知栏按钮怎么触发、蓝牙设备怎么连、系统媒体栏如何展示——都通过 Session 与 Controller 之间的机制自动流转。2.2 一次蓝牙点按的完整数据通路当蓝牙耳机按了“下一首”时底层走的是 AVRCP 协议。耳机把按键行为转化为 AVRCP 命令发给手机手机蓝牙协议栈解析之后Android 系统会找到当前 active 的 MediaSession把命令包装成媒体按键事件KeyEvent 或媒体动作最后分发到你的 Callback。对应关系大致这样耳机/外设按键系统收到的 KeyCode分发到 Callback 的方法播放键KEYCODE_MEDIA_PLAYonPlay()暂停键KEYCODE_MEDIA_PAUSEonPause()播放/暂停切换键KEYCODE_MEDIA_PLAY_PAUSE由当前状态决定下一首KEYCODE_MEDIA_NEXTonSkipToNext()上一首KEYCODE_MEDIA_PREVIOUSonSkipToPrevious()拖动进度媒体动作onSeekTo(long)有一点很重要App 侧不要自己猜KEYCODE_MEDIA_PLAY_PAUSE到底该播放还是暂停系统会根据当前 Session 上报的 PlaybackState 状态自动决定分发到onPlay()还是onPause()。如果你自己的状态上报乱了就会出现“耳机明明按的是播放App 却暂停了”这种诡异问题后面第 5 节细说。2.3 为什么 isActive 和 setFlags 是半条命新手最容易忽略两行代码session.isActive true session.setFlags(MediaSessionCompat.FLAG_HANDLES_MEDIA_BUTTONS)isActive true告诉系统“这个 Session 现在要参与系统媒体中枢的竞选”。系统媒体栏对外展示的是哪些 App蓝牙按键分发给谁很大程度上依赖这个状态。FLAG_HANDLES_MEDIA_BUTTONS则明确表示这个 Session 希望接收硬件媒体按钮事件。如果不声明系统可能认为你只处理媒体按钮以外的通知栏点击耳机按键就不会走到你的回调里。这两行缺一不可。我最初只设置了 PlaybackState 和 Metadata忘记setActive(true)结果通知栏卡片能显示但蓝牙耳机怎么按都没反应排查了半天才发现是这个原因。3. 从零搭建一套能响应蓝牙耳机的完整代码3.1 依赖权限与最小准备要用原生 MediaSession先确认项目里是否有 AndroidX Media 库。我自己用的是implementation(androidx.media:media:1.7.0)这个库提供了MediaSessionCompat、MediaControllerCompat、PlaybackStateCompat和通知栏用的MediaStyle是老项目接入成本最低的方案。Manifest 里还需要声明uses-permission android:nameandroid.permission.MEDIA_BUTTON / uses-permission android:nameandroid.permission.MODIFY_AUDIO_SETTINGS /如果播放时打算起前台服务还要加上视 targetSdk 而定uses-permission android:nameandroid.permission.FOREGROUND_SERVICE / uses-permission android:nameandroid.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK /另外Android 13 及以上需要POST_NOTIFICATIONS运行时权限否则通知栏媒体卡片会直接被系统拦截。3.2 创建 Session 并设置 PlaybackState、Metadata核心初始化代码我放在播放器 Service 里而不是 Activity。因为媒体播放的生命周期比界面长Activity 销毁后 Session 还要继续存在。大致长这样class PlaybackService : Service() { private lateinit var mediaSession: MediaSessionCompat override fun onCreate() { super.onCreate() mediaSession MediaSessionCompat(this, LocalPlayerSession).apply { setFlags( MediaSessionCompat.FLAG_HANDLES_MEDIA_BUTTONS or MediaSessionCompat.FLAG_HANDLES_TRANSPORT_CONTROLS ) setCallback(sessionCallback) isActive true } // 这里先给一个初始状态避免系统媒体栏拿到空壳 updatePlaybackState(PlaybackStateCompat.STATE_NONE, 0L) updateMetadata(songTitle 未知歌曲, artist , duration 0L) } private fun updatePlaybackState(state: Int, position: Long) { val playbackState PlaybackStateCompat.Builder() .setActions( PlaybackStateCompat.ACTION_PLAY or PlaybackStateCompat.ACTION_PAUSE or PlaybackStateCompat.ACTION_PLAY_PAUSE or PlaybackStateCompat.ACTION_SKIP_TO_NEXT or PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS or PlaybackStateCompat.ACTION_SEEK_TO or PlaybackStateCompat.ACTION_STOP ) .setState(state, position, 1.0f) .build() mediaSession.setPlaybackState(playbackState) } private fun updateMetadata(title: String, artist: String, duration: Long) { val metadata MediaMetadataCompat.Builder() .putString(MediaMetadataCompat.METADATA_KEY_TITLE, title) .putString(MediaMetadataCompat.METADATA_KEY_ARTIST, artist) .putString(MediaMetadataCompat.METADATA_KEY_ALBUM, 本地音乐) .putLong(MediaMetadataCompat.METADATA_KEY_DURATION, duration) .build() mediaSession.setMetadata(metadata) } }如果你希望用户点系统媒体栏的卡片能跳回 App再补一个 Session Activityval intent packageManager.getLaunchIntentForPackage(packageName) val pendingIntent PendingIntent.getActivity( this, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE ) mediaSession.setSessionActivity(pendingIntent)3.3 回调里哪些方法必须重写Session 的 Callback 是整个指令链路的终点。我的做法是不管指令来自蓝牙、通知栏还是系统媒体栏都统一汇入这里再转发给一个封装的 PlayerController。播放引擎那层再用回调把最新位置和状态回报回来。样例如下private val sessionCallback object : MediaSessionCompat.Callback() { override fun onPlay() { playerController.play() updatePlaybackState(PlaybackStateCompat.STATE_PLAYING, playerController.currentPosition) } override fun onPause() { playerController.pause() updatePlaybackState(PlaybackStateCompat.STATE_PAUSED, playerController.currentPosition) } override fun onSkipToNext() { playerController.skipToNext() } override fun onSkipToPrevious() { playerController.skipToPrevious() } override fun onSeekTo(pos: Long) { playerController.seekTo(pos) updatePlaybackState(PlaybackStateCompat.STATE_PLAYING, pos) } override fun onStop() { playerController.stop() updatePlaybackState(PlaybackStateCompat.STATE_STOPPED, 0L) } }setActions()里的掩码必须和回调方法匹配。比如你没声明ACTION_SEEK_TO系统侧就算收到耳机的 seek 指令也可能会忽略哪怕回调里写了onSeekTo也没用。4. 通知栏媒体控制与 MediaButtonReceiver 的联动4.1 MediaStyle 通知必须挂 sessionToken通知栏的媒体卡片是用户最常接触的入口。很多教程只告诉你用MediaStyle但没强调必须挂上 Session 的 tokenval controller mediaSession.controller val playAction NotificationCompat.Action.Builder( R.drawable.ic_play, 播放, controller.plays() // MediaControllerCompat 会自动生成 PendingIntent ).build() val pauseAction NotificationCompat.Action.Builder( R.drawable.ic_pause, 暂停, controller.pauses() ).build() val notification NotificationCompat.Builder(this, media_channel) .setSmallIcon(R.drawable.ic_music) .setContentTitle(metadata.title) .setContentText(metadata.artist) .setContentIntent(mediaSession.sessionActivity) .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) .setStyle( androidx.media.app.NotificationCompat.MediaStyle() .setMediaSession(mediaSession.sessionToken) .setShowActionsInCompactView(0, 1, 2) ) .addAction(previousAction) .addAction(playOrPauseAction) .addAction(nextAction) .build()controller.plays()这种写法是 MediaController 提供的快捷方法会自动给通知按钮接入对应的 Session 指令。这样 UI 按钮和系统媒体栏的逻辑走的是同一条链路。有个细节我一开始没注意setShowActionsInCompactView()控制的是“下拉折叠”和锁屏时显示三个按钮的列数。如果只放两个 action却传了settleShowActionsInCompactView(0, 1, 2)系统取不到第三个按钮可能报错或显示异常。建议 action 数量保持和参数位置一致。4.2 MediaButtonReceiver 担负起“电话转接员”通知栏按钮走 controller 的 PendingIntent 一般没问题但系统在部分场景下会直接把媒体按键事件广播出来需要一个MediaButtonReceiver来接再转交给 Session。典型场景是物理耳机按钮和车载控制。传统接入方式是在 Manifest 里注册静态广播接收者receiver android:nameandroidx.media.session.MediaButtonReceiver intent-filter action android:nameandroid.intent.action.MEDIA_BUTTON / /intent-filter /receiver然后在 Service 的onStartCommand里把事件转给 Sessionoverride fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { MediaButtonReceiver.handleIntent(mediaSession, intent) return START_NOT_STICKY }如果你使用新版 Media3AndroidX.Media3接收器的包路径会变成androidx.media3.session.MediaButtonReceiver分发逻辑由 Media3 的MediaSessionService接管写法和这里的传统 MediaSessionCompat 稍有差异但思路一致广播事件进来交给一个中心化的 Session 处理。4.3 一个容易忽略的权限问题前台服务类型播放类前台服务从 Android 14 开始要求声明mediaPlayback类型。如果你只写了FOREGROUND_SERVICE而没加对应类型通知栏媒体控制可能无法正常跟随播放。在 Manifest 里加service android:name.PlaybackService android:foregroundServiceTypemediaPlayback android:exportedfalse /同时记得在启动前台服务时传类型参数startForeground(notificationId, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK)这部分的坑比较隐蔽报错日志经常是MissingForegroundServiceTypeException但我见过不少人在群里问的时候根本没往通知栏控制方向想先把经验摆出来。5. 实测真坑状态不同步、回调不触发、按键映射错位5.1 蓝牙能连按键没反应的第一排查项如果你的代码看起来都对但蓝牙耳机按键就是没反应按我总结的顺序查mediaSession.isActive是否为 true且每次 Session 重建后都重新设过。FLAG_HANDLES_MEDIA_BUTTONS有没有设置。Manifest 里有没有MEDIA_BUTTON权限和 MediaButtonReceiver。onStartCommand里有没有调用MediaButtonReceiver.handleIntent()。有一次我更新了 Session旧实例没有isActive false新实例激活后系统状态乱套蓝牙按键偶尔响应偶尔不响应。最后我统一在重建 Session 前把旧实例释放掉mediaSession.release() mediaSession MediaSessionCompat(...) // 重新创建实测下来清爽很多推荐你要是遇到状态混乱也试试这个。5.2 PlaybackState 更新时序问题很多播放器的状态卡在“UI 显示暂停但歌还在放”问题大多出在状态更新顺序上。系统媒体栏和通知栏是根据 Session 里的 PlaybackState 来切换图标的如果你先调了播放引擎再异步回头更新 PlaybackState中间会有几百毫秒的窗口期用户刚好在窗口期按暂停系统分发到的可能是旧状态。我的规矩是先更新 PlaybackState再执行播放动作。也就是把第 3.3 里的顺序反过来override fun onPlay() { updatePlaybackState(PlaybackStateCompat.STATE_PLAYING, playerController.currentPosition) playerController.play() }这样哪怕播放引擎内部初始化慢外部 UI 至少不会拿到“还在暂停”的误导状态。Android 系统里状态一致性大于瞬时精确性。5.3 有些耳机连击会触发送快进/慢进这个坑比较隐蔽。部分蓝牙耳机的“连按三下”会发送 seek 类指令而不是简单的下一首/上一首。如果 PlaybackState 里没有上报时长信息或者 Metadata 的METADATA_KEY_DURATION缺失系统在处理连击时的表现就会很怪看起来像“按下一首没反应”“按着按着变成快进”。处理方式就是在歌曲切换时完整更新 MetadatamediaSession.setMetadata( MediaMetadataCompat.Builder() .putString(MediaMetadataCompat.METADATA_KEY_TITLE, currentSong.title) .putString(MediaMetadataCompat.METADATA_KEY_ARTIST, currentSong.artist) .putLong(MediaMetadataCompat.METADATA_KEY_DURATION, currentSong.durationMs) .build() )顺手再把 PlaybackState 里的 position 也同步上。别觉得这只是“补个字段”很多硬件控制失灵其实就是因为系统拿到的信息太残缺没法做按键语义判断。5.4 回调重复触发与适配多端指令还有一个我调试蓝牙耳机时遇到的现象同一个暂停操作通知栏按一下只触发一次蓝牙耳机按一下却可能触发两次onPause。因为部分耳机的物理按键会同时产生 KeyEvent 和 MediaAction广播链路里出现一次重复。播放引擎是幂等的还好但如果引擎每次 pause 都要切音频焦点重复调用就会导致焦点状态被切来切去。我的做法是在 PlayerController 加一层状态过滤fun pause() { if (_state STATE_PAUSED) return _state STATE_PAUSED // 真正执行暂停 }播放同理。用状态机保证外部指令重复到达也不会有副作用这是做系统级接入口袋里必备的工具。5.5 状态不同步的完整排查链路如果你最后还是发现设置都对、状态也对、按键就是不工作我建议你按这条链路走一遍效率远高于盲猜先看系统媒体栏有没有当前播放卡片。没有问题大概率在 Session 的isActive或PlaybackState。有卡片但按键不响应用 logcat 过滤MediaSession和MediaButtonReceiver看事件是否进入你的 Service。进入 Service 但没进 Callback检查onStartCommand里是否把 intent 正确转给MediaButtonReceiver.handleIntent。进入 Callback 播放引擎没反应检查自己的 PlayerController 状态机是不是被重复指令卡住了。这套流程帮我解决过至少三次线上反馈每次最后定位到的原因都不一样但都比漫无目的地改代码快得多。6. 已知局限与扩展思路单应用抢占、系统媒体栏、Car 模式6.1 同一时间只有一个 Session 能成为系统默认MediaSession 有一个“隐藏规则”系统媒体栏通常只展示一个最关键的 Session。多个 App 同时处于活跃状态时系统会按焦点和调用顺序选一个优先展示。对大多数播放器场景我们只要保证自己播放时 Session 是 active、暂停后不要立刻把isActive改成 false 就行。不然每次暂停再播放Session 重新激活的那一瞬间系统可能几秒钟都找不到可用的媒体控制器。我见过有人为了省电在暂停时释放 Session结果用户体验极差。正确做法是保留 Session 和通知播放时才真正起前台服务并维持完整状态。6.2 想统一 UI 和控制引入 MediaController 做双向反馈如果你的 App 有自定义控制面板不想和 Session 状态脱节可以获取 Session 的 controllerval controller mediaSession.controller controller.registerCallback(mediaControllerCallback)外部指令发生比如蓝牙耳机切歌后controller会收到onPlaybackStateChanged回调界面据此同步按钮状态。这样一来不管指令来自哪个入口UI 永远跟着状态走而不是跟着本地逻辑猜。6.3 我在项目里的最后一点沉淀玩到后面你会发现MediaSession 看似只是一组系统 API实际是一条“状态管道”。用得好所有外部入口自动收敛用不好蓝牙、通知栏、系统媒体栏各玩各的光状态同步就能让你排查到崩溃。我自己后来把播放器状态源统一收拢成了一个PlayerStateStore播放引擎和 MediaSession 都只跟它同步外部控制也只是写入这个 Store 的一个指令来源。这样重复回调、多端同步、后台状态恢复都变得可预期从根上消灭了一大批“奇怪问题”。如果你也在做播放器建议可以参考这套思路不必让每个页面对裸的 Session 指手画脚。