通过上一篇的学习实践,我们了解了ExoPlayer的优缺点以及基本用法,今天我们进入ExoPlayer的音频播放实践,我们来一起实现一个简单的音频播放器。
目录
- 媒体播放框架MediaSession
- MediaSession框架+ExoPlayer 简单音乐播放器实践
- 播放网络音乐
- 播放/暂停
- 歌曲切换
- 倍速播放
- 资料
- 收获
一、媒体播放框架MediaSession
音频播放器并不总是需要使其UI可见。一旦开始播放音频,播放器就可以作为后台任务运行。用户可以切换到另一个应用程序,并继续听。
要在Android中实现这一设计,您可以使用两个组件构建一个音频应用程序: activity(展示所用) 和播放器service。如果用户切换到另一个应用程序,则该service可以在后台运行。通过将音频应用程序的两个部分分解为单独的组件,每个组件可以独立运行。与播放器相比,UI通常是短暂的,可能会在没有UI的情况下运行很长时间。
在设计音乐播放器APP架构时,有几种常用的做法
方案一
- 注册Service,用于数据设置、音乐控制,在Service中自定义播放器的一些状态值和回调接口用于流程控制
- 通过广播、aidl等实现和页面层逻辑的通信,使得用户可以通过界面控制音乐的播放、暂停、切换、seek等操作
- 使用RemoteControlClient(低版本)或者MediaSession(>5.0或者MediaSessionCompat)进行多端设备或者跨APP媒体会话
方案二
Android5.0时推出的MediaSession框架(Supprot包中MediaSessionCompat也对低版本做了支持),专门用来解决媒体播放时界面和Service通信的问题,在结构低耦合方面的设计做的比较好
支持库提供了两个类来实现此客户端/服务器方法:MediaBrowserService和MediaBrowser。该服务组件被实现为包含媒体会话及其播放器的MediaBrowserService的子类。使用UI和媒体控制器的活动应包括与MediaBrowserService进行通信的MediaBrowser。
使用MediaBrowserService可以让随身设备(如Android Auto and Wear)轻松发现您的应用,连接到它,浏览内容和控制播放,而无需访问您的Activity
我们今天的学习实践是基于方案二的MediaSession的框架
图片来自 媒体应用架构概览
MediaBrowser
用来连接MediaBrowserService和订阅数据,通过他的回调可以获取和Service的连接状态以及获取在Service中异步获取的音乐数据(这个一般不在Service中进行获取,因为涉及到的是具体的业务逻辑)
MediaBrowserService
是一个Service,封装了媒体相关的一些功能,通过onGetRoot的返回值决定是否允许客户端连接。onLoadChildren回调在Sercive中异步获取的数据给到MediaBrowser。也包含媒体播放器实例(比如我们本篇实践的ExoPlayer)
MediaSession
一般在MediaBrowserService的onCreate中创建,通过MediaSession.CallBack回调接收MediaController发来的指令,触发对应的播放器相关的操作
MediaController
MediaContoller的创建需要MediaSession的配对令牌,在MediaBrowser连接服务成功之后创建。MediaController可以主动的发送指令或者被动的接收MediaController.Callback回调来改变播放状态和界面刷新。
更详细的介绍请参考官方文档或者Android 媒体播放框架MediaSession分析与实践
二、 简单实践
下面我们看下如何使用MediaSession框架实现简单的音频播放
2.1 Server端实现
首先我们继承MediaBrowserServiceCompat实现和注册Service
public class MusicService extends MediaBrowserServiceCompat {
private static final String TAG = "MusicService";
private SimpleExoPlayer exoPlayer;
private MediaSessionCompat mediaSession;
/**
* 当服务收到onCreate()生命周期回调方法时,它应该执行以下步骤:
* 1. 创建并初始化media session
* 2. 设置media session回调
* 3. 设置media session token
*/
@Override
public void onCreate() {
Log.i(TAG, "onCreate: ");
super.onCreate();
//1. 创建并初始化MediaSession
mediaSession = new MediaSessionCompat(getApplicationContext(), TAG);
mediaSession.setFlags(MediaSessionCompat.FLAG_HANDLES_MEDIA_BUTTONS
| MediaSessionCompat.FLAG_HANDLES_TRANSPORT_CONTROLS);
PlaybackStateCompat playbackState = new PlaybackStateCompat.Builder()
.setActions(PlaybackStateCompat.ACTION_PLAY | PlaybackStateCompat.ACTION_PLAY_PAUSE
| PlaybackStateCompat.ACTION_STOP | PlaybackStateCompat.ACTION_PLAY_PAUSE |
PlaybackStateCompat.ACTION_PLAY_FROM_MEDIA_ID |
PlaybackStateCompat.ACTION_PLAY_FROM_SEARCH |
PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS |
PlaybackStateCompat.ACTION_SKIP_TO_NEXT | PlaybackStateCompat.ACTION_SEEK_TO)
.build();
mediaSession.setPlaybackState(playbackState);
//2. 设置mediaSession回调
mediaSession.setCallback(new MyMediaSessionCallBack());
//3. 设置mediaSessionToken
setSessionToken(mediaSession.getSessionToken());
//创建播放器实例
exoPlayer = new SimpleExoPlayer.Builder(getApplicationContext()).build();
}
}
MediaSessionCompat.Callback的回调用于接收业务成通过mediaController.getTransportControls进行播放相关操作(播放、暂停、seek、倍速等等)的回调
/**
* 用于接收由MediaControl触发的改变,内部封装实现播放器和播放状态的改变
*/
private class MyMediaSessionCallBack extends MediaSessionCompat.Callback {
@Override
public void onPlay() {
super.onPlay();
Log.i(TAG, "onPlay: ");
exoPlayer.play();
}
@Override
public void onPause() {
super.onPause();
Log.i(TAG, "onPause: ");
exoPlayer.pause();
}
@Override
public void onSeekTo(long pos) {
super.onSeekTo(pos);
Log.i(TAG, "onSeekTo: pos=" + pos);
exoPlayer.seekTo(pos);
}
...
}
MediaBrowserServiceCompat有两个回调方法onGetRoot和onLoadChildren。其中onGetRoot用于告诉MediaBrowser是否连接连接成功;onLoadChildren则是加载音视频数据。
具体使用如下:
@Nullable
@Override
public BrowserRoot onGetRoot(@NonNull String clientPackageName, int clientUid, @Nullable Bundle rootHints) {
Log.i(TAG, "onGetRoot: clientPackageName=" + clientPackageName + " clientUid=" + clientUid + " pid=" + Binder.getCallingPid()
+ " uid=" + Binder.getCallingUid());
//返回非空,表示连接成功
return new BrowserRoot("media_root_id", null);
}
//获取音视频信息(这个更应该是在业务层处理事情)
@Override
public void onLoadChildren(@NonNull String parentId, @NonNull Result<List<MediaBrowserCompat.MediaItem>> result) {
Log.i(TAG, "onLoadChildren: parentId=" + parentId);
List<MediaBrowserCompat.MediaItem> mediaItems = new ArrayList<>();
if (TextUtils.equals("media_root_id", parentId)) {
}
ArrayList<MusicEntity> musicEntityList = getMusicEntityList();
for (int i = 0; i < musicEntityList.size(); i++) {
MusicEntity musicEntity = musicEntityList.get(i);
MediaMetadataCompat metadataCompat = buildMediaMetadata(musicEntity);
if (i == 0) {
mediaSession.setMetadata(metadataCompat);
}
mediaItems.add(new MediaBrowserCompat.MediaItem(metadataCompat.getDescription(), MediaBrowserCompat.MediaItem.FLAG_BROWSABLE));
exoPlayer.addMediaItem(MediaItem.fromUri(musicEntity.source));
}
//当设置多首歌曲组成队列时报错
// IllegalStateException: sendResult() called when either sendResult() or sendError() had already been called for: media_root_id
//原因,之前在for处理了,应该在设置好mediaItems列表后,统一设置result
result.sendResult(mediaItems);
Log.i(TAG, "onLoadChildren: addMediaItem");
initExoPlayerListener();
exoPlayer.prepare();
Log.i(TAG, "onLoadChildren: prepare");
}
private void initExoPlayerListener() {
exoPlayer.addListener(new Player.EventListener() {
@Override
public void onPlaybackStateChanged(int state) {
long currentPosition = exoPlayer.getCurrentPosition();
long duration = exoPlayer.getDuration();
//状态改变(播放器内部发生状态变化的回调,
// 包括
// 1. 用户触发的 比如: 手动切歌曲、暂停、播放、seek等;
// 2. 播放器内部触发 比如: 播放结束、自动切歌曲等)
//该如何通知给ui业务层呐??好些只能通过回调
//那有该如何 --》查看源码得知通过setPlaybackState设置
Log.i(TAG, "onPlaybackStateChanged: currentPosition=" + currentPosition + " duration=" + duration + " state=" + state);
int playbackState;
switch (state) {
default:
case Player.STATE_IDLE:
playbackState = PlaybackStateCompat.STATE_NONE;
break;
case Player.STATE_BUFFERING:
playbackState = PlaybackStateCompat.STATE_BUFFERING;
break;
case Player.STATE_READY:
if(exoPlayer.getPlayWhenReady()){
playbackState = PlaybackStateCompat.STATE_PLAYING;
}else {
playbackState = PlaybackStateCompat.STATE_PAUSED;
}
break;
case Player.STATE_ENDED:
playbackState = PlaybackStateCompat.STATE_STOPPED;
break;
}
//播放器的状态变化,通过mediasession告诉在ui业务层注册的MediaControllerCompat.Callback进行回调
setPlaybackState(playbackState);
}
private void setPlaybackState(int playbackState) {
float speed = exoPlayer.getPlaybackParameters() == null ? 1f : exoPlayer.getPlaybackParameters().speed;
mediaSession.setPlaybackState(new PlaybackStateCompat.Builder().setState(playbackState, exoPlayer.getCurrentPosition(), speed).build());
}
@NotNull
private ArrayList<MusicEntity> getMusicEntityList() {
ArrayList<MusicEntity> list = new ArrayList<MusicEntity>();
...
MusicEntity musicEntity2 = new MusicEntity();
musicEntity2.id = "wake_up_02";
musicEntity2.title = "Geisha";
musicEntity2.album = "Wake Up";
musicEntity2.artist = "Media Right Productions";
musicEntity2.genre = "Electronic";
musicEntity2.source = "https://storage.googleapis.com/uamp/The_Kyoto_Connection_-_Wake_Up/02_-_Geisha.mp3";
musicEntity2.image = "https://storage.googleapis.com/uamp/The_Kyoto_Connection_-_Wake_Up/art.jpg";
musicEntity2.trackNumber = 2;
musicEntity2.totalTrackCount = 13;
musicEntity2.duration = 267;
musicEntity2.site = "http://freemusicarchive.org/music/The_Kyoto_Connection/Wake_Up_1957/";
list.add(musicEntity2);
return list;
}
2.2 Client端实现
下面我们再来看下Client端的实现
public class ExoSimpleAudioPlayerActivity extends Activity implements View.OnClickListener {
private MediaBrowserCompat mediaBrowser;
private MediaBrowserCompat.ConnectionCallback mConnectionCallbacks = new MyConnectionCallback();
private MediaControllerCompat.Callback mMediaControllerCallback;
private MediaBrowserCompat.SubscriptionCallback mSubscriptionCallback;
@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_simple_audio);
...
//mConnectionCallbacks 是C-S连接的callback
mediaBrowser = new MediaBrowserCompat(this, new ComponentName(this, MusicService.class),
mConnectionCallbacks, null);
}
@Override
protected void onStart() {
super.onStart();
Log.i(TAG, "onStart: ");
//发出C-S连接请求 创建MusicService,收到onGetRoot回调值不为空说明建立连接成功--》然后触发MyConnectionCallback的回调onConnected
mediaBrowser.connect();
// subscribe();
}
@Override
protected void onStop() {
super.onStop();
Log.i(TAG, "onStop: ");
mediaBrowser.disconnect();
}
}
MediaBrowserCompat.ConnectionCallback用于接收与Server端连接的状态回调
public class MyConnectionCallback extends MediaBrowserCompat.ConnectionCallback {
@Override
public void onConnected() {
super.onConnected();
Log.i(TAG, "onConnected: MyConnectionCallback");
//MediaBrowser和MediaBrowerService建立连接之后会回调该方法
MediaSessionCompat.Token sessionToken = mediaBrowser.getSessionToken();
//建立连接之后再创建MediaController
mediaController = new MediaControllerCompat(ExoSimpleAudioPlayerActivity.this, sessionToken);
MediaControllerCompat.setMediaController(ExoSimpleAudioPlayerActivity.this, mediaController);
subscribe();
//MediaController发送命令
buildTransportControls();
if (mMediaControllerCallback == null) {
//这个callback 是Controller的callback,即用户触发了播放、暂停,后发生状态变化的回调。
//像播放结束、自动切歌,则无法收到该回调(那该如何处理呐?)
mMediaControllerCallback = new MediaControllerCompat.Callback() {
//这里的回调,只有用户触发的才会有相应的回调。
//播放结束 这里没有
//ExoPlayer getDuration : https://stackoverflow.com/questions/35298125/exoplayer-getduration
// Override
public void onPlaybackStateChanged(PlaybackStateCompat state) {
super.onPlaybackStateChanged(state);
Log.i(TAG, "onPlaybackStateChanged: state=" + state.getState());
if (PlaybackStateCompat.STATE_PLAYING == state.getState()) {
playButton.setText("暂停");
} else {
playButton.setText("播放");
}
updatePlaybackState(state);
MediaMetadataCompat metadata = mediaController.getMetadata();
updateDuration(metadata);
}
@Override
public void onMetadataChanged(MediaMetadataCompat metadata) {
super.onMetadataChanged(metadata);
durationSet = false;
Log.i(TAG, "onMetadataChanged: metadata=" + metadata.toString());
updateDuration(metadata);
}
}
mediaController.registerCallback(mMediaControllerCallback);
PlaybackStateCompat state = mediaController.getPlaybackState();
updatePlaybackState(state);
updateProgress();
if (state != null && (state.getState() == PlaybackStateCompat.STATE_PLAYING ||
state.getState() == PlaybackStateCompat.STATE_BUFFERING)) {
scheduleSeekbarUpdate();
}
//通过mediaController获取MediaMetadataCompat
MediaMetadataCompat metadata = mediaController.getMetadata();
updateDuration(metadata);
}
@Override
public void onConnectionFailed() {
super.onConnectionFailed();
}
}
2.3 基本功能
歌曲播放播放暂停
当用户点击了播放/暂停按钮后,获取当前的播放状态,通过mediaController.getTransportControls给到通过Binder给到mediaSession,在service中MediaSessionCompat.Callback改变Exoplayer的播放状态,exoplayer的onPlaybackStateChanged收到播放状态改变的通知后触发,给mediasession设置mediaSession.setPlaybackState
对应关键代码如下:
client端用户点击事件处理
//ExoSimpleAudioPlayerActivity.java
PlaybackStateCompat playbackState = mediaController.getPlaybackState();
int state = playbackState.getState();
Log.i(TAG, "onClick: state=" + state);
//通过 mediaController.getTransportControls 触发MediaSessionCompat.Callback回调--》进行播放控制
if (state == PlaybackStateCompat.STATE_PLAYING) {
mediaController.getTransportControls().pause();
} else {
mediaController.getTransportControls().play();
}
//Server端MediasessionCallback实现,接收mediaController.getTransportControls()的事件
//com.example.myplayer.audio.MusicService.MyMediaSessionCallBack
@Override
public void onPlay() {
super.onPlay();
Log.i(TAG, "onPlay: ");
exoPlayer.play();
}
@Override
public void onPause() {
super.onPause();
Log.i(TAG, "onPause: ");
exoPlayer.pause();
}
//server端 exoplayer状态变化监听
//com.example.myplayer.audio.MusicService#initExoPlayerListener
exoPlayer.addListener(new Player.EventListener() {
@Override
public void onPlaybackStateChanged(int state) {
long currentPosition = exoPlayer.getCurrentPosition();
long duration = exoPlayer.getDuration();
//状态改变(播放器内部发生状态变化的回调,
// 包括
// 1. 用户触发的 比如: 手动切歌曲、暂停、播放、seek等;
// 2. 播放器内部触发 比如: 播放结束、自动切歌曲等)
//该如何通知给ui业务层呐??好些只能通过回调
//那有该如何 --》查看源码得知通过setPlaybackState设置
Log.i(TAG, "onPlaybackStateChanged: currentPosition=" + currentPosition + " duration=" + duration + " state=" + state);
int playbackState;
switch (state) {
default:
case Player.STATE_IDLE:
playbackState = PlaybackStateCompat.STATE_NONE;
break;
case Player.STATE_BUFFERING:
playbackState = PlaybackStateCompat.STATE_BUFFERING;
break;
case Player.STATE_READY:
if(exoPlayer.getPlayWhenReady()){
playbackState = PlaybackStateCompat.STATE_PLAYING;
}else {
playbackState = PlaybackStateCompat.STATE_PAUSED;
}
break;
case Player.STATE_ENDED:
playbackState = PlaybackStateCompat.STATE_STOPPED;
break;
}
//播放器的状态变化,通过mediasession告诉在ui业务层注册的MediaControllerCompat.Callback进行回调
setPlaybackState(playbackState);
}
}
private void setPlaybackState(int playbackState) {
float speed = exoPlayer.getPlaybackParameters() == null ? 1f : exoPlayer.getPlaybackParameters().speed;
mediaSession.setPlaybackState(new PlaybackStateCompat.Builder().setState(playbackState, exoPlayer.getCurrentPosition(), speed).build());
}
虽然知道了怎么使用,但是整个流程是怎样的呐?
其中用到了Handler和Binder的线程和进程通信相关的知识,后续我们专题单独深入学习实践下,这里我们先顺着流程画下播放/暂停的流程图,从用户按下按钮到播放器开始播放以及页面更新的整个流程是怎样的。
上一首下一首切换
歌曲切换流程个上面的播放流程基本上一致,
//com.example.myplayer.audio.ExoSimpleAudioPlayerActivity#onClick
if (id == R.id.prev) {
if (mediaController != null) {
mediaController.getTransportControls().skipToPrevious();
}
} else if (id == R.id.next) {
if (mediaController != null) {
mediaController.getTransportControls().skipToNext();
}
}
区别在于 没有触发ExoPlayer的播放回调,需要再sessionCallback中调用exoplayer的next/prev进行歌曲切换,并且设置新的playstate状态给到mession
//com.example.myplayer.audio.MusicService.MyMediaSessionCallBack
@Override
public void onSkipToNext() {
super.onSkipToNext();
Log.i(TAG, "onSkipToNext: ");
exoPlayer.next();
exoPlayer.setPlayWhenReady(true);
setPlaybackState(PlaybackStateCompat.STATE_SKIPPING_TO_NEXT);
mediaSession.setMetadata(getMediaMetadata(1));
}
@Override
public void onSkipToPrevious() {
super.onSkipToPrevious();
Log.i(TAG, "onSkipToPrevious: ");
exoPlayer.previous();
exoPlayer.setPlayWhenReady(true);
setPlaybackState(PlaybackStateCompat.STATE_SKIPPING_TO_PREVIOUS);
mediaSession.setMetadata(getMediaMetadata(0));
}
最终MediaControllerCallback的onPlaybackStateChanged收到回调,根据状态进行
public void onPlaybackStateChanged(PlaybackStateCompat state) {
super.onPlaybackStateChanged(state);
...
if (state.getState() == PlaybackStateCompat.STATE_SKIPPING_TO_PREVIOUS || state.getState() == PlaybackStateCompat.STATE_SKIPPING_TO_NEXT) {
updateShowMediaInfo(description);
}
}
private void updateShowMediaInfo(MediaDescriptionCompat description) {
if (description == null) return;
titleView.setText(description.getTitle());
artistView.setText(description.getSubtitle());
Glide.with(ExoSimpleAudioPlayerActivity.this).load(description.getIconUri().toString()).into(iconView);
Uri mediaUri = description.getMediaUri();
Uri iconUri = description.getIconUri();
Log.i(TAG, "onChildrenLoaded: title=" + description.getTitle() + " subtitle=" + description.getSubtitle()
+ " mediaUri=" + mediaUri + " iconUri=" + iconUri);
}
倍速
//com.example.myplayer.audio.ExoSimpleAudioPlayerActivity#onClick
if (id == R.id.speed) {
if (mediaController != null) {
float speed = getSpeed();
speedView.setText("倍速 " + speed);
mediaController.getTransportControls().setPlaybackSpeed(speed);
}
}
float[] speedArray = new float[]{0.5f, 1f, 1.5f, 2f};
int curSpeedIndex = 1;
private float getSpeed() {
if (curSpeedIndex > 3) {
curSpeedIndex = 0;
}
return speedArray[curSpeedIndex++];
}
然后再MediaSessionCallBack中实现onSetPlaybackSpeed回调,进行播放倍速设置以及mession的设置
//com.example.myplayer.audio.MusicService.MyMediaSessionCallBack
@Override
public void onSetPlaybackSpeed(float speed) {
super.onSetPlaybackSpeed(speed);
Log.i(TAG, "onSetPlaybackSpeed: speed=" + speed);
PlaybackParameters playParams = new PlaybackParameters(speed);
exoPlayer.setPlaybackParameters(playParams);
//重新设置mediaSession.setPlaybackState 告知 监听者 speed变化
setPlaybackState(exoPlayer.getPlaybackState());
}
private void setPlaybackState(int playbackState) {
float speed = exoPlayer.getPlaybackParameters() == null ? 1f : exoPlayer.getPlaybackParameters().speed;
mediaSession.setPlaybackState(new PlaybackStateCompat.Builder().setState(playbackState, exoPlayer.getCurrentPosition(), speed).build());
}
需要注意
播放状态 MediaSession框架和ExoPlayer的不同与联系
//android.support.v4.media.session.PlaybackStateCompat
TATE_NONE, STATE_STOPPED, STATE_PAUSED, STATE_PLAYING, STATE_FAST_FORWARDING,
STATE_REWINDING, STATE_BUFFERING, STATE_ERROR, STATE_CONNECTING,
STATE_SKIPPING_TO_PREVIOUS, STATE_SKIPPING_TO_NEXT, STATE_SKIPPING_TO_QUEUE_ITEM
//com.google.android.exoplayer2.Player.State
STATE_IDLE, STATE_BUFFERING, STATE_READY, STATE_ENDED
2.4 存在的问题
上面的实践中存在一些问题,比如数据如何交互,我们看到Activity直接和Service通过MediaSession框架中的各种回调进行通信,播放器ExoPlayer封装在Service内,数据的获取也在Service中。这明显和真实的场景有差异。
另外播放管理相关的没有分离,播放队列的维护,播放状态的管理等等没有统一的管理,不利于扩展扩展更换播放器等。
下一篇我们来分析umap的实现,它是如何进行架构的,如何解决上面的问题的。
完整代码已上传至 github https://github.com/ayyb1988/mediajourney
三、资料
ExoPlayer
- Android开发之ExoPlayer的学习和使用(音频)讲解
- Media streaming with ExoPlayer
- ExoPlayer blog
- ExoPlayer developer guide
- Easy Audio Focus with ExoPlayer
UAMP相关
- Android 解读开源项目UniversalMusicPlayer(播放控制层)
- Android 媒体播放框架MediaSession分析与实践
- Android媒体应用(一)
- 音频应用概览
- 打造基于MediaSessionCompat的音乐播放(一)
- 打造基于MediaSessionCompat的音乐播放(二)
音频播放器相关开源项目
其他
网络接口以及歌曲来源
来自google官方的uamp开源项目
http://storage.googleapis.com/automotive-media/music.json
https://storage.googleapis.com/uamp/catalog.json
Music provided by the [Free Music Archive](http://freemusicarchive.org/).
- [Irsen's Tale](http://freemusicarchive.org/music/Kai_Engel/Irsens_Tale/) by
[Kai Engel](http://freemusicarchive.org/music/Kai_Engel/).
- [Wake Up](http://freemusicarchive.org/music/The_Kyoto_Connection/Wake_Up_1957/) by
[The Kyoto Connection](http://freemusicarchive.org/music/The_Kyoto_Connection/).
长音频:https://v.typlog.com/oohomechat/8385162738_706123.mp3
四、收获
通过本篇的学习实践,
- 了解媒体播放框架MediaSession
- 使用MediaSession框架实现简单的音频播放器(播放/暂停、切歌、倍速)
- 了解原理、具体实践以及流程分析,我们基本了解MediaSession的框架以及ExoPlayer简单实用。
但是一个音频播放器以下功能也是基本功能:边缓存变播放、播放队列、淡入淡出、音频焦点、后台播放,该如何比较好的实现呐?在具体实践之前我们先来学习分析下uamp这个google开源的音频播放器是如何架构的,看看在数据源设置以及播放管理方面是否可以学习借鉴。
感谢你的阅读
下一篇我们继续学习实践ExoPlayer,关注公众号“音视频开发之旅”,回复“源码”,获取音视频开发相关代码,一起学习成长。
欢迎交流