上一篇笔记我们实现了推荐页面,接下来,当用户点击页面上的歌曲并播放,又应该如何实现呢?
1 音乐列表管理器
上一篇笔记讲到了从网络获取音乐数据,并配置给adapter,但adapter仅负责为RecyclerView提供数据。要实现播放功能,我们还需要一个音乐列表管理器,来持久化音乐数据,并管理播放逻辑。如以下代码注释部分:
private void loadData() {
DefaultRepository
.getInstance()
.songs()
.subscribe(new HttpObserver<ListResponse<Song>>() {
@Override
public void onSucceeded(ListResponse<Song> data) {
List<Song> songs = data.getData().getData();
adapter.setNewInstance(songs);
// 音乐列表管理器单例初始化
if (musicListManager == null) {
musicListManager = MusicListManager.getInstance(getHostActivity());
}
// 为音乐列表管理器加载数据并持久化
musicListManager.setDatum(songs);
}
});
}
1.1 持久化音乐数据
如上面的代码:
// 音乐列表管理器单例初始化
if (musicListManager == null) {
musicListManager = MusicListManager.getInstance(getHostActivity());
}
// 为音乐列表管理器加载数据并持久化
musicListManager.setDatum(songs);
在setDatum(List<Song> songs)方法中,要往数据库插入数据项,所以在实现音乐列表管理器类之前,我们先完成数据库的构造工作,在这里,我们使用Android官方的Room数据库。
Room主要由三个组件组成:
- 数据库类(
class AppDatabase) - 数据实体(
class Song和class SearchHistory) - 数据访问对象(
interface SongDao和interface SearchHistoryDao)
数据库类和数据实体的介绍和实现可以参考Android Developer官网,这里不再赘述。我们主要讲讲如何使用RxJava对Room进行异步编程,并实现两个DAO和class RoomUtil。
1.1.1 DAO
先来看看两个DAO
/**
* 定义一个 SongDao 接口,包含基本的数据库操作方法
*/
@Dao
public interface SongDao {
@Insert
Completable insert(Song song);
@Insert
Completable insertAll(List<Song> songs);
@Delete
Completable delete(Song song);
@Query("DELETE FROM songs")
Completable deleteAllSongs();
@Query("SELECT * FROM songs ORDER BY created_at DESC")
Flowable<List<Song>> getAllSongs();
@Update
Completable updateSong(Song song);
}
@Dao
public interface SearchHistoryDao {
@Insert
Completable insert(SearchHistory searchHistory);
@Insert
Completable insertAll(List<SearchHistory> searchHistories);
@Delete
Completable delete(SearchHistory searchHistory);
@Query("SELECT * FROM search_history ORDER BY created_at DESC")
Flowable<List<SearchHistory>> getAllSearchHistory();
}
可以看到,没有返回值的异步操作如插入,删除,更新等都返回Completable,而查询操作则返回Flowable。
Completable
Completable类表示一个延迟计算,没有返回值,仅表示完成或异常的指示。Completable的行为类似于Observable,不同之处在于它只能发出完成或错误信号(没有像其他响应式类型那样的onNext或onSuccess)。
Flowable
Flowable是RxJava中用来处理流式数据的类型,它会发出一个数据流,可以是一个或多个数据项。
1.1.2 RoomUtil
接下来,我们还需要一个Room工具类,对SongDao和SearchHistoryDao中的操作进行封装,避免直接使用DAO的复杂性。
public class RoomUtil {
// ......
//region 搜索历史
/**
* 创建或更新搜索历史
*/
public Completable insertOrUpdateSearchHistory(SearchHistory data) {
return searchHistoryDao.insert(data);
}
/**
* 查询所有搜索历史
*/
public Flowable<List<SearchHistory>> getAllSearchHistory() {
return searchHistoryDao.getAllSearchHistory();
}
/**
* 删除搜索历史
*/
public Completable deleteSearchHistory(SearchHistory data) {
return searchHistoryDao.delete(data);
}
//endregion
//region 播放列表
/**
* 查询播放列表
*/
public Flowable<List<Song>> getAllSongs() {
return songDao.getAllSongs()
.map(songs -> {
localConverts(songs);// 本地字段(Song)转换为 User字段
return songs;
});
}
/**
* 删除音乐
*/
public Completable deleteSong(Song song) {
return songDao.delete(song);
}
/**
* 删除所有音乐
*/
public Completable deleteAllSongs() {
return songDao.deleteAllSongs();
}
/**
* 保存所有音乐
*/
public Completable insertAllSongs(List<Song> songs) {
// 转换为本地字段(Song)
for (Song song : songs) {
song.convertLocal();
}
return songDao.insertAll(songs);
}
/**
* 保存单个音乐
*/
public Completable insertSong(Song song) {
return songDao.insert(song);
}
public Completable updateSong(Song song) {
return songDao.updateSong(song);
}
//endregion
/**
* 本地字段转换为 User字段
*
* @param songs
*/
private void localConverts(List<Song> songs) {
for (Song song : songs) {
song.localConvert();
}
}
2 监听器
准备好数据库配置工作后,我们再回到HomeFragment的监听器方法。
当用户点击了界面上的某个选项,我们可以使用监听器(Listener)来监听RecylerView的哪个子视图被点击了。这里,我们继续使用BaseQuickAdapter的OnItemClickListener监听器。
private MusicListManager musicListManager;
protected void initListeners() {
super.initListeners();
adapter.setOnItemClickListener((adapter, view, position) -> {
Song data = (Song) adapter.getItem(position);
musicListManager.play(data);
startMusicPlayerActivity();
});
}
从以上代码可以看到,当获取到用户点击的音乐后,执行播放,最后启动播放页面。
3 继续音乐列表管理器
完成Room的配置后,接着回到开头所讲的setDatum(List<Song> songs)方法,看看它是如何完成数据加载的:
public void setDatum(List<Song> songs) {
disposables.add(
room.deleteAllSongs()
.andThen(room.insertAllSongs(songs))
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(() -> {
datum.clear();
datum.addAll(songs);
sendPlayListChangedEvent(-1);
}, throwable -> {
Log.e(TAG, "Error setting play list", throwable);
})
);
}
为了防止音乐重复以及占用过多存储空间,简单起见首先清空原来的数据库,然后在io线程插入新的数据项,datum是一个ArrayList,将音乐数据保存在内存中供播放逻辑方法使用。
我们前面提到,音乐列表管理器是用来持久化音乐数据,并管理播放逻辑。我们先来看看这个类的构造方法:
private MusicListManager(Context context) {
this.context = context;
musicPlayerManager = MusicPlayerManager.getInstance(context);
musicPlayerManager.addMusicPlayerListener(this);
sp = PreferenceUtil.getInstance(context);
room = RoomUtil.getInstance(context);
//初始化播放列表
initPlayList();
}
MusicPlayerManager, addMusicPlayerListener后面再讲,我们先来看看列表初始化方法initPlayList():
private void initPlayList() {
disposables.add(
room.getAllSongs()
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(songs -> {
if (!songs.isEmpty()) {
datum.clear();
datum.addAll(songs);
// 获取最后播放音乐 id
String id = sp.getLastPlaySongId();
if (StringUtils.isNotBlank(id)) {
// 在播放列表中找到该音乐
for (Song it : songs) {
if (it.getId().equals(id)) {
data = it;
break;
}
}
if (data == null) {
defaultPlaySong();
}
} else {
// 默认播放第一首
defaultPlaySong();
}
}
}, throwable -> {
Log.e(TAG, "Error initializing play list", throwable);
})
);
}
首先从数据库中查询出所有音乐,如果数据库已经完成初始化,我们首先为内存中的音乐列表datum加载最新数据,并从用户偏好中获取上一次播放的音乐,如果为空则直接播放第一首。
3.1 播放逻辑
介绍完MusicListManager的数据初始化,我们来看看这个音乐列表管理器如何管理播放逻辑的呢。
播放
public void play(Song song) {
if (currSong == null || isPlay) {
return;
}
// 如果非WIFI环境并且不允许移动网络下播放,取消播放
if (!NetworkStatusUtil.isWiFi(context) && !PreferenceUtil.getInstance(context).allowMobileNetworkPlay()) {
showMobilePlayTip();
return;
}
// 标记为正在播放
isPlay = true;
// 保存正在播放的音乐
this.currSong = song;
// 拼接在线音乐uri
String path = ResourceUtil.resourceUri(song.getUri());
// 播放
musicPlayerManager.play(path, song);
// 保存最后播放音乐的Id
sp.setLastPlaySongId(song.getId());
}
检查网络状态
在播放前,先检查处于是否WIFI状态还是移动网络:
public class NetworkStatusUtil {
public static final int WIFI = 1;
public static final int MOBILE_4G = 4;
public static final int MOBILE_3G = 3;
public static final int MOBILE_2G = 2;
public static final int MOBILE_5G = 5;
public static final int NO_NETWORK = 0;
/*
* 是否是wifi网络
*/
public static boolean isWiFi(Context context) {
return getNetworkType(context) == WIFI;
}
/**
* 获取当前的网络状态
*
*/
public static int getNetworkType(Context context) {
int netType = NO_NETWORK;
try {
ConnectivityManager manager = (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE);
if (manager == null) {
return netType;
}
NetworkInfo networkInfo = manager.getActiveNetworkInfo();
if (networkInfo == null || !networkInfo.isAvailable()) {
return netType;
}
int nType = networkInfo.getType();
if (nType == ConnectivityManager.TYPE_WIFI) {
//WIFI
netType = WIFI;
} else if (nType == ConnectivityManager.TYPE_MOBILE) {
int nSubType = networkInfo.getSubtype();
TelephonyManager telephonyManager = (TelephonyManager) context.getSystemService(Context.TELEPHONY_SERVICE);
if (telephonyManager == null) {
return netType;
}
// 检查 5G
if (nSubType == TelephonyManager.NETWORK_TYPE_NR) {
// 5G
netType = MOBILE_5G;
} else if (nSubType == TelephonyManager.NETWORK_TYPE_LTE && !telephonyManager.isNetworkRoaming()) {
//4G
netType = MOBILE_4G;
} else if (nSubType == TelephonyManager.NETWORK_TYPE_UMTS
|| nSubType == TelephonyManager.NETWORK_TYPE_HSDPA
|| nSubType == TelephonyManager.NETWORK_TYPE_EVDO_0
&& !telephonyManager.isNetworkRoaming()) {
// 3G,联通的3G为 UMTS或 HSDPA,电信的 3G为 EVDO
netType = MOBILE_3G;
} else if (nSubType == TelephonyManager.NETWORK_TYPE_GPRS
|| nSubType == TelephonyManager.NETWORK_TYPE_EDGE
|| nSubType == TelephonyManager.NETWORK_TYPE_CDMA
&& !telephonyManager.isNetworkRoaming()) {
// 2G,移动和联通的 2G为 GPRS或 EGDE,电信的 2G为 CDMA
netType = MOBILE_2G;
} else {
// 其他未知类型(通常为 2G)
netType = 2;
}
}
} catch (Exception e) {
e.printStackTrace();
}
return netType;
}
}
可以看到播放任务实际由musicPlayerManager执行,同样的暂停、继续播放等也是由这个类执行,我们稍后会讲这个类。
其中,暂停方法直接跳转musicPlayerManager中的暂停方法。
继续播放方法,此时分为两种情况:
- 原本在播放而暂停,现在继续播放,直接调用
musicPlayerManager中的继续播放方法。 - 原本在播放但中途退出了程序,现在点击播放按钮,需要跳转到
play(Song song)方法,完成标记正在播放等其他初始化工作,接着便可以跳转上次播放的进度。
public void resume() {
if (!NetworkStatusUtil.isWiFi(context) && !PreferenceUtil.getInstance(context).allowMobileNetworkPlay()) {
showMobilePlayTip();
return;
}
if (isPlay) {
musicPlayerManager.resume();
} else {
play(currSong);
if (currSong.getProgress() > 0) {
musicPlayerManager.seekTo(currSong.getProgress());
}
}
}
其他的方法如改变循环模式方法,上一首下一首,删除等方法比较简单,不再赘述。
保存播放进度方法,设置一个更新时间间隔,若当前时间减上次更新时间大于这个间隔,则使用Room数据库update当前播放的音乐。这个方法会在musicPlayerManager中的Handler调用,稍后会讲到。
public void saveProgress(Song song) {
long currentTime = System.currentTimeMillis();
if (currentTime - lastTime >= SAVE_PROGRESS_TIME) {
disposables.add(
room.updateSong(song)
.subscribeOn(Schedulers.io())
.subscribe(() -> {
// 更新成功
lastTime = currentTime;
}, throwable -> {
Log.e(TAG, "Error updating song progress", throwable);
})
);
}
}
4 音乐播放器管理器
现在,我们来完成真正实现播放任务的管理器MusicPlayerManager,这个管理器主要负责两个任务,使用ExoPlayer实现音频播放,以及完成播放状态的发布。
4.1 ExoPlayer
认音频和视频渲染程序以及处理媒体缓冲的组件。
与Android的MediaPlayer API相比,ExoPlayer增加了额外的便利性,例如支持多种流式传输协议、默认音频和视频渲染程序以及处理媒体缓冲的组件。
4.1.1 创建ExoPlayer实例
ExoPlayer相比于MediaPlayer的一大优点是有缓存机制,现在我们来配置缓存。
缓存大小为1G:
long maxBytes = 1024 * 1024 * 1024;
使用SQLite存储音乐缓存,并创建缓存:
DatabaseProvider databaseProvider = new StandaloneDatabaseProvider(context);
Cache cache = new SimpleCache(
context.getCacheDir(), new LeastRecentlyUsedCacheEvictor(maxBytes), databaseProvider);
配置数据源工厂,这里使用Okhttp作为网络堆栈:
OkHttpClient okHttpClient = new OkHttpClient();
OkHttpDataSource.Factory okHttpDataSourceFactory = new OkHttpDataSource.Factory(okHttpClient);
配置缓存数据源工厂,这是一个读写缓存的数据源,优先从前面设置的缓存读取数据,如果缓存没有数据,将从上游数据源即OkHttp数据源请求数据:
DataSource.Factory cacheDataSourceFactory =
new CacheDataSource.Factory()
.setCache(cache)
.setUpstreamDataSourceFactory(okHttpDataSourceFactory);
创建ExoPlayer实例:
player = new ExoPlayer.Builder(context).setMediaSourceFactory(
new DefaultMediaSourceFactory(context).setDataSourceFactory(cacheDataSourceFactory)).build();
4.1.2 设置ExoPlayer播放器监听器
接着,还要设置ExoPlayer自带的监听器,在这里我们只重写了两个方法:播放遇到错误时onPlayerError(),播放状态改变时onPlaybackStateChanged(),将触发这两个方法。
player.addListener(new Player.Listener() {
@Override
public void onPlayerError(@NonNull PlaybackException error) {
handlePlayerError(error);
}
@Override
public void onPlaybackStateChanged(int playbackState) {
// 检查播放器是否已准备好播放
if (playbackState == Player.STATE_READY) {
// 将总进度保存到音乐对象
if (currSong != null) {
currSong.setDuration(player.getDuration());
}
// 通知所有监听器
for (MusicPlayerListener listener : listeners) {
listener.onPrepared(player, currSong);
}
}
// 当前音乐播放完毕时,调用监听器方法,
// 通知各个监听器播放完了(如根据循环模式切换切换下一首歌曲)
if (playbackState == Player.STATE_ENDED) {
onCompletion();
}
}
})
4.1.2.1 处理错误
遇到网络错误时加入重试机制,整体比较简单:
@Override
public void onPlayerError(@NonNull PlaybackException error) {
handlePlayerError(error);
}
使用监听器通知各UI模块处理错误(显示错误提醒):
private void handlePlayerError(PlaybackException error) {
// 记录错误日志
Log.e(TAG, "播放错误: ", error);
// 通过监听器通知上层错误
for (MusicPlayerListener listener : listeners) {
listener.onError(error);
}
// 根据错误类型采取不同策略
switch (error.errorCode) {
case PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_FAILED:
case PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_TIMEOUT:
// 重试播放
retryPlayback();
break;
default:
break;
}
}
/**
* 重试播放逻辑
*/
private void retryPlayback() {
if (retryCount < MAX_RETRY_COUNT) {
long delay = (long) Math.pow(2, retryCount) * 1000; // 1s, 2s, 4s
handler.postDelayed(this::playNow, delay);
retryCount++;
Log.d(TAG, "正在重试播放,次数: " + retryCount);
} else {
Log.e(TAG, "达到最大重试次数,停止重试。");
Toast.makeText(context, "无法恢复播放,请稍后重试。", Toast.LENGTH_LONG).show();
}
}
在MusicPlayerActivity中重写onError(PlaybackException error)方法,通过监听器,实现播放器与UI解耦:
@Override
public void onError(PlaybackException error) {
// 在主线程中处理错误
runOnUiThread(() -> {
switch (error.errorCode) {
case PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_FAILED:
case PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_TIMEOUT:
Toast.makeText(this, "网络连接错误,请检查网络", Toast.LENGTH_LONG).show();
break;
case PlaybackException.ERROR_CODE_DECODING_FAILED:
Toast.makeText(this, "媒体文件格式不支持", Toast.LENGTH_LONG).show();
break;
default:
Toast.makeText(this, "播放发生未知错误", Toast.LENGTH_LONG).show();
break;
}
});
}
4.1.2.2 onPlaybackStateChanged(int playbackState)
当处于STATE_READY状态,便保存当前音乐时长到对象里,并且我们还看到它使用for循环通知所有监听器已经准备好播放了(onPrepared(ExoPlayer mp, Song data)方法),那么这个MusicPlayerListener是什么?
@Override
public void onPlaybackStateChanged(int playbackState) {
// 检查播放器是否已准备好播放
if (playbackState == Player.STATE_READY) {
// 将总进度保存到音乐对象
if (currSong != null) {
currSong.setDuration(player.getDuration());
}
// 通知所有监听器
for (MusicPlayerListener listener : listeners) {
listener.onPrepared(player, data);
}
}
// 当前音乐播放完毕时,调用监听器方法,
// 通知各个监听器播放完了(如根据循环模式切换切换下一首歌曲)
if (playbackState == Player.STATE_ENDED) {
onCompletion();
}
}
4.1.2.3 监听器模式
需要注意的是,这个MusicPlayerListener是我们自己自定义的监听器,不是ExoPlayer自带的监听器。
监听器模式其实就是观察者模式,当被监听者/事件源(MusicPlayerManager)的播放状态发生变化时(如开始播放,暂停,继续播放等),便通知监听器(UI组件如Activity和Fragment)来改变UI行为。
这样便实现了播放逻辑与UI逻辑的解耦,也更方便扩展接口了。
4.1.2.3.1 监听器
我们可以创建一个监听器接口(listener),每个实现了这个接口的类都作为监听器,每个监听器可以对接口方法实现自定义重写。当播放状态改变,MusicPlayerManager便会通知所有实现了这个接口的的监听器,让它们执行相应方法,比如准备好播放时要做什么,播放中要做什么等:
public interface MusicPlayerListener {
/**
* 暂停
*/
default void onPaused(Song data) {
}
/**
* 播放
*/
default void onPlaying(Song data) {
}
/**
* 播放器准备完毕
*
* @param mp
* @param data
*/
default void onPrepared(ExoPlayer mp, Song data) {
}
/**
* 保存播放进度
*
* @param data
*/
default void saveProgress(Song data) {
}
/**
* 播放完毕
*
* @param
*/
default void onCompletion() {
}
/**
* 播放失败
*/
default void onError(PlaybackException error) {
}
}
在我们的项目中,MusicListManager和稍后实现的MusicPlayerActivity(上面的onError)它们作为监听者,就是实现了这个接口。
4.1.2.3.2 被监听者(事件源)
举个例子,如前面讲过的MusicListManager中的保存播放进度方法,便实现了监听器接口中的saveProgress方法,所以它是一个监听器。多提一点,因为这个方法用到Room保存播放进度,加之我们在MusicListManager中使用了Room,所以在MusicListManager中实现saveProgress(Song song)。
接着MusicPlayerManager它是一个被监听者(或者按RxJava中的叫法为事件源),它会启动一个定时任务,并使用Handler将以下两个保存播放进度的任务发送到主进程,我们马上会讲到这个完整过程。
private final Handler handler = new Handler(Looper.getMainLooper()) {
@Override
public void handleMessage(@NonNull Message msg) {
super.handleMessage(msg);
if (msg.what == MESSAGE_PROGRESS)
// 将进度设置到音乐对象
currSong.setProgress((int) player.getCurrentPosition());
// 通知监听器保存进度
for (MusicPlayerListener listener : listeners) {
listener.saveProgress(currSong);
}
}
};
回到 onPlaybackStateChanged(int playbackState),我们并没有实现onPrepared(ExoPlayer mp, Song data),因为在这里只需要直接播放音乐即可,后续有需要再实现它即可。
再举个例子,MusicPlayerActivity实现了onPlaying(Song data)、onPaused(Song data)等播放逻辑相关方法,MusicListManager执行播放操作时,就会通知每个监听器执行它们相应的实现,如改变播放按钮图片,唱片动画开始转动等等。
4.1.2.3.3 CoyOnWriteArrayList
我们讲了监听器的整体概念,那么应该如何注册监听器以便让MusicListManager发布通知呢?
// 监听器列表
private final CopyOnWriteArrayList<MusicPlayerListener> listeners = new CopyOnWriteArrayList<>();
/**
* 添加监听器
*
* @param listener
*/
public void addMusicPlayerListener(MusicPlayerListener listener) {
if (!listeners.contains(listener)) {
listeners.add(listener);
startPublishProgress();
}
}
/**
* 移除监听器
*
* @param listener
*/
public void removeMusicPlayerListener(MusicPlayerListener listener) {
listeners.remove(listener);
}
之前的MusicPlayerManager,在构造方法中添加:
musicPlayerManager.addMusicPlayerListener(this);
在destroy()中移除:
public static void destroy() {
if (instance != null) {
instance.musicPlayerManager.removeMusicPlayerListener(instance);
instance.disposables.dispose();
instance = null;
}
}
这里我们使用CopyOnWriteArrayList这个并发容器来保存监听器,前面可以看到有许多遍历监听器的操作,如果此时注册或注销监听器(列表的add/remove操作),使用ArrayList会抛出ConcurrentModificationException。
CopyOnWriteArrayList是ArrayList的线程安全变体,它的主要应用场景是读多写少的场景。
既然是读多写少,就要解决读取列表的过程中,因其他线程修改列表而导致异常的问题:
CopyOnWriteArrayList,从名字中可以看出这个列表的核心是写时复制机制(Copy On Write)。那么什么是写时复制呢?我们来看它的源码:
CopyOnWriteArrayList底层使用的array,它是一个引用:
返回引用:
设置(修改)引用:
以add(E e)方法为例,可以看到,首先在add方法中加锁确保同一时刻只有一个线程进入。Arrays.copyOf方法创建副本,在副本上执行修改操作,最后将array引用到副本。
使用了写时复制机制,就能解决遍历时修改列表而抛出异常的问题吗?我们看下图:
可以看到,虽然使用了写时复制,引用指向的数组由旧数组指向了新数组(CopyOnWriteArrayList -> CopyOnWriteArrayList_copy,这里画得不太好),但是因为遍历数组的引用没有改变,导致遍历到一半数组就被换了,出现了脏数据。
那么应该如何解决这个引用的问题呢?这就要提出CopyOnWriteArrayList的第二个机制:快照读。我们来看源码:
在创建迭代器时,先使用一个快照(snapshot)把旧数组的引用保存下来,不管后来新数组如何改变,都不会影响旧数组的遍历,所以是读写隔离的:
这样,CopyOnWriteArrayList就解决了读取过程中的并发问题。同时可见它只适用于读多写少的场景,毕竟写操作要复制整体数组,是非常耗费资源的。
4.2 ExoPlayer播放逻辑
我们花了很多时间来构建Exoplayer的构造方法,包括缓存,监听器等等,现在我们来看看它的播放操作吧。
播放
public void play(String uri, Song data) {
// 重置重试次数
retryCount = 0;
//保存信息
this.uri = uri;
this.currSong = data;
// 释放已加载的媒体和播放所需的资源
player.stop();
playNow();
}
private void playNow() {
// 将字符串转换为标准的 Uri 对象,以便 ExoPlayer 能够正确识别资源
// uri 是音频资源的路径(可以是本地文件路径、网络 URL 或 ContentProvider 的 URI)
Uri mediaUri = Uri.parse(uri);
// 通过 Uri 创建一个 MediaItem 对象
// MediaItem 是 ExoPlayer 中表示媒体内容的载体(如音频、视频)
MediaItem mediaItem = MediaItem.fromUri(mediaUri);
// 告诉播放器当前要播放的媒体内容
player.setMediaItem(mediaItem);
// 异步触发播放器初始化媒体资源(如缓冲网络数据、读取本地文件)
//准备完成后,会触发 onPlaybackStateChanged 回调(状态变为 STATE_READY)。
player.prepare();
// 开始播放
player.play();
// 通知所有监听器当前正在播放歌曲
publishPlayingStatus();
//启动进度更新机制,定期触发监听器保存进度
startPublishProgress();
}
暂停
public void pause() {
if (isPlaying()) {
player.pause();
for (MusicPlayerListener listener : listeners) {
listener.onPaused(currSong);
}
stopPublishProgress();
}
}
public void resume() {
if (!isPlaying()) {
resumeNow();
}
}
private void resumeNow() {
player.play();
//回调监听器
publishPlayingStatus();
//启动播放进度通知
startPublishProgress();
}
。。。。。。
我们注意到playNow()中有这个方法,下面我们来详细讲讲它。
//启动进度更新机制,定期触发监听器保存进度
startPublishProgress();
4.3 使用Handler执行定时任务
在播放一首音乐时,需要每隔一个时间间隔将播放进进度保存到Song对象中,并且通知所有监听器执行它们自己的saveProgress(Song data)方法。如:通知MusicListManager更新数据库;通知MusicPlayerActivity从Song对象获取进度,更新进度条等。那么应该如何实现这个定时任务呢?
对于上述简单需求,使用Handler是一个比RxJava更好的选择。Handler是Android框架的核心组件之一,它的实现非常轻量级,仅用于简单的消息传递和线程调度,内存占用极小。
我们来看一下以上需求的整体逻辑:
- 如果音乐正在播放且监听器列表不为空,使用
Handler发送一个Runnable到主线程,它负责定时任务。 Handler负责更新音乐进度并通知监听器。
直接上完整代码:
private volatile boolean isProgressUpdaterRunning = false;
private void startPublishProgress() {
if (isEmptyListeners() || !isPlaying() || isProgressUpdaterRunning) {
return;
}
isProgressUpdaterRunning = true;
handler.post(progressUpdater);
}
private final Runnable progressUpdater = new Runnable() {
@Override
public void run() {
if (!isPlaying() || isEmptyListeners()) {
stopPublishProgress();
return;
}
handler.sendEmptyMessage(MESSAGE_PROGRESS);
handler.postDelayed(this, DEFAULT_PUBLISH_MUSIC_PROGRESS_TIME);
}
};
private final Handler handler = new Handler(Looper.getMainLooper()) {
@Override
public void handleMessage(@NonNull Message msg) {
super.handleMessage(msg);
if (msg.what == MESSAGE_PROGRESS) {
// 将进度设置到音乐对象
currSong.setProgress((int) player.getCurrentPosition());
// 通知监听器保存进度
for (MusicPlayerListener listener : listeners) {
listener.saveProgress(currSong);
}
}
}
};
private void stopPublishProgress() {
isProgressUpdaterRunning = false;
handler.removeCallbacks(progressUpdater);
}
4.3.1 什么是Handler?
来看看源码注释:
Handler可以发送和处理与线程的MessageQueue相关的Message和Runnable对象。每个Handler实例都关联一个线程及其消息队列。当创建一个新的Handler时,它会绑定一个Looper,并把Message和Runnable对象传递到该Looper的消息队列中,并在该Looper的线程上执行它们。
在我们的代码中,Handler绑定的是一个在主线程上的Looper,因为更新UI必须在主线程。Looper是一个循环器,它管理一个线程的消息队列(MessageQueue)。它保持线程运行,并按顺序处理消息队列中的消息:
private final Handler handler = new Handler(Looper.getMainLooper()) {
Handler主要有两种用途:(1) 安排Message和Runnable对象在未来的某个时间点执行;(2) 将操作排入队列,以便在不同于您自己的线程上执行。
通过post、postAtTime(Runnable, long)、postDelayed、sendEmptyMessage、sendMessage、sendMessageAtTime和sendMessageDelayed方法可以实现Message的安排。(虽然这些post接收的参数是Runnable,但底层实现是将其转换为Message并调用sendMessageAtTime和sendMessageDelayed)
post允许将Runnable对象排入队列,当消息队列接收到它们时将调用这些Runnable;sendMessage允许将包含数据包的Message对象排入队列,这些数据将由Handler的handleMessage 方法处理(需要实现Handler的子类)。
在下面的Runnable中,使用Handler发送一个int类型的Message,以供Handler中的handleMessage 接收。并且,每隔DEFAULT_PUBLISH_MUSIC_PROGRESS_TIME毫秒,重复执行run方法:
private void startPublishProgress() {
if (isEmptyListeners() || !isPlaying() || isProgressUpdaterRunning) {
return;
}
isProgressUpdaterRunning = true;
handler.post(progressUpdater);
}
private final Runnable progressUpdater = new Runnable() {
@Override
public void run() {
if (!isPlaying() || isEmptyListeners()) {
stopPublishProgress();
return;
}
handler.sendEmptyMessage(MESSAGE_PROGRESS);
handler.postDelayed(this, DEFAULT_PUBLISH_MUSIC_PROGRESS_TIME);
}
};
5 MusicPlayerActivity
在上面的工作中,我们完成了各种点击操作的底层逻辑,这是一种分层的、抽象的模式。如果说MusicListManager是第二层,负责各种播放操作下音乐列表的管理;MusicPlayerManager是第三层就是最底层,负责直接操作ExoPlayer;那么MusicPlayerActivity就是第一层,也就是UI界面。
MusicPlayerActivity是播放器的界面,主要功能包括:
- 显示音乐播放器的界面,包括封面、进度条、播放按钮、循环模式按钮、播放列表按钮等。
- 当前音乐列表窗口。
- 实现监听器方法,更新界面显示,如播放进度、歌曲信息等。
类似于:
MusicPlayerActivity和上一篇笔记用了整个篇幅讲的DiscoveryFragment音乐推荐页面是类似的,都是UI页面,结构也类似,也是多个Activty类的分层继承模型。MusicPlayerActivity也是主要由initViews, initDatum, initListeners。
5.1 Activity与分层继承结构
5.1.1 什么是Activity
Activity是Android应用程序的四大组件之一(其他三个是Service、ContentProvider和 BroadcastReceiver)。它主要用于定义用户图形界面,并且管理与用户在这个界面交互相关的操作。可以把它理解为一个屏幕,例如,在一个邮箱应用中,写邮件的界面就是一个Activity。
正如上一篇笔记讲过,Activity可以通过Intent来启动另一个Activity。Intent是一个消息对象,用于在Android组件之间传递信息。例如,当用户想要进入设置界面时,点击主界面点击底部的设置图标,就会通过Intent来启动设置界面的Activity。
Activity的生命周期
Activity的生命周期是指当用户浏览、退出和返回到应用时,应用中的Activity实例在其不同状态之间转换的过程,如下图:
Activity的生命周期由Android系统管理。这些状态包括创建 (onCreate())、启动 (onStart())、恢复 (onResume())、暂停 (onPause())、停止 (onStop()) 和销毁 (onDestroy())。可以利用这些回调方法在用户离开和返回Activity时,为Activity定义特定的行为。
-
onCreate(Bundle): 当Activity首次创建时调用,用于初始化组件、设置布局和进行基本的设置工作。 -
onStart(): 当Activity准备变得对用户可见时调用,此时可能会加载必要的数据或资源。 -
onResume(): 当Activity开始与用户交互时调用,这是Activity处于前台时的回调。 -
onPause(): 当Activity被部分遮挡或用户离开该 Activity 时调用。在此回调中,应释放可能影响性能的资源。 -
onStop(): 当Activity完全不可见时调用,通常用于释放资源或停止不必要的操作。 -
onDestroy(): 当系统即将销毁Activity时调用。这可能是由于用户关闭应用或系统回收资源导致的。
5.1.2 Activity分层继承结构
类似于DiscoveryFragment,MusicPlayerActivity继承于以下Activity:
-
BaseActivity:为所有Activity的基类,封装通用的生命周期逻辑。覆写onCreate和onPostCreate,提供统一的初始化流程;定义initViews(初始化视图)、initDatum(初始化数据)、initListeners(设置监听器)三个空方法,并在onPostCreate中按顺序调用,强制子类遵循一致的初始化步骤。 -
BaseCommonActivity:处理通用的界面跳转逻辑,避免每个Activity重复编写Intent代码。封装startActivity和startActivityAfterFinishThis方法,简化界面跳转。 -
BaseLogicActivity:集中处理应用特有的业务逻辑(如跳转登录、跳转播放界面)。通过单例模式管理全局组件(PreferenceUtil),提供getMusicListManager。 -
BaseViewModelActivity<VB extends ViewBinding>:利用泛型VB支持不同类型的ViewBinding,提升类型安全性,并通过反射自动创建ViewBinding实例。 -
BaseTitleActivity<VB extends ViewBinding>:为所有需要标题栏的界面提供一致的交互和样式。初始化Toolbar,设置返回按钮,处理返回点击事件。通过isShowBackMenu()让子类控制是否显示返回按钮。
5.2 initViews()
使用QMUIStatusBarHelper设置状态栏为半透明,实现沉浸式效果。
5.2 initDatum()
注册EventBus用于接收事件(如播放列表变化)。
获取MusicPlayerManager单例,管理音乐播放。
5.3 initListeners()(相当于前面的总结)
监听器,顾名思义就是对用户的点击作出反应。到了这里,终于可以把之前的MUsicListManager, MUsicPlayerManager,包括各种监听器方法的实现一一串起来了。先看完整代码,再一个个讲。
protected void initListeners() {
super.initListeners();
binding.loopModel.setOnClickListener(view -> {
getMusicListManager().changeLoopModel();
showLoopModel();
});
binding.previous.setOnClickListener(view -> getMusicListManager().play(getMusicListManager().previous()));
binding.play.setOnClickListener(view -> {
if (musicPlayerManager.isPlaying()) {
getMusicListManager().pause();
} else {
getMusicListManager().resume();
}
});
binding.next.setOnClickListener(view -> {
Song data = getMusicListManager().next();
getMusicListManager().play(data);
});
binding.listButton.setOnClickListener(view -> MusicPlayListDialogFragment.show(getSupportFragmentManager()));
binding.progress.setOnSeekBarChangeListener(this);
}
5.3.1 循环模式
首先来思考一个问题,当一首音乐播放完,播放器如何自动切换下一首呢?要知道ExoPlayer不会保存所有的音乐列表,它不知道下一首要播放什么,它只会单曲循环。所有需要我们手动控制下一首音乐的播放。
还记得ExoPlayrer监听器吗,里面有个onPlaybackStateChanged(@State int playbackState)方法,当播放状态被改变时会被调用:
player.addListener(new Player.Listener() {
@Override
public void onPlayerError(@NonNull PlaybackException error) {
handlePlayerError(error);
}
@Override
public void onPlaybackStateChanged(int playbackState) {
// 检查播放器是否已准备好播放
if (playbackState == Player.STATE_READY) {
// 将总进度保存到音乐对象
if (currSong != null) {
currSong.setDuration(player.getDuration());
}
// 通知所有监听器
for (MusicPlayerListener listener : listeners) {
listener.onPrepared();
}
}
// 当前音乐播放完毕时,调用监听器方法,
// 通知各个监听器播放完了(如根据循环模式切换切换下一首歌曲)
if (playbackState == Player.STATE_ENDED) {
onCompletion();
}
}
});
当播放状态为STATE_ENDED时,调用onCompletion:
// 当前音乐播放完毕时,调用监听器方法,
// 通知各个监听器播放完了(如根据循环模式切换切换下一首歌曲)
if (playbackState == Player.STATE_ENDED) {
onCompletion();
}
onCompletion遍历每个监听器的onCompletion方法:
public void onCompletion() {
for (MusicPlayerListener listener : listeners) {
listener.onCompletion();
}
}
在MusicListManager中,实现这个方法:
public void onCompletion() {
// 非单曲循环,从播放列表中找到并播放下一首
if (model != MODEL_LOOP_ONE) {
Song nextSong = next();
if (nextSong != null) {
play(nextSong);
}
}
}
如果是单曲循环呢?回到MusicPlayerActivity中的initListeners,当用户点击循环模式按钮时,最终会调用MusicPlayerManager中的setLooping方法,根据当前循环模式将ExoPlayer设置是否为单曲循环。为了避免啰嗦,不再贴上所有代码:
binding.loopModel.setOnClickListener(view -> {
//更改循环模式
getMusicListManager().changeLoopModel();
//显示循环模式
showLoopModel();
});
5.3.2 上一首
调用MusicListManager中的previous(先非空判断,接着根据是否是随机循环模式设置下一首音乐的index),以及play方法。
5.3.3 播放
如果当前是播放,调用MusicListManager的暂停方法,否则调用resume方法。
5.3.4 下一首
调用MusicListManager中的next以及play方法。
5.3.5 展示音乐列表小窗
音乐列表小窗是由MusicPlayListDialogFragment完成,后面会讲。
我们先来看音乐列表按钮:
binding.listButton.setOnClickListener(view ->
MusicPlayListDialogFragment.show(getSupportFragmentManager()));
getSupportFragmentManager()返回与当前Activity联系的fragment的管理器,MusicPlayListDialogFragment中的show()利用它来打开音乐列表窗口:
public static MusicPlayListDialogFragment newInstance() {
Bundle args = new Bundle();
MusicPlayListDialogFragment fragment = new MusicPlayListDialogFragment();
fragment.setArguments(args);
return fragment;
}
public static void show(FragmentManager fragmentManager) {
MusicPlayListDialogFragment fragment = newInstance();
fragment.show(fragmentManager, "MusicPlayListDialogFragment");
}
FragmentManager是用来管理Fragment的工具,主要功能包括:添加、移除或替换Fragment,管理 Fragment的生命周期,处理Fragment的事务,保存和恢复Fragment的状态等。
MusicPlayListDialogFragment继承自DialogFragment,它的show方法使用FragmentManager来开启一个事务,然后把MusicPlayListDialogFragment的实例(newInstance())add到事务中并提交,FragmentManager便会自动显示窗口:
public void show(@NonNull FragmentManager manager, @Nullable String tag) {
mDismissed = false;
mShownByMe = true;
FragmentTransaction ft = manager.beginTransaction();
ft.setReorderingAllowed(true);
ft.add(this, tag);
ft.commit();
}
newInstance()中的Bundle类似于Map,可以保存键值对在Activity之间,Fragment之间传递数据。
5.3.6 SeekBar监听器
binding.progress.setOnSeekBarChangeListener(this);
实现对SeekBar的进度变化事件的监听。
5.4 生命周期方法
5.4.1 onResume()
onResume()与onPause()是成对出现的生命周期回调方法,在Activity获得了一个适当的用户输入焦点时调用。在Activity生命周期中,onResume的执行顺序为:
新启动或恢复时:onRestart → onStart → onResume.
首次启动时:onCreate → onStart → onResume.
onResume()执行的重要条件:
前台可见 :只有当Activity位于前台且完全可见时,onResume才会被调用。例如,如果一个Activity 被另一个透明主题的Activity部分覆盖,onResume不会被触发。
获得焦点 :Activity需要获得用户输入焦点才能进入运行状态。例如,当用户通过后台任务列表切换回该 Activity时,onResume会被执行,表示Activity已获得焦点。
在这里,我们让onResume()负责在Activity可见/恢复时显示UI和播放状态:
protected void onResume() {
super.onResume();
// 显示初始数据(歌曲信息、背景模糊图)
showInitData();
// 显示播放状态(播放/暂停图标)
showMusicPlayStatus();
// 显示歌曲总时长和当前进度
showDuration();
showProgress();
// 显示循环模式
showLoopModel();
// 注册播放状态监听器
musicPlayerManager.addMusicPlayerListener(this);
}
这里主要看看如何为背景加载高斯模糊化的封面。
使用Glide加载封面图:
RequestBuilder<Drawable> requestBuilder = Glide.with(this).asDrawable();
if (StringUtils.isBlank(data.getIcon())) {
requestBuilder.load(R.drawable.default_cover);
} else {
requestBuilder.load(ResourceUtil.resourceUri(data.getIcon()));
}
使用BlurTransformation为封面设置高斯模糊,radius = 25为模糊半径,sampling = 3为采样率,值越大越模糊:
RequestOptions requestOptions = RequestOptions.bitmapTransform(new BlurTransformation(25, 3));
异步加载:Glide的into()方法会将图片加载任务提交到后台线程池,防止阻塞主线程。Glide自动在后台线程下载、解码、模糊处理图片。图片在后台线程加载完成后,Glide切换到主线程调用CustomTarget的回调方法onResourceReady,为UI加载高斯模糊图。onLoadCleared用于页面销毁时触发,释放资源。
requestBuilder
.apply(requestOptions)
.into(new CustomTarget<Drawable>() {
/**
* 资源下载成功
*
*/
@Override
public void onResourceReady(@NonNull Drawable resource, @Nullable Transition<? super Drawable> transition) {
binding.background.setImageDrawable(resource);
}
@Override
public void onLoadCleared(@Nullable Drawable placeholder) {
}
});
6 实现黑胶唱片旋转
接下来,我们来实现唱片的旋转功能。
6.1 唱片指针的转动
先来看看唱片指针如何实现转动。
先设置指针的中心点,视图的旋转和缩放将以这个中心点进行:
float x = DensityUtil.dip2px(getContext(), 15);
binding.recordThumb.setPivotX(x);
binding.recordThumb.setPivotY(x);
使用ObjectAnimator设置播放动画:
playThumbAnimator = ObjectAnimator.ofFloat(binding.recordThumb,
"rotation",
THUMB_ROTATION_PAUSE, THUMB_ROTATION_PLAY);
ObjectAnimator.ofFloat中的target是直接操作的目标View;propertyName通常是View通用属性如alpha(透明度)、translationX/Y(平移)、rotation(旋转)、scaleX/Y(缩放)等;values通过THUMB_ROTATION_PAUSE, THUMB_ROTATION_PLAY将recordThumb的rotation属性从 -25°过渡到0°。
public static ObjectAnimator ofFloat(Object target,
String propertyName,
float... values) {...}
使用ValueAnimator设置暂停动画:
pauseThumbAnimator = ValueAnimator.ofFloat(
THUMB_ROTATION_PLAY, // 起始值
THUMB_ROTATION_PAUSE // 结束值
);
pauseThumbAnimator.addUpdateListener(this); // 手动监听值变化
通过ValueAnimator的监听器绑定监听器并手动更新属性,这样是为了方便在invalidatePlayingStatus 方法中,检查指针是否已经处于暂停角度以避免重复动画,用ValueAnimator可以灵活处理这种边界条件,确保动画仅在必要时执行。
@Override
public void onAnimationUpdate(ValueAnimator animation) {
// 手动更新属性
binding.recordThumb.setRotation((Float) animation.getAnimatedValue());
}
public void setPlaying(boolean isPlaying) {
if (this.isPlaying == isPlaying) {
return;
}
this.isPlaying = isPlaying;
invalidatePlayingStatus();
}
private void invalidatePlayingStatus() {
if (isPlaying) {
playThumbAnimator.start();
} else {
float rotation = binding.recordThumb.getRotation();
if (THUMB_ROTATION_PAUSE == rotation) {
return;
}
pauseThumbAnimator.start();
}
}
6.2 唱片的转动
还记得MusicPlayerManager中的Handler吗,通过startPublishProgress方法每隔16毫秒触发发送一个Message给Handler的消息队列,定时执行showProgress中的incrementRotate方法,这样就会让唱片转动起来。
private void showProgress() {
binding.record.incrementRotate();
if (isSeekTracking) {
return;
}
int progress = getMusicListManager().getCurrSong().getProgress();
//格式化进度
binding.start.setText(SuperDateUtil.ms2ms(progress));
binding.progress.setProgress(progress);
}
public void incrementRotate() {
if (recordRotation > 360) {
recordRotation = 0;
}
recordRotation += Constant.ROTATION_PER;
binding.content.setRotation(recordRotation);
}
private final Handler handler = new Handler(Looper.getMainLooper()) {
@Override
public void handleMessage(@NonNull Message msg) {
super.handleMessage(msg);
if (msg.what == MESSAGE_PROGRESS) {
// 将进度设置到音乐对象
currSong.setProgress((int) player.getCurrentPosition());
// 通知监听器保存进度
for (MusicPlayerListener listener : listeners) {
listener.saveProgress(currSong);
}
}
}
};
6.3 播放与暂停触发动画改变的不同路径
6.3.1 播放
- 指针转动:
-
用户首次点击音乐进入
Activity时,onResume会让指针转动放在唱片上。 -
点击播放按钮时,
MusicPlayerManager中的playNow方法将触发监听器遍历执行播放方法,binding.record.setPlaying(true);控制指针转动:
@Override
public void onPlaying(Song data) {
showPauseStatus();
}
private void showPauseStatus() {
binding.play.setImageResource(R.drawable.music_pause);
binding.record.setPlaying(true);
}
- 唱片转动:
MusicPlayerManager中的playNow方法中的startPublishProgress触发Handler。
6.3.2 暂停
- 指针转动:通过
MusicPlayerManager回调监听器方法onPaused使指针回到原位。 - 唱片转动:
MusicPlayerManager的stopPublishProgress将延时发送播放消息Runnable从Handler消息队列移除。