ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

Android MediaSession 框架详解:构建标准化媒体播放控制

Android MediaSession 框架详解:构建标准化媒体播放控制 1. 项目概述为什么我们需要 MediaSession如果你开发过音乐播放器、播客应用或者任何需要后台播放音频、视频的应用那你一定对 Android 的媒体播放控制头疼过。在早期我们可能用Service配合Notification和RemoteViews自己画一个播放控制通知栏还要处理耳机按键、蓝牙设备指令代码又乱又容易出兼容性问题。后来Android 5.0 (Lollipop) 引入了MediaSession这个框架它就像 Android 官方为媒体应用设计的一个“控制中枢”和“对外接口”。简单来说MediaSession把你的播放逻辑播放、暂停、切歌和播放状态当前歌曲、播放进度封装成一个标准化的对象。然后系统、其他应用如车载系统、智能手表、蓝牙设备、耳机线控甚至锁屏界面和通知栏都可以通过这个标准接口来控制和获取你的播放信息。你不用再为每个控制渠道写一遍逻辑只需要维护好这一个MediaSession就行。我接手过几个老项目里面自己实现的播放控制通知栏代码有几百行还经常在 Android 不同版本上出 Bug。换成MediaSession后不仅代码量减少了一半而且自动获得了对 Android Auto、Wear OS 等平台的支持稳定性也大大提升。所以无论你是从零开始做一个播放应用还是重构老代码掌握MediaSession都是性价比极高的投入。2. MediaSession 核心架构与组件拆解要理解MediaSession得先把它拆成几个核心部件来看。它不是单一的一个类而是一套协作的 API 组合。2.1 MediaSession 本体会话与控制核心MediaSession对象是整个框架的核心。你可以把它理解为一个“会话”代表了一次完整的媒体播放交互。一个应用可以创建多个MediaSession比如一个用于音乐一个用于播客但通常一个播放服务只持有一个。创建它很简单val mediaSession MediaSession(context, “MyMusicPlayerSession”)创建时需要传入一个Tag这个标签主要用于调试在adb shell dumpsys media_session命令查看系统当前所有媒体会话时会显示出来。它的核心作用有两个持有PlaybackState和MediaMetadata这是会话的“状态”和“内容”数据。设置Callback这是所有控制命令的“回调处理中心”。当用户点击通知栏的暂停按钮或者蓝牙耳机发送播放指令时最终都会触发Callback里的对应方法如onPlay,onPause。2.2 MediaSession.Callback命令处理中枢这是你编写业务逻辑最多的地方。Callback是一个抽象类你需要继承它并实现一系列回调方法。当外部想要控制播放时就会调用这些方法。关键的回调方法包括onPlay(): 响应播放命令。onPause(): 响应暂停命令。onSkipToNext(): 响应下一首命令。onSkipToPrevious(): 响应上一首命令。onSeekTo(pos: Long): 响应跳转到特定播放位置。onStop(): 响应停止命令。注意这与暂停不同通常意味着会话即将结束。你需要在这些回调方法里调用你自己播放引擎如ExoPlayer,MediaPlayer的对应控制方法。MediaSession本身不负责播放它只负责转发命令。一个常见的误区是开发者只在用户点击应用内按钮时才更新播放状态。正确的做法是无论播放状态是通过应用内UI还是外部命令如通知栏改变的都必须同步更新MediaSession的PlaybackState。否则会导致系统UI如锁屏控件显示的状态与实际播放状态不一致。2.3 PlaybackState播放状态快照PlaybackState对象描述了播放器当前处于什么状态以及它能支持哪些操作。它使用“状态”和“操作”两个维度来描述。状态是一个预定义的常量比如PlaybackState.STATE_PLAYING(正在播放)PlaybackState.STATE_PAUSED(已暂停)PlaybackState.STATE_STOPPED(已停止)PlaybackState.STATE_BUFFERING(正在缓冲)PlaybackState.STATE_ERROR(出错)操作是一组位掩码long类型表示当前会话支持哪些控制命令。例如PlaybackState.ACTION_PLAYPlaybackState.ACTION_PAUSEPlaybackState.ACTION_SKIP_TO_NEXTPlaybackState.ACTION_SEEK_TO你需要在播放状态变化时例如从暂停变为播放构建一个新的PlaybackState并设置给MediaSession。fun updatePlaybackState(player: ExoPlayer) { val state when (player.playbackState) { Player.STATE_READY - if (player.isPlaying) PlaybackState.STATE_PLAYING else PlaybackState.STATE_PAUSED Player.STATE_BUFFERING - PlaybackState.STATE_BUFFERING else - PlaybackState.STATE_STOPPED } val position if (player.isPlaying) player.currentPosition else playbackState?.position ?: 0L val actions PlaybackState.ACTION_PLAY_PAUSE or PlaybackState.ACTION_SKIP_TO_NEXT or PlaybackState.ACTION_SKIP_TO_PREVIOUS or PlaybackState.ACTION_SEEK_TO val playbackState PlaybackState.Builder() .setState(state, position, player.playbackParameters.speed) .setActions(actions) .build() mediaSession.setPlaybackState(playbackState) }注意上面代码中的position参数。当状态是STATE_PAUSED或STATE_STOPPED时你需要提供一个静态的播放位置这个位置会被系统记住并在通知栏等地方显示进度条。我遇到过一个问题暂停后锁屏进度条消失了。就是因为构建STATE_PAUSED状态时没有提供有效的position。2.4 MediaMetadata媒体内容信息MediaMetadata描述了正在播放的媒体内容本身的信息比如歌曲名、歌手、专辑、封面图等。它使用MediaMetadata.METADATA_KEY_*系列常量作为键来存储信息。设置媒体信息val metadata MediaMetadata.Builder() .putString(MediaMetadata.METADATA_KEY_TITLE, “Song Title”) .putString(MediaMetadata.METADATA_KEY_ARTIST, “Artist Name”) .putString(MediaMetadata.METADATA_KEY_ALBUM, “Album Name”) .putLong(MediaMetadata.METADATA_KEY_DURATION, durationInMs) // 时长很重要 .putBitmap(MediaMetadata.METADATA_KEY_ALBUM_ART, albumArtBitmap) .build() mediaSession.setMetadata(metadata)这里有一个关键点METADATA_KEY_DURATION媒体总时长必须设置。很多系统UI如Android Auto和进度条控件都依赖这个值来计算和显示播放进度。如果没设置虽然基础播放控制可能正常但进度显示会异常。2.5 MediaController内部控制的桥梁MediaController是MediaSession在应用内部的“代理”或“客户端”。通常你的Activity或FragmentUI层不应该直接持有或操作MediaSession。UI层应该通过MediaController来发送控制命令和获取当前状态。如何关联呢在创建MediaSession后你可以获取到一个Tokenval sessionToken mediaSession.sessionToken在你的Activity中你可以用这个Token来创建MediaControllerval mediaController MediaController(this, sessionToken) MediaControllerCompat.setMediaController(this, mediaController) // 关联到Activity之后UI层就可以通过这个mediaController来调用transportControls.play(),.pause()等方法这些调用最终会路由到MediaSession.Callback的对应方法。同时UI层也可以注册回调来监听播放状态和元数据的变化从而更新界面。这种设计清晰地将UI与控制逻辑解耦。3. 从零搭建一个 MediaSession 播放服务理论讲完了我们动手搭一个最简单的音乐播放服务。这里我们用Service来托管播放器和MediaSession因为播放需要在后台持续运行。3.1 创建后台播放 Service首先在AndroidManifest.xml中声明你的 Service并申请必要的权限如果播放网络音频需要网络权限。service android:name“.MusicPlaybackService” android:exported“false” !-- 通常不需要导出给其他应用 -- intent-filter action android:name“android.media.browse.MediaBrowserService” / /intent-filter /service注意那个intent-filter这是为了支持 Android Auto 等媒体浏览器客户端后续扩展时会用到。即使暂时不用先加上也是好习惯。然后创建 Service 类。我们将使用 AndroidX 的MediaSessionCompat它提供了更好的兼容性支持支持到 API 21 之前。class MusicPlaybackService : Service() { private lateinit var mediaSession: MediaSessionCompat private lateinit var player: ExoPlayer // 这里以ExoPlayer为例你也可以用MediaPlayer override fun onCreate() { super.onCreate() initializePlayer() initializeMediaSession() } private fun initializePlayer() { // 初始化你的播放器例如ExoPlayer player ExoPlayer.Builder(this).build() // 设置播放器监听器以便在状态变化时更新MediaSession player.addListener(object : Player.Listener { override fun onPlaybackStateChanged(playbackState: Int) { updatePlaybackState() } override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) { updateMetadata() } }) } private fun initializeMediaSession() { // 1. 创建MediaSession mediaSession MediaSessionCompat(this, “MusicPlaybackService”) // 2. 设置Callback mediaSession.setCallback(mediaSessionCallback) // 3. 设置初始的PlaybackState例如状态为NONE支持播放 val initialState PlaybackStateCompat.Builder() .setState(PlaybackStateCompat.STATE_NONE, 0, 1.0f) .setActions(PlaybackStateCompat.ACTION_PLAY) .build() mediaSession.setPlaybackState(initialState) // 4. 激活会话这一步至关重要否则会话不会被系统识别。 mediaSession.isActive true } // ... 后续填充 mediaSessionCallback 和 update 方法 }关键点mediaSession.isActive true这行代码必须调用。只有激活的MediaSession才会被系统纳入管理才能接收外部命令和显示在系统UI中。在 Service 的onDestroy()方法中记得将其设为false并释放资源。3.2 实现 MediaSession.Callback接下来在 Service 内部实现MediaSessionCompat.Callback。private val mediaSessionCallback object : MediaSessionCompat.Callback() { override fun onPlay() { player.play() updatePlaybackState() // 启动前台服务避免播放被系统杀死Android O 必须 startForegroundService() } override fun onPause() { player.pause() updatePlaybackState() } override fun onStop() { player.stop() updatePlaybackState() // 停止前台服务 stopForeground(false) // 可以考虑在完全停止后调用 stopSelf() } override fun onSkipToNext() { // 实现你的切到下一首逻辑 player.seekToNextMediaItem() updateMetadata() updatePlaybackState() } override fun onSkipToPrevious() { // 实现你的切到上一首逻辑 player.seekToPreviousMediaItem() updateMetadata() updatePlaybackState() } override fun onSeekTo(pos: Long) { player.seekTo(pos) // 注意seekTo之后播放状态不变但位置变了也需要更新PlaybackState updatePlaybackState() } }这里的逻辑很直观将MediaSession接收到的命令转发给实际的播放器player去执行然后更新会话状态。3.3 构建并更新 PlaybackState 与 Metadata我们需要实现上面用到的updatePlaybackState()和updateMetadata()方法。private fun updatePlaybackState() { val state when (player.playbackState) { Player.STATE_READY - { if (player.isPlaying) PlaybackStateCompat.STATE_PLAYING else PlaybackStateCompat.STATE_PAUSED } Player.STATE_BUFFERING - PlaybackStateCompat.STATE_BUFFERING Player.STATE_ENDED - PlaybackStateCompat.STATE_STOPPED else - PlaybackStateCompat.STATE_NONE } // 计算当前支持的操作 var actions PlaybackStateCompat.ACTION_PLAY_PAUSE or PlaybackStateCompat.ACTION_STOP or PlaybackStateCompat.ACTION_SEEK_TO if (player.hasNextMediaItem()) actions actions or PlaybackStateCompat.ACTION_SKIP_TO_NEXT if (player.hasPreviousMediaItem()) actions actions or PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS val playbackState PlaybackStateCompat.Builder() .setState(state, player.currentPosition, player.playbackParameters.speed) .setActions(actions) .build() mediaSession.setPlaybackState(playbackState) } private fun updateMetadata() { val currentMediaItem player.currentMediaItem ?: return val metadata MediaMetadataCompat.Builder().apply { putString(MediaMetadataCompat.METADATA_KEY_TITLE, currentMediaItem.mediaMetadata.title?.toString()) putString(MediaMetadataCompat.METADATA_KEY_ARTIST, currentMediaItem.mediaMetadata.artist?.toString()) putString(MediaMetadataCompat.METADATA_KEY_ALBUM, currentMediaItem.mediaMetadata.albumTitle?.toString()) putLong(MediaMetadataCompat.METADATA_KEY_DURATION, currentMediaItem.mediaMetadata.durationMs ?: 0) // 封面图处理稍复杂需要异步加载为Bitmap // putBitmap(MediaMetadataCompat.METADATA_KEY_ALBUM_ART, albumArtBitmap) }.build() mediaSession.setMetadata(metadata) }在updatePlaybackState中我动态计算了支持的actions。例如只有在有下一首时才添加ACTION_SKIP_TO_NEXT动作。这样系统UI如通知栏上的“下一首”按钮在播放列表最后一首歌时会自动变灰或隐藏体验更好。3.4 创建并管理媒体播放通知在 Android 8.0 (Oreo) 及以上版本后台服务需要以前台服务形式运行才能执行播放。这意味着我们必须创建一个带有通知栏控件的通知。MediaSession与MediaStyle通知样式是绝配。首先在 Service 的onPlay()回调中启动前台服务private fun startForegroundService() { val notification buildMediaStyleNotification() startForeground(NOTIFICATION_ID, notification) }然后实现buildMediaStyleNotification方法private fun buildMediaStyleNotification(): Notification { // 1. 创建PendingIntent点击通知时打开应用 val intent Intent(this, MainActivity::class.java).apply { flags Intent.FLAG_ACTIVITY_SINGLE_TOP } val pendingIntent PendingIntent.getActivity(this, 0, intent, PendingIntent.FLAG_IMMUTABLE) // 2. 构建通知 val builder NotificationCompat.Builder(this, CHANNEL_ID) // 需要创建通知渠道 .setContentTitle(“正在播放”) .setContentText(mediaSession.controller.metadata?.getString(MediaMetadata.METADATA_KEY_TITLE)) .setLargeIcon(albumArtBitmap) // 专辑大图 .setContentIntent(pendingIntent) .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) // 锁屏可见 .setSmallIcon(R.drawable.ic_music_note) .setStyle(androidx.media.app.NotificationCompat.MediaStyle() .setMediaSession(mediaSession.sessionToken) // 关键关联MediaSession .setShowActionsInCompactView(0, 1, 2) // 在折叠视图小视图中显示哪几个按钮 ) .setDeleteIntent(mediaSession.controller.sessionActivity) // 滑动删除时触发 // 3. 添加通知栏动作按钮播放/暂停、上一首、下一首 // 这些动作会直接发送给关联的MediaSession val playPauseAction NotificationCompat.Action( if (player.isPlaying) R.drawable.ic_pause else R.drawable.ic_play, if (player.isPlaying) “暂停” else “播放”, MediaButtonReceiver.buildMediaButtonPendingIntent(this, PlaybackStateCompat.ACTION_PLAY_PAUSE) ) val nextAction NotificationCompat.Action( R.drawable.ic_skip_next, “下一首”, MediaButtonReceiver.buildMediaButtonPendingIntent(this, PlaybackStateCompat.ACTION_SKIP_TO_NEXT) ) // ... 添加上一首等动作 builder.addAction(prevAction) .addAction(playPauseAction) .addAction(nextAction) return builder.build() }核心技巧使用NotificationCompat.MediaStyle()并调用setMediaSession(mediaSession.sessionToken)。这样做之后通知栏上的按钮点击事件会自动被系统转换为标准的媒体按键事件并发送给你的MediaSession.Callback处理。你几乎不需要再为这些按钮单独设置PendingIntent和广播接收器大大简化了代码。MediaButtonReceiver是一个帮助类用于将按钮点击转换为标准媒体按键事件。你需要将其在AndroidManifest.xml中声明receiver android:name“androidx.media.session.MediaButtonReceiver” intent-filter action android:name“android.intent.action.MEDIA_BUTTON” / /intent-filter /receiver4. 与系统及其他应用集成MediaSession的强大之处在于其标准化带来的无缝集成能力。4.1 响应物理按键和蓝牙设备一旦你的MediaSession被激活且处于播放状态它就会自动成为音频焦点Audio Focus的持有者并开始响应耳机线控、蓝牙设备如车载音响、蓝牙耳机的媒体控制按键。你不需要写任何额外的代码来处理ACTION_MEDIA_BUTTON广播。系统会自动将按键事件路由到当前活跃的MediaSession。注意事项为了确保蓝牙设备信息能正确显示MediaMetadata中的METADATA_KEY_ALBUM_ART专辑封面最好提供。一些车载系统会显示封面。封面图片不宜过大建议压缩到 512x512 像素左右以避免内存和传输问题。4.2 支持锁屏和快速设置面板控件在 Android 锁屏界面和下拉快速设置面板的媒体控件区域系统会自动显示当前活跃的MediaSession信息。你只需要确保PlaybackState和MediaMetadata信息正确且及时更新。通知使用了MediaStyle并关联了MediaSession。通知的可见性设置为VISIBILITY_PUBLIC。这样用户无需解锁手机就能控制播放体验非常流畅。4.3 为 Android Auto 和 Wear OS 做准备如果你希望应用能支持 Android Auto车载系统或 Wear OS智能手表那么你需要将 Service 实现为MediaBrowserServiceCompat。这并不复杂它主要增加了一个“浏览媒体库”的能力允许外部客户端如车载屏幕获取你的播放列表。基本步骤是让你的MusicPlaybackService继承MediaBrowserServiceCompat。实现onGetRoot()和onLoadChildren()方法用于向客户端返回媒体库的根目录和子项列表。在onCreate()中除了设置MediaSession还要调用setSessionToken(mediaSession.sessionToken)。这样当用户的车载系统连接后就能看到你的应用图标并浏览、播放你应用内的歌曲列表。这是一个提升应用专业度和用户体验的重要特性。5. 常见问题排查与实战技巧在实际开发中你肯定会遇到一些坑。下面是我总结的几个典型问题和解决方法。5.1 通知栏控件点击无反应这是最常见的问题。请按以下顺序排查检查MediaSession是否激活确认mediaSession.isActive true已被调用。检查通知样式确认使用了MediaStyle并正确调用了setMediaSession(mediaSession.sessionToken)。检查PlaybackState中的 Actions确保当前PlaybackState的actions包含了对应的操作如ACTION_PLAY_PAUSE。如果没包含系统UI上的按钮会被禁用。检查MediaButtonReceiver确保已在AndroidManifest.xml中正确声明。使用命令调试在终端运行adb shell dumpsys media_session找到你的应用和会话查看其状态、激活情况以及注册的Callback是否正常。5.2 锁屏或通知栏显示信息滞后现象是歌曲已经切换了但锁屏上显示的还是上一首歌的信息。确保状态更新在主线player的状态监听回调可能在工作线程触发。更新MediaSession的PlaybackState和Metadata必须在主线程进行。player.addListener(object : Player.Listener { override fun onPlaybackStateChanged(playbackState: Int) { runOnUiThread { updatePlaybackState() } } })更新时机要全不仅在onPlay/onPause回调里更新在切歌onSkipToNext、SeekonSeekTo以及播放器自身状态变化时都要调用updatePlaybackState()和updateMetadata()。5.3 与其他音频应用冲突音频焦点当你的应用在播放时另一个应用如微信语音也开始播放音频这时应该如何处理这需要用到音频焦点Audio Focus管理。 虽然MediaSession在播放时会自动请求音频焦点但你需要处理焦点丢失的通知。可以在初始化MediaSession后设置一个OnAudioFocusChangeListener。更优雅的方式是使用AudioManagerCompat来请求焦点并在MediaSession.Callback的onPlay()中处理焦点请求结果。如果焦点请求失败就不应该开始播放。同时监听焦点丢失事件在收到AUDIOFOCUS_LOSS时暂停播放并在收到AUDIOFOCUS_GAIN时恢复播放如果需要。谷歌官方推荐将音频焦点逻辑与MediaSession结合使用。5.4 后台播放被系统杀死在 Android O 及以上版本长时间后台播放必须使用前台服务。确保在开始播放时调用startForeground(NOTIFICATION_ID, notification)并在播放停止或暂停时根据产品需求决定是调用stopForeground(false)移除前台状态但保留通知还是stopForeground(true)移除前台状态和通知。记住只要播放还在继续前台服务就必须保持。另外合理设置Notification的priority和channel重要性避免因用户关闭了通知渠道而导致服务被杀死。5.5 自定义播放动作除了标准的播放、暂停、切歌你还可以支持自定义动作。例如喜欢点赞、播放模式切换单曲循环、列表循环。在PlaybackState的actions中添加自定义动作的标识符例如val customAction PlaybackStateCompat.CustomAction.Builder(“FAVORITE”, “喜欢”, R.drawable.ic_favorite).build()然后通过PlaybackState.Builder.addCustomAction()添加。在MediaSession.Callback中重写onCustomAction(action: String, extras: Bundle?)方法来处理这个自定义动作的点击。这样你就可以在通知栏或锁屏控件上添加一个“喜欢”按钮了。
返回列表