Android 媒體播放框架MediaSession分析與實踐

Anlia發表於2018-03-14

版權宣告:本文為博主原創文章,未經博主允許不得轉載
原始碼:AnliaLee/BauzMusic
大家要是看到有錯誤的地方或者有啥好的建議,歡迎留言評論

前言

最近一直在忙著學習和研究音樂播放器,發現介紹MediaSession框架的資料非常少,更多的是一些原始碼和開源庫,這對於初學者來說不是很友好,可能看著看著就繞暈了,遂博主決定動手寫點這方面的部落格分享給大家

參考資料
googlesamples/android-UniversalMusicPlayer
Media Apps Overview(有前輩翻譯後的版本Android媒體應用(一)


MediaSession框架簡介

我們先來看看如何設計一款音樂播放App的架構,傳統的做法是這樣的:

  • 註冊一個Service,用於非同步獲取音樂庫資料、音樂控制等,在Service中我們可能還需要自定義一些狀態值回撥介面用於流程控制
  • 通過廣播(其他方式如介面Messenger都可以)實現ActivityService之間的通訊,使得使用者可以通過介面上的元件控制音樂的播放、暫停、拖動進度條等操作

如果我們的音樂播放器還需要支援通知欄快捷控制音樂播放的功能,那麼又得新增一套廣播和相應的介面去響應通知欄按鈕的事件

如果還需要支援多端(電視、手錶、耳機等)控制同一個播放器,那麼整個系統架構可能會變得非常複雜,我們要花費大量的時間和精力去設計、優化程式碼的結構。那麼有什麼方法可以節省這些工作,提高我們的效率,然後還可以優雅地實現上述這些功能呢?

GoogleAndroid 5.0中加入了MediaSession框架(在support-v4中同樣提供了相應的相容包,相關的類以Compat結尾,Api基本相同),專門用來解決媒體播放時介面和Service通訊的問題,意在規範上述這些功能的流程。使用這個框架我們可以減少一些流程複雜的開發工作,例如使用各種廣播來控制播放器,而且其程式碼可讀性、結構耦合度方面都控制得非常好,因此推薦大家嘗試下這個框架。下面我們就開始介紹MediaSession框架的核心成員和使用流程


MediaSession框架的使用

常用成員類概述

MediaSession框架中有四個常用的成員類,它們是整個流程控制的核心

  • MediaBrowser
    媒體瀏覽器,用來連線MediaBrowserService訂閱資料,通過它的回撥介面我們可以獲取和Service的連線狀態以及獲取在Service中非同步獲取的音樂庫資料。媒體瀏覽器一般建立於客戶端(可以理解為各個終端負責控制音樂播放的介面)中

  • MediaBrowserService
    瀏覽器服務,提供onGetRoot(控制客戶端媒體瀏覽器的連線請求,通過返回值決定是否允許該客戶端連線服務)和onLoadChildren(媒體瀏覽器向Service傳送資料訂閱時呼叫,一般在這執行非同步獲取資料的操作,最後將資料傳送至媒體瀏覽器的回撥介面中)這兩個抽象方法
    同時MediaBrowserService還作為承載媒體播放器(如MediaPlayer、ExoPlayer等)和MediaSession的容器

  • MediaSession
    媒體會話,即受控端,通過設定MediaSessionCompat.Callback回撥來接收媒體控制器MediaController傳送的指令,當收到指令時會觸發Callback中各個指令對應的回撥方法(回撥方法中會執行播放器相應的操作,如播放、暫停等)。Session一般在Service.onCreate方法中建立,最後需呼叫setSessionToken方法設定用於和控制器配對的令牌並通知瀏覽器連線服務成功

  • MediaController
    媒體控制器,在客戶端中開發者不僅可以使用控制器向Service中的受控端傳送指令,還可以通過設定MediaControllerCompat.Callback回撥方法接收受控端的狀態,從而根據相應的狀態重新整理介面UIMediaController的建立需要受控端的配對令牌,因此需在瀏覽器成功連線服務的回撥執行建立的操作

通過上述的簡介中我們不難看出這四個成員之間有著非常明確的分工和作用範圍,使得整個程式碼結構變得清晰易讀。可以通過下面這張圖來簡單歸納它們之間的關係

Android 媒體播放框架MediaSession分析與實踐

除此之外,MediaSession框架中還有一些同樣重要的類需要拿出來講,例如封裝了各種播放狀態PlaybackState,和Map相似通過鍵值對儲存媒體資訊MediaMetadata,以及用於MediaBrowserMediaBrowserService之間進行資料互動的MediaItem等等,下面我們通過實現一個簡單的demo來具體分析這套框架的工作流程

使用MediaSession框架構建簡單的音樂播放器

例如我們的demo是這樣的(見下圖),只提供簡單的播放暫停操作,音樂資料來源從raw資原始檔夾中獲取

Android 媒體播放框架MediaSession分析與實踐

按照工作流程,我們就從獲取音樂庫資料開始吧。首先介面上方新增一個RecyclerView來展示獲取的音樂列表,我們在DemoActivity中完成一些RecyclerView的初始化操作

public class DemoActivity extends AppCompatActivity {
    private RecyclerView recyclerView;
    private List<MediaBrowserCompat.MediaItem> list;
    private DemoAdapter demoAdapter;
    private LinearLayoutManager layoutManager;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_demo);

        list = new ArrayList<>();
        layoutManager = new LinearLayoutManager(this);
        layoutManager.setOrientation(LinearLayoutManager.VERTICAL);
        demoAdapter = new DemoAdapter(this,list);

        recyclerView = (RecyclerView) findViewById(R.id.recyclerView);
        recyclerView.setLayoutManager(layoutManager);
        recyclerView.setAdapter(demoAdapter);
    }
}
複製程式碼

