简介:这是一份面向Android初学者的简易音乐播放器项目资源,覆盖从界面搭建到后台播放的完整实现思路。包内共68个文件,包含Java源码、XML布局、资源图片、AndroidManifest配置、APK安装包及项目工程文件等,便于直接导入学习或对照修改。资源重点展示了MediaPlayer播放控制、本地音乐扫描与列表展示、运行时权限申请、SeekBar进度同步、通知栏播放控制等关键环节,并涉及Activity、Service、RecyclerView、SQLite等常用组件与数据存储方式,内容紧凑、目录清晰,适合正在学习Android移动开发或希望快速理解音乐播放器架构的读者参考。已有264人浏览学习,可用于巩固Android基础并拓展对多媒体应用开发的认识。
1. Android简易播放器:为什么说MediaPlayer依然是后台播放最稳的起点
做移动开发的人,尤其是第一次接音频需求的,总有一种执念:一上来就要上ExoPlayer或者商业播放器SDK。但真把一个音乐播放需求拆开看,无非是播放、暂停、切歌、进度条、后台保活这五件事。用Android自带的MediaPlayer加一个前台Service完全能稳定跑起来,而且不用引入任何额外依赖。这份资源就是这样一套整理好的简易播放器骨架,覆盖播放器初始化、本地音乐扫描、通知栏控制、音频焦点处理,代码量不大但能直接落到项目里。适合刚接触Android音频开发的从业者,也适合想给现有App快速塞入基础播放能力的人。
2. 先把地基铺平:MediaPlayer状态机与音频资源管理
2.1 状态机不是玄学:Idle、Prepared、Started之间为什么容易翻车
MediaPlayer的状态机是整个Android体系里最容易翻车、又最容易被忽略的部分。很多开发者第一次写播放器时,都是new MediaPlayer之后立刻调start(),在部分设备上偶尔能出声音,换一台手机就直接抛IllegalStateException,典型的"本地能跑、真机就崩"。核心流程是:new之后处于Idle状态,此时必须setDataSource传入数据源,再经过prepare()或prepareAsync()进入Prepared状态,之后start()才有效。每切一次歌还要先reset()回到Idle,否则连续setDataSource也会报状态错误。
在决定用MediaPlayer之前,我其实也考虑过ExoPlayer。ExoPlayer在流媒体协议支持上确实更完整,但面对本地MP3播放、后台播放列表这组最简单的需求,它带来的依赖和配置成本并不划算。MediaPlayer由系统层封装音频解码与硬件支持,对本地音频和基础HTTP流表现稳定,也没有额外依赖。如果你的App后续要主攻DASH、HLS这类流媒体协议,再迁移到ExoPlayer也不迟,底层播放逻辑换掉并不会影响UI层。
// 播放器初始化:从Idle到Prepared的标准路径 private MediaPlayer player; private void initPlayer(String source) { if (player == null) { player = new MediaPlayer(); // Prepared事件回调:此时才能安全start player.setOnPreparedListener(mp -> { mp.start(); if (seekBar != null) { seekBar.setMax(mp.getDuration()); } }); // 错误回调必须处理,否则异常会冒泡到主线程 player.setOnErrorListener((mp, what, extra) -> { Log.e("SimplePlayer", "onError what=" + what + " extra=" + extra); return true; // 消费掉错误,避免继续触发onCompletion }); player.setOnCompletionListener(mp -> playNext()); } try { player.reset(); player.setDataSource(source); player.prepareAsync(); } catch (IOException e) { Log.e("SimplePlayer", "setDataSource failed", e); } }这段代码的思路是:实例只创建一次,后面切歌时用reset()回到Idle状态,避免状态错乱。setOnPreparedListener里才执行start,是因为异步prepare完成前调用start,播放器不会立即响应。setOnErrorListener返回true表示错误已经被处理,系统不再触发后续回调;一旦返回false,播放器会进入Error状态,后续所有操作全部失效。
setDataSource()支持三类数据源:本地文件路径String、ContentResolver返回的Uri、以及网络http/https地址。使用网络地址时要在AndroidManifest申请INTERNET权限,另外Android 9及以上默认禁止明文HTTP,需要给Application配置usesCleartextTraffic="true",或者单独写networkSecurityConfig白名单。
还有一个经验值:能异步就不要同步。本地文件在几百KB到几十MB不等,如果直接在UI线程调prepare(),文件大一点整个界面卡住;网络音频更严重,可能卡几十秒才超时。所以统一用prepareAsync,并且只依赖onPrepared回调执行start。如果数据源路径写错,onError会先于onPrepared触发,查日志时要看线程名和message tag。
2.2 释放与复用:一个MediaPlayer实例还是每次新建
连续切歌场景下,很多人会陷入两难:每次播放都new一个MediaPlayer,代码是简单了,但频繁创建、释放有性能损耗;复用同一个实例又得面对状态管理的复杂度。我的建议是:播放列表连续播放的情况下,优先复用单实例,配合reset()完成切换;只有播放器彻底不用时,才调用release()释放。
// 切换歌曲:复用实例,严格走重置流程 private void switchTrack(String nextSource) { try { handler.removeCallbacks(progressRunnable); // 停止进度刷新,避免脏数据 player.reset(); // 回到Idle player.setDataSource(nextSource); player.prepareAsync(); } catch (IOException e) { e.printStackTrace(); // 这里需要在UI层给出加载失败的提示 } }这里有一个容易忽略的细节:reset之后要把正在跑的进度刷新Runnable移除,否则上一首歌的进度值还会继续覆盖新界面。我以前就在这个位置踩过坑,表现为切歌后SeekBar一秒内跳回上一首进度,排查了很久才发现是旧回调没清掉。判断标准很简单:不要在reset之后、onPrepared之前继续读getCurrentPosition(),那会拿不到新数据,只会读到旧实例的残留值。
release和reset的区别,我一般这样向新同事讲:reset是让播放器重新做人,之后还能继续用;release是彻底销毁实例,释放底层Native资源,之后所有方法都不能再调,否则会崩。所以release只在生命周期结束时调用,比如页面onDestroy,而且release之后记得把引用置为null。特别注意:如果Service持有同一个播放器,不要在Activity销毁时提前release掉,会出现Service还在用、对象已经空了的线上崩溃。
下面这个表是我整理方法调用场景时常用的对照,方便新手快速判断:
| 方法 | 效果 | 适用场景 |
|---|---|---|
| reset() | 回到Idle,实例可复用 | 切换歌曲、出错重试 |
| release() | 释放Native资源,不可再用 | 页面销毁、播放器退出 |
| prepareAsync() | 异步准备,不阻塞UI线程 | 网络音频、大文件加载 |
| seekTo(ms) | 跳转到指定时间点 | 用户拖动进度条 |
2.3 错误回调里藏着大多数无声故障
MediaPlayer的onError(MediaPlayer mp, int what, int extra)是很多"没声音"问题的唯一线索。常见错误码有MEDIA_ERROR_IO、MEDIA_ERROR_MALFORMED和MEDIA_ERROR_UNKNOWN,what只能说明大类,具体原因要看extra参数。我在实际项目里会写一个简单的错误日志封装,把what和extra组合起来输出到日志,方便线上排查。
还有一个高频误用:onError回调如果返回false,系统会把错误当作一次播放完成,进而触发onCompletionListener,导致列表莫名其妙跳到下一首。所以统一写成true返回,在错误回调里自己决定是重试还是通知UI切歌。
// 错误处理:记录错误码并决定是否重试 player.setOnErrorListener((mp, what, extra) -> { String msg = "what=" + what + ", extra=" + extra; Log.e("SimplePlayer", "Play error: " + msg); // 简单策略:前两次IO错误自动重试,后续交给上层提示 if (what == MediaPlayer.MEDIA_ERROR_IO && retryCount < 2) { retryCount++; mp.reset(); try { mp.setDataSource(currentSource); mp.prepareAsync(); } catch (IOException e) { Log.e("SimplePlayer", "retry setDataSource failed", e); } return true; } retryCount = 0; return true; });这段逻辑专门针对网络音频偶发断流。IO错误频繁发生在Wi-Fi切4G的瞬间,简单重试比直接提示"播放失败"的体验好得多。retryCount是类字段,每次成功进入onPrepared就要重置为0,避免无限重试。要说明的是reset后MediaPlayer回到Idle,但错误监听、Prepared监听都还在,不需要重新setOnPreparedListener,这些listener是跟着实例走的。
real-world里还有一类无声故障来自编码格式支持:MediaPlayer对国内一些杂牌MP3文件里的ID3标签、双声道采样率组合处理不友好,表现就是无报错但不出声。遇到这类文件,我会先用MediaMetadataRetriever读一下编码信息,确认是AAC还是MP3,如果格式太怪就直接提示用户换源,不值得为一个文件去折腾解码器。
3. 把播放器接进界面:进度条、播放列表与后台服务
3.1 进度条双向绑定:自己动手写也不难
进度条是播放器UI里最容易出现"跳来跳去"的部分。初学的人喜欢用死循环刷新进度,结果内存占用高、刷新又频繁,加上没有用isPlaying保护状态,暂停时进度条还在动。这里的常见做法是用Handler定时器,500毫秒刷新一次,配合播放状态判断,就足够顺滑。500毫秒是一个经验值,要求精确一点可以用250毫秒,但再低就只会增加无意义的主线程刷新压力,滑动列表时反而变卡。
// 进度刷新Runnable:暂停时自动跳过,避免空转 private final Runnable progressRunnable = new Runnable() { @Override public void run() { if (player != null && player.isPlaying()) { int current = player.getCurrentPosition(); seekBar.setProgress(current); timeText.setText(formatTime(current)); } handler.postDelayed(this, 500); } };player.isPlaying()是这段代码的先行条件,不管是否Prepared,只要不是Playing状态就直接跳过更新。formatTime是一个自己封装的格式化方法,把毫秒数转成mm:ss。需要知道的是,MediaPlayer的getCurrentPosition在暂停状态也能读到值,但配合isPlaying保护后,不会出现暂停时反复刷新造成的进度漂移。
用户拖动SeekBar时还有一个细节:拖动过程中不要每动一格就去seekTo,否则会听到明显卡顿甚至声音撕裂。常见做法是drag过程中只更新UI,手指松开的onStopTrackingTouch里才真正seekTo。
// SeekBar拖动结束才真正跳转 seekBar.setOnSeekBarChangeListener(new SeekBar.OnSeekBarChangeListener() { @Override public void onProgressChanged(SeekBar sb, int progress, boolean fromUser) { if (fromUser) { timeText.setText(formatTime(progress)); // 拖动时只改显示 } } @Override public void onStopTrackingTouch(SeekBar seekBar) { if (player != null) { player.seekTo(seekBar.getProgress()); // 松手后真正seek } } });这套交互逻辑对新手有个心理门槛:"我明明拖了,为什么不马上生效?"其实不是不生效,而是刻意延迟到松手这一下,避免瞬间出现大量seek操作把播放引擎搞乱。真机上几十秒的音频还好,一小时以上的音频频繁seek,底层解码器会不断重新定位,极易出现短暂破音。
3.2 前台服务:让音乐在App退到后台之后继续响
这可能是很多人第一次写播放器时最崩溃的环节:Activity一退到后台,十几秒之后音乐停了。Android 8.0之后,后台Service被系统严格限制,国内定制ROM的限制比原生还狠。解决方案不是想办法绕过限制,而是把Service升级为前台服务,用startForeground在通知栏展示常驻通知。
// 前台通知栏播放服务骨架 public class PlayerService extends Service { private static final String CHANNEL_ID = "playback_channel"; @Override public void onCreate() { super.onCreate(); createNotificationChannel(); } @Override public int onStartCommand(Intent intent, int flags, int startId) { Notification notification = buildNotification("正在播放", "第1首 · 示例音频"); startForeground(1, notification); return START_NOT_STICKY; } }buildNotification()里要注册NotificationChannel,Android 8.0及以上必须指定channelId,否则通知栏不显示;Android 13及以上还要动态申请POST_NOTIFICATIONS权限,如果被用户拒绝,前台服务依然在,但通知隐身,部分机型上播放会被静默杀掉。这类边界最好在一个检查方法里集中处理。
startForeground的第一个参数是通知ID,这个ID要固定下来,后续更新通知时用同一个ID去notify。另一个常见误区是Service里没有调startForeground,直接返回START_STICKY,一旦系统回收后重启Service,前台状态丢失,表现为播放又断了,通知栏也出不来。
关于Service与Activity通信,这个项目里我用的是最简单的方式:PlayerService里持有一个静态播放器引用,Activity通过PlayerService.getPlayer()直接拿MediaPlayer。不引入bindService的原因是单一播放器场景足够用,bindService的绑定与解绑生命周期反而容易漏掉。如果后续要做多任务音轨混播,再迁到Binder不迟。
3.3 本地音乐列表解析:MediaStore查询与权限适配
播放器的另一个基础是"能拿到歌单"。我用MediaStore查询外部存储的音频文件,这个官方API比直接遍历文件系统更稳,而且能拿到歌手、时长、专辑名这些元数据,还不用自己处理那些隐藏目录和系统mkdir噪声。
// 查询本地音频文件的简洁实现 private List<MusicItem> queryLocalAudio() { List<MusicItem> musicList = new ArrayList<>(); ContentResolver resolver = getContentResolver(); Uri uri = MediaStore.Audio.Media.EXTERNAL_CONTENT_URI; String[] projection = { MediaStore.Audio.Media._ID, MediaStore.Audio.Media.DISPLAY_NAME, MediaStore.Audio.Media.ARTIST, MediaStore.Audio.Media.DURATION }; String selection = MediaStore.Audio.Media.IS_MUSIC + " != 0"; Cursor cursor = resolver.query(uri, projection, selection, null, null); if (cursor != null) { while (cursor.moveToNext()) { MusicItem item = new MusicItem(); item.id = cursor.getLong(0); item.name = cursor.getString(1); item.artist = cursor.getString(2); item.duration = cursor.getInt(3); musicList.add(item); } cursor.close(); } return musicList; }MediaStore像一个黑匣子,我们通过ContentResolver查询系统数据库里已索引的媒体信息。代码里projection字段决定了播放时用_ID拼Uri来加载,这是Android 10分区存储之后必须走的路——外部存储公共目录的文件路径在API 29以上不可直接访问,但content://media/external/audio/media/{id}可以由系统代为处理读取权限。
权限上要区分两个版本:Android 12及以下需要READ_EXTERNAL_STORAGE,Android 13及以上要改用READ_MEDIA_AUDIO。很多老项目的坑在于只在Manifest里声明新权限,但运行时没申请,结果Android 13设备上列表永远为空。我一般写一个checkAudioPermission方法,针对Build.VERSION.SDK_INT做分流请求。
4. 避坑笔记:播放器开发中常见的五个翻车现场
4.1 状态与生命周期相关的坑
坑一:第二次播放直接崩。现象:第一次点播放正常,退出页面再进来,点击播放按钮直接IllegalStateException。原因:在Activity的onDestroy里调了player.release(),但没有把播放器引用置空,重新进入后再次调用start,MediaPlayer已经是不可用状态。解决:release的同时把引用置null,每次播放前判断player == null就先初始化再start,不能依赖"反正页面只创建一次"这种假设。
坑二:切歌后SeekBar短暂显示上一首进度。现象:从歌A切到歌B,进度条先闪现一秒A歌的百分比,然后才跳到0。原因:reset后没有移除旧的progressRunnable,旧回调在prepare完成前执行了一次,读到的是A歌最后一次位置。解决:在reset之前就handler.removeCallbacks(progressRunnable),并且在onPrepared回调里把seekBar的progress和max一起复位。从那以后我每写一个播放器,第一步就是先排查回调注册和注销的对称性。
4.2 后台与通知栏相关的坑
坑三:App退后台后音乐约一分钟就停。现象:不同ROM表现不一致,有的锁屏即停,有的几分钟后被系统杀掉。原因:Service没有注册为前台服务,或者通知渠道在部分版本上没有正确创建,系统认为应用处于非活跃状态就回收。解决:统一改成前台Service方案,notification channel设置成setShowBadge(false)、setSilent(true),让通知低调常驻。同时要在代码里处理Android 13的动态权限,否则通知不出来,前台服务也白搭。
坑四:用户在通知栏点关闭,Service被杀死。现象:用户左滑通知或点删除按钮,音乐停了还在后台继续跑一段时间才被回收。原因:通知被删除时默认会让Service停掉,而播放器状态没做保存。解决:重写Service的onTaskRemoved或监听通知删除回调,把"关闭"行为改成暂停播放并更新通知内容,而不是真正stopService。这个"假关闭"效果用户感知不到差异,但能避免很多国产ROM上的强杀问题。
4.3 数据源与格式相关的坑
坑五:网络音频偶发卡顿或无声。现象:同一首歌Wi-Fi下正常,4G下静默几秒后继续,偶尔报MEDIA_ERROR_IO。原因:网络抖动导致音频流中断,MediaPlayer默认不自动重试,一旦底层读不到数据,就直接进入错误状态。解决:在onError回调里针对MEDIA_ERROR_IO做有限次重试,加载过程中给UI加一个loading状态。也要注意播放地址如果是临时签名URL,过期之后重试同样失败,这种场景要给上层一个明确的错误码。
5. 下一步进阶:音频焦点与代码结构的可维护性
到这一步,一个能正常播放本地音频和简单网络音频的播放器已经成形。如果想在这版骨架上继续深化,我建议优先处理两件事:做一层播放器封装,再把音频焦点接进来。
先看播放器封装。现在的代码逻辑看起来清晰,但播放器直接暴露给Activity还是太裸。我一般会抽一个PlayerManager单例,内部持有MediaPlayer、AudioFocusHelper、通知栏刷新逻辑,对外只暴露play、pause、next、seekTo、setPlaylist几个方法。这样UI层拿到的是一个稳定接口,后面就算要换ExoPlayer或者其他内核,也只需要改动PlayerManager内部,Activity完全不动。
音频焦点是很多简易播放器都不处理、但实际非常影响体验的部分。当手机来电、其他App要出声时,系统会回调我们注册的AudioFocusListener;不处理的话会出现两个App同时出声的尴尬。我用的是标准AudioFocusRequest写法:
// 请求音频焦点的标准写法 AudioManager am = (AudioManager) getSystemService(AUDIO_SERVICE); AudioAttributes attrs = new AudioAttributes.Builder() .setUsage(AudioAttributes.USAGE_MEDIA) .setContentType(AudioAttributes.CONTENT_TYPE_MUSIC) .build(); AudioFocusRequest request = new AudioFocusRequest.Builder(AudioFocusManager.AUDIOFOCUS_GAIN) .setAudioAttributes(attrs) .setOnAudioFocusChangeListener(focusChange -> { switch (focusChange) { case AUDIOFOCUS_LOSS: player.pause(); // 长期失去焦点,必须暂停并释放焦点 break; case AUDIOFOCUS_LOSS_TRANSIENT: player.pause(); // 短时失去焦点,暂停即可 break; case AUDIOFOCUS_GAIN: // 如果用户之前是播放状态,可以恢复 if (wasPlayingBefore) player.start(); break; } }) .build(); int result = am.requestAudioFocus(request);setUsage(USAGE_MEDIA)配合CONTENT_TYPE_MUSIC,把播放器声明为媒体音乐场景,系统才能正确处理与其他音频的混合策略。AUDIOFOCUS_GAIN表示长期取得焦点,适合音乐持续播放;如果只是短时提示音,可以改成AUDIOFOCUS_GAIN_TRANSIENT。回调里最关键的是AUDIOFOCUS_LOSS必须暂停播放并放弃焦点,我之前在这里有个血泪经验:接完电话之后自动恢复播放,结果用户在开会时突然外放,体验极其糟糕。从那以后我每次处理AudioFocus回调,都强制要求自己先判断"用户是否真的期望恢复播放",再决定要不要执行start,希望能帮到正在折腾同样问题的你。
本文还有配套的精品资源,点击获取