最近完成了項目中關於音樂播放器開發相關的內容,以後又花了兩天進行總結,特此記錄。java
另外一方面,音樂播放器也同時用到了 Android 四大組件,對於剛接觸 Android 開發的人來講也是值得去學習開發的一個功能。部份內容可能不會說的太詳細。git
關於音樂播放器的開發,官方在 5.0 以上提供的 MediaSession 框架來更方便完成音樂相關功能的開發。github
大體流程是:瀏覽器
分爲 UI 端和 Service 端。UI 端負責控制播放,暫停等操做,經過 MediaController 進行信息傳遞到 Service 端。服務器
Service 進行相關指令的處理,並將播放狀態(歌曲信息, 播放進度)經過MediaSession 回傳給 UI 端,UI 端更新顯示。網絡
如上圖顯示:(圖片不能查看請移步:github.com/yunshuipiao…session
UI 界面上半部分是播放狀態,中間部分是歌曲列表,下半部分是控制器。其中 加載歌曲 模擬從不一樣渠道獲取播放列表。app
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 端實現。ide
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 端如何獲取播放列表並傳給 Service 播放。
UI 端經過以下函數模擬從網絡獲取播放列表。
fun getNetworkPlayList() {
val playList = MusicLibrary.getMusicList()
playList.forEach {
mMediaControllerCompat.addQueueItem(it.description)
}
}
複製代碼
並經過 播放控制器添加到 Service。
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 對播放列表進行去重,播放歌曲下標設置。
經過 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 相關對象的做用及使用,才能更容易的理解播放器的通訊機制。
)