Reac Native音乐播放服务的依赖库选择和通知栏控件

0 阅读11分钟

前言

react native应用有一大特色就是大部分功能都依赖于依赖库,因为本质上讲react native是编写js代码操控调用原生实现功能,随着新架构JSI的更新,使其流畅性相比旧架构的JSBridge大大提升,因为新架构使用同步通信方式与原生交互,而JSBridge则是异步与原生交互,并且在我的[音乐播放器项目](expo rn: expo创建的react native的音乐播放器应用)中,实际构建apk后在手机上运行很流畅。当然开发react-native应用传统的方式是相对于expo的bare workflow裸工作流,它赋予我们更多操作原生的空间,同时需要更多原生的知识,同时expo的项目也可以转换为bare workflow,一旦转换不可逆,而应用expo的最大好处就是免去繁杂的开发环境搭建(尤其是国内网络环境,搭建原生react native开发环境非常困难)。但是也导致react native项目与expo捆绑过深,导致依赖库选择需要额外考虑与expo的兼容性,对于依赖原生代码的依赖库,需要使用expo的EAS build构建开发构建版本,不能使用expo go调试。这也导致了我在项目开发过程中音乐播放功能依赖库遇到了很多难以解决的问题和麻烦。

其实说了很多核心意思就是react native选择安装使用依赖库可能面临很多未知问题,而我的音乐播放器项目中,在音乐播放依赖库选择经历了漫长的调试与挣扎。

音乐播放核心依赖选择历程

如果有匹配可靠的依赖,这个音乐播放器项目就很简单,但是问题就在这里,我的诉求也不复杂:具备稳定的音乐服务和音乐播放器的通知栏控件!

依赖库1:expo-audio

该依赖库具有音乐播放,后台播放,背景播放,录音等功能,但是没有音乐播放器的通知栏控件,新版本中添加了锁屏提醒,可以设置歌曲信息。由于是第一次使用react native因此没有多想,构建了一套基于expo-audio的音乐播放功能代码。但是还是想要通知栏控件,通过询问AI提示可以使用react-native-track-player或者expo-av、 react-native-sound

依赖库2:react-native-track-player 为了更完美的效果,我当然是循着ai的指引安装测试,expo-av算是expo-audio的前身,已废弃;react-native-sound不活跃的依赖库;react-native-track-player依赖该依赖库是一个功能完备的专业的音乐播放依赖库,具备一个完整音乐播放服务的功能,非常符合我的需求,那就安装吧,由于不清楚它依赖原生代码需要开发构建,也费了很大周章,开发构建后它需要自定义入口文件,在入口文件中增加通知栏控件的事件监听处理代码,结果很令我失望:通知栏控件事件无法触发,点击通知栏无法正确进入应用,其实这与新版本react native有关系,我在react native 0、79和现在的0.81中都不行,看网上一些项目代码0.78是可以的,而且该依赖库目前不是很活跃,提issue回复使用最新的5版本,但是问题依旧,文档中也提到对expo支持有限,并且目前不支持新架构

依赖库3:react-native-audio-pro 该依赖库是一个可以稳定使用的依赖库,也具备比较完善的功能和通知栏播放控件,但是它的最新版本有个问题是播放音乐时通知栏会创建播放控件和一条通知,而且通知中的信息更新及时,控件信息更新不及时,并且目前不支持新架构

依赖库4:react-native-nitro-player 该依赖库提供音乐播放、歌单处理功能和通知栏播放控件等功能,依赖库很较新,最近出的,最近版本更新到了0.4,我使用时是0.3版本,它需要react-native-nitro-modules作为对等依赖,在我的项目中开始的问题是不能设置开启通知栏控件,一旦开启进入应用会闪退,后续版本可以开启控件但是控件按钮没有响应,不能正确跳转到应用中,提issure人家说它们测试的设备没有问题