注意List元素的型別為MediaBrowserCompat.MediaItem,因為MediaBrowser從服務中獲取的每一首音樂都會封裝成MediaItem物件。接下來我們建立MediaBrowser,並執行連線服務端和訂閱資料的操作

public class DemoActivity extends AppCompatActivity {
    ...
    private MediaBrowserCompat mBrowser;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        ...
        mBrowser = new MediaBrowserCompat(
                this,
                new ComponentName(this, MusicService.class),//繫結瀏覽器服務
                BrowserConnectionCallback,//設定連線回撥
                null
        );
    }

    @Override
    protected void onStart() {
        super.onStart();
        //Browser傳送連線請求
        mBrowser.connect();
    }

    @Override
    protected void onStop() {
        super.onStop();
        mBrowser.disconnect();
    }

    /**
     * 連線狀態的回撥介面,連線成功時會呼叫onConnected()方法
     */
    private MediaBrowserCompat.ConnectionCallback BrowserConnectionCallback =
            new MediaBrowserCompat.ConnectionCallback(){
                @Override
                public void onConnected() {
                    Log.e(TAG,"onConnected------");
                    //必須在確保連線成功的前提下執行訂閱的操作
                    if (mBrowser.isConnected()) {
                        //mediaId即為MediaBrowserService.onGetRoot的返回值
                        //若Service允許客戶端連線,則返回結果不為null,其值為資料內容層次結構的根ID
                        //若拒絕連線,則返回null
                        String mediaId = mBrowser.getRoot();

                        //Browser通過訂閱的方式向Service請求資料,發起訂閱請求需要兩個引數,其一為mediaId
                        //而如果該mediaId已經被其他Browser例項訂閱,則需要在訂閱之前取消mediaId的訂閱者
                        //雖然訂閱一個 已被訂閱的mediaId 時會取代原Browser的訂閱回撥,但卻無法觸發onChildrenLoaded回撥

                        //ps:雖然基本的概念是這樣的,但是Google在官方demo中有這麼一段註釋...
                        // This is temporary: A bug is being fixed that will make subscribe
                        // consistently call onChildrenLoaded initially, no matter if it is replacing an existing
                        // subscriber or not. Currently this only happens if the mediaID has no previous
                        // subscriber or if the media content changes on the service side, so we need to
                        // unsubscribe first.
                        //大概的意思就是現在這裡還有BUG,即只要傳送訂閱請求就會觸發onChildrenLoaded回撥
                        //所以無論怎樣我們發起訂閱請求之前都需要先取消訂閱
                        mBrowser.unsubscribe(mediaId);
                        //之前說到訂閱的方法還需要一個引數,即設定訂閱回撥SubscriptionCallback
                        //當Service獲取資料後會將資料傳送回來,此時會觸發SubscriptionCallback.onChildrenLoaded回撥
                        mBrowser.subscribe(mediaId, BrowserSubscriptionCallback);
                    }
                }

                @Override
                public void onConnectionFailed() {
                    Log.e(TAG,"連線失敗!");
                }
            };
    /**
     * 向媒體瀏覽器服務(MediaBrowserService)發起資料訂閱請求的回撥介面
     */
    private final MediaBrowserCompat.SubscriptionCallback BrowserSubscriptionCallback =
            new MediaBrowserCompat.SubscriptionCallback(){
                @Override
                public void onChildrenLoaded(@NonNull String parentId,
                                             @NonNull List<MediaBrowserCompat.MediaItem> children) {
                    Log.e(TAG,"onChildrenLoaded------");
                    //children 即為Service傳送回來的媒體資料集合
                    for (MediaBrowserCompat.MediaItem item:children){
                        Log.e(TAG,item.getDescription().getTitle().toString());
                        list.add(item);
                    }
                    //在onChildrenLoaded可以執行重新整理列表UI的操作
                    demoAdapter.notifyDataSetChanged();
                }
            };
}
複製程式碼

