前几天被组里测试拉到会议室说升级到 targetSdk 33 之后蓝牙耳机的播放/暂停键全部失灵App 里的 MediaSession 收不到媒体按键。当时我第一反应是代码改动出了问题结果回查 commitMediaSession 相关的代码一行没动。真正开始排查才发现API 33 之后的媒体按键分发机制比之前想象中复杂很多通知权限、前台服务类型、MediaButtonReceiver 的导出声明、音频焦点、PlaybackState 的 actions任何一环没对上系统都会默默把按键交给别的应用或者直接吞掉。这篇文章不是讲基础 API 怎么调而是分享我从按键完全没反应到稳定复现并修复的完整排查过程以及最终沉淀下来的一套可复用实现。如果你正在做音乐播放器、播客、有声书这类媒体类 App升级 targetSdk 33 后也遇到媒体按键失灵这篇内容应该能帮你省下至少两天排查时间。1. 问题现象与触发链路API 33 之后媒体按键为什么忽然没人接了1.1 现象回顾不同设备表现差别很大先说现象。测试反馈里同样是升级后的包表现却不完全一致某台 Android 13 真机上蓝牙耳机按键完全没反应App 内按钮正常。另一台 Android 12 的真机上一切正常。还有一台 Android 13 设备锁屏页能看到媒体卡片但点卡片上的暂停键没响应。这个不同设备表现不同很关键。如果只有一台设备出问题可能是设备蓝牙协议问题但多台 Android 13/14 设备同时异常几乎可以确定是系统版本变化导致的路由策略变了。我当时的排查方向一度走偏以为是耳机兼容性问题甚至换了三副耳机测试。直到把系统日志打开发现媒体按键事件根本没有进到我们 Service 的onStartCommand才意识到问题出在系统要不要把按键发给你这一层而不是你收到之后怎么处理这一层。1.2 系统媒体按键路由的三条判定规则API 33 之后Android 系统判断媒体按键应该交给哪个 App时核心看三件事是否存在一个处于活跃状态的 MediaSession。mediaSession.isActive true是最基本的前提。该 MediaSession 是否绑定了一个能被系统识别为媒体通知的通知。也就是说通知必须使用NotificationCompat.MediaStyle并且调用了setMediaSession(session.sessionToken)。当前 App 是否有媒体控制权。这个控制权由音频焦点、最近活跃状态、系统媒体控制中心里的会话优先级共同决定。以前很多开发者的做法是在onCreate里setActive(true)然后就不管了。API 33 之前这套确实能跑因为系统对媒体按键的分发相对宽松哪怕没有通知、没有焦点只要 session 存在并且 active按键事件大概率能到MediaSession.Callback。API 33 之后系统媒体控制中心Media Controls成为了媒体会话的总调度台。它不只负责 UI 显示还负责把MEDIA_BUTTON事件路由给它认为当前应该响应的媒体会话。如果你的 App 没有满足上面三条判定规则你在系统眼里就不是活跃媒体应用音量键旁边的媒体卡片不会出现耳机上的播放/暂停键自然也不会发给你。1.3 大多数升级后失灵的触发点我后来把这个问题的触发点总结成了一张图排查时对着看非常快通知权限被拒绝。Android 13 开始POST_NOTIFICATIONS变成了运行时权限。如果用户没有授权App 无法发布任何通知媒体通知自然发不出来。没有媒体通知的 session在部分机型上会被系统直接忽略。媒体通知没用 MediaStyle。有些项目直接用普通NotificationCompat.Builder构建通知然后塞一个contentIntent就算了。这种通知在系统媒体控制中心里不会被识别为媒体会话锁屏媒体控件不显示按键也不分发。前台服务类型没声明。targetSdk 34 之后启动媒体播放前台服务必须声明foregroundServiceTypemediaPlayback并携带FOREGROUND_SERVICE_MEDIA_PLAYBACK权限否则 Service 直接抛异常session 根本没机会 active。MediaButtonReceiver的exported设置不对。API 31 之后强制要求显式声明 exported很多项目从老版本升上来随手写了android:exportedfalse系统广播进不来按键事件直接丢失。你可能已经发现了这些触发点都不是什么高深的东西但因为分散在 Manifest、通知权限、前台服务、音频焦点好几个模块里单独看哪一块都觉得自己没问题合在一起才导致按键完全失联。2. 从通知权限到前台服务一份按优先级排序的排查清单2.1 第一站通知权限POST_NOTIFICATIONS很多项目升级 targetSdk 33 之后只在 Manifest 里加了uses-permission android:nameandroid.permission.POST_NOTIFICATIONS /然后就没有然后了。这是不够的。POST_NOTIFICATIONS是运行时权限targetSdk 33 应用在 Android 13 及以上设备上运行时必须像请求存储权限一样在代码里动态申请。如果你不申请或者用户拒绝最直接的结果是NotificationManager.notify()虽然不会抛异常但通知不会显示。媒体通知不显示系统媒体控制中心里就没有你的会话耳机按键也就不给你。我用了一个很直接的验证方式把 App 的通知权限从系统设置里手动打开再去按耳机键按键立刻恢复。这样就能确认问题是不是出在通知权限上。处理方案是在启动阶段就主动申请if (Build.VERSION.SDK_INT 33 ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) ! PackageManager.PERMISSION_GRANTED ) { ActivityCompat.requestPermissions( activity, arrayOf(Manifest.permission.POST_NOTIFICATIONS), REQUEST_CODE_POST_NOTIFICATIONS ) }建议不要用用户拒绝一次就永久不问的策略。媒体类 App 的通知权限直接影响核心功能最好在权限弹窗之前先给用户解释一下这个权限用于显示正在播放的媒体通知让你能用耳机和锁屏控制播放。用户的接受率会高很多。2.2 第二站前台服务声明与 Android 14 的类型要求媒体播放类 App 几乎一定会用到前台服务。一方面是为了在后台持续播放另一方面系统对媒体应用的判定也和前台服务强相关。Android 14API 34开始前台服务必须指定类型。如果你的 App targetSdk 升到了 34Manifest 里的 service 声明必须长这样service android:name.MediaPlaybackService android:foregroundServiceTypemediaPlayback android:exportedfalse /并声明权限uses-permission android:nameandroid.permission.FOREGROUND_SERVICE / uses-permission android:nameandroid.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK /代码里启动前台服务时Android 14 上也要传类型if (Build.VERSION.SDK_INT 34) { startForeground(NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK) } else { startForeground(NOTIFICATION_ID, notification) }这里有个很容易忽略的点如果你只在 Manifest 里写了foregroundServiceType但代码里调用startForeground()时没有传第三参或者第三参传的 mask 和 Manifest 不一致API 34 上同样会出问题。我遇到过的情况是Manifest 已经写了mediaPlayback但startForeground()漏传了类型结果真机直接MissingForegroundServiceTypeException。顺带一提如果你的 App 还有一个下载或播放缓存的前台服务不要混用类型。后台下载用dataSync媒体播放用mediaPlayback分开声明否则在系统层面可能出现类型冲突导致某些机型上前台服务启动失败。2.3 第三站MediaButtonReceiver 的 exported 与广播分发这是老项目升级时最容易踩的坑。Android 12API 31开始所有带intent-filter的四大组件都必须显式声明android:exported否则安装或更新时会直接失败。很多项目为了通过编译和安装随手统一加了android:exportedfalse正好把MediaButtonReceiver也给设成了 false。问题在于MediaButtonReceiver接收的是系统发出的android.intent.action.MEDIA_BUTTON广播系统属于外部调用方。如果 exported 是 false这个广播根本无法到达你的 receiver。正确的声明方式receiver android:name.MediaButtonReceiver android:exportedtrue intent-filter action android:nameandroid.intent.action.MEDIA_BUTTON / /intent-filter /receiver注意不要因为担心安全风险就把它设成 false。这里的 exported true 是系统分发媒体按钮事件所必需的跟普通的外部应用自定义广播不是一回事。当然你可以在 Receiver 内部校验 intent 的 action 和 package 来源做一层保护但 exported 属性不能改。2.4 用 dumpsys media_session 验证路由排查到这一步如果还不行就该用系统工具看看 session 的真实状态了。adb shell dumpsys media_session会输出当前所有 MediaSession 的信息包括包名、是否 active、PlaybackState 的 state、actions以及系统当前认定的 button receiver。我自己排查时重点关注这几块输出里有没有我们 App 的包名。如果没有说明 service 没启动或者 session 没创建或者 session 被释放了。有没有activetrue。如果 session 存在但没 active系统不会把它纳入媒体按键候选。state是什么。如果是STATE_NONE系统会认为你当前没有播放内容很可能不显示媒体卡片也不分发按键。actions里有没有PLAY_PAUSE之类。actions 为空的话系统可能认为你这个 session 不支持媒体控制。通过 dumpsys 基本可以把问题缩小到具体某一层比来回看日志高效太多。下面是一个检查清单直接照着过检查项期望值常见错误POST_NOTIFICATIONS 权限已动态申请且授权只在 Manifest 声明未 request前台服务类型mediaPlayback未声明类型或漏传 startForeground 第三参FOREGROUND_SERVICE_MEDIA_PLAYBACK声明targetSdk 34 未声明MediaButtonReceiver exportedtrue误写 falseMediaStyle 通知setMediaSession 绑定用普通通知样式MediaSession isActivetrue只在 onCreate 设置未保持PlaybackState statePLAYING / PAUSED一直 STATE_NONEPlaybackState actions包含 PLAY/PAUSE 等未设置3. 一个能直接跑通的最小实现Service、MediaSession 与媒体通知配合3.1 工程依赖与清单配置排查完之后我重新整理了一个最小可运行实现关键是把 Service、MediaSession、媒体通知、MediaButtonReceiver 四个角色的关系理顺。首先在依赖里加上 AndroidX Mediaimplementation androidx.media:media:1.7.0Manifest 里的完整配置uses-permission android:nameandroid.permission.FOREGROUND_SERVICE / uses-permission android:nameandroid.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK / uses-permission android:nameandroid.permission.POST_NOTIFICATIONS / uses-permission android:nameandroid.permission.WAKE_LOCK / application ... service android:name.MediaPlaybackService android:foregroundServiceTypemediaPlayback android:exportedfalse / receiver android:name.MediaButtonReceiver android:exportedtrue intent-filter action android:nameandroid.intent.action.MEDIA_BUTTON / /intent-filter /receiver /applicationWAKE_LOCK不是必须的但媒体播放场景通常需要持有唤醒锁防止播放时休眠所以我一般一起加上。3.2 Service 中 MediaSession 的初始化Service 的核心任务是创建 MediaSession保持 active设置正确的 PlaybackState并且把返回的 session token 交给媒体通知。class MediaPlaybackService : Service() { private lateinit var mediaSession: MediaSessionCompat private val notificationManager by lazy { getSystemService(NotificationManager::class.java) } override fun onCreate() { super.onCreate() mediaSession MediaSessionCompat(this, MediaPlaybackService).apply { setCallback(object : MediaSessionCompat.Callback() { override fun onPlay() { startPlayback() } override fun onPause() { pausePlayback() } override fun onSkipToNext() { skipToNext() } override fun onSkipToPrevious() { skipToPrevious() } }) isActive true setPlaybackState( 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 ) .setState(PlaybackStateCompat.STATE_NONE, 0L, 1.0f) .build() ) } } override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { if (intent?.action Intent.ACTION_MEDIA_BUTTON) { MediaButtonReceiver.handleIntent(mediaSession, intent) } return START_STICKY } override fun onBind(intent: Intent?): IBinder? null override fun onDestroy() { mediaSession.release() super.onDestroy() } }注意onStartCommand里那句MediaButtonReceiver.handleIntent(mediaSession, intent)它负责把广播里的KeyEvent解析成对应的onPlay、onPause、onSkipToNext等回调。这个调用不要漏不然 MediaButtonReceiver 收到广播后事件就断在中间了。3.3 媒体通知的绑定细节媒体通知不是普通通知它必须满足两个条件使用NotificationCompat.MediaStyle并且通过setMediaSession()绑定 session token。private fun buildMediaNotification(): Notification { val contentIntent PendingIntent.getActivity( this, 0, Intent(this, MainActivity::class.java), PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE ) return NotificationCompat.Builder(this, CHANNEL_ID) .setSmallIcon(R.drawable.ic_notification) .setContentTitle(正在播放测试歌曲) .setContentText(歌手信息) .setContentIntent(contentIntent) .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) .setOnlyAlertOnce(true) .setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE) .setStyle( androidx.media.app.NotificationCompat.MediaStyle() .setMediaSession(mediaSession.sessionToken) .setShowActionsInCompactView(0, 1, 2) ) .addAction(R.drawable.ic_prev, 上一首, mediaButtonPendingIntent(PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS)) .addAction(R.drawable.ic_play, 播放, mediaButtonPendingIntent(PlaybackStateCompat.ACTION_PLAY)) .addAction(R.drawable.ic_next, 下一首, mediaButtonPendingIntent(PlaybackStateCompat.ACTION_SKIP_TO_NEXT)) .build() }setMediaSession(mediaSession.sessionToken)非常关键。少了这一行通知就是一个长得像媒体通知的普通通知系统不会把它和 MediaSession 关联起来。3.4 处理 MediaButtonReceiver 的广播MediaButtonReceiver自己不需要写太多逻辑继承androidx.media.session.MediaButtonReceiver即可class MediaButtonReceiver : MediaButtonReceiver() { // 默认 onReceive 会负责把广播转为 startService 调用 // 如果 Service 需要额外处理可以重写 onReceive但必须调用 super }它在收到广播后会启动我们声明的MediaPlaybackService并把 intent 传进去。随后 Service 的onStartCommand调用MediaButtonReceiver.handleIntent(mediaSession, intent)把按键事件转成 Callback 回调。这里有一个小细节MediaButtonReceiver默认是通过ContextCompat.startForegroundService()启动服务的。也就是说广播接收器里发生了前台服务启动。从 Android 14 开始后台启动前台服务限制也更严了但 从媒体按钮广播启动媒体播放前台服务 是系统明确允许的例外场景所以这个链路上没有问题。如果你在onCreate里没有调用setMediaButtonReceiver()系统在某些情况下会走直接调用 MediaSession.Callback的路径不经过广播。两条路径我都在代码里做了兼容实测下来 Android 13 和 Android 14 都能稳定收到按键。4. 实测中很难复现的四个隐蔽坑从模拟器到多应用抢占4.1 模拟器测试与真机差异第一坑来自测试环境。Android 模拟器上媒体按键的模拟方式非常有限。adb shell input keyevent KEYCODE_MEDIA_PLAY_PAUSE在部分模拟器上会被当成普通键盘事件根本不会进入 MediaSession 的路由流程。哪怕按键事件到了系统模拟器也没有真实的 A2DP 蓝牙协议栈很多跟蓝牙耳机相关的按键状态变化在模拟器上根本不会发生。所以如果你在模拟器上测试发现按键没反应先别慌。换一台真机连接真实的蓝牙耳机或者用手机自带的耳机按键测试结果往往完全不同。我在这个坑上浪费了小半天最后发现代码逻辑没问题纯粹是模拟器环境不支持媒体按键分发。如果手头实在没有真机可以用系统媒体控制中心通知栏下拉后的媒体卡片点击播放/暂停来验证 session 是否活跃这比模拟器按键更接近真实场景。4.2 通知被清理后媒体按键立刻失效第二个坑和用户行为相关。媒体通知如果被用户滑掉但 Service 还在运行、MediaSession 还 active这时候按蓝牙耳机播放键经常会出现按键事件被系统接收到但没有任何 App 响应。原因在于系统媒体控制中心主要依赖媒体通知来维持当前活跃媒体会话的认知。通知没了系统可能还保留 session 在手势队列里但不会再去主动分发媒体按键给它。解决方案我在项目里改成了媒体通知设置为不可滑动删除setOngoing(true)并且在onTaskRemoved、服务被销毁等场景下保证通知状态的一致性。如果你不希望用户无法清除通知至少要在用户滑动通知时通过PendingIntent把停止播放并释放焦点的逻辑执行完否则就会出现通知没了但播放还在后台的中间状态按键自然乱套。顺带一提很多用户清理最近任务列表时会把你的 App 进程一起杀掉。如果服务是START_STICKY系统会尝试重建但重建后的 MediaSession 是否还能拿到媒体按键的优先路由取决于重建时机和通知是否恢复。建议在onTaskRemoved里面不要简单 stopSelf而是根据当前是否在播放来决定是继续驻留还是优雅停止。4.3 音频焦点被忽略的隐形条件第三个坑在代码之外。很多媒体 App 调用了setAudioAttributes、setAudioFocusRequest但有些项目从来不管音频焦点觉得只要 MediaSession active系统就该把按键给我。API 33 之后不是这样。系统在决定媒体按键归属时会把音频焦点状态作为一个重要参考。如果你的 App 在播放时没有持有音频焦点系统会认为你并不是真正在播放的那一方甚至可能把按键路由给其他正在持有焦点的媒体 App。我的做法是在onPlay()回调中请求音频焦点在onPause()/onStop()中放弃焦点。Android 8.0API 26以上推荐用AudioFocusRequestprivate val audioFocusRequest by lazy { AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN) .setAudioAttributes( AudioAttributes.Builder() .setUsage(AudioAttributes.USAGE_MEDIA) .setContentType(AudioAttributes.CONTENT_TYPE_MUSIC) .build() ) .setOnAudioFocusChangeListener { focusChange - when (focusChange) { AudioManager.AUDIOFOCUS_LOSS - pausePlayback() AudioManager.AUDIOFOCUS_LOSS_TRANSIENT - pausePlayback() AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK - { // 降低音量而不暂停 } } } .build() }只有拿到焦点播放状态和系统媒体控制中心的状态才能保持一致。4.4 多个媒体应用抢着认领按键第四个坑和竞争对手有关。如果手机里装了多个媒体类 App比如系统自带音乐、QQ 音乐、网易云而你自己的 App 只是偶尔播放一下那么系统更容易把耳机按键优先给最近活跃过的媒体 App而不是你的 App。这种情况下光把 MediaSession setActive 不够还需要在每次播放前主动请求音频焦点并确保 media notification 已经发布。因为系统媒体控制中心一般把最近一次持有焦点且发布过媒体通知的会话放在优先位置。我还遇到过一种情况用户用语音助手长按耳机键后系统会把短按媒体键的处理权临时交给语音助手。这时候不是你的代码问题而是系统层面的语音助手抢占。判断方法很简单用dumpsys media_session看当时的 button receiver 指向谁如果指向的是语音助手的包名那就不是你能控制的场景。5. 让 MediaSession 更稳的进阶配置焦点、动作、适配 Android 145.1 PlaybackState 的 state 和 actions 必须跟着播放状态走一个非常常见的问题App 启动后立刻把 MediaSession 设为 active但 PlaybackState 一直停在STATE_NONE然后去按耳机键系统不给任何响应。原因是系统媒体控制中心倾向于只把媒体按键分发给当前有播放状态的会话。STATE_NONE在系统看来等于这个应用暂时没有在放东西。所以正确的做法是开始播放setState(STATE_PLAYING, position, playbackSpeed)。暂停setState(STATE_PAUSED, position, 0f)。还有STATE_STOPPED、STATE_BUFFERING等按场景设置。actions 也一样不要只设置一次就忘记更新。比如播放中把ACTION_PAUSE放出来暂停时把ACTION_PLAY放出来这样锁屏控件和耳机按键的行为才会符合预期。5.2 锁屏场景和耳机事件的处理策略如果你的 App 常驻后台并且需要支持锁屏媒体控制可以额外做两件事在onPlay()过程中尽早把前台服务拉起来并发布媒体通知。锁屏控件依赖通知的可见性setVisibility(NotificationCompat.VISIBILITY_PUBLIC)必须设置否则锁屏上不显示。对于耳机线控很多现代耳机发送的是KEYCODE_HEADSETHOOK系统通常会把它转换成KEYCODE_MEDIA_PLAY_PAUSE。如果你的自定义耳机按键希望区分单击、双击建议在onPlayPause和onSkipToNext上处理逻辑而不是自己拦截 KeyEvent。因为系统在分发时可能已经做了动作合并。Android 系统本身对媒体按键的 KeyEvent 分发偶尔会有抖动我见过耳机按一次出现两次onPlayPause的情况。稳妥的做法是在播放状态切换前增加一个短时间去重比如 300ms避免明显的停顿和恢复抖动。这个在低端蓝牙耳机上尤其明显。5.3 适配 Android 14 的强制前台服务类型最后再说一次 Android 14 的适配因为这是API 33里最容易让应用崩溃的一环而且和媒体按键看起来毫无关系实际关系极大。如果需要把 targetSdk 升到 34请务必做这三件事Manifest 里声明FOREGROUND_SERVICE_MEDIA_PLAYBACK权限。Service 的android:foregroundServiceType设为mediaPlayback。代码里startForeground()传入正确的类型枚举。如果漏掉任何一项前台服务启动会抛MissingForegroundServiceTypeException或SecurityException。服务起不来MediaSession 自然不会 active媒体按键就像石沉大海一样毫无反应。那种崩溃日志在启动 Service 那一刻就出现但我一开始根本没往崩溃上想的经历希望你不要再经历一遍。另外Android 14 对后台启动前台服务的限制进一步收紧。从后台点击通知启动 mediaPlayback 类型的服务是允许的但普通startService()启动 mediaPlayback 服务则可能被拒绝。所以你的媒体按键链路里MediaButtonReceiver转发广播这个环节不能省它是系统认可的合法启动路径。5.4 最后的调试姿势整个排查过程中对我帮助最大的两个命令分享给你# 查看当前 MediaSession 状态、路由优先级、button receiver adb shell dumpsys media_session # 查看系统当前前台服务以及类型 adb shell dumpsys activity servicesdumpsys media_session的输出里重点看有没有你的包名、activetrue、state以及buttonReceiver。当你在多个媒体应用之间来回切换时这个输出能直观展示系统把按键优先给了谁。我个人在实际操作中的体会是MediaSession 在 API 33 上面临的问题绝大多数都不是MediaSession 本身的问题而是它和通知权限、前台服务、音频焦点、系统媒体控制中心之间的协作没有闭环。把这几个模块串起来看问题定位会快很多。最后再说一个有点反直觉的小技巧如果你的播放器在通知栏媒体卡片上点击正常但耳机按键不响应优先去看dumpsys media_session里的 button receiver 指向很多时候是系统把按键路由给了上一次控制过媒体的其他 App和你的代码没有半点关系。