最近在做一个音乐播放器项目,把 targetSdk 升到 34 之后,用户反馈说蓝牙耳机和有线耳机的播放/暂停键都没反应了。我一开始以为是蓝牙协议栈或者耳机兼容性的问题,排查了一圈才发现根本不是——是 API 33 之后媒体按键的分发机制变了,MediaSession 压根没拿到按键事件。这个问题在 Android 13/14 上非常典型,尤其是从 targetSdk 32 以下升上来、或者新建项目直接跑在 API 33+ 设备上的场景,十有八九会踩中。
这篇文章不聊虚的,直接把我踩过的坑、验证过的方案、以及最后能稳定收到媒体按键的写法全部整理出来。整个排查过程涉及 MediaSession 的注册、通知权限、前台服务类型、PlaybackState 状态、音频焦点等几个关键环节,任何一个环节漏了,耳机按键都可能失灵。建议开发播放器、收音机、播客类 App 的同学仔细看一下,尤其是那些还在用旧版 registerMediaButtonEventReceiver 方案的老项目。
1. 问题本质:API 33 前后媒体按键处理机制到底改了什么
1.1 从“谁都能收”到“系统说了算”
在 Android 8.0(API 26)之前,媒体按键事件通过广播分发,应用可以注册MediaButtonReceiver来接收耳机按键,只要你的 receiver 在清单里声明了对应的 intent-filter,按键按下时系统广播就会发给你。但从 Android 8.0 开始,系统把媒体按键集中到了MediaSession上,不再向普通广播接收器分发按键事件。
到了 Android 13(API 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 和 actions)
- App 是否在前台或处于允许的后台运行状态
- 是否有可见的媒体通知(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 34(Android 14)对前台服务类型要求非常严格,播放音频必须声明foregroundServiceType="mediaPlayback"。API 33 虽然没有强制,但从 34 开始如果不声明,启动前台服务直接抛ForegroundServiceStartNotAllowedException或MissingForegroundServiceTypeException。
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" /> <uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> <uses-permission android:name="android.permission.WAKE_LOCK" />Service 声明部分要这样加:
<service android:name=".PlaybackService" android:exported="true" android:foregroundServiceType="mediaPlayback"> <intent-filter> <action android:name="androidx.media3.session.MediaSessionService" /> <action android:name="android.media.browse.MediaBrowserService" /> </intent-filter> </service>注意:如果你的 Service 要能被系统媒体中心发现,android:exported必须为true。很多开发者在适配的时候为了安全把exported设成false,结果媒体中心里永远看不到这个会话,按键自然也不会过来。
另外,如果你使用MediaSessionCompat(AndroidX 版本),还需要在 Manifest 里声明MediaButtonReceiver,但注意这个 Receiver 并不是用来接收按键的,而是用来在应用被系统启动时把Intent转交给MediaSessionCompat处理的。我见过不少项目在升级过程中把这个 Receiver 删了,导致按耳机键时应用能被拉起,但按键动作不生效。
2.2 通知权限的动态申请不能省略
从 API 33 开始,POST_NOTIFICATIONS是运行时权限,必须在代码里动态申请。这一步如果漏了,用户安装后默认是“拒绝”状态,媒体通知无法展示。
但这里有个坑:很多播放器是在用户点击播放按钮时才弹出权限弹窗,用户如果点了一次“拒绝”,之后再想申请就只能去设置里手动开启。如果你的用户量上去了,这个体验问题会被无限放大。
我的建议是:
- 在第一次进入 App 时就用一个说明性的弹窗解释为什么要通知权限(“用于显示锁屏播放控制和媒体通知”),用户同意后再调用系统权限请求
- 不要在用户刚打开 App 的第一秒就弹权限,那样拒绝率极高
- 如果权限被拒绝,播放功能可以正常使用,但要明确告诉用户“锁屏控制不可用”这一后果
权限请求的标准写法(Kotlin):
private 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_GRANTED |
| App 在前台时按键有效,退到后台后失效 | Service 被系统杀死或未正确 startForeground | 检查 Service 是否还在运行,查看adb shell dumpsys activity services |
| 只有按一下有效,连续按第二下就失效 | 通知的setOngoing(true)未设置,或通知被系统回收 | 确保通知常驻,并设置 ongoing |
| 按键事件触发了 App 启动,但没触发播放/暂停 | MediaButtonReceiver 没有调用 handleIntent | 检查 Service onStartCommand 里是否调用了MediaButtonReceiver.handleIntent |
4.2 三步定位:日志、状态、系统分发
如果看了速查表还没定位到问题,我推荐按下面的顺序系统排查。
第一步:在onMediaButtonEvent回调里加日志。如果这个回调都没被触发,说明系统压根没把按键分发给你的 Session,问题出在注册/状态上。如果回调触发了但没有走到onPlay/onPause,问题出在 Intent 转交逻辑上。
第二步:通过dumpsys media_session检查 Session 的实时状态。注意看这些字段:
adb shell dumpsys media_session | grep -A 20 -i "your.package.name"重点关注state=PlaybackState {state=3(3 是 STATE_PLAYING),以及isActive=true。如果 state 是 0(STATE_NONE)或者isActive=false,不管权限怎么配,按键都不该到你这边。
第三步:检查系统音频策略的按键分发日志。这一步对普通开发者来说有点黑盒,但可以试试:
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 变化,不是系统版本变化。
举个例子:同一个 APK,targetSdk 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 的完整度、通知的可见性和音频焦点这几件事做对,按键自然就回来了。