通過上述的程式碼和註釋大家應該清楚MediaBrowser連線服務到向其訂閱資料的流程了,簡單總結一下就是

connect → onConnected → subscribe → onChildrenLoaded
複製程式碼

那麼Service端那邊在這段流程中又做了什麼呢?首先我們得繼承MediaBrowserService(這裡使用了support-v4包的類)建立MusicService類。MediaBrowserService繼承自Service,所以記得在AndroidManifest.xml中完成配置

<service
    android:name=".demo.MusicService">
    <intent-filter>
        <action android:name="android.media.browse.MediaBrowserService" />
    </intent-filter>
</service>
複製程式碼

我們需要在Service初始化的時候就完成MediaSession的構建,併為它設定相應的標誌、狀態等,具體的程式碼如下

public class MusicService extends MediaBrowserServiceCompat {
    private MediaSessionCompat mSession;
    private PlaybackStateCompat mPlaybackState;

    @Override
    public void onCreate() {
        super.onCreate();
        mPlaybackState = new PlaybackStateCompat.Builder()
                .setState(PlaybackStateCompat.STATE_NONE,0,1.0f)
                .build();

        mSession = new MediaSessionCompat(this,"MusicService");
        mSession.setCallback(SessionCallback);//設定回撥
        mSession.setFlags(MediaSessionCompat.FLAG_HANDLES_TRANSPORT_CONTROLS);
        mSession.setPlaybackState(mPlaybackState);

        //設定token後會觸發MediaBrowserCompat.ConnectionCallback的回撥方法
        //表示MediaBrowser與MediaBrowserService連線成功
        setSessionToken(mSession.getSessionToken());
    }
}
複製程式碼

這裡解釋下其中的一些細節,首先是呼叫MediaSession.setFlagSession設定標誌位,以便Session接收控制器的指令。然後是播放狀態的設定,需呼叫MediaSession.setPlaybackState,那麼PlaybackState又是什麼呢?之前我們簡單介紹過它是封裝了各種播放狀態的類,我們可以通過判斷當前播放狀態來控制各個成員的行為,而PlaybackState類為我們定義了各種狀態的規範。此外我們還需要設定SessionCallback回撥,當客戶端使用控制器傳送指令時,就會觸發這些回撥方法,從而達到控制播放器的目的

public class MusicService extends MediaBrowserServiceCompat {
    ...
    private MediaPlayer mMediaPlayer;

    @Override
    public void onCreate() {
        ...
        mMediaPlayer = new MediaPlayer();
        mMediaPlayer.setOnPreparedListener(PreparedListener);
        mMediaPlayer.setOnCompletionListener(CompletionListener);
    }

