资讯动态

Android音乐播放器大作业:MediaPlayer状态机、前台Service与音频焦点详解

发布时间:2026/9/17 10:20:31 来源:尧图企业网站定制
简介这是一份基于Android Studio开发的安卓音乐播放器App完整项目来自高分期末大作业评审分为99分。项目面向计算机专业正在完成课程设计或期末大作业的学生也适合Android入门者进行项目实战。资源包内共375个文件包含116个Java源码文件、74个XML布局与配置文件、161张PNG图片资源、6张JPG图片以及Gradle构建配置和APK安装包压缩后整体约4.13MB目录结构清晰便于按模块学习与二次开发。资源提供可直接运行的app-release.apkAndroid Studio导入工程后即可查看和调试源码Java文件负责播放器核心逻辑与页面交互XML文件定义界面布局与资源PNG/JPG用作界面切图和图标素材可帮助读者完整掌握音乐播放器从界面到功能的实现思路。目前已有273人在线学习适合需要高质量项目参考、快速完成期末大作业或提升Android开发实战能力的读者。1. Android Studio 音乐播放器大作业为什么最普通也最容易拉开分差同一个班里音乐播放器这个题目做的人最多分数也最两极分化能点开放歌的拿及格能把后台播放、音频焦点、异常恢复讲清楚的拿优秀。差距不在功能多少而在你能不能把每个技术点背后的机制说透。这个题覆盖面正好对应安卓课的核心考点Activity、Service、媒体库查询、权限申请、RecyclerView、Handler 进度刷新一门课的重点技能全在里面。它又是一道开放题往深了能做倍速、播放队列、通知栏联动往浅了至少能交一个完整可跑的 app。下面按「能直接交作业」的粒度写主语言选 Java 8Kotlin 读者把匿名内部类换成 lambda 即可。每一章先讲选型理由再给可复现代码和参数说明最终你会得到一个答辩能讲清楚、功能没有硬伤的音乐播放器 app。2. 音乐播放器内核选型MediaPlayer 与 ExoPlayer期末大作业首选哪个2.1 不是最新就是最好ExoPlayer 的优势正好是它的答辩劣势Google 现在的官方推荐是 Media3ExoPlayer它在 Android Studio 里只需要一行依赖就能引入支持 DASH、HLS 自适应流、DRM 这些商业级能力。真做流媒体项目比如在线电台、短视频配乐我一般直接上 ExoPlayer。但期末大作业放的是本地 mp3用 ExoPlayer 属于给自己挖坑解码、渲染、缓冲全部封装在内部答辩时老师问一句「从点按到出声中间经历了哪几步」你很难从源码层面接住话。MediaPlayer 是系统自带的高层封装底层走的是 Stagefright/OMX 解码。它暴露了一个完整的状态机这个状态机本身就是最好的答辩素材。你要做的不是「选出最强播放器」而是「选一个你能讲透的播放器」。2.2 MediaPlayer 状态机答辩高频考点的 8 个状态MediaPlayer 的用法和它的状态机强绑定。绝大多数运行时崩溃比如prepareAsync called in state 8、start called in state 0都是在错误的状态调用了错误的方法。期末答辩时老师大概率会拿这个状态机提问能准确说出「Prepared 之前不能 start」和「release 之后不能再用」基本就过关了。状态进入方式典型错误Idlenew MediaPlayer()在 Idle 直接start()抛 IllegalStateExceptionInitializedsetDataSource()成功未设置数据源直接 preparePreparing调用prepareAsync()过渡状态很快跳到 PreparedPrepared准备完成可播放未 Prepared 就start()Started调用start()重复调用会闪断Paused调用pause()Paused 时getCurrentPosition()仍有效PlaybackCompleted播放到结尾未设置循环时播放结束Error / End出错或release()之后任何操作都会抛异常关键点是prepareAsync()是异步的成功后会回调setOnPreparedListener。同步的prepare()虽然代码短但在大文件上会阻塞主线程导致 ANR本地小 mp3 不明显放在答辩演示时的低端模拟器上就可能卡顿。2.3 最小可运行的播放代码三种写法背后的差异先看一个标准的异步准备写法MediaPlayer player new MediaPlayer(); player.setAudioAttributes( new AudioAttributes.Builder() .setContentType(AudioAttributes.CONTENT_TYPE_MUSIC) .setUsage(AudioAttributes.USAGE_MEDIA) .build()); player.setDataSource(/sdcard/Music/test.mp3); player.setOnPreparedListener(mp - mp.start()); player.prepareAsync();这段代码里setAudioAttributes是 API 21 之后的推荐写法老的setAudioStreamType(AudioManager.STREAM_MUSIC)已废弃。USAGE_MEDIA告诉系统这是媒体声音按媒体音量调整而不是铃声音量。prepareAsync()让 MediaPlayer 从 Initialized 进入 Preparing准备好后回到主线程回调onPrepared此时才能安全调用start()。如果只是演示还有更短的版本MediaPlayer tmp MediaPlayer.create(context, uri); tmp.start();create()内部等价于new setDataSource prepare()同步执行。优势是代码少、不易用错劣势是只能等它全部跑完文件稍微大一点就会有可见卡顿且无法监听准备失败。实际交作业我一般把两种都写上然后在答辩时解释「为什么线上放歌不用create()」——一句「同步 prepare 阻塞主线程」就能看出你踩过这个坑。setDataSource还有三个重载路径字符串、Uri、FileDescriptor。播放 assets 目录里的音频时用 fd 形式直接传路径会找不到文件。3. 让音乐播放器在后台继续响前台 Service、通知栏与音频焦点3.1 为什么 Activity 里放了歌退后台可能被系统杀掉很多初学的作业把 MediaPlayer 直接放在 MainActivity 里退到桌面音乐还在响是因为 Activity 还没被回收锁屏或者切了几个应用之后音乐停了那是系统把进程杀了。从 Android 8 开始后台应用受限越来越严格Activity 不可见之后进程随时可能被 LMK 结束MediaPlayer 也跟着一起没了。正确做法是把播放器放进 Service用startForeground()发一条常驻通知。通知栏是「用户知道这个应用在后台干活」的证据系统因此不会轻易杀它。这里不需要做任何保活、自启动之类的偏门操作遵守官方前台服务规范就是大作业里最稳的方案。3.2 前台服务最小骨架与 Android 14 的类型声明先声明前台服务类型。Android 14API 34要求前台服务必须在 Manifest 里声明类型否则运行时报MissingForegroundServiceTypeExceptionuses-permission android:nameandroid.permission.FOREGROUND_SERVICE / uses-permission android:nameandroid.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK / service android:name.MusicService android:exportedfalse android:foregroundServiceTypemediaPlayback /FOREGROUND_SERVICE_MEDIA_PLAYBACK是 API 34 新增的权限不放这张卡Android 14 真机上会直接崩。Manifest 里的android:exportedfalse表示这个 Service 只允许本应用启动是安全要求的默认项。Service 本体按最简方式写只处理播放、暂停、停止三个事件public class MusicService extends Service { private static final int ACTION_PLAY 1; private static final int ACTION_PAUSE 2; private MediaPlayer player; Override public int onStartCommand(Intent intent, int flags, int startId) { if (intent null) return START_NOT_STICKY; String uri intent.getStringExtra(uri); int action intent.getIntExtra(action, -1); if (action ACTION_PLAY) { play(uri); } else if (action ACTION_PAUSE) { pause(); } else { stopSelf(); } return START_NOT_STICKY; } private void play(String uri) { if (player null) { player MediaPlayer.create(this, Uri.parse(uri)); player.setLooping(false); } player.start(); startForeground(1, buildNotification(正在播放)); } private void pause() { if (player ! null player.isPlaying()) { player.pause(); } stopForeground(false); } }onStartCommand返回START_NOT_STICKY的含义是进程被系统杀掉后不重建服务。音乐播放器场景下这个返回值是合理的——被杀了说明系统内存紧张强行重建反而会引发连环启动。MediaPlayer.create在play()里同步执行这里是简化写法真实项目要挪到子线程避免解析大文件时 ANR答辩时主动提这一点是加分项。提示Android 12 之后不要在 Activity 的onStop或后台回调里调用startForegroundService()系统会抛ForegroundServiceStartNotAllowedException。从按钮点击、列表点击等前台事件里启动就没问题。3.3 通知栏三个按钮用 PendingIntent 回调 onStartCommand通知栏需要一条可见通知并且提供暂停、下一首等操作按钮。最直接的做法是按钮的 PendingIntent 指向 Service 本身携带不同的 action 参数让onStartCommand继续分发private Notification buildNotification(String text) { Intent pauseIntent new Intent(this, MusicService.class) .putExtra(action, ACTION_PAUSE); PendingIntent pausePi PendingIntent.getService( this, 0, pauseIntent, PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT); NotificationChannel channel new NotificationChannel( playback, 播放控制, NotificationManager.IMPORTANCE_LOW); NotificationManager nm getSystemService(NotificationManager.class); nm.createNotificationChannel(channel); return new NotificationCompat.Builder(this, playback) .setSmallIcon(R.drawable.ic_music_note) .setContentTitle(音乐播放器) .setContentText(text) .addAction(R.drawable.ic_pause, 暂停/播放, pausePi) .setOngoing(true) .build(); }参数说明PendingIntent.FLAG_IMMUTABLE是 Android 12 起的强制要求不加会崩FLAG_UPDATE_CURRENT保证多次点击时 Intent 里的 action 参数能被更新。IMPORTANCE_LOW让通知不弹横幅、不响铃只安静地待在状态栏这是音乐通知的标准配置。setOngoing(true)表示这是常驻通知用户不能手动滑掉配合stopForeground(false)才能移除。PendingIntent.getService的requestCode填 0因为多个按钮靠 action 区分不靠 requestCode 区分。addAction一次加一个按钮三个分别是暂停、上一首、下一首参数照抄即可。3.4 音频焦点电话进来时音乐应该做什么不申请音频焦点电话打进来时 MediaPlayer 还在放用户会同时听到铃声和音乐。这是测试作业时最容易露馅的场景也是老师最喜欢追问的场景之一。Android 8 之后推荐用AudioFocusRequest申请AudioManager am (AudioManager) getSystemService(Context.AUDIO_SERVICE); if (Build.VERSION.SDK_INT Build.VERSION_CODES.O) { AudioAttributes attrs new AudioAttributes.Builder() .setUsage(AudioAttributes.USAGE_MEDIA) .setContentType(AudioAttributes.CONTENT_TYPE_MUSIC) .build(); AudioFocusRequest request new AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN) .setAudioAttributes(attrs) .setOnAudioFocusChangeListener(this::onFocusChange) .build(); am.requestAudioFocus(request); } else { am.requestAudioFocus(this::onFocusChange, AudioManager.STREAM_MUSIC, AudioManager.AUDIOFOCUS_GAIN); }焦点回调的处理粒度决定体验回调值典型场景正确处理AUDIOFOCUS_LOSS其他音乐 App 接管暂停并释放焦点AUDIOFOCUS_LOSS_TRANSIENT来电、闹钟暂停结束后恢复AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK导航播报音量降到 30%播完恢复private void onFocusChange(int focusChange) { if (focusChange AudioManager.AUDIOFOCUS_LOSS) { pause(); // 永久失去不能自动恢复 } else if (focusChange AudioManager.AUDIOFOCUS_LOSS_TRANSIENT) { pause(); // 临时失去记录 isResume 状态 } else if (focusChange AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK) { player.setVolume(0.3f, 0.3f); } else if (focusChange AudioManager.AUDIOFOCUS_GAIN) { player.setVolume(1f, 1f); } }AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK是最多人忽略的一个分支导航播报时只需要压低音乐音量不应该暂停播放。而AUDIOFOCUS_LOSS需要真正调用abandonAudioFocusRequest否则下次申请焦点会拿到先前的残留。4. 音乐列表是怎么进来的MediaStore、权限适配和 RecyclerView 联动4.1 Android 13 的音频权限两个分支加一个 maxSdkVersion扫描本地音乐的核心是MediaStore.Audio.Media这张系统媒体表。访问它需要存储权限但 Android 13API 33把原来的存储权限拆了音频读写单独用READ_MEDIA_AUDIO旧的READ_EXTERNAL_STORAGE在 API 33 及以上完全失效。Manifest 里要同时兼容两代系统uses-permission android:nameandroid.permission.READ_EXTERNAL_STORAGE android:maxSdkVersion32 / uses-permission android:nameandroid.permission.READ_MEDIA_AUDIO /maxSdkVersion32是这套适配里最容易漏的一行。它的意思是旧权限只对 Android 12 及以下生效33 及以上系统会忽略旧权限声明转而检查READ_MEDIA_AUDIO。运行时申请同样按版本分支if (Build.VERSION.SDK_INT 33) { requestPermissions(new String[]{Manifest.permission.READ_MEDIA_AUDIO}, 1); } else { requestPermissions(new String[]{Manifest.permission.READ_EXTERNAL_STORAGE}, 1); }不要两支都申请33 及以上申请旧权限会被系统直接拒绝弹窗都不出现。真机测试优先用 Android 13/14 设备因为模拟器通常没有音乐文件且权限行为差异是高频踩坑点。4.2 用 ContentResolver 扫描本地音乐为什么不用遍历目录拿到权限后用getContentResolver().query查询媒体库。不要自己去递归遍历/sdcard目录——MediaStore 背后自带 ID3 标签解析、缓存和增量更新机制效率高得多查询代码也更短public ListMusicItem loadMusic() { ListMusicItem list new ArrayList(); Uri collection MediaStore.Audio.Media.EXTERNAL_CONTENT_URI; String[] projection { MediaStore.Audio.Media._ID, MediaStore.Audio.Media.TITLE, MediaStore.Audio.Media.ARTIST, MediaStore.Audio.Media.DURATION }; String selection MediaStore.Audio.Media.IS_MUSIC ! 0; try (Cursor cursor getContentResolver().query( collection, projection, selection, null, MediaStore.Audio.Media.TITLE ASC)) { if (cursor null) return list; while (cursor.moveToNext()) { MusicItem item new MusicItem(); long id cursor.getLong(cursor.getColumnIndexOrThrow(MediaStore.Audio.Media._ID)); item.uri ContentUris.withAppendedId(collection, id).toString(); item.title cursor.getString(cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.TITLE)); item.artist cursor.getString(cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.ARTIST)); item.duration cursor.getInt(cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.DURATION)); list.add(item); } } return list; }这段代码有意识地规避了一个旧写法问题不取DATA列拼文件路径而是用_ID生成content://形式的 Uri。Android 10 分区存储之后直接拿文件路径去读其他目录受限而content://交给MediaPlayer.create(context, uri)是官方推荐姿势也省了文件权限判断。try-with-resources包住 Cursor离开作用域自动关闭IS_MUSIC ! 0过滤掉录音、铃声片段等非音乐条目排序用TITLE ASC中文歌名会按 Unicode 排序答辩时老师问到「怎么排序的」直接答这一句。MusicItem是一个只有字段和构造器的普通 JavaBean字段就是上面 projection 里的四个。4.3 RecyclerView 单击播放与进度条刷新Handler 的 removeCallbacks列表页用 RecyclerViewAdapter 只负责把MusicItem绑到两个 TextView 上点击事件传出去public class MusicAdapter extends RecyclerView.AdapterMusicAdapter.VH { private final ListMusicItem data; private final OnItemClick listener; public MusicAdapter(ListMusicItem data, OnItemClick listener) { this.data data; this.listener listener; } Override public void onBindViewHolder(VH holder, int position) { MusicItem item data.get(position); holder.title.setText(item.title); holder.artist.setText(item.artist); holder.itemView.setOnClickListener(v - listener.onClick(item)); } }关注点不在 Adapter 本身而在单击后的两个动作启动 Service 播放以及同步开始刷新进度条。播放交给 ServiceUI 只负责展示状态这是「高分」和「能跑」之间的架构分水岭。进度条刷新用 Handler 每 500ms 拉一次private final Runnable progressRunnable new Runnable() { Override public void run() { MediaPlayer mp MusicService.getPlayer(); if (mp ! null) { seekBar.setMax(mp.getDuration()); seekBar.setProgress(mp.getCurrentPosition()); } handler.postDelayed(this, 500); } };这段代码里最容易翻车的是postDelayed叠加。Activity 每次onResume都postDelayed一次退出再进来多个 Runnable 同时在跑进度条会「跳着走」。正确姿势是在onStop里handler.removeCallbacks(progressRunnable);Activity 销毁时还要把 setOnPreparedListener 里的回调清掉否则播放完成的回调会打到一个已经销毁的界面轻则空指针重则崩溃。这些细节老师不一定会看到但代码 review 时一眼能看出你理解生命周期。5. 音乐播放器提分细节异常恢复、倍速播放和答辩前的自测命令5.1 三个容易被追问的边界分支第一个分支是播放出错。OnErrorListener里默认返回 false 会抛异常正确的做法是返回 true 表示已处理然后reset()加release()把播放器对象置空。这句话说出来答辩就多了一个技术点setOnErrorListener的返回值代表「你是否吞掉了这个错误」。第二个分支是播放完成自动切歌。在 Service 的 MediaPlayer 回调里发一条广播Activity 收到后取列表里的下一首再交给 ServiceregisterReceiver(completeReceiver, new IntentFilter(MusicService.ACTION_PLAY_COMPLETED), Context.RECEIVER_NOT_EXPORTED);RECEIVER_NOT_EXPORTED是 Android 13 动态注册的要求不加这个 flagtargetSdk 34 的设备会直接抛 SecurityException。第三个分支是倍速播放。setPlaybackParams必须在 Prepared 之后调用很多人在onCreate里提前设置结果就是IllegalStateException。它还可以配合进度条做 1.5 倍速切换演示效果非常显眼。5.2 倍速与均衡器30 行内的加分功能player.setPlaybackParams(new PlaybackParams().setSpeed(1.5f)); // 均衡器挂在当前播放会话上 Equalizer equalizer new Equalizer(0, player.getAudioSessionId()); equalizer.setEnabled(true); short band (short) (equalizer.getNumberOfBands() / 2); equalizer.setBandLevel(band, (short) 1000);PlaybackParams的setSpeed传入 1.5 表示 1.5 倍速需要在 Prepared 状态调用。Equalizer的第二个参数是audioSessionIdgetAudioSessionId()获取当前 MediaPlayer 的会话 IDsetBandLevel的单位是毫分贝1000 大约是 1dB 增益。这两个功能加起来不到 30 行视觉冲击力强是期末演示里性价比极高的加分项。5.3 答辩前用两条 adb 命令自测演示前先在命令行确认播放状态adb shell dumpsys media_session adb shell dumpsys audio | grep -i focus第一条命令能看到当前系统的媒体会话确认通知栏的播放控制和前台服务是否注册成功第二条命令能确认音频焦点是否被正确申请和释放。手动执行「播放 → 来电模拟 → 挂断恢复」的完整流程记录每步焦点变化答辩时这套自测结论就是最好的「我验证过」的证据。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价