兜兜转转,又回到了最初的地方:expo-audio,expo推荐的音频播放库,但是音乐播放控件怎么解决呢?就在前几天偶然在[react-native依赖库参考目录网站](React Native Directory)发现了一个让我很意外的依赖库:expo-media-control!该依赖库也很年轻,可是早没有发现它,它专门提供通知栏播放控件,其余不管,音乐播放的控制更新的交互要手动对接。控件显示信息主要是上一曲下一曲播放暂停,歌曲艺术家,封面图片,暂时没有播放时间显示。这样的话我可以利用expo-audio和expo-media-control搭配完成想要的功能了!

配置expo-audio

expo-audio的初始化配置非常简单,它提供了两种创建音乐播放器实例的方法:useAudioPlayer和createAudioPlayer两个api,如果不使用hooks则需要手动管理生命周期,主要是在合适的时机调用release方法, hooks则在内部有相关代码。由于是音乐播放器应用,如果没有特殊情况我们只需要一个AudioPlayer实例,而音乐播放器的状态更新,比如当前是否在播放,是否播放结束,当前播放进度等状态获取除了从AudioPlayer 实例上获取外还可以使用useAudioPlayerStatus持续监听,这在音乐播放页面更新歌词,播放进度非常必要:

    import { useAudioPlayerStatus, setAudioModeAsync, useAudioPlayer } from 'expo-audio';
    const player = useAudioPlayer('', { updateInterval: 1000 });
    const status = useAudioPlayerStatus(player);
    const currentSong = useProMusicStore(useShallow(({ currentSong }) => currentSong));
    /**
     * 监听歌曲播放结束后下一曲
     */
    useEffect(() => {
        if (status.didJustFinish) {
            const { prevOrNextSong } = useProMusicStore.getState();
            prevOrNextSong(true)
        };
    }, [status.didJustFinish])

由于是音乐播放器应用,我们还应该具备后台播放能力:

        // 配置音频会话以支持后台播放
        const setupAudioSession = async () => {
            // 设置音频会话为播放模式
            await setAudioModeAsync({
                playsInSilentMode: true,      // iOS: 在静音模式下也能播放
                shouldPlayInBackground: true,
                interruptionMode: 'mixWithOthers'  // iOS: 后台保持活跃  // Android: 中断时停止
            });
        };
        setupAudioSession();

经过如上配置并在store中保存player实例便于在需要的地方调用,就完成了音乐播放器的初始化配置

配置expo-media-control

通知栏控件有信息展示和音乐播放暂停等控制操作,控件初始化的时机可以在音乐播放真正执行或者应用初始化时进行:

import { MediaControl, PlaybackState, Command, MediaControlEvent, type MediaMetadata } from 'expo-media-control';
/**
 * 初始化配置通知栏控件,
 * 特别注意:一旦设置了artwork则必须设置uri且uri本地路径一定要存在
 * 一旦不存在通知栏控件更新数据就不正常了
 * @param currentTrack 当前歌曲信息
 */
const initializeControls = async (currentTrack: MediaMetadata) => {
    try {
        await MediaControl.enableMediaControls({
            capabilities: [
                Command.PLAY,
                Command.PAUSE,
                Command.NEXT_TRACK,
                Command.PREVIOUS_TRACK,
                Command.SKIP_FORWARD,
                Command.SKIP_BACKWARD,
            ],
            notification: {
                icon: 'ic_music_note',
                color: '#1976D2',
            },
            ios: {
                skipInterval: PLAY_STEP_LENGTH, // iOS需要设置skipInterval才能显示快进/快退按钮
            },
            android: {
                skipInterval: PLAY_STEP_LENGTH, // Android skip按钮注册间隔
            },
        });

        // Set initial metadata
        await MediaControl.updateMetadata(currentTrack);
        await MediaControl.updatePlaybackState(PlaybackState.STOPPED);

    } catch (error) {
        console.error('Failed to initialize media controls:', error);
    }
};

