ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

Android 13/14 媒体按键失效?MediaSession 适配指南与避坑实践

2026/9/9 19:00:24 拓冰建站 浏览量
Android 13/14 媒体按键失效?MediaSession 适配指南与避坑实践 最近在做一个音乐播放器项目把 targetSdk 升到 34 之后用户反馈说蓝牙耳机和有线耳机的播放/暂停键都没反应了。我一开始以为是蓝牙协议栈或者耳机兼容性的问题排查了一圈才发现根本不是——是 API 33 之后媒体按键的分发机制变了MediaSession 压根没拿到按键事件。这个问题在 Android 13/14 上非常典型尤其是从 targetSdk 32 以下升上来、或者新建项目直接跑在 API 33 设备上的场景十有八九会踩中。这篇文章不聊虚的直接把我踩过的坑、验证过的方案、以及最后能稳定收到媒体按键的写法全部整理出来。整个排查过程涉及 MediaSession 的注册、通知权限、前台服务类型、PlaybackState 状态、音频焦点等几个关键环节任何一个环节漏了耳机按键都可能失灵。建议开发播放器、收音机、播客类 App 的同学仔细看一下尤其是那些还在用旧版 registerMediaButtonEventReceiver 方案的老项目。1. 问题本质API 33 前后媒体按键处理机制到底改了什么1.1 从“谁都能收”到“系统说了算”在 Android 8.0API 26之前媒体按键事件通过广播分发应用可以注册MediaButtonReceiver来接收耳机按键只要你的 receiver 在清单里声明了对应的 intent-filter按键按下时系统广播就会发给你。但从 Android 8.0 开始系统把媒体按键集中到了MediaSession上不再向普通广播接收器分发按键事件。到了 Android 13API 33这个趋势进一步加强。系统会只把媒体按键事件分发给“当前活跃的 MediaSession”而不是所有注册过的 MediaSession。而且这个“活跃”是有明确条件的必须是setActive(true)状态必须设置了有效的PlaybackState并且 state 不能是STATE_NONE同时还需要持有音频焦点。最容易被忽略的一点是——从 API 33 开始如果应用没有显示媒体通知也就是没有MediaStyle通知系统在部分场景下会直接不把这个会话当作可接收媒体按键的候选对象。这就是很多 App 升级后按键失灵的根源老代码里可能只调用了setActive(true)但没正确处理通知权限或者通知权限被用户拒绝后媒体通知根本发不出来系统就认为这个 App 没有在播放媒体。1.2 到底是 Permission 问题还是 Session 问题我那次排查的第一个误区就是只盯着权限看。API 33 新增了POST_NOTIFICATIONS运行时权限很多人以为是这个权限没申请导致按键失效。但实际上这个权限影响的是“媒体通知能不能显示出来”而不是“MediaSession 能不能注册”。实际现象是这样如果POST_NOTIFICATIONS权限被拒绝应用仍然可以创建 MediaSession、可以调setActive(true)也可以正常播放音频。但在 Android 13 以上的部分版本和 ROM 上系统 UI 的媒体控制中心就是通知栏下拉后那个媒体卡片不会显示这个会话耳机的媒体按键也可能会被分发给其他 Session甚至直接丢弃。所以正确的理解是通知权限是媒体按键链路上的一环但不是唯一环节。真正决定按键能不能到你这边的是系统端MediaSessionManager对“当前主媒体会话”的判定。这个判定涉及下面几个要素Session 是否setActive(true)Session 是否设置了合法的 PlaybackState尤其是 state 和 actionsApp 是否在前台或处于允许的后台运行状态是否有可见的媒体通知API 33 较为关键是否成功请求并持有音频焦点任何一个环节出问题MediaButton 事件就不会进入你的onMediaButtonEvent回调。1.3 和旧代码的兼容性对比我在 Stack Overflow 和各类技术社区看到了大量同类问题基本上可以分为三类第一类是项目之前用了AudioManager.registerMediaButtonEventReceiver()或者MediaSessionManager的旧接口这些接口在新系统上要么被废弃要么行为不一致导致按键事件丢失。第二类是项目引入了多个 MediaSession 实例比如播放器一个、录音一个、甚至广告 SDK 一个系统不知道把按键交给谁。第三类是播放器用了 Media3/ExoPlayer它的内部会创建自己的 MediaSession结果开发者自己又手动创建了一个两个 Session 互相打架。我自己的项目属于第一类加第三类的混合体。旧的逻辑里注册了一个MediaButtonReceiver同时 ExoPlayer 内部又维护了一个 Session升级之后新老机制互相干扰最终表现就是什么都没收到。所以在真正动手改代码之前我建议你先做一件事好好梳理一下当前项目里到底有哪几个地方能创建 MediaSession有没有重复注册有没有多个播放器同时争抢。2. 核心细节解析与实操要点2.1 检查你的 Manifest 有没有漏掉必要声明如果你使用的是androidx.media:media:1.6.0或更高版本并且 targetSdk 是 33 以上这里有几个容易漏掉的清单配置。第一个是前台服务类型。API 34Android 14对前台服务类型要求非常严格播放音频必须声明foregroundServiceTypemediaPlayback。API 33 虽然没有强制但从 34 开始如果不声明启动前台服务直接抛ForegroundServiceStartNotAllowedException或MissingForegroundServiceTypeException。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 /Service 声明部分要这样加service android:name.PlaybackService android:exportedtrue android:foregroundServiceTypemediaPlayback intent-filter action android:nameandroidx.media3.session.MediaSessionService / action android:nameandroid.media.browse.MediaBrowserService / /intent-filter /service注意如果你的 Service 要能被系统媒体中心发现android:exported必须为true。很多开发者在适配的时候为了安全把exported设成false结果媒体中心里永远看不到这个会话按键自然也不会过来。另外如果你使用MediaSessionCompatAndroidX 版本还需要在 Manifest 里声明MediaButtonReceiver但注意这个 Receiver 并不是用来接收按键的而是用来在应用被系统启动时把Intent转交给MediaSessionCompat处理的。我见过不少项目在升级过程中把这个 Receiver 删了导致按耳机键时应用能被拉起但按键动作不生效。2.2 通知权限的动态申请不能省略从 API 33 开始POST_NOTIFICATIONS是运行时权限必须在代码里动态申请。这一步如果漏了用户安装后默认是“拒绝”状态媒体通知无法展示。但这里有个坑很多播放器是在用户点击播放按钮时才弹出权限弹窗用户如果点了一次“拒绝”之后再想申请就只能去设置里手动开启。如果你的用户量上去了这个体验问题会被无限放大。我的建议是在第一次进入 App 时就用一个说明性的弹窗解释为什么要通知权限“用于显示锁屏播放控制和媒体通知”用户同意后再调用系统权限请求不要在用户刚打开 App 的第一秒就弹权限那样拒绝率极高如果权限被拒绝播放功能可以正常使用但要明确告诉用户“锁屏控制不可用”这一后果权限请求的标准写法Kotlinprivate fun requestNotificationPermission() { if (Build.VERSION.SDK_INT 33) { if (ContextCompat.checkSelfPermission( this, Manifest.permission.POST_NOTIFICATIONS ) ! PackageManager.PERMISSION_GRANTED ) { ActivityCompat.requestPermissions( this, arrayOf(Manifest.permission.POST_NOTIFICATIONS), REQUEST_CODE_POST_NOTIFICATIONS ) } } }这个流程看起来简单但和第 2.1 节的内容是配合使用的没有权限 → 通知不显示 → 系统媒体中心看不到你 → 按键不给你。2.3 PlaybackState 里的 Actions 决定了系统愿意给你发什么按键这是一个非常隐蔽但极其关键的点。有些开发者把 PlaybackState 设置成了STATE_PLAYING但actions字段里没有包含ACTION_PLAY_PAUSE结果就是耳机上按播放/暂停键时系统判断这个 Session 不支持该操作就直接把按键丢掉了。我建议你把这些 actions 全部加上除非你的播放器真的不支持某些操作val actions ( PlaybackStateCompat.ACTION_PLAY or PlaybackStateCompat.ACTION_PAUSE or PlaybackStateCompat.ACTION_PLAY_PAUSE or PlaybackStateCompat.ACTION_STOP or PlaybackStateCompat.ACTION_SEEK_TO or PlaybackStateCompat.ACTION_SKIP_TO_NEXT or PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS or PlaybackStateCompat.ACTION_FAST_FORWARD or PlaybackStateCompat.ACTION_REWIND ).toLong()注意actions要从Builder的setActions方法传入并且state不能是STATE_NONE否则系统会认为这是一个无效会话。我自己的经验是state要区分播放和暂停两种状态不要永远停留在STATE_PLAYING。很多系统 ROM 在判断“当前主媒体会话”时会优先选择正在STATE_PLAYING的那个会话。如果你的 App 实际上暂停了但 state 还写PLAYING会导致锁屏界面状态错乱甚至出现两个 App 同时显示播放状态。2.4 音频焦点容易被忽略的按键分发前提我遇到的一个诡异情况是App 内播放正常锁屏媒体卡片也显示了但蓝牙耳机按键就是没反应。后来通过抓取系统日志发现MediaSession 收到了按键但在处理按键时因为音频焦点不在自己身上被系统拦截了。这里要理清两个概念requestAudioFocus和 MediaSession 的setActive是两回事。MediaSession 的活跃状态负责“系统把按键分发给谁”而音频焦点负责“按键分发过来之后你的 onPlay/onPause 能不能顺利执行”。实际上在部分 ROM 上音频焦点还会反向影响 MediaSession 的优先级。我建议在播放开始时这样处理val audioFocusRequest 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 - { // 暂停播放并且可以降低 MediaSession 的 active 状态 pausePlayback() } AudioManager.AUDIOFOCUS_LOSS_TRANSIENT - { // 暂停播放等焦点恢复后再继续 pausePlayback() } AudioManager.AUDIOFOCUS_GAIN - { // 焦点恢复继续播放 resumePlayback() } } } .build() val result audioManager.requestAudioFocus(audioFocusRequest)这里有一个容易被忽略的点AUDIOFOCUS_GAIN请求成功之后要等一小段时间再设置 MediaSession 的PlaybackState。我遇到过在焦点还没完全授予时就去更新 Session 状态结果 Session 被其他应用抢占的情况。稳妥的做法是在onAudioFocusChangeListener收到AUDIOFOCUS_GAIN回调后再调用mediaSession.isActive true和playbackState的更新。3. 实操过程与核心环节实现3.1 一个最小可用的 MediaSession 实现写一个能正常接收媒体按键的最小工程。这里我以普通的 Service 播放器为例子用MediaSessionCompat来保证在 Android 13/14 上都能工作同时兼容旧版本。先看 Service 的核心部分class PlaybackService : Service() { private lateinit var mediaSession: MediaSessionCompat private lateinit var audioManager: AudioManager private var audioFocusRequest: AudioFocusRequest? null override fun onCreate() { super.onCreate() audioManager getSystemService(Context.AUDIO_SERVICE) as AudioManager setupMediaSession() startAsForeground() } private fun setupMediaSession() { val componentName ComponentName(this, MediaButtonReceiver::class.java) mediaSession MediaSessionCompat(this, PlaybackService, componentName, null) mediaSession.setCallback( object : MediaSessionCompat.Callback() { override fun onPlay() { super.onPlay() startPlayback() updatePlaybackState(PlaybackStateCompat.STATE_PLAYING) } override fun onPause() { super.onPause() pausePlayback() updatePlaybackState(PlaybackStateCompat.STATE_PAUSED) } override fun onSkipToNext() { super.onSkipToNext() playNext() } override fun onSkipToPrevious() { super.onSkipToPrevious() playPrevious() } override fun onMediaButtonEvent(mediaButtonEvent: Intent): Boolean { Log.d(MediaButtonTest, onMediaButtonEvent: ${mediaButtonEvent.action}) return super.onMediaButtonEvent(mediaButtonEvent) } override fun onPlayFromMediaId(mediaId: String, extras: Bundle?) { super.onPlayFromMediaId(mediaId, extras) playSpecificMedia(mediaId) } } ) val mediaStyle NotificationCompat.MediaStyle() mediaStyle.setMediaSession(mediaSession.sessionToken) mediaStyle.setShowActionsInCompactView(0, 1, 2) val notification NotificationCompat.Builder(this, CHANNEL_ID) .setSmallIcon(R.drawable.ic_notification) .setContentTitle(正在播放) .setContentText(测试媒体会话) .setStyle(mediaStyle) .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) .setPriority(NotificationCompat.PRIORITY_LOW) .setOnlyAlertOnce(true) .setOngoing(true) .build() mediaSession.isActive true updatePlaybackState(PlaybackStateCompat.STATE_PLAYING) } private fun updatePlaybackState(state: Int) { val actions ( PlaybackStateCompat.ACTION_PLAY or PlaybackStateCompat.ACTION_PAUSE or PlaybackStateCompat.ACTION_PLAY_PAUSE or PlaybackStateCompat.ACTION_STOP or PlaybackStateCompat.ACTION_SKIP_TO_NEXT or PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS or PlaybackStateCompat.ACTION_SEEK_TO ).toLong() val playbackState PlaybackStateCompat.Builder() .setActions(actions) .setState(state, PlaybackStateCompat.PLAYBACK_POSITION_UNKNOWN, 1.0f) .build() mediaSession.setPlaybackState(playbackState) } override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { MediaButtonReceiver.handleIntent(mediaSession, intent) return START_NOT_STICKY } override fun onDestroy() { super.onDestroy() mediaSession.release() if (Build.VERSION.SDK_INT 26) { audioFocusRequest?.let { audioManager.abandonAudioFocusRequest(it) } } } override fun onBind(intent: Intent?): IBinder? null }注意几个关键点onStartCommand里调用了MediaButtonReceiver.handleIntent(mediaSession, intent)这一步是将系统通过PendingIntent发过来的Intent转成对应的onPlay、onPause回调。如果不调用这个你将只能收到onMediaButtonEvent而onPlay、onPause这些语义化回调不会被触发。updatePlaybackState中的setState第三参数是播放速度。这个参数不能传 0否则系统在计算播放位置时会产生异常行为极端情况下会导致 Session 被系统判定为无效。播放中应该传1.0f暂停时传0.0f。我见过有些人统一传1.0f暂停时位置还在往前跑锁屏界面进度条就乱了。startAsForeground()方法里要传通知。有些人会在onCreate里先调startForeground再创建 MediaSession这个顺序没问题。但注意不能先创建 MediaSession 再后补通知也不要在通知还没有构建出来时调用 startForeground否则服务会崩溃或者通知不显示。3.2 用 adb 模拟媒体按键来验证问题有些情况下没有实体耳机或者蓝牙连接不稳定怎么验证 MediaSession 能不能收到按键用 adb 模拟是最快的方案。# 模拟播放/暂停键按下 adb shell input keyevent 85 # 模拟下一曲 adb shell input keyevent 87 # 模拟上一曲 adb shell input keyevent 88 # 停止 adb shell input keyevent 86按键事件发出后你会希望看到 Logcat 里出现对应的日志。但这里有个大前提adb 模拟的 keyevent 走的是系统输入管道不一定会经过音频策略模块直接分发给 MediaSession。在部分设备上adb shell input keyevent 85确实会触发 MediaSession 的onMediaButtonEvent但在另外一些设备上毫无反应。如果你发现 adb 模拟按键无效不要急着下结论。更可靠的方法是直接用媒体控制器发送命令# 通过 cmd media_session 发送 play 命令 adb shell cmd media_session dispatch play # 发送 pause 命令 adb shell cmd media_session dispatch pause # 发送 next 命令 adb shell cmd media_session dispatch next如果系统返回错误可以查看帮助adb shell cmd media_session help我通常用两种方式组合验证先cmd media_session dispatch play确认 MediaSession 本身没问题再用input keyevent 85模拟真实按键。前者验证回调链路后者验证系统级按键分发。两者都通过说明从 MediaSession 到业务逻辑这条链路是通的。另外推荐用dumpsys media_session来确认会话状态adb shell dumpsys media_session输出里重点看当前是否有活跃的 MediaSession每个 Session 的 package name、isActive 状态playbackState 的 state 和 actions是否有“Media button receiver”相关的信息如果你在输出里看不到自己的包名说明 MediaSession 根本没有成功注册。如果看到了但 show 的内容里 state 是STATE_NONE说明是 playbackState 设置有问题。3.3 ExoPlayer/Media3 场景下的特殊处理如果你的播放器是基于 ExoPlayer 或 Media3 的实际上 Media3 内部已经帮你创建了MediaSession你不需要自己再手动 new 一个。但很多老项目的架构是在 ExoPlayer 之外又包了一层自己定义的 MediaSession两个 Session 同名甚至不同名同时存在系统会随机选择一个进行分发。我建议的方案是如果使用 ExoPlayer直接用MediaSession来自androidx.media3:media3-session包装你的Player实例不要自己再创建MediaSessionCompat旧代码里的MediaButtonReceiver和AudioManager.registerMediaButtonEventReceiver逻辑全部移除Media3 的具体写法// Player 是 ExoPlayer 的实例 mediaSession MediaSession.Builder(context, player) .setCallback(object : MediaSession.Callback { override fun onMediaButtonEvent( session: MediaSession, intent: Intent ): Boolean { Log.d(Media3Test, onMediaButtonEvent: ${intent.action}) return MediaSession.Callback.CONTINUE_PROPAGATION } }) .build()如果你需要在收到按键时做一些自定义逻辑比如记录打点可以在Callback里拦截然后返回CONTINUE_PROPAGATION让 Media3 继续处理。返回Handled或者返回 false 则会让 Media3 认为你已经消费了事件。这里有个细节Media3 的MediaSession.Callback默认会处理onPlay、onPause等所有操作。如果你在自定义逻辑里把事件消费掉了播放器就不会暂停/继续了所以一般情况下回调处理完业务后要放行。多 Session 并存的问题也是 Media3 场景常见的坑。如果你的项目里有多个Player实例比如一个用于主播放一个用于试听那就对应多个MediaSession。此时要确保同一时间只有一个 Session 是active的。fun switchToMainPlayer() { previewMediaSession?.isActive false mainMediaSession?.isActive true }系统在处理媒体按键时会优先选择 active 且有音频焦点的 Session。如果两个 Session 都是 active那结果就是随机的用户按键时有时这边反应、有时那边反应非常难排查。3.4 锁屏和通知栏控制中心的兼容适配锁屏媒体控制这一块在 API 33 之后也变了不少。如果你发现耳机按键能收到但锁屏页面没有出现媒体卡片那问题多半出在通知的visibility和category上。MediaStyle 通知需要满足这些条件才会出现在锁屏上通知的 visibility 为VISIBILITY_PUBLIC通知的 category 设置为CATEGORY_TRANSPORT通知的 channel 优先级不能太低不能是IMPORTANCE_NONE通知必须通过MediaStyle.setMediaSession()绑定 MediaSession其中setCategory(NotificationCompat.CATEGORY_TRANSPORT)这一步经常被忽略。虽然有部分 ROM 在不设置时也能显示但为了保证兼容性最好写上。另外如果你需要展示下一曲/上一曲按钮通过addAction添加ACTION_MEDIA_PREVIOUS、ACTION_PLAY_PAUSE、ACTION_MEDIA_NEXT三个 action然后在MediaStyle.setShowActionsInCompactView(0, 1, 2)里把这三个索引传进去。这样在锁屏/通知栏下拉时会显示这三个按钮。4. 常见问题与排查技巧实录4.1 问题排查速查表这张表是我在实际排障过程中整理出来的基本涵盖了 API 33 设备上 MediaSession 监听不到媒体按键的绝大多数原因。现象可能原因验证方法耳机按键完全无反应Logcat 无任何输出MediaSession 未注册成功或未 active执行adb shell dumpsys media_session查看包名是否在列表中有反应但时灵时不灵多个 MediaSession 冲突系统随机分发在 onCreate 打印所有 Session 的创建堆栈确认只有一个实例播放正常但暂停后按键无法恢复播放PlaybackState 在暂停时 actions 不包含 ACTION_PLAY设置 PlaybackState 时把播放和暂停的 actions 同时加上通知栏不显示媒体卡片POST_NOTIFICATIONS 权限被拒绝检查通知权限状态在系统设置中手动开启锁屏不显示媒体卡片但通知栏显示通知未设置 visibility 或 category检查 MediaStyle 通知的 visibility/category 配置有线耳机按键无效蓝牙耳机无效但实体按键有效音频焦点未成功请求或 AudioAttributes 类型不对打印 requestAudioFocus 结果确认是 AUDIOFOCUS_REQUEST_GRANTEDApp 在前台时按键有效退到后台后失效Service 被系统杀死或未正确 startForeground检查 Service 是否还在运行查看adb shell dumpsys activity services只有按一下有效连续按第二下就失效通知的setOngoing(true)未设置或通知被系统回收确保通知常驻并设置 ongoing按键事件触发了 App 启动但没触发播放/暂停MediaButtonReceiver 没有调用 handleIntent检查 Service onStartCommand 里是否调用了MediaButtonReceiver.handleIntent4.2 三步定位日志、状态、系统分发如果看了速查表还没定位到问题我推荐按下面的顺序系统排查。第一步在onMediaButtonEvent回调里加日志。如果这个回调都没被触发说明系统压根没把按键分发给你的 Session问题出在注册/状态上。如果回调触发了但没有走到onPlay/onPause问题出在 Intent 转交逻辑上。第二步通过dumpsys media_session检查 Session 的实时状态。注意看这些字段adb shell dumpsys media_session | grep -A 20 -i your.package.name重点关注statePlaybackState {state33 是 STATE_PLAYING以及isActivetrue。如果 state 是 0STATE_NONE或者isActivefalse不管权限怎么配按键都不该到你这边。第三步检查系统音频策略的按键分发日志。这一步对普通开发者来说有点黑盒但可以试试adb shell logcat -d | grep -i MediaSession adb shell logcat -d | grep -i MediaButton adb shell logcat -d | grep -i AudioService在一些原生系统上你甚至能在 Logcat 里看到按键被分发给了哪个包名。比如看到dispatchMediaKeyEvent to com.spotify就知道系统把按键给了别人那就要检查你的 Session 优先级为什么比别人的低。4.3 我踩过的一个非常隐蔽的坑targetSdk 32 和 33 的行为差异如果同一套代码在 API 33 上按键失效但在 API 32 或更低版本上正常那问题很可能是targetSdk升级导致的。注意这里说的是 targetSdk 变化不是系统版本变化。举个例子同一个 APKtargetSdk 32跑在 Android 14 手机上MediaSession 按键正常但把 targetSdk 升到 34 后打出来的包跑在同一台手机上按键就失效了。原因很可能是权限模型的差异。targetSdk 32 及以下通知权限默认授予系统不知道你没有POST_NOTIFICATIONS权限媒体通知照常显示媒体会话照常可见。targetSdk 33 后通知权限默认拒绝如果你的代码在权限弹窗没出现或者用户没授权的情况下继续播放媒体通知就被系统隐藏了媒体会话在一个“不可见”的状态下运行按键行为就发生变化。所以我在适配时总是先检查一个事情targetSdk从 32 升到 33 之后Manifest.permission.POST_NOTIFICATIONS有没有在代码里动态申请。如果只是在 Manifest 里声明了uses-permission但没动态申请那等于没加权限默认还是拒绝。还有一种情况是权限弹窗出现了但用户在弹窗出现前就点了播放。这会触发后续的时序问题权限还没回调播放已经开始MediaSession 已经 active但通知因为权限不足发不出来。当用户最终授权后服务虽然还在跑但通知和 Session 之间的绑定可能已经错位了。我最终的解决办法是在PlaybackService的onCreate里先保证通知权限状态已知再执行startForeground。如果权限没有授予就先把播放操作挂起等授权回调后再继续。虽然这样稍微复杂一点但能够从根本上避免时序问题。4.4 厂商 ROM 的额外“惊喜”最后单独说一嘴厂商 ROM。国内各种定制 ROM 对后台播放、媒体按键的处理策略五花八门同一个原生逻辑在不同 ROM 上表现差异巨大。我遇到过的问题包括小米手机在省电策略下会直接杀后台播放 Service按键自然失效vivo 的某个版本需要应用在“自启动管理”里被允许才能收到媒体按键华为手机在 API 33 升级后必须把应用加入“电池优化白名单”否则后台播放一段时间后 MediaSession 会被系统回收。这一块无法通过代码完全规避但可以做到两点一是保证START_STICKY和服务重建逻辑让进程被杀后能快速恢复二是在用户首次使用时引导用户进行电池策略设置。另外如果测试时发现设备上按键时而有效时而无效先别急着怀疑代码建议在另一台不同品牌的设备上复测一遍排除 ROM 干扰。我在实测中还有一个体会很多问题在原生 Android 模拟器上是完全复现不出来的比如通知权限被拒后媒体卡片消失、MediaButtonReceiver 失效等。有条件还是多借几台不同厂商、不同 Android 版本的实体设备来测试这比在模拟器上反复调代码有效得多。4.5 从旧项目迁移的替代方案如果你的项目比较大不方便把所有逻辑都迁移到 Media3那可以在保留MediaSessionCompat的基础上做好兼容。我给你一个可落地的改造清单在AndroidManifest.xml中保留MediaButtonReceiver声明不要删除在PlaybackService.onStartCommand中调用MediaButtonReceiver.handleIntent(mediaSession, intent)动态申请POST_NOTIFICATIONS权限并在权限回调后再展示媒体通知确保MediaSession只在一个地方创建并且播放状态切换时更新PlaybackState在setCallback里实现onPlay、onPause、onSkipToNext等核心方法在播放和暂停时正确更新PlaybackState不要停留在STATE_NONE保持MediaStyle通知常驻并设置setOngoing(true)这套改造方案不依赖 Media3纯用 AndroidX Media 库也可以稳定工作。我在一个线上音乐 App 里就是这么改的改造完之后在 Android 13、14 上无论有线耳机还是蓝牙耳机按键都能正常触发。如果你有精力继续往下优化建议后续把整个播放器迁移到 Media3毕竟 Google 官方的新特性都在 Media3 上迭代MediaSessionCompat 虽然暂时还能用但维护精力会越来越大。我个人在实际排障中的体会是遇到 MediaSession 按键失效先不要慌着改代码把dumpsys media_session的状态看一遍把onMediaButtonEvent的日志打出来分清是“系统没发给你”还是“发给你了但你没处理”这两个方向排查路径完全不同。很多时候问题都不是出在“监听”本身而是出在“系统认为你没有资格监听”。把 Session 的 active 状态、PlaybackState 的完整度、通知的可见性和音频焦点这几件事做对按键自然就回来了。