    /**
     * 響應控制器指令的回撥
     */
    private android.support.v4.media.session.MediaSessionCompat.Callback SessionCallback = new MediaSessionCompat.Callback(){
        /**
         * 響應MediaController.getTransportControls().play
         */
        @Override
        public void onPlay() {
            Log.e(TAG,"onPlay");
            if(mPlaybackState.getState() == PlaybackStateCompat.STATE_PAUSED){
                mMediaPlayer.start();
                mPlaybackState = new PlaybackStateCompat.Builder()
                        .setState(PlaybackStateCompat.STATE_PLAYING,0,1.0f)
                        .build();
                mSession.setPlaybackState(mPlaybackState);
            }
        }

        /**
         * 響應MediaController.getTransportControls().onPause
         */
        @Override
        public void onPause() {
            Log.e(TAG,"onPause");
            if(mPlaybackState.getState() == PlaybackStateCompat.STATE_PLAYING){
                mMediaPlayer.pause();
                mPlaybackState = new PlaybackStateCompat.Builder()
                        .setState(PlaybackStateCompat.STATE_PAUSED,0,1.0f)
                        .build();
                mSession.setPlaybackState(mPlaybackState);
            }
        }

        /**
         * 響應MediaController.getTransportControls().playFromUri
         * @param uri
         * @param extras
         */
        @Override
        public void onPlayFromUri(Uri uri, Bundle extras) {
            Log.e(TAG,"onPlayFromUri");
            try {
                switch (mPlaybackState.getState()){
                    case PlaybackStateCompat.STATE_PLAYING:
                    case PlaybackStateCompat.STATE_PAUSED:
                    case PlaybackStateCompat.STATE_NONE:
                        mMediaPlayer.reset();
                        mMediaPlayer.setDataSource(MusicService.this,uri);
                        mMediaPlayer.prepare();//準備同步
                        mPlaybackState = new PlaybackStateCompat.Builder()
                                .setState(PlaybackStateCompat.STATE_CONNECTING,0,1.0f)
                                .build();
                        mSession.setPlaybackState(mPlaybackState);
                        //我們可以儲存當前播放音樂的資訊,以便客戶端重新整理UI
                        mSession.setMetadata(new MediaMetadataCompat.Builder()
                                .putString(MediaMetadataCompat.METADATA_KEY_TITLE,extras.getString("title"))
                                .build()
                        );
                        break;
                }
            }catch (IOException e){
                e.printStackTrace();
            }
        }

        @Override
        public void onPlayFromSearch(String query, Bundle extras) {
        }
    };

    /**
     * 監聽MediaPlayer.prepare()
     */
    private MediaPlayer.OnPreparedListener PreparedListener = new MediaPlayer.OnPreparedListener() {
        @Override
        public void onPrepared(MediaPlayer mediaPlayer) {
            mMediaPlayer.start();
            mPlaybackState = new PlaybackStateCompat.Builder()
                    .setState(PlaybackStateCompat.STATE_PLAYING,0,1.0f)
                    .build();
            mSession.setPlaybackState(mPlaybackState);
        }
    } ;

    /**
     * 監聽播放結束的事件
     */
    private MediaPlayer.OnCompletionListener CompletionListener = new MediaPlayer.OnCompletionListener() {
        @Override
        public void onCompletion(MediaPlayer mediaPlayer) {
            mPlaybackState = new PlaybackStateCompat.Builder()
                    .setState(PlaybackStateCompat.STATE_NONE,0,1.0f)
                    .build();
            mSession.setPlaybackState(mPlaybackState);
            mMediaPlayer.reset();
        }
    };
}
複製程式碼

MediaSessionCompat.Callback中還有許多回撥方法,大家可以按需覆蓋重寫即可

Android 媒體播放框架MediaSession分析與實踐

構建好MediaSession後記得呼叫setSessionToken儲存Session的配對令牌,同時呼叫此方法也會回撥MediaBrowser.ConnectionCallbackonConnected方法,告知客戶端BrowserBrowserService連線成功了,我們也就完成了MediaSession的建立和初始化

之前我們還講到BrowserBrowserService訂閱關係,在MediaBrowserService中我們需要重寫onGetRootonLoadChildren方法,其作用之前已經講過就不多贅述了

public class MusicService extends MediaBrowserServiceCompat {
    @Nullable
    @Override
    public BrowserRoot onGetRoot(@NonNull String clientPackageName, int clientUid, @Nullable Bundle rootHints) {
        Log.e(TAG,"onGetRoot-----------");
        return new BrowserRoot(MEDIA_ID_ROOT, null);
    }

    @Override
    public void onLoadChildren(@NonNull String parentId, @NonNull final Result<List<MediaBrowserCompat.MediaItem>> result) {
        Log.e(TAG,"onLoadChildren--------");
        //將資訊從當前執行緒中移除,允許後續呼叫sendResult方法
        result.detach();

        //我們模擬獲取資料的過程,真實情況應該是非同步從網路或本地讀取資料
        MediaMetadataCompat metadata = new MediaMetadataCompat.Builder()
                .putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, ""+R.raw.jinglebells)
                .putString(MediaMetadataCompat.METADATA_KEY_TITLE, "聖誕歌")
                .build();
        ArrayList<MediaBrowserCompat.MediaItem> mediaItems = new ArrayList<>();
        mediaItems.add(createMediaItem(metadata));

