Potato
Potato copied to clipboard
Android:Music Android MediaSession
简易音乐播放器开发实录
[TOC]
最近完成了项目中关于音乐播放器开发相关的内容,之后又花了两天进行总结,特此记录。
另一方面,音乐播放器也同时用到了 Android 四大组件,对于刚接触 Android 开发的人来说也是值得去学习开发的一个功能。部分内容可能不会说的太详细。
需求:音乐播放器具有的功能
- 音乐后台播放(Service),UI 显示进度,歌曲信息
- 音乐播放通知和锁屏通知,可操作(播放,暂停,上下一曲)
- 音频焦点的处理(其他音乐播放器播放时相关状态更新)
- 耳机线控模式的处理
UI 控制音乐播放,更新进度
关于音乐播放器的开发,官方在 5.0 以上提供的 MediaSession 框架来更方便完成音乐相关功能的开发。
大致流程是:
分为 UI 端和 Service 端。UI 端负责控制播放,暂停等操作,通过 MediaController 进行信息传递到 Service 端。
Service 进行相关指令的处理,并将播放状态(歌曲信息, 播放进度)通过MediaSession 回传给 UI 端,UI 端更新显示。



如上图显示:
UI 界面上半部分是播放状态,中间部分是歌曲列表,下半部分是控制器。其中 加载歌曲 模拟从不同渠道获取播放列表。
UI 部分使用 ViewModel + livedata 实现,如下:
/**
* 上一首
*/
mf_to_previous.setOnClickListener {
viewModel.skipToPrevious()
}
/**
* 下一首
*/
mf_to_next.setOnClickListener {
viewModel.skipToNext()
}
/**
* 播放暂停
*/
mf_to_play.setOnClickListener {
viewModel.playOrPause()
}
/**
* 加载音乐
*/
mf_to_load.setOnClickListener {
viewModel.getNetworkPlayList()
}
下面主要来看一下加载歌曲, 播放暂停是如何进行控制的,主要的逻辑在 ViewModel 端实现。
ViewModel 的相关对象:
class MainViewModel : ViewModel() {
private lateinit var mContext: Context
/**
* 播放控制器,对 Service 发出播放,暂停,上下一曲的指令
*/
private lateinit var mMediaControllerCompat: MediaControllerCompat
/**
* 媒体浏览器,负责连接 Service,得到 Service 的相关信息
*/
private lateinit var mMediaBrowserCompat: MediaBrowserCompat
/**
* 播放状态的数据(是否正在播放,播放进度)
*/
public var mPlayStateLiveData = MutableLiveData<PlaybackStateCompat>()
/**
* 播放歌曲的数据(歌曲,歌手等)
*/
public var mMetaDataLiveData = MutableLiveData<MediaMetadataCompat>()
/**
* 播放列表的数据
*/
public var mMusicsLiveData = MutableLiveData<MutableList<MediaDescriptionCompat>>()
/**
* 播放控制器的回调
* (比如 UI 发出下一曲指令,Service 端切换歌曲播放之后,将播放状态信息传回 UI 端, 更新 UI)
*/
private var mMediaControllerCompatCallback = object : MediaControllerCompat.Callback() {
override fun onQueueChanged(queue: MutableList<MediaSessionCompat.QueueItem>?) {
super.onQueueChanged(queue)
// 服务端的queue变化
MusicHelper.log("onQueueChanged: $queue" )
mMusicsLiveData.postValue(queue?.map { it.description } as MutableList<MediaDescriptionCompat>)
}
override fun onRepeatModeChanged(repeatMode: Int) {
super.onRepeatModeChanged(repeatMode)
}
override fun onPlaybackStateChanged(state: PlaybackStateCompat?) {
super.onPlaybackStateChanged(state)
mPlayStateLiveData.postValue(state)
MusicHelper.log("music onPlaybackStateChanged, $state")
}
override fun onMetadataChanged(metadata: MediaMetadataCompat?) {
super.onMetadataChanged(metadata)
MusicHelper.log("onMetadataChanged, $metadata")
mMetaDataLiveData.postValue(metadata)
}
override fun onSessionReady() {
super.onSessionReady()
}
override fun onSessionDestroyed() {
super.onSessionDestroyed()
}
override fun onAudioInfoChanged(info: MediaControllerCompat.PlaybackInfo?) {
super.onAudioInfoChanged(info)
}
}
/**
* 媒体浏览器连接 Service 的回调
*/
private var mMediaBrowserCompatConnectionCallback: MediaBrowserCompat.ConnectionCallback = object :
MediaBrowserCompat.ConnectionCallback() {
override fun onConnected() {
super.onConnected()
// 连接成功
MusicHelper.log("onConnected")
mMediaControllerCompat = MediaControllerCompat(mContext, mMediaBrowserCompat.sessionToken)
mMediaControllerCompat.registerCallback(mMediaControllerCompatCallback)
mMediaBrowserCompat.subscribe(mMediaBrowserCompat.root, mMediaBrowserCompatSubscriptionCallback)
}
override fun onConnectionSuspended() {
super.onConnectionSuspended()
}
override fun onConnectionFailed() {
super.onConnectionFailed()
}
}
/**
* 媒体浏览器订阅 Service 数据的回调
*/
private var mMediaBrowserCompatSubscriptionCallback = object : MediaBrowserCompat.SubscriptionCallback() {
override fun onChildrenLoaded(
parentId: String,
children: MutableList<MediaBrowserCompat.MediaItem>
) {
super.onChildrenLoaded(parentId, children)
// 服务器 setChildLoad 的回调方法
MusicHelper.log("onChildrenLoaded, $children")
}
}
相关信息看注释,流程会逐步介绍。
初始化
fun init(context: Context) {
mContext = context
mMediaBrowserCompat = MediaBrowserCompat(context, ComponentName(context, MusicService::class.java),
mMediaBrowserCompatConnectionCallback, null)
mMediaBrowserCompat.connect()
}
先初始化 MedaBrowserCompat, 对 Service 发出连接指令。连接成功之后 Service 进行初始化。
Service 的相关内容如下:
class MusicService : MediaBrowserServiceCompat() {
private var mRepeatMode: Int = PlaybackStateCompat.REPEAT_MODE_NONE
/**
* 播放状态,通过 MediaSession 回传给 UI 端。
*/
private var mState = PlaybackStateCompat.Builder().build()
/**
* UI 可能被销毁,Service 需要保存播放列表,并处理循环模式
*/
private var mPlayList = arrayListOf<MediaSessionCompat.QueueItem>()
/**
* 当前播放音乐的相关信息
*/
private var mMusicIndex = -1
private var mCurrentMedia: MediaSessionCompat.QueueItem? = null
/**
* 播放会话,将播放状态信息回传给 UI 端。
*/
private lateinit var mSession: MediaSessionCompat
/**
* 真正的音乐播放器
*/
private var mMediaPlayer: MediaPlayer = MediaPlayer()
/**
* 播放控制器的事件回调,UI 端通过播放控制器发出的指令会在这里接收到,交给真正的音乐播放器处理。
*/
private var mSessionCallback = object : MediaSessionCompat.Callback() {
....
}
上面了解了整个音乐播放器分别在 UI 端和 Service 端的相关对象。
继续初始化过程,连接成功之后,Service 会进行初始化工作。
override fun onCreate() {
super.onCreate()
mSession = MediaSessionCompat(applicationContext, "MusicService")
mSession.setCallback(mSessionCallback)
mSession.setFlags(MediaSessionCompat.FLAG_HANDLES_QUEUE_COMMANDS)
sessionToken = mSession.sessionToken
mMediaPlayer.setOnCompletionListener(mCompletionListener)
mMediaPlayer.setOnPreparedListener(mPreparedListener)
mMediaPlayer.setOnErrorListener { mp, what, extra -> true }
}
这是 UI 端 MediaBrowser 的工作。UI 端会收到连接成功的回调。
代码如上,连接成功之后会初始化 MediaController, 设置监听回调。MediaBrowser 并订阅 Service 端的播放列表。
mMediaBrowserCompat.subscribe(mMediaBrowserCompat.root,mMediaBrowserCompatSubscriptionCallback)
上面有两个参数,其中 root 是:当 Service 初始化成功时, Service端 会实现两个方法:
override fun onLoadChildren(
parentId: String,
result: Result<MutableList<MediaBrowserCompat.MediaItem>>
) {
MusicHelper.log("onLoadChildren, $parentId")
result.detach()
val list = mPlayList.map { MediaBrowserCompat.MediaItem(it.description, MediaBrowserCompat.MediaItem.FLAG_PLAYABLE) }
result.sendResult(list as MutableList<MediaBrowserCompat.MediaItem>?)
}
override fun onGetRoot(
clientPackageName: String,
clientUid: Int,
rootHints: Bundle?
): BrowserRoot? {
return BrowserRoot("MusicService", null)
}
onGetRoot 方法提供 root。订阅之后 onLoadChildren 会将当前播放列表发送出去,这时 UI 端在 媒体浏览器就能收到当前 Service 的播放列表数据。
因为这时播放列表为空,所以 UI 端接收到的播放列表也为空。
因为 MediaSession 支持多个 UI 端接入。比如 UI 端 A 设置了播放列表,此时 UI 端 B 进行连接,则可以获取当前的播放列表进行操作。
总结:UI 端 和 Service 端 的初始化过程
- UI 端 通过 MediaBroswer 发出对 Service 的连接指令。
- Service 创建初始化,设置 token,进行 Service 的初始化工作。
- UI 端收到连接成功的回调,对 MediaController 进行初始化,MediaBroswer 订阅 Service 的播放列表信息。Service 通过 onLoadChildren 将当前播放信息传回 UI 端。
- UI 端收到播放列表的信息,进行 UI 更新,显示播放列表。
设置播放列表
在出初始化的过程中,播放列表为空。下面介绍 UI 端如何获取播放列表并传给 Service 播放。
UI 端通过如下函数模拟从网络获取播放列表。
fun getNetworkPlayList() {
val playList = MusicLibrary.getMusicList()
playList.forEach {
mMediaControllerCompat.addQueueItem(it.description)
}
}
并通过 播放控制器添加到 Service。
- MediaMetadataCompat:UI 端播放列表的数据类型是 MediaMetadataCompat,包含了歌曲内容的全部信息(歌名,歌手,播放uri,图标等等)
- MediaDescriptionCompat: UI 端传到 Service 的数据,是 MediaMetadataCompat 的部分内容,主要用于简单信息的展示。
Service 端收到播放列表添加的回调:
override fun onAddQueueItem(description: MediaDescriptionCompat) {
super.onAddQueueItem(description)
// 客户端添加歌曲
if (mPlayList.find { it.description.mediaId == description.mediaId } == null) {
mPlayList.add(
MediaSessionCompat.QueueItem(description, description.hashCode().toLong())
)
}
mMusicIndex = if (mMusicIndex == -1) 0 else mMusicIndex
mSession.setQueue(mPlayList)
}
上面根据 mediaId 对播放列表进行去重,播放歌曲下标设置。
- QueueItem:播放列表的内容,里面存有 MediaDescriptionCompat。
通过 Session.setQueue() 设置播放列表, UI 端获取回调,更新播放列表。
override fun onQueueChanged(queue: MutableList<MediaSessionCompat.QueueItem>?) {
super.onQueueChanged(queue)
// 服务端的queue变化
MusicHelper.log("onQueueChanged: $queue" )
mMusicsLiveData.postValue(queue?.map { it.description } as MutableList<MediaDescriptionCompat>)
}
后面就是 livedata 将数据通知到 UI 端,进行列表更新。
viewModel.mMusicsLiveData.observe(this, Observer {
mMusicAdapter.setList(it)
})
public fun setList(datas: List<MediaDescriptionCompat>) {
mList.clear()
mList.addAll(datas)
notifyDataSetChanged()
}
这里解释一下,为什么在 UI 端获取到播放列表之后,不直接更新UI: 因为获取播放列表,传到Service 之后可能会失败,造成歌曲不可播放。
这也符合响应式的操作:UI 发出 Action -> 处理Action -> UI 收到 Action 造成的状态改变,更新 UI。
UI 端不应该在操作之后主动更新。后面的播放暂停也是这个做法。
播放暂停
有了设置播放列表的前提,下面接着进行播放暂停的相关流程介绍。
UI端通过 mediaController 发出播放歌曲的指令 -> Service 端收到指令,切换歌曲播放 -> 通过 MediaSession 将播放状态信息传回 UI 端 -> UI 端进行更新。
fun playOrPause() {
if (mPlayStateLiveData.value?.state == PlaybackStateCompat.STATE_PLAYING) {
mMediaControllerCompat.transportControls.pause()
} else {
mMediaControllerCompat.transportControls.play()
}
}
UI 端: 如果当前播放状态是正在播放,则发送暂停播放的指令;反之,则发送播放的指令。
override fun onPlay() {
super.onPlay()
if (mCurrentMedia == null) {
onPrepare()
}
if (mCurrentMedia == null) {
return
}
mMediaPlayer.start()
setNewState(PlaybackStateCompat.STATE_PLAYING)
}
Service端:收到播放指令后,当前播放歌曲为空,进行播放前处理,准备资源。如果此时当前歌曲还是为空(比如没有播放列表时点击播放),则返回。否则进行播放。
override fun onPrepare() {
super.onPrepare()
if (mPlayList.isEmpty()) {
MusicHelper.log("not playlist")
return
}
if (mMusicIndex < 0 || mMusicIndex >= mPlayList.size) {
MusicHelper.log("media index error")
return
}
mCurrentMedia = mPlayList[mMusicIndex]
val uri = mCurrentMedia?.description?.mediaUri
MusicHelper.log("uri, $uri")
if (uri == null) {
return
}
// 加载资源要重置
mMediaPlayer.reset()
try {
if (uri.toString().startsWith("http")) {
mMediaPlayer.setDataSource(applicationContext, uri)
} else {
// assets 资源
val assetFileDescriptor = applicationContext.assets.openFd(uri.toString())
mMediaPlayer.setDataSource(
assetFileDescriptor.fileDescriptor,
assetFileDescriptor.startOffset,
assetFileDescriptor.length
)
}
mMediaPlayer.prepare()
} catch (e: Exception) {
e.printStackTrace()
}
}
这里获取到当前需要播放的歌曲,使用 MediaPlayer 进行加载准备。准备完成之后:
private var mPreparedListener: MediaPlayer.OnPreparedListener =
MediaPlayer.OnPreparedListener {
val mediaId = mCurrentMedia?.description?.mediaId ?: ""
val metadata = MusicLibrary.getMeteDataFromId(mediaId)
mSession.setMetadata(metadata.putDuration(mMediaPlayer.duration.toLong()))
mSessionCallback.onPlay()
}
获取到当前播放的歌曲信息,MediaSession 通过 setMetaData() 发送到客户端,进行UI 更新。
准备完成之后会再次进行播放。回到上面的代码,此时 MediaSession 会将 播放状态 通过 setNewState() 发送到客户端,进行 UI 更新。
private fun setNewState(state: Int) {
val stateBuilder = PlaybackStateCompat.Builder()
stateBuilder.setActions(getAvailableActions(state))
stateBuilder.setState(
state,
mMediaPlayer.currentPosition.toLong(),
1.0f,
SystemClock.elapsedRealtime()
)
mState = stateBuilder.build()
mSession.setPlaybackState(mState)
}
这里的播放状态包括四个参数,是否正在播放,当前进度,播放速度,最近更新时间(用过UI播放进度更新)。
UI 端收到 MediaMession 的歌曲信息,进行 UI 更新。
override fun onPlaybackStateChanged(state: PlaybackStateCompat?) {
super.onPlaybackStateChanged(state)
mPlayStateLiveData.postValue(state)
MusicHelper.log("music onPlaybackStateChanged, $state")
}
override fun onMetadataChanged(metadata: MediaMetadataCompat?) {
super.onMetadataChanged(metadata)
MusicHelper.log("onMetadataChanged, $metadata")
mMetaDataLiveData.postValue(metadata)
}
viewModel.mPlayStateLiveData.observe(this, Observer {
if (it.state == PlaybackStateCompat.STATE_PLAYING) {
mf_to_play.text = "暂停"
mPlayState = it
mf_tv_seek.progress = it.position.toInt()
handler.sendEmptyMessageDelayed(1, 250)
} else {
mf_to_play.text = "播放"
handler.removeMessages(1)
}
})
viewModel.mMetaDataLiveData.observe(this, Observer {
val title = it.getString(MediaMetadataCompat.METADATA_KEY_TITLE)
val singer = it.getString(MediaMetadataCompat.METADATA_KEY_ARTIST)
val duration = it.getLong(MediaMetadataCompat.METADATA_KEY_DURATION)
val durationShow = "${duration / 60000}: ${duration / 1000 % 60}"
mf_tv_title.text = "标题:$title"
mf_tv_singer.text = "歌手:$singer"
mf_tv_progress.text = "时长:$durationShow"
mMusicAdapter.notifyPlayingMusic(it.getString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID))
mf_tv_seek.max = duration.toInt()
})
viewModel.mMusicsLiveData.observe(this, Observer {
mMusicAdapter.setList(it)
})
这里也可以看到,如果 UI 端需要显示进度条,但是 MediaSession 并不会一直回传进度给 UI 端。
inner class SeekHandle: Handler() {
override fun handleMessage(msg: Message?) {
super.handleMessage(msg)
var position = (SystemClock.elapsedRealtime() - mPlayState.lastPositionUpdateTime ) * mPlayState.playbackSpeed + mPlayState.position
mf_tv_seek.progress = position.toInt()
sendEmptyMessageDelayed(1, 250)
}
}
这是使用 handle 执行定时循环任务,去通过计算得到当前的进度,注意 handler 的处理,防止内存泄漏。
以上就是整个音乐播放器的初始化,播放暂停的过程。
前台通知保持音乐播放
由于 Service 在退到后台之后会被销毁,音乐就会停止播放。后面介绍使用前台通知的方式,在通知栏显示播放信息及控制按钮,防止 Service 被销毁;并在锁屏界面也支持控制播放。
在切换不同播放状态的基础上,创建并启动通知。
sessionToken?.let {
val description = mCurrentMedia?.description ?: MediaDescriptionCompat.Builder().build()
when(state) {
PlaybackStateCompat.STATE_PLAYING -> {
val notification = mNotificationManager.getNotification(description, mState, it)
ContextCompat.startForegroundService(
this@MusicService,
Intent(this@MusicService, MusicService::class.java)
)
startForeground(MediaNotificationManager.NOTIFICATION_ID, notification)
}
PlaybackStateCompat.STATE_PAUSED -> {
val notification = mNotificationManager.getNotification(
description, mState, it
)
mNotificationManager.notificationManager
.notify(MediaNotificationManager.NOTIFICATION_ID, notification)
}
PlaybackStateCompat.STATE_STOPPED -> {
stopSelf()
}
}
}
根据当前的状态,播放状态则启动前台服务,并显示通知在通知栏上(包括锁屏通知)
暂停状态则更新通知的显示,更新相关按钮。相关代码参考 MediaNotificationManager 文件。
音频焦点的处理
当播放器 A 在播放音乐,此时其他到播放器播放音乐,此时两个音乐播放器都会在播放,涉及音频焦点的处理。
当耳机拔出时,也要暂停音乐的播放。
回到 onPlay 方法,在播放一首歌之前, 需要主动去获取音频的焦点,有了音频焦点才能播放(其他播放器失去音频焦点暂停音乐播放)。
override fun onPlay() {
super.onPlay()
if (mCurrentMedia == null) {
onPrepare()
}
if (mCurrentMedia == null) {
return
}
if (mAudioFocusHelper.requestAudioFocus()) {
mMediaPlayer.start()
setNewState(PlaybackStateCompat.STATE_PLAYING)
}
}
fun requestAudioFocus(): Boolean {
registerAudioNoisyReceiver()
val result = mAudioManager.requestAudioFocus(
this,
AudioManager.STREAM_MUSIC,
AudioManager.AUDIOFOCUS_GAIN
)
return result == AudioManager.AUDIOFOCUS_REQUEST_GRANTED
}
在请求音频焦点的时候,注册广播接收器,可以在耳机拨出时收到广播,暂停音乐播放。
fun registerAudioNoisyReceiver() {
if (!mAudioNoisyReceiverRegistered) {
context.registerReceiver(mAudioNoisyReceiver, AUDIO_NOISY_INTENT_FILTER)
mAudioNoisyReceiverRegistered = true
}
}
fun unregisterAudioNoisyReceiver() {
if (mAudioNoisyReceiverRegistered) {
context.unregisterReceiver(mAudioNoisyReceiver)
mAudioNoisyReceiverRegistered = false
}
}
在请求音频焦点时传入了接口,可以在音频焦点变化时改变播放状态。
override fun onAudioFocusChange(focusChange: Int) {
when (focusChange) {
/**
* 获取音频焦点
*/
AudioManager.AUDIOFOCUS_GAIN -> {
if (mPlayOnAudioFocus && !mMediaPlayer.isPlaying) {
mSessionCallback.onPlay()
} else if (mMediaPlayer.isPlaying) {
setVolume(MEDIA_VOLUME_DEFAULT)
}
mPlayOnAudioFocus = false
}
/**
* 暂时失去音频焦点,但可降低音量播放音乐,类似导航模式
*/
AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK -> setVolume(MEDIA_VOLUME_DUCK)
/**
* 暂时失去音频焦点,一段时间后会重新获取焦点,比如闹钟
*/
AudioManager.AUDIOFOCUS_LOSS_TRANSIENT -> if (mMediaPlayer.isPlaying) {
mPlayOnAudioFocus = true
mSessionCallback.onPause()
}
/**
* 失去焦点
*/
AudioManager.AUDIOFOCUS_LOSS -> {
mAudioManager.abandonAudioFocus(this)
mPlayOnAudioFocus = false
// 这里暂停播放
mSessionCallback.onPause()
}
}
}
线控模式
当耳机连接时,通过耳机上的按钮也要控制音乐的播放。
在耳机上的按钮按下时,Service 端会收到回调。
override fun onMediaButtonEvent(mediaButtonEvent: Intent?): Boolean {
return super.onMediaButtonEvent(mediaButtonEvent)
}
这个方法有默认实现,包括通知栏的按钮,耳机的按钮。默认实现是:音量加减,单击暂停,单机播放, 双击下一曲。返回值为 true 表示按钮事件被处理。因此可以通过重写该方法满足线控的相关要求。
override fun onMediaButtonEvent(mediaButtonEvent: Intent?): Boolean {
val action = mediaButtonEvent?.action
val keyevent = mediaButtonEvent?.getParcelableExtra<KeyEvent>(Intent.EXTRA_KEY_EVENT)
val keyCode= keyevent?.keyCode
MusicHelper.log("action: $action, keyEvent: $keyevent")
return if (keyevent?.keyCode == KeyEvent.KEYCODE_HEADSETHOOK && keyevent.action == KeyEvent.ACTION_UP) {
//耳机单机操作
mHeadSetClickCount += 1
if (mHeadSetClickCount == 1) {
handler.sendEmptyMessageDelayed(1, 800)
}
true
} else {
super.onMediaButtonEvent(mediaButtonEvent)
}
}
这里判断如果是耳机按钮的操作,则统计800毫秒内按钮按了几次,来实现自己的线控模式。
inner class HeadSetHandler: Handler() {
override fun handleMessage(msg: Message) {
super.handleMessage(msg)
// 根据耳机按下的次数决定执行什么操作
when(mHeadSetClickCount) {
1 -> {
if (mMediaPlayer.isPlaying) {
mSessionCallback.onPause()
} else {
mSessionCallback.onPlay()
}
}
2 -> {
mSessionCallback.onSkipToNext()
}
3 -> {
mSessionCallback.onSkipToPrevious()
}
4 -> {
mSessionCallback.onSkipToPrevious()
mSessionCallback.onSkipToPrevious()
}
}
}
}
总结
到目前为止,已经实现了文章开头说的几个音乐播放器具有的功能,使用到了 MediaSession 来作为 UI端 和 Service 端通信的基础(底层Binder)。
重点在于理解 MediaSession 相关对象的作用及使用,才能更容易的理解播放器的通信机制。
赞👍