Android 解讀開源專案UniversalMusicPlayer(資料管理)

Anlia發表於2018-04-01

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

前言

上篇部落格我們主要講了UAMP專案中播放控制層的實現,而這次就從資料層方面入手,著重分析音訊資料服務端展示給使用者的過程(ps:UAMP播放器是基於MediaSession框架的,相關資料可參考Android 媒體播放框架MediaSession分析與實踐

參考資料
googlesamples/android-UniversalMusicPlayer


專案簡介

UAMP播放器作為Google的官方demo展示瞭如何去開發一款音訊媒體應用,該應用可跨多種外接裝置使用,併為Android手機,平板電腦,Android Auto,Android Wear,Android TV和Google Cast裝置提供一致的使用者體驗

Android 解讀開源專案UniversalMusicPlayer(資料管理)

Android 解讀開源專案UniversalMusicPlayer(資料管理)

Android 解讀開源專案UniversalMusicPlayer(資料管理)

專案按照標準的MVC架構管理各個模組,模組結構如下圖所示

Android 解讀開源專案UniversalMusicPlayer(資料管理)

其中modeluiplayback模組分別代表MVC架構中的model層、view層以及controller層。此外,UAMP專案中深度使用了MediaSession框架實現了資料管理、播放控制、UI更新等功能,本系列部落格將從各個模組入手,分析其原始碼及重要功能的實現邏輯,這期主要講的是資料管理這塊的內容


獲取音樂庫資料

我們在Android 媒體播放框架MediaSession分析與實踐一文中提到,客戶端向服務端請求資料的過程從MediaBrowser.subscribe訂閱資料開始,到SubscriptionCallback.onChildrenLoaded回撥中拿到返回的資料結束,我們就按著這個流程一步步講解UAMP中音訊資料的流向

MediaBrowserFragment是展示音樂列表的介面,在它的onStart方法中發起資料的訂閱操作:

public class MediaBrowserFragment extends Fragment {
    ...
    @Override
    public void onAttach(Activity activity) {
        super.onAttach(activity);
        mMediaFragmentListener = (MediaFragmentListener) activity;
    }

    @Override
    public void onStart() {
        ...
        MediaBrowserCompat mediaBrowser = mMediaFragmentListener.getMediaBrowser();
        if (mediaBrowser.isConnected()) {
            onConnected();
        }
    }
    
    public void onConnected() {
        ...
        mMediaFragmentListener.getMediaBrowser().unsubscribe(mMediaId);
        mMediaFragmentListener.getMediaBrowser().subscribe(mMediaId, mSubscriptionCallback);
    }
}
複製程式碼

發起的訂閱請求後最終會呼叫MediaBrowserService.onLoadChildren方法,即請求從客戶端來到了Service層:

public class MusicService extends MediaBrowserServiceCompat implements
       PlaybackManager.PlaybackServiceCallback {
   ...
   @Override
   public void onLoadChildren(@NonNull final String parentMediaId,
                              @NonNull final Result<List<MediaItem>> result) {
       LogHelper.d(TAG, "OnLoadChildren: parentMediaId=", parentMediaId);
       if (MEDIA_ID_EMPTY_ROOT.equals(parentMediaId)) {//如果之前驗證客戶端沒有許可權請求資料,則返回一個空的列表
           result.sendResult(new ArrayList<MediaItem>());
       } else if (mMusicProvider.isInitialized()) {//如果音樂庫已經準備好了,立即返回
           result.sendResult(mMusicProvider.getChildren(parentMediaId, getResources()));
       } else {//音樂資料檢索完畢後返回結果
           result.detach();
           mMusicProvider.retrieveMediaAsync(new MusicProvider.Callback() {//載入音樂資料後的回撥
               @Override
               public void onMusicCatalogReady(boolean success) {
                   result.sendResult(mMusicProvider.getChildren(parentMediaId, getResources()));
               }
           });
       }
   }
}
複製程式碼

這裡做了兩次判斷,首先是判斷該客戶端請求資料的許可權是否為空,這個驗證的過程在onGetRoot方法中,這個我們後面再細說,總之如果客戶端許可權為空,Service則會呼叫result.sendResult方法傳送一個空的列表至客戶端。第二次判斷是Service之前是否已經從服務端獲取過一次資料,顯然這個判斷是為了使用者離開MediaBrowserFragment後再次回到這個介面時無需再次與服務端進行互動,直接傳送之前的結果即可。當上述兩個條件都不符合時,則表示Service需要連線服務端獲取資料,這個過程是通過MusicProvider這個類完成的,先來看MusicProvider.retrieveMediaAsync這個方法

//MusicProvider.java
public void retrieveMediaAsync(final Callback callback) {
    LogHelper.d(TAG, "retrieveMediaAsync called");
    if (mCurrentState == State.INITIALIZED) {
        if (callback != null) {
            // Nothing to do, execute callback immediately
            callback.onMusicCatalogReady(true);
        }
        return;
    }

    new AsyncTask<Void, Void, State>() {
        @Override
        protected State doInBackground(Void... params) {
            retrieveMedia();
            return mCurrentState;
        }

        @Override
        protected void onPostExecute(State current) {
            if (callback != null) {
                callback.onMusicCatalogReady(current == State.INITIALIZED);
            }
        }
    }.execute();
}

public interface Callback {
    void onMusicCatalogReady(boolean success);
}
複製程式碼

這裡使用了AsyncTask進行非同步獲取資料的操作,先來看onPostExecute方法,這裡執行了Callback.onMusicCatalogReady回撥,由於Callback的例項是在Service層中建立的,即執行回撥的結果便是通知Service獲取資料完畢,Service可以將資料傳送至客戶端了。然後再來看doInBackground方法,這裡實現了非同步獲取資料的操作,我們繼續跟進retrieveMedia方法:

//MusicProvider.java
private synchronized void retrieveMedia() {
    try {
        if (mCurrentState == State.NON_INITIALIZED) {
            mCurrentState = State.INITIALIZING;

            Iterator<MediaMetadataCompat> tracks = mSource.iterator();
            while (tracks.hasNext()) {
                MediaMetadataCompat item = tracks.next();
                String musicId = item.getString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID);
                mMusicListById.put(musicId, new MutableMediaMetadata(musicId, item));
            }
            buildListsByGenre();
            mCurrentState = State.INITIALIZED;
        }
    } finally {
        if (mCurrentState != State.INITIALIZED) {
            // Something bad happened, so we reset state to NON_INITIALIZED to allow
            // retries (eg if the network connection is temporary unavailable)
            mCurrentState = State.NON_INITIALIZED;
        }
    }
}
複製程式碼