capabilities配置项是具体控件要使用的功能,虽然配置了这么多,但是只有上一曲下一曲,播放暂停按钮显示,具体在iOS上怎么显示没有测试,接下来是控件的事件监听交互处理:

        // 添加监听器,监听通知栏控件事件
        const removeListener = MediaControl.addListener((event: MediaControlEvent) => {
            switch (event.command) {
                case Command.PLAY:
                    player.play();
                    MediaControl.updatePlaybackState(PlaybackState.PLAYING);
                    break;
                case Command.PAUSE:
                    player.pause();
                    MediaControl.updatePlaybackState(PlaybackState.PAUSED)
                    break;
                case Command.NEXT_TRACK:
                    prevOrNextSong(true);
                    MediaControl.updatePlaybackState(PlaybackState.PLAYING);
                    break;
                case Command.PREVIOUS_TRACK:
                    prevOrNextSong(false);
                    MediaControl.updatePlaybackState(PlaybackState.PLAYING);
                    break;
                case Command.SKIP_FORWARD:
                    player.seekTo(player.currentTime + PLAY_STEP_LENGTH);
                    MediaControl.updatePlaybackState(PlaybackState.PLAYING);
                    break;
                case Command.SKIP_BACKWARD:
                    const newTime = player.currentTime - PLAY_STEP_LENGTH;
                    player.seekTo(newTime < 0 ? 0 : newTime);
                    MediaControl.updatePlaybackState(PlaybackState.PLAYING);
                    break;
            }
        });

特别提醒:控件显示中有artwork的uri配置,值是图片封面的路径,网络路径没有测试,但是如果uri是本地路径如果图片不存在则会有大问题:它会导致控件信息不更新!还有最重要的是在播放的歌曲变化时更新控件的元信息:

    /**
     * 控件显示信息调整为监听currentSong的变化
     * 修改控件信息,特别注意artwork的uri属性必传且文件必须
     * 存在,一旦不存在则会导致通知栏控件不切换的bug
     */
    useEffect(() => {
        if (currentSong) {
            const { title, artist, album, coverUrl, duration } = currentSong;
            const { exists } = new File(coverUrl);
            const currentTrack: MediaMetadata = {
                title, artist, album, artwork: { uri: exists ? coverUrl : DEFAULT_COVER }, duration
            };
            MediaControl.updateMetadata(currentTrack)
        }
    }, [currentSong?.id])

至此音乐播放器和控件的初始化就完成了!完整代码如下:

import useProMusicStore from '@/store/proMusic';
import { useEffect } from 'react';
import type { SqliteSongInfo } from '@/types';
import { getAllList, getAllCollectionSongs, getAllSingerSongs, getAllAlbumSongs, getPlaylistSongs } from '@/libs/sqlite';
import { SQLITE_SONG_TABLE_NAME, SQLITE_CONVERTED_TABLE_NAME, PLAY_STEP_LENGTH, DEFAULT_COVER, LYRICS_FILE_NAME } from '@/constants/SongList';
import { useAudioPlayerStatus, setAudioModeAsync, useAudioPlayer } from 'expo-audio';
import { MediaControl, PlaybackState, Command, MediaControlEvent, type MediaMetadata } from 'expo-media-control';
import { useShallow } from 'zustand/react/shallow';
import { File } from 'expo-file-system';
import { copyImgFromAssetsToApp, copyLrcFromAssetsToApp } from '@/utils/findLrcFiles';
import { findLrcFiles } from '@/utils/findLrcFiles';
/**
 * 初始化配置通知栏控件,
 * 特别注意:一旦设置了artwork则必须设置uri且uri本地路径一定要存在
 * 一旦不存在通知栏控件更新数据就不正常了
 * @param currentTrack 当前歌曲信息
 */
