Android 解读开源项目UniversalMusicPlayer(数据管理)

版权声明:本文为博主原创文章,未经博主允许不得转载
源码:AnliaLee/android-UniversalMusicPlayer
首发地址:Anlia_掘金
大家要是看到有错误的地方或者有啥好的建议,欢迎留言评论

前言

上篇博客我们主要讲了UAMP项目中播放控制层的实现,而这次就从数据层方面入手,着重分析音频数据服务端展示给用户的过程(ps:UAMP播放器是基于MediaSession框架的,相关资料可参考Android 媒体播放框架MediaSession分析与实践

参考资料
googlesamples/android-UniversalMusicPlayer


项目简介

UAMP播放器作为Google的官方demo展示了如何去开发一款音频媒体应用,该应用可跨多种外接设备使用,并为Android手机,平板电脑,Android Auto,Android Wear,Android TV和Google Cast设备提供一致的用户体验

项目按照标准的MVC架构管理各个模块,模块结构如下图所示

其中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中的一些工具类来讲。最后是惯例:若有什么遗漏或者建议的欢迎留言评论,如果觉得博主写得还不错麻烦点个赞,你们的支持是我最大的动力~

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,126评论 6 481
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,254评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 152,445评论 0 341
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,185评论 1 278
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,178评论 5 371
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,970评论 1 284
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,276评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,927评论 0 259
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,400评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,883评论 2 323
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,997评论 1 333
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,646评论 4 322
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,213评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,204评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,423评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,423评论 2 352
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,722评论 2 345

推荐阅读更多精彩内容

  • Android 自定义View的各种姿势1 Activity的显示之ViewRootImpl详解 Activity...
    passiontim阅读 171,498评论 25 707
  • 我的问题核心是亲密关系的疗愈。 在这个里面仍然是自己的问题,看人之大,也就是格局两字。 事实上也就是接纳自己的过程...
    jessica的记忆阅读 153评论 0 0
  • 作者:周维格 图片来源:pinterest 育儿的过程,是一场自我的修行。 因为在这个过程,我们总是教育孩子不要怕...
    原子妈咪阅读 428评论 0 0
  • 我写不出好的诗句,也写不出自己的心情,却有种拿笔写几段文字记录一下情绪的冲动,越是这个时候,越显的自己阅读量贫瘠词...
    时代在召唤阅读 193评论 0 0
  • 【0428今日分享】 001 近日重温了影片《桃姐》,再次被情节和演员的表演所打动。非亲非故却又胜似一家人的桃姐与...
    洪苙婕2009阅读 97评论 0 0