简介:这是一份面向Android初学者与移动开发入门者的简易音乐播放器实战项目,聚焦音频播放核心功能实现与典型UI交互设计,帮助开发者系统掌握MediaPlayer控制、本地音乐扫描、列表展示、进度条同步及后台服务等关键技能。资源包共68个文件,包含20个编译后class文件、14个XML布局与配置文件(涵盖Activity界面、菜单及资源定义)、13个PNG图标资源、8个Java源码文件(含主逻辑与工具类),以及APK安装包、支持库jar、ProGuard混淆配置等,整体体积3.22MB,结构完整,可直接导入Android Studio运行调试。目前已有263人学习下载,项目代码组织清晰,覆盖从权限申请、MediaStore数据查询、RecyclerView音乐列表渲染到通知栏控制的全流程,附带可执行APK与详细目录模块,是理解Android多媒体开发实践路径的优质参考样本。
1. 这不是“Hello World”式播放器:一个能真正在 Android 设备上播本地 MP3、支持暂停/进度拖拽/后台续播的轻量级播放器工程
你可能已经写过MediaPlayer的setDataSource()+prepare()+start()三连,但一加个进度条就跳不准,切到后台音乐就停,锁屏后通知栏没控制按钮,甚至换台手机就报IllegalStateException——这不是你代码写得差,是 Android 音频生命周期和MediaPlayer状态机本身就很反直觉。这个「android 简易播放器」项目,就是为解决这些真实落地卡点而生:它不追求 UI 多炫,但把MediaPlayer在 Activity/Service/Notification/SeekBar 四者间的协作逻辑全拆开、压平、注释透。它用纯 Java(兼容 Android 8.0+),不依赖 ExoPlayer,不引入任何第三方音频库,所有状态流转都靠Handler+BroadcastReceiver显式驱动。适合刚学完四大组件、正卡在「为什么我 resume 后 MediaPlayer 就报错」的移动开发新手;也适合需要快速嵌入一个稳定音频控制模块、又不想为 ExoPlayer 学一整套事件总线的老手。它不是教学 Demo,而是一个可直接git clone、改两行路径就能在真机跑起来的最小可行音频控制骨架。
2. 播放器核心模块拆解:从 MediaPlayer 状态机到 Service 生命周期绑定
2.1 为什么不用 Activity 直接管理 MediaPlayer?——状态泄漏与后台中断的真实代价
很多初学者把MediaPlayer声明在Activity里,onCreate()初始化,onStart()播放,onPause()暂停。看似合理,但实际踩坑极多:
- 当用户按 Home 键切到后台,系统可能在
onStop()后直接回收 Activity,MediaPlayer被destroy(),但音频线程还在跑,导致MediaPlayer内部状态错乱; - 若此时用户从通知栏点“继续播放”,Activity 重建,但旧
MediaPlayer实例已不可达,新实例无法接管正在播放的音频流; - 更隐蔽的是内存泄漏:
MediaPlayer持有SurfaceHolder或AudioManager引用,若未在onDestroy()中显式release(),Activity 实例无法被 GC,长期驻留导致 OOM。
本项目采用Service+Binder方案,将MediaPlayer完全托管在前台 Service 中。Service生命周期独立于 Activity,即使 Activity 被销毁,只要 Service 在运行,音频就不会中断。关键设计点在于:
MusicService继承Service,内部持有一个单例MediaPlayer实例;MusicActivity通过bindService()获取IBinder,调用MusicBinder提供的play(),pause(),seekTo()等方法;- 所有
MediaPlayer状态变更(如onCompletion())均通过LocalBroadcastManager发送广播,由Activity中的BroadcastReceiver接收并更新 UI。
提示:
Service必须声明为android:exported="false"(Android 12+ 强制要求),且启动方式必须为startForegroundService(),否则在 Android 8.0+ 上会被系统静默杀死。
2.2 MediaPlayer 状态机实战:prepareAsync() 是唯一安全起点
MediaPlayer有 10+ 种状态(Idle → Initialized → Prepared → Started → Paused → Stopped → PlaybackCompleted → Error),官方文档状态图看着复杂,但实际开发中只需盯死4 个关键跃迁点:
| 当前状态 | 触发动作 | 下一状态 | 是否需 prepare() | 常见错误 |
|---|---|---|---|---|
| Idle | setDataSource() | Initialized | 否 | 未 setDataSource 就调 start() → IllegalStateException |
| Initialized | prepare()或prepareAsync() | Prepared | 是 | prepare()在主线程阻塞 → ANR;prepareAsync()必须注册OnPreparedListener |
| Prepared | start() | Started | 否 | start()前未 prepare → IllegalStateException |
| Started | pause() | Paused | 否 | pause()后立即seekTo()→ 部分机型跳转失败 |
本项目强制使用prepareAsync(),并在onPrepared()回调中执行start()和 UI 更新:
// MusicService.java private void initMediaPlayer(String audioPath) { if (mediaPlayer == null) { mediaPlayer = new MediaPlayer(); } else { mediaPlayer.reset(); // 必须 reset,否则状态残留 } try { mediaPlayer.setDataSource(audioPath); mediaPlayer.setOnPreparedListener(mp -> { isPrepared = true; // 此时才真正进入 Prepared 状态,可安全 start() mp.start(); // 通知 UI 更新播放按钮、进度条最大值 sendBroadcast(new Intent(ACTION_PLAYING)); }); mediaPlayer.prepareAsync(); // 关键:异步准备,避免 ANR } catch (IOException e) { Log.e(TAG, "Failed to set data source", e); sendBroadcast(new Intent(ACTION_ERROR)); } }参数说明:
audioPath:必须是绝对路径(如/storage/emulated/0/Music/song.mp3),不能传res/raw/资源 ID,否则setDataSource()会抛IOException;reset():每次重用MediaPlayer前必须调用,清空所有状态,否则setDataSource()可能失败;OnPreparedListener:prepareAsync()的回调,唯一安全调用start()的位置,其他任何地方调用都可能触发IllegalStateException。
2.3 Service 与 Notification 联动:让播放器真正“活”在后台
仅靠Service还不够——Android 系统对后台 Service 有严格限制(尤其 Android 8.0+)。必须将Service提升为前台服务(Foreground Service),否则会在后台运行几分钟后被系统终止。前台服务的核心是startForeground(),它要求传入一个Notification,该通知会常驻状态栏,用户可点击控制播放。
本项目MusicService的onStartCommand()中强制调用:
// MusicService.java private void startForegroundNotification() { Intent notificationIntent = new Intent(this, MusicActivity.class); notificationIntent.setAction(ACTION_MAIN); notificationIntent.setFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP); PendingIntent pendingIntent = PendingIntent.getActivity( this, 0, notificationIntent, PendingIntent.FLAG_IMMUTABLE); // 构建通知:包含播放/暂停/上一首/下一首按钮 NotificationCompat.Builder builder = new NotificationCompat.Builder(this, CHANNEL_ID) .setContentTitle("简易播放器") .setContentText(currentSongName) .setSmallIcon(R.drawable.ic_play) .setContentIntent(pendingIntent) .setOngoing(true) // 设置为持续通知,不可滑动清除 .addAction(R.drawable.ic_pause, "暂停", buildPendingIntent(ACTION_PAUSE)) .addAction(R.drawable.ic_next, "下一首", buildPendingIntent(ACTION_NEXT)); // 关键:startForeground() 第二个参数必须是非 0 的 notification ID startForeground(NOTIFICATION_ID, builder.build()); }参数说明:
CHANNEL_ID:Android 8.0+ 必须先创建NotificationChannel,本项目在Application.onCreate()中完成初始化;setOngoing(true):标记为持续性通知,用户无法手动清除,确保 Service 不被误杀;addAction():每个 Action 对应一个PendingIntent,点击后触发BroadcastReceiver处理,而非直接调用 Service 方法(避免跨进程调用复杂度);PendingIntent.FLAG_IMMUTABLE:Android 12+ 强制要求,表示该 PendingIntent 不可被接收方修改。
3. UI 层协同机制:SeekBar 拖拽精度控制与 Activity/Service 数据同步
3.1 SeekBar 拖拽的“玄学”问题:为什么松手后总是跳回原位置?
这是本项目最常被问的问题。现象是:用户按住 SeekBar 拖动,onProgressChanged()不断回调,但一松手,SeekBar 自动弹回原来位置,MediaPlayer.seekTo()似乎没生效。根本原因有两个:
- UI 线程与 MediaPlayer 线程不同步:
SeekBar.setProgress()在主线程调用,但MediaPlayer.getCurrentPosition()返回的是毫秒级瞬时值,而SeekBar的max默认是 100,未按音频总时长缩放; - 未处理
MediaPlayer的 seek 完成回调:seekTo()是异步操作,getCurrentPosition()在seekTo()后立即调用,返回的仍是旧位置,导致 UI 被“纠正”回旧值。
本项目采用双监听 + 主动同步策略:
// MusicActivity.java private void setupSeekBar() { seekBar.setMax(100); // 统一设为 100,便于计算百分比 seekBar.setOnSeekBarChangeListener(new SeekBar.OnSeekBarChangeListener() { @Override public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) { if (fromUser && isPlaying()) { // 用户拖拽时,计算目标毫秒值 int duration = getDuration(); // 从 Service 获取当前音频总时长 int targetMs = (int) ((progress / 100.0) * duration); // 主动调用 Service 的 seekTo() musicControl.seekTo(targetMs); } } @Override public void onStartTrackingTouch(SeekBar seekBar) { // 拖拽开始,暂停定时更新,避免干扰 handler.removeCallbacks(updateSeekBarRunnable); } @Override public void onStopTrackingTouch(SeekBar seekBar) { // 拖拽结束,恢复定时更新 handler.postDelayed(updateSeekBarRunnable, 500); } }); } // 定时更新 Runnable:每 500ms 同步一次当前播放位置 private final Runnable updateSeekBarRunnable = new Runnable() { @Override public void run() { if (isPlaying()) { int currentPosition = getCurrentPosition(); // 从 Service 获取 int duration = getDuration(); if (duration > 0) { int progress = (int) ((currentPosition * 100L) / duration); seekBar.setProgress(progress, true); // true 表示不触发 onProgressChanged } } handler.postDelayed(this, 500); } };关键逻辑说明:
onProgressChanged()中fromUser参数必须为true才响应拖拽,否则会因seekBar.setProgress()的内部回调形成死循环;seekBar.setProgress(progress, true)的第二个参数true表示“静默设置”,不触发onProgressChanged(),避免拖拽时反复调用seekTo();- 定时更新间隔设为
500ms而非100ms,既保证 UI 流畅,又降低getCurrentPosition()频次,减少线程竞争。
3.2 Activity 与 Service 的数据同步:用 LocalBroadcastManager 替代 EventBus
跨组件通信,新手常倾向用EventBus或LiveData,但本项目坚持用LocalBroadcastManager,原因很实在:
LocalBroadcastManager仅在本应用内广播,无跨应用安全风险,无需动态注册权限;- 广播接收器生命周期与 Activity 绑定,
registerReceiver()/unregisterReceiver()成对出现,不易内存泄漏; - 与
Service的sendBroadcast()配合天然,无需额外学习事件总线 API。
所有关键状态变更均由MusicService主动广播:
// MusicService.java private void sendBroadcast(String action) { Intent intent = new Intent(action); intent.putExtra("current_position", getCurrentPosition()); intent.putExtra("duration", getDuration()); intent.putExtra("is_playing", isPlaying()); LocalBroadcastManager.getInstance(this).sendBroadcast(intent); }MusicActivity在onResume()注册,在onPause()注销:
// MusicActivity.java private BroadcastReceiver playerStateReceiver = new BroadcastReceiver() { @Override public void onReceive(Context context, Intent intent) { String action = intent.getAction(); if (ACTION_PLAYING.equals(action)) { playButton.setImageResource(R.drawable.ic_pause); isPlaying = true; handler.post(updateSeekBarRunnable); // 开始定时更新 } else if (ACTION_PAUSED.equals(action)) { playButton.setImageResource(R.drawable.ic_play); isPlaying = false; handler.removeCallbacks(updateSeekBarRunnable); } else if (ACTION_UPDATE_PROGRESS.equals(action)) { int position = intent.getIntExtra("current_position", 0); int duration = intent.getIntExtra("duration", 0); updateUI(position, duration); } } }; @Override protected void onResume() { super.onResume(); LocalBroadcastManager.getInstance(this) .registerReceiver(playerStateReceiver, new IntentFilter(ACTION_PLAYING)); LocalBroadcastManager.getInstance(this) .registerReceiver(playerStateReceiver, new IntentFilter(ACTION_PAUSED)); LocalBroadcastManager.getInstance(this) .registerReceiver(playerStateReceiver, new IntentFilter(ACTION_UPDATE_PROGRESS)); } @Override protected void onPause() { super.onPause(); LocalBroadcastManager.getInstance(this).unregisterReceiver(playerStateReceiver); }参数说明:
ACTION_PLAYING/ACTION_PAUSED:自定义广播 Action 字符串,定义在Constants.java中,避免硬编码;putExtra()传递关键数据,比LiveData的observe()更直观,调试时可直接Log.d()打印;registerReceiver()必须在onResume(),否则 Activity 不可见时收到广播会 crash(Receiver not registered)。
4. 避坑指南:那些让你调试到凌晨三点的 MediaPlayer 真实陷阱
4.1 现象:MediaPlayer报Error (-38,0),日志显示start called in state 0
原因:MediaPlayer状态为Idle(状态码 0),但代码直接调用了start()。常见于prepareAsync()的OnPreparedListener未正确注册,或reset()后忘记setDataSource()。
解决:检查initMediaPlayer()方法,确认setDataSource()在reset()后、prepareAsync()前调用;OnPreparedListener必须在prepareAsync()之前设置,否则回调永远不会触发。
4.2 现象:真机上播放正常,模拟器里无声,或报Prepare failed.: status=0x1
原因:模拟器默认不启用音频硬件加速,且部分系统镜像缺少音频编解码器(尤其 AAC、FLAC)。MediaPlayer无法解码音频流,prepareAsync()失败。
解决:优先在真机测试;若必须用模拟器,创建 AVD 时勾选 “Use Host GPU”,并在build.gradle中添加android.useAndroidX=true和android.enableJetifier=true确保兼容性;测试音频文件优先用 MP3(兼容性最好)。
4.3 现象:锁屏后播放停止,通知栏按钮点击无响应
原因:未调用startForeground(),或Notification构建时未设置setOngoing(true),导致 Service 被系统回收;另外,BroadcastReceiver未在AndroidManifest.xml中静态注册(本项目用LocalBroadcastManager,无需静态注册,但若用sendBroadcast()则必须)。
解决:确认MusicService.onStartCommand()中startForeground()调用成功;检查NotificationCompat.Builder是否调用setOngoing(true);验证AndroidManifest.xml中<service>标签是否包含android:foregroundServiceType="mediaPlayback"(Android 9.0+ 推荐)。
4.4 现象:SeekBar 拖拽后,MediaPlayer.getCurrentPosition()返回值远小于seekTo()目标值,且播放卡顿
原因:seekTo()是异步操作,getCurrentPosition()在seekTo()后立即调用,返回的是 seek 前的位置;同时,MediaPlayer在 seek 过程中会短暂缓冲,若网络音频(本项目为本地文件,此条可忽略),缓冲不足会导致卡顿。
解决:不要依赖getCurrentPosition()的即时返回值;改为监听MediaPlayer.OnSeekCompleteListener,在回调中更新 UI;本项目虽未实现该监听,但通过500ms定时轮询已规避该问题。
4.5 现象:切换歌曲时,MediaPlayer报Error (-19,0),日志显示stop called in state 0
原因:MediaPlayer状态为Idle或Initialized,但代码调用了stop()。常见于next()方法中未判断当前状态,直接mediaPlayer.stop()。
解决:在MusicService.next()中增加状态判断:
if (mediaPlayer != null && (mediaPlayer.isPlaying() || mediaPlayer.isLooping())) { mediaPlayer.stop(); } // 然后 reset() -> setDataSource() -> prepareAsync()5. 文件结构与资源加载:如何让播放器真正“认出”你的 MP3 文件
5.1 项目文件树:看清哪些文件你必须改,哪些可以不动
本项目采用标准 Android Studio 结构,但关键路径需特别注意:
app/ ├── src/main/ │ ├── java/com/example/musicplayer/ │ │ ├── MusicActivity.java // 主界面,含 UI 控制逻辑 │ │ ├── MusicService.java // 核心播放服务,MediaPlayer 托管处 │ │ ├── MusicBinder.java // Binder 实现,提供 play/pause/seekTo 接口 │ │ ├── Constants.java // 所有 ACTION、KEY、CHANNEL_ID 常量 │ │ └── MusicReceiver.java // 处理 Notification Action 的 BroadcastReceiver │ ├── res/ │ │ ├── layout/activity_main.xml // 包含 playButton、SeekBar、songTitle TextView │ │ ├── values/strings.xml // “播放”、“暂停”等文案 │ │ └── drawable/ // ic_play、ic_pause 等图标 │ └── AndroidManifest.xml // 必须声明 MusicService 和权限 └── assets/ // 【重点】你的 MP3 文件放这里! └── songs/ ├── song1.mp3 └── song2.mp3关键约束:
assets/songs/是本项目约定的音频根目录,MusicService初始化时会扫描此路径下所有.mp3文件;AndroidManifest.xml中<service>标签必须添加android:exported="false"和android:foregroundServiceType="mediaPlayback";strings.xml中app_name可改,但notification_channel_name必须保留,用于创建 Notification Channel。
5.2 加载 assets 中 MP3 的完整路径拼接逻辑
Android 的assets目录内容在 APK 中是只读的,无法直接用File访问,必须通过AssetManager获取InputStream,再转换为FileDescriptor供MediaPlayer使用。本项目封装了getAudioFilePath()方法:
// MusicService.java private String getAudioFilePath(String fileName) { try { AssetManager assetManager = getAssets(); InputStream inputStream = assetManager.open("songs/" + fileName); File file = new File(getCacheDir(), fileName); FileOutputStream outputStream = new FileOutputStream(file); byte[] buffer = new byte[1024]; int length; while ((length = inputStream.read(buffer)) > 0) { outputStream.write(buffer, 0, length); } outputStream.close(); inputStream.close(); return file.getAbsolutePath(); // 返回可被 MediaPlayer 识别的绝对路径 } catch (IOException e) { Log.e(TAG, "Failed to copy asset file", e); return null; } }参数说明:
fileName:如"song1.mp3",必须带.mp3后缀;getCacheDir():将 assets 中的 MP3 复制到应用私有缓存目录,生成一个File对象,MediaPlayer.setDataSource()可直接接受其getAbsolutePath();- 复制操作只在首次播放时执行,后续播放直接读取缓存文件,避免重复 IO。
注意:若你的音频文件在 SD 卡,路径应为
/storage/emulated/0/Music/xxx.mp3,需在AndroidManifest.xml中添加<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />,且 Android 6.0+ 需动态申请。
5.3 权限配置与 Android 11+ 分区存储适配
Android 10(API 29)起引入分区存储(Scoped Storage),Android 11(API 30)强制执行。本项目默认从assets加载,不涉及外部存储,因此无需申请READ_EXTERNAL_STORAGE。但若你修改为读取 SD 卡,必须适配:
targetSdkVersion设为30或更高时,<application>标签中添加android:requestLegacyExternalStorage="true"(临时兼容方案);- 更推荐方案:使用
MediaStoreAPI 查询音频,而非直接File遍历。本项目未实现,但预留了queryMediaStore()方法占位,你可在MusicService.scanSongs()中替换为:
// 替换 scanSongs() 中的 File 遍历逻辑 String[] projection = {MediaStore.Audio.Media._ID, MediaStore.Audio.Media.TITLE, MediaStore.Audio.Media.DATA}; Cursor cursor = getContentResolver().query(MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, projection, null, null, MediaStore.Audio.Media.DATE_ADDED + " DESC");适配要点:
MediaStore返回的是Uri,MediaPlayer.setDataSource()需用ContentResolver.openFd(uri)获取FileDescriptor;DATA列在 Android 10+ 已废弃,应改用ContentUris.withAppendedId(MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, id)构造 Uri。
6. 进阶技巧:添加音效控制与播放队列管理,让简易播放器真正可用
6.1 音量与播放速度控制:MediaPlayer 的隐藏能力
MediaPlayer原生支持音量调节和播放速率调整,但文档极少提及。本项目在MusicService中暴露两个方法:
// MusicService.java public void setVolume(float leftVolume, float rightVolume) { if (mediaPlayer != null) { mediaPlayer.setVolume(leftVolume, rightVolume); } } public void setPlaybackSpeed(float speed) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { PlaybackParams params = new PlaybackParams(); params.setSpeed(speed); mediaPlayer.setPlaybackParams(params); } }参数说明:
leftVolume/rightVolume:范围0.0f(静音)到1.0f(最大音量),可实现淡入淡出效果;speed:播放速率,1.0f为正常速度,0.5f为半速,2.0f为双倍速;仅 Android 6.0+ 支持,低版本调用会抛UnsupportedOperationException;setPlaybackParams()会重置setLooping()状态,若需循环播放,需在setPlaybackParams()后重新调用setLooping(true)。
6.2 播放队列管理:用 ArrayList 实现可增删改查的 Playlist
“简易”不等于“只能播一首”。本项目用ArrayList<String>管理播放列表,支持动态添加、删除、跳转:
// MusicService.java private List<String> playlist = new ArrayList<>(); private int currentIndex = 0; public void addToPlaylist(String fileName) { if (!playlist.contains(fileName)) { playlist.add(fileName); if (playlist.size() == 1) { play(fileName); // 首首自动播放 } } } public void removeFromPlaylist(int index) { if (index >= 0 && index < playlist.size()) { playlist.remove(index); if (index == currentIndex && !playlist.isEmpty()) { // 删除当前播放项,自动跳到下一首 currentIndex = Math.min(currentIndex, playlist.size() - 1); play(playlist.get(currentIndex)); } } } public void playNext() { if (playlist.isEmpty()) return; currentIndex = (currentIndex + 1) % playlist.size(); play(playlist.get(currentIndex)); }关键设计:
playlist存储的是文件名(如"song1.mp3"),而非绝对路径,解耦资源定位逻辑;playNext()使用模运算(currentIndex + 1) % playlist.size()实现循环播放,避免IndexOutOfBoundsException;addToPlaylist()先contains()判断,防止重复添加。
6.3 验证播放器健壮性的 5 个必测场景
写完代码不等于能用,以下是我在某高校课程设计中让 A同学 逐项验证的清单,覆盖 90% 真实用户行为:
| 场景 | 操作步骤 | 预期结果 | 验证要点 |
|---|---|---|---|
| 1. 后台存活 | 播放中按 Home 键 → 打开 Settings → 查看“正在运行的服务” | MusicService显示为“前台服务” | 确认startForeground()生效 |
| 2. 通知栏控制 | 播放中下拉状态栏 → 点击通知栏“暂停”按钮 | 播放停止,Activity 中播放按钮变为“播放”图标 | BroadcastReceiver正确接收 Action |
| 3. 锁屏续播 | 播放中按电源键锁屏 → 等待 30 秒 → 解锁 | 音频持续播放,通知栏控制按钮仍可点击 | Service未被系统杀死 |
| 4. Seek 精度 | 拖拽 SeekBar 至 75% 位置 → 松手 → 立即调用getCurrentPosition() | 返回值 ≈ 总时长 × 0.75,误差 < 500ms | seekTo()异步完成,UI 同步无抖动 |
| 5. 多文件切换 | 播放中调用next()→ 连续调用 5 次 | 每首歌都能正常加载、准备、播放,无IllegalStateException | MediaPlayer.reset()/setDataSource()/prepareAsync()流程闭环 |
从那以后我每次交付播放器模块,都强制走一遍这 5 个场景——哪怕只是改了一行seekBar.setMax()。因为MediaPlayer的坑不在代码多难写,而在它不报错却默默失效。希望帮到你。
本文还有配套的精品资源,点击获取