const initializeControls = async (currentTrack: MediaMetadata) => {
    try {
        await MediaControl.enableMediaControls({
            capabilities: [
                Command.PLAY,
                Command.PAUSE,
                Command.NEXT_TRACK,
                Command.PREVIOUS_TRACK,
                Command.SKIP_FORWARD,
                Command.SKIP_BACKWARD,
            ],
            notification: {
                icon: 'ic_music_note',
                color: '#1976D2',
            },
            ios: {
                skipInterval: PLAY_STEP_LENGTH, // iOS需要设置skipInterval才能显示快进/快退按钮
            },
            android: {
                skipInterval: PLAY_STEP_LENGTH, // Android skip按钮注册间隔
            },
        });

        // Set initial metadata
        await MediaControl.updateMetadata(currentTrack);
        await MediaControl.updatePlaybackState(PlaybackState.STOPPED);

    } catch (error) {
        console.error('Failed to initialize media controls:', error);
    }
};
const useSetupExpoAudioProPlayer = () => {
    const player = useAudioPlayer('', { updateInterval: 1000 });
    const status = useAudioPlayerStatus(player);
    const currentSong = useProMusicStore(useShallow(({ currentSong }) => currentSong));
    /**
     * 监听歌曲播放结束后下一曲
     */
    useEffect(() => {
        if (status.didJustFinish) {
            const { prevOrNextSong } = useProMusicStore.getState();
            prevOrNextSong(true)
        };
    }, [status.didJustFinish])


    // Move side effects into useEffect to avoid triggering store updates during render
    useEffect(() => {
        // 配置音频会话以支持后台播放
        const setupAudioSession = async () => {
            // 设置音频会话为播放模式
            await setAudioModeAsync({
                playsInSilentMode: true,      // iOS: 在静音模式下也能播放
                shouldPlayInBackground: true,
                interruptionMode: 'mixWithOthers'  // iOS: 后台保持活跃  // Android: 中断时停止
            });
        };
        setupAudioSession();
        /**
         * 预处理音乐数据,将最近一次播放的歌曲数据请求到内存中
         * 使用store.getState()获取状态而不是useProMusicStore(),避免在render期间触发store更新相互影响
         * 因为这里只读取最近一次播放的歌曲数据,所以不需要考虑性能问题
         */
        const {
            prevOrNextSong, setSongList, currentMusicType, setCustomMapData, customType, customListId, setPlayer, currentSong
        } = useProMusicStore.getState();
        setPlayer(player);
        if (currentMusicType === 'local') {
            getAllList<SqliteSongInfo>(SQLITE_SONG_TABLE_NAME, 'ORDER BY rowid DESC')
                .then(res => setSongList(res, 'localList'))
                .catch(() => setSongList([], 'localList'));
        } else if (currentMusicType === 'collection') {
            getAllCollectionSongs()
                .then(res => setSongList(res, 'collectionList'))
                .catch(() => setSongList([], 'collectionList'));
        } else if (currentMusicType === 'custom') {
            if (customListId) {
                if (customType === 'singer') {
                    getAllSingerSongs(customListId).then(res => {
                        setCustomMapData(res, customListId)
                        setSongList(res, 'customList')
                    }).catch(() => setSongList([], 'customList'));
                } else if (customType === 'album') {
                    getAllAlbumSongs(customListId).then(res => {
                        setCustomMapData(res, customListId);
                        setSongList(res, 'customList');
                    }).catch(() => setSongList([], 'customList'))
                } else if (customType === 'userPlay') {
                    getPlaylistSongs(customListId).then(res => {
                        setCustomMapData(res, customListId)
                        setSongList(res, 'customList');
                    }).catch(() => setSongList([], 'customList'));
                } else {
                    getAllList<SqliteSongInfo>(SQLITE_CONVERTED_TABLE_NAME).then(res => {
                        setCustomMapData(res, customListId)
                        setSongList(res, 'customList');
                    }).catch(() => setSongList([], 'customList'));
                };
            };
        };
        if (currentSong) {
            const { title, artist, album, coverUrl, duration, uri } = currentSong;
            const { exists } = new File(coverUrl);
            const currentTrack: MediaMetadata = {
                title, artist, album, artwork: { uri: exists ? coverUrl : DEFAULT_COVER }, duration
            };
            /**
             * 初始化操作
             */
            initializeControls(currentTrack);
            player.replace(uri)
        };

        // 添加监听器,监听通知栏控件事件
        const removeListener = MediaControl.addListener((event: MediaControlEvent) => {
            switch (event.command) {
                case Command.PLAY:
                    player.play();
                    MediaControl.updatePlaybackState(PlaybackState.PLAYING);
                    break;
                case Command.PAUSE:
                    player.pause();
                    MediaControl.updatePlaybackState(PlaybackState.PAUSED)
                    break;
                case Command.NEXT_TRACK:
                    prevOrNextSong(true);
                    MediaControl.updatePlaybackState(PlaybackState.PLAYING);
                    break;
                case Command.PREVIOUS_TRACK:
                    prevOrNextSong(false);
                    MediaControl.updatePlaybackState(PlaybackState.PLAYING);
                    break;
                case Command.SKIP_FORWARD:
                    player.seekTo(player.currentTime + PLAY_STEP_LENGTH);
                    MediaControl.updatePlaybackState(PlaybackState.PLAYING);
                    break;
                case Command.SKIP_BACKWARD:
                    const newTime = player.currentTime - PLAY_STEP_LENGTH;
                    player.seekTo(newTime < 0 ? 0 : newTime);
                    MediaControl.updatePlaybackState(PlaybackState.PLAYING);
                    break;
            }
        });
        /**
         * 检测默认图片是否存在,如果不存在从assets中写入
         */
        const { exists } = new File(DEFAULT_COVER);
        if (!exists) {
            copyImgFromAssetsToApp(require('@/assets/images/unknown_track.png'), 'unknownTrack')
        };
        findLrcFiles().then(res => {
            if (res.length === 0) {
                const lrc = require('@/assets/lyrics/威尼斯的泪-永邦-歌词.lrc');
                copyLrcFromAssetsToApp(lrc, '威尼斯的泪-永邦-歌词.lrc', LYRICS_FILE_NAME)
            }
        }).catch(() => { })
        // Cleanup on unmount
        return () => {
            removeListener();
            MediaControl.disableMediaControls();
            // player.release()
        };
    }, []);
    /**
     * 控件显示信息调整为监听currentSong的变化
     * 修改控件信息,特别注意artwork的uri属性必传且文件必须
     * 存在,一旦不存在则会导致通知栏控件不切换的bug
     */
    useEffect(() => {
        if (currentSong) {
            const { title, artist, album, coverUrl, duration } = currentSong;
            const { exists } = new File(coverUrl);
            const currentTrack: MediaMetadata = {
                title, artist, album, artwork: { uri: exists ? coverUrl : DEFAULT_COVER }, duration
            };
            MediaControl.updateMetadata(currentTrack)
        }
    }, [currentSong?.id])
};