        //向Browser傳送資料
        result.sendResult(mediaItems);
    }

    private MediaBrowserCompat.MediaItem createMediaItem(MediaMetadataCompat metadata){
        return new MediaBrowserCompat.MediaItem(
                metadata.getDescription(),
                MediaBrowserCompat.MediaItem.FLAG_PLAYABLE
        );
    }
}
複製程式碼

最後我們回到客戶端這邊,四大成員還剩下控制器MediaController沒講。MediaController的建立依賴於Session配對令牌,當BrowserBrowserService連線成功我們就可以通過Browser拿到這個令牌了。控制器建立後,我們就可以通過MediaController.getTransportControls的方法傳送播放指令,同時也可以註冊MediaControllerCompat.Callback回撥接收播放狀態,用以重新整理介面UI

public class DemoActivity extends AppCompatActivity {
    ...
    private Button btnPlay;
    private TextView textTitle;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        ...
        btnPlay = (Button) findViewById(R.id.btn_play);
        textTitle = (TextView) findViewById(R.id.text_title);
    }

    public void clickEvent(View view) {
    	switch (view.getId()) {
            case R.id.btn_play:
                if(mController!=null){
                    handlerPlayEvent();
                }
                break;
    	}
    }

    /**
     * 處理播放按鈕事件
     */
    private void handlerPlayEvent(){
        switch (mController.getPlaybackState().getState()){
            case PlaybackStateCompat.STATE_PLAYING:
                mController.getTransportControls().pause();
                break;
            case PlaybackStateCompat.STATE_PAUSED:
                mController.getTransportControls().play();
                break;
            default:
                mController.getTransportControls().playFromSearch("", null);
                break;
        }
    }

    /**
     * 連線狀態的回撥介面,連線成功時會呼叫onConnected()方法
     */
    private MediaBrowserCompat.ConnectionCallback BrowserConnectionCallback =
            new MediaBrowserCompat.ConnectionCallback(){
                @Override
                public void onConnected() {
                    Log.e(TAG,"onConnected------");
                    if (mBrowser.isConnected()) {
                        ...
                        try{
                            mController = new MediaControllerCompat(DemoActivity.this,mBrowser.getSessionToken());
                            //註冊回撥
                            mController.registerCallback(ControllerCallback);
                        }catch (RemoteException e){
                            e.printStackTrace();
                        }
                    }
                }

                @Override
                public void onConnectionFailed() {
                    Log.e(TAG,"連線失敗!");
                }
            };

    /**
     * 媒體控制器控制播放過程中的回撥介面,可以用來根據播放狀態更新UI
     */
    private final MediaControllerCompat.Callback ControllerCallback =
            new MediaControllerCompat.Callback() {
                /***
                 * 音樂播放狀態改變的回撥
                 * @param state
                 */
                @Override
                public void onPlaybackStateChanged(PlaybackStateCompat state) {
                    switch (state.getState()){
                        case PlaybackStateCompat.STATE_NONE://無任何狀態
                            textTitle.setText("");
                            btnPlay.setText("開始");
                            break;
                        case PlaybackStateCompat.STATE_PAUSED:
                            btnPlay.setText("開始");
                            break;
                        case PlaybackStateCompat.STATE_PLAYING:
                            btnPlay.setText("暫停");
                            break;
                    }
                }

                /**
                 * 播放音樂改變的回撥
                 * @param metadata
                 */
                @Override
                public void onMetadataChanged(MediaMetadataCompat metadata) {
                    textTitle.setText(metadata.getDescription().getTitle());
                }
            };

    private Uri rawToUri(int id){
        String uriStr = "android.resource://" + getPackageName() + "/" + id;
        return Uri.parse(uriStr);
    }
}
複製程式碼

MediaSession框架的基本用法我們已經分析完了,後續將會分析Google官方demo UniversalMusicPlayer 的原始碼,看看播放進度條、播放佇列控制、通知欄上的快捷操作等等這些功能是如何結合MediaSession框架實現的


更新

UniversalMusicPlayer的原始碼分析已經開始更新了:

相關文章