拋開狀態位的設定,這個方法可以劃分成三個部分來看,其一是拿到mSource的迭代器為接下來的遍歷做準備,那麼mSource是什麼呢?

//MusicProvider.java
private MusicProviderSource mSource;
複製程式碼

mSource的型別為MusicProviderSource,這是一個介面,定義了一個常量及一個迭代器:

//MusicProviderSource.java
public interface MusicProviderSource {
    String CUSTOM_METADATA_TRACK_SOURCE = "__SOURCE__";
    Iterator<MediaMetadataCompat> iterator();
}
複製程式碼

我們得繼續找它的具體實現,這可以在MusicProvider的構造方法中找到:

//MusicProvider.java
public MusicProvider() {
    this(new RemoteJSONSource());
}
public MusicProvider(MusicProviderSource source) {
    mSource = source;
    ...
}
複製程式碼

那麼最終連線服務端並獲取資料的操作應該是在RemoteJSONSource這個類完成的,我們重點看下它是如何重寫iterator方法的:

//RemoteJSONSource.java
public class RemoteJSONSource implements MusicProviderSource {
    ...
    protected static final String CATALOG_URL =
        "http://storage.googleapis.com/automotive-media/music.json";

    @Override
    public Iterator<MediaMetadataCompat> iterator() {
        try {
            int slashPos = CATALOG_URL.lastIndexOf('/');
            String path = CATALOG_URL.substring(0, slashPos + 1);
            JSONObject jsonObj = fetchJSONFromUrl(CATALOG_URL);//下載JSON檔案
            ArrayList<MediaMetadataCompat> tracks = new ArrayList<>();
            if (jsonObj != null) {
                JSONArray jsonTracks = jsonObj.getJSONArray(JSON_MUSIC);

                if (jsonTracks != null) {
                    for (int j = 0; j < jsonTracks.length(); j++) {
                        tracks.add(buildFromJSON(jsonTracks.getJSONObject(j), path));
                    }
                }
            }
            return tracks.iterator();
        } catch (JSONException e) {
            LogHelper.e(TAG, e, "Could not retrieve music list");
            throw new RuntimeException("Could not retrieve music list", e);
        }
    }

    /**
     * 解析JSON格式的資料,構建MediaMetadata物件
     */
    private MediaMetadataCompat buildFromJSON(JSONObject json, String basePath) throws JSONException {
        ...
    }

    /**
     * 從服務端下載JSON檔案,解析並返回JSON object
     */
    private JSONObject fetchJSONFromUrl(String urlString) throws JSONException {
        ...
    }
}
複製程式碼

程式碼不復雜,整個流程可以歸納為:根據url從服務端獲取封裝了音樂源資訊的JSON檔案解析JSON物件並構建成MediaMetadata物件 → 將所有資料加入列表集合中返回給MusicProvider,至此資料的獲取就完成了


構建按型別劃分的音訊集合

我們回到MusicProvider.retrieveMedia方法。第二步是遍歷之前拿到的迭代器資料,取出各個MediaMetadata物件,以鍵值對的方式重新插入mMusicListById集合中

//MusicProvider.java
while (tracks.hasNext()) {
    MediaMetadataCompat item = tracks.next();
    String musicId = item.getString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID);
    mMusicListById.put(musicId, new MutableMediaMetadata(musicId, item));
}
複製程式碼

mMusicListById的型別為ConcurrentHashMap,這點從MusicProvider的構造方法中可以得知,具體資料大家可以自行搜尋瞭解

//MusicProvider.java
private final ConcurrentMap<String, MutableMediaMetadata> mMusicListById;
public MusicProvider(MusicProviderSource source) {
    ...
    mMusicListById = new ConcurrentHashMap<>();
}
複製程式碼

所有資料儲存至mMusicListById集合之後,呼叫buildListsByGenre方法將這些資料重新按音樂型別進行劃分並存至mMusicListByGenre集合中(注意比對Mapvalue型別):

//MusicProvider.java
private ConcurrentMap<String, List<MediaMetadataCompat>> mMusicListByGenre;
public MusicProvider(MusicProviderSource source) {
    ...
    mMusicListByGenre = new ConcurrentHashMap<>();
}
private synchronized void buildListsByGenre() {
    ConcurrentMap<String, List<MediaMetadataCompat>> newMusicListByGenre = new ConcurrentHashMap<>();
    for (MutableMediaMetadata m : mMusicListById.values()) {
        String genre = m.metadata.getString(MediaMetadataCompat.METADATA_KEY_GENRE);
        List<MediaMetadataCompat> list = newMusicListByGenre.get(genre);
        if (list == null) {
            list = new ArrayList<>();
            newMusicListByGenre.put(genre, list);
        }
        list.add(m.metadata);
    }
    mMusicListByGenre = newMusicListByGenre;
}
複製程式碼

分析一下buildListsByGenre的邏輯:遍歷mMusicListById的音訊元素,以音訊的型別genre作為key值在臨時的newMusicListByGenre集合中查詢對應的列表,若這個列表為空,則證明之前此型別的音訊還未存入newMusicListByGenre中,新建一個空的列表儲存當前遍歷到的音訊元素,並以genre作為key值構建鍵值對。當遍歷到下一個元素時,newMusicListByGenre若已儲存了該型別的音訊列表,則直接將此元素存進該列表即可。這樣通過一次遍歷即可將所有音訊資料按型別分成多個列表集合,客戶端就可以按音訊型別選擇播放的佇列了


更新列表展示資料

buildListsByGenre結束後,設定相應的狀態,retrieveMediaAsync中的非同步任務,即AsyncTaskdoInBackground的工作就完成了,接下來在onPostExecute中執行回撥,回到MusicService中將資料傳送至客戶端

//MusicService.java
@Override
public void onLoadChildren(@NonNull final String parentMediaId,
                           @NonNull final Result<List<MediaItem>> result) {
    ...                          
    mMusicProvider.retrieveMediaAsync(new MusicProvider.Callback() {
        //完成音樂載入後的回撥
        @Override
        public void onMusicCatalogReady(boolean success) {
            result.sendResult(mMusicProvider.getChildren(parentMediaId, getResources()));
        }
    });
}
複製程式碼

客戶端(MediaBrowserFragment)拿到資料後重新整理列表Adapter即可將內容展示給使用者了

//MediaBrowserFragment.java
private final MediaBrowserCompat.SubscriptionCallback mSubscriptionCallback = 
              new MediaBrowserCompat.SubscriptionCallback() {
    ...
    @Override
    public void onChildrenLoaded(@NonNull String parentId,
                                 @NonNull List<MediaBrowserCompat.MediaItem> children) {
        try {
            ...
            mBrowserAdapter.clear();
            for (MediaBrowserCompat.MediaItem item : children) {
                mBrowserAdapter.add(item);
            }
            mBrowserAdapter.notifyDataSetChanged();
        } catch (Throwable t) {
            LogHelper.e(TAG, "Error on childrenloaded", t);
        }
    }
};
複製程式碼

MusicProvider其他功能

作為內容提供者,MusicProvider當然不止上述這點功能。MusicProvider支援亂序播放音訊,這個主要通過Collections.shuffle方法實現的:

//MusicProvider.java
public Iterable<MediaMetadataCompat> getShuffledMusic() {
    if (mCurrentState != State.INITIALIZED) {
        return Collections.emptyList();
    }
    List<MediaMetadataCompat> shuffled = new ArrayList<>(mMusicListById.size());
    for (MutableMediaMetadata mutableMetadata: mMusicListById.values()) {
        shuffled.add(mutableMetadata.metadata);
    }
    Collections.shuffle(shuffled);//打亂列表的順序
    return shuffled;
}
複製程式碼

支援個人“喜歡”,即收藏功能:

//MusicProvider.java
private final Set<String> mFavoriteTracks;
public MusicProvider(MusicProviderSource source) {
    ...
    mFavoriteTracks = Collections.newSetFromMap(new ConcurrentHashMap<String, Boolean>());
}
public void setFavorite(String musicId, boolean favorite) {
    if (favorite) {
        mFavoriteTracks.add(musicId);
    } else {
        mFavoriteTracks.remove(musicId);
    }
}
/**
 * 判斷該音樂是否在"喜歡"列表中
 */
public boolean isFavorite(String musicId) {
    return mFavoriteTracks.contains(musicId);
}
複製程式碼

此外還支援多種簡易的檢索功能:

//MusicProvider.java
public List<MediaMetadataCompat> searchMusicBySongTitle(String query) {
    return searchMusic(MediaMetadataCompat.METADATA_KEY_TITLE, query);
}

public List<MediaMetadataCompat> searchMusicByAlbum(String query) {
    return searchMusic(MediaMetadataCompat.METADATA_KEY_ALBUM, query);
}

public List<MediaMetadataCompat> searchMusicByArtist(String query) {
    return searchMusic(MediaMetadataCompat.METADATA_KEY_ARTIST, query);
}

public List<MediaMetadataCompat> searchMusicByGenre(String query) {
    return searchMusic(MediaMetadataCompat.METADATA_KEY_GENRE, query);
}

private List<MediaMetadataCompat> searchMusic(String metadataField, String query) {
    if (mCurrentState != State.INITIALIZED) {
        return Collections.emptyList();
    }
    ArrayList<MediaMetadataCompat> result = new ArrayList<>();
    query = query.toLowerCase(Locale.US);
    for (MutableMediaMetadata track : mMusicListById.values()) {
        if (track.metadata.getString(metadataField).toLowerCase(Locale.US)
            .contains(query)) {
            result.add(track.metadata);
        }
    }
    return result;
}
複製程式碼

那麼UAMP播放器資料管理方面的內容到這就暫告一段落了,後續可能會挑UAMP中的一些工具類來講。最後是慣例:若有什麼遺漏或者建議的歡迎留言評論,如果覺得博主寫得還不錯麻煩點個贊,你們的支援是我最大的動力~

相關文章