播放 | 学习笔记

287 阅读27分钟

上一篇笔记我们实现了推荐页面,接下来,当用户点击页面上的歌曲并播放,又应该如何实现呢?

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数据库。

image.png

Room主要由三个组件组成:

  1. 数据库类(class AppDatabase)
  2. 数据实体(class Songclass SearchHistory)
  3. 数据访问对象(interface SongDaointerface SearchHistoryDao

数据库类和数据实体的介绍和实现可以参考Android Developer官网,这里不再赘述。我们主要讲讲如何使用RxJavaRoom进行异步编程,并实现两个DAOclass RoomUtil

image.png

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,不同之处在于它只能发出完成或错误信号(没有像其他响应式类型那样的onNextonSuccess)。

Flowable

  • FlowableRxJava中用来处理流式数据的类型,它会发出一个数据流,可以是一个或多个数据项。

1.1.2 RoomUtil

接下来,我们还需要一个Room工具类,对SongDaoSearchHistoryDao中的操作进行封装,避免直接使用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的哪个子视图被点击了。这里,我们继续使用BaseQuickAdapterOnItemClickListener监听器。

image.png

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中的暂停方法。

继续播放方法,此时分为两种情况:

  1. 原本在播放而暂停,现在继续播放,直接调用musicPlayerManager中的继续播放方法。
  2. 原本在播放但中途退出了程序,现在点击播放按钮,需要跳转到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

认音频和视频渲染程序以及处理媒体缓冲的组件。

AndroidMediaPlayer 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组件如ActivityFragment)来改变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

CopyOnWriteArrayListArrayList的线程安全变体,它的主要应用场景是读多写少的场景。

既然是读多写少,就要解决读取列表的过程中,因其他线程修改列表而导致异常的问题:

1.png

CopyOnWriteArrayList,从名字中可以看出这个列表的核心是写时复制机制(Copy On Write)。那么什么是写时复制呢?我们来看它的源码:

CopyOnWriteArrayList底层使用的array,它是一个引用:

image.png

返回引用:

image.png

设置(修改)引用:

image.png

add(E e)方法为例,可以看到,首先在add方法中加锁确保同一时刻只有一个线程进入。Arrays.copyOf方法创建副本,在副本上执行修改操作,最后将array引用到副本。

image.png

使用了写时复制机制,就能解决遍历时修改列表而抛出异常的问题吗?我们看下图:

2.png

可以看到,虽然使用了写时复制,引用指向的数组由旧数组指向了新数组(CopyOnWriteArrayList -> CopyOnWriteArrayList_copy,这里画得不太好),但是因为遍历数组的引用没有改变,导致遍历到一半数组就被换了,出现了脏数据。

那么应该如何解决这个引用的问题呢?这就要提出CopyOnWriteArrayList的第二个机制:快照读。我们来看源码:

image.png

在创建迭代器时,先使用一个快照(snapshot)把旧数组的引用保存下来,不管后来新数组如何改变,都不会影响旧数组的遍历,所以是读写隔离的:

3.png

这样,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更新数据库;通知MusicPlayerActivitySong对象获取进度,更新进度条等。那么应该如何实现这个定时任务呢?

对于上述简单需求,使用Handler是一个比RxJava更好的选择。HandlerAndroid框架的核心组件之一,它的实现非常轻量级,仅用于简单的消息传递和线程调度,内存占用极小。

我们来看一下以上需求的整体逻辑:

  1. 如果音乐正在播放且监听器列表不为空,使用Handler发送一个Runnable到主线程,它负责定时任务。
  2. 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相关的MessageRunnable对象。每个Handler实例都关联一个线程及其消息队列。当创建一个新的Handler时,它会绑定一个Looper,并把MessageRunnable对象传递到该Looper的消息队列中,并在该Looper的线程上执行它们。

在我们的代码中,Handler绑定的是一个在主线程上的Looper,因为更新UI必须在主线程。Looper是一个循环器,它管理一个线程的消息队列(MessageQueue)。它保持线程运行,并按顺序处理消息队列中的消息:

private final Handler handler = new Handler(Looper.getMainLooper()) {

Handler主要有两种用途:(1) 安排MessageRunnable对象在未来的某个时间点执行;(2) 将操作排入队列,以便在不同于您自己的线程上执行。

通过postpostAtTime(Runnable, long)postDelayedsendEmptyMessagesendMessagesendMessageAtTimesendMessageDelayed方法可以实现Message的安排。(虽然这些post接收的参数是Runnable,但底层实现是将其转换为Message并调用sendMessageAtTimesendMessageDelayed

post允许将Runnable对象排入队列,当消息队列接收到它们时将调用这些RunnablesendMessage允许将包含数据包的Message对象排入队列,这些数据将由HandlerhandleMessage 方法处理(需要实现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是播放器的界面,主要功能包括:

  • 显示音乐播放器的界面,包括封面、进度条、播放按钮、循环模式按钮、播放列表按钮等。
  • 当前音乐列表窗口。
  • 实现监听器方法,更新界面显示,如播放进度、歌曲信息等。

类似于: 40f714c28a2ea44c90623dea5dddd42.jpg

b9a2ed411bacb63e0da21e2e51856c8.jpg

MusicPlayerActivity和上一篇笔记用了整个篇幅讲的DiscoveryFragment音乐推荐页面是类似的,都是UI页面,结构也类似,也是多个Activty类的分层继承模型。MusicPlayerActivity也是主要由initViews, initDatum, initListeners

5.1 Activity与分层继承结构

5.1.1 什么是Activity

image.png

ActivityAndroid应用程序的四大组件之一(其他三个是ServiceContentProviderBroadcastReceiver)。它主要用于定义用户图形界面,并且管理与用户在这个界面交互相关的操作。可以把它理解为一个屏幕,例如,在一个邮箱应用中,写邮件的界面就是一个Activity

正如上一篇笔记讲过,Activity可以通过Intent来启动另一个ActivityIntent是一个消息对象,用于在Android组件之间传递信息。例如,当用户想要进入设置界面时,点击主界面点击底部的设置图标,就会通过Intent来启动设置界面的Activity

Activity的生命周期

Activity的生命周期是指当用户浏览、退出和返回到应用时,应用中的Activity实例在其不同状态之间转换的过程,如下图:

image.png

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分层继承结构

类似于DiscoveryFragmentMusicPlayerActivity继承于以下Activity:

  1. BaseActivity:为所有Activity的基类,封装通用的生命周期逻辑。覆写onCreateonPostCreate,提供统一的初始化流程;定义initViews(初始化视图)、initDatum(初始化数据)、initListeners(设置监听器)三个空方法,并在onPostCreate中按顺序调用,强制子类遵循一致的初始化步骤。

  2. BaseCommonActivity:处理通用的界面跳转逻辑,避免每个Activity重复编写Intent代码。封装startActivitystartActivityAfterFinishThis方法,简化界面跳转。

  3. BaseLogicActivity:集中处理应用特有的业务逻辑(如跳转登录、跳转播放界面)。通过单例模式管理全局组件(PreferenceUtil),提供getMusicListManager

  4. BaseViewModelActivity<VB extends ViewBinding>:利用泛型VB支持不同类型的ViewBinding,提升类型安全性,并通过反射自动创建ViewBinding实例。

  5. 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的执行顺序为:

新启动或恢复时:onRestartonStartonResume.

首次启动时:onCreateonStartonResume.

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));

异步加载:Glideinto()方法会将图片加载任务提交到后台线程池,防止阻塞主线程。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 实现黑胶唱片旋转

接下来,我们来实现唱片的旋转功能。

38a681de646b91b94e278ce438f98a7.jpg

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是直接操作的目标ViewpropertyName通常是View通用属性如alpha(透明度)、translationX/Y(平移)、rotation(旋转)、scaleX/Y(缩放)等;values通过THUMB_ROTATION_PAUSE, THUMB_ROTATION_PLAYrecordThumbrotation属性从 -25°过渡到

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毫秒触发发送一个MessageHandler的消息队列,定时执行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 播放

  • 指针转动:
  1. 用户首次点击音乐进入Activity时,onResume会让指针转动放在唱片上。

  2. 点击播放按钮时,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使指针回到原位。
  • 唱片转动:MusicPlayerManagerstopPublishProgress将延时发送播放消息RunnableHandler消息队列移除。