export default useSetupExpoAudioProPlayer;

在应用启动时执行如上hook便完成了音乐播放器和播放控件的初始化,但是还有一个细节需要注意:在切换歌曲时应该更新播放状态:MediaControl.updatePlaybackState,比如点击歌曲播放时:

        setCurrentSong(currentSong, type = 'local') {
            set(({ localList, collectionList, customList, remoteSongList, player }) => {
                const config: Record<MusicType, Array<any>> = {
                    local: localList,
                    remote: remoteSongList,
                    collection: collectionList,
                    custom: customList
                };
                /**
                 * 点击播放某个歌曲时找到它的下标
                 */
                if (type !== 'remote') {
                    let currentIndex = -1;
                    const list = config[type];
                    currentIndex = list.findIndex(item => item.id === currentSong?.id);
                    player?.replace(currentSong.uri);
                    player?.play();
                    MediaControl.updatePlaybackState(PlaybackState.PLAYING);
                    return {
                        [`${type}CurrentSong`]: currentSong,
                        [`${type}Current`]: currentIndex > -1 ? currentIndex : 0,
                        currentMusicType: type,
                        currentSong
                    }
                } else {
                    // 远程歌曲暂不处理
                    return {}
                }
            })
        },

react-native-nitro-player依赖库的通知栏控件样式和expo-media-control样式一样,大概率内部使用的就是该依赖库。只可惜早不知道expo-media-control这个依赖库,以上的音乐播放器依赖库我分别写过对应的代码,千辛万苦最后回到原点,而原点好像是最优解。

目前项目打包出的apk已经在我手机上使用,除了操作性的bug外发现一个问题:可能会在后台播放两三个小时后进入应用后卡顿闪退,具体的原因我目前不清楚。项目地址:[音乐播放器项目](expo rn: expo创建的react native的音乐播放器应用),欢迎star 交流 建议