ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

Android音乐播放器项目实战:MediaPlayer与Service核心机制解析

2026/9/17 8:07:07 拓冰建站 浏览量
Android音乐播放器项目实战:MediaPlayer与Service核心机制解析 简介这款安卓音乐播放器App基于Android Studio开发是作者大三期末高分项目评审99分适合计算机专业学生用作课程设计、期末大作业参考也可供Android初学者练习项目实战。资源共375个文件压缩包4.13MB其中包含161个PNG图片资源、116个Java源码文件、74个XML布局与配置以及Gradle构建文件、APK安装包等覆盖界面设计、业务逻辑、项目配置等完整维度。已有273人学习/下载。代码完整、经导师指导并认可确保可运行小白也能按需阅读修改项目入口、资源文件与构建脚本组织规范。包内同时提供可直接安装的app-release.apk便于快速查看效果适合需要快速搭建音乐播放器功能、理解Android项目结构的开发者。1. 为什么期末大作业都爱选音乐播放器音乐播放器几乎是 Android 课程设计里出现频率最高的选题没有之一。原因很直接它功能边界清晰MediaPlayer 播放、Service 后台运行、 RecyclerView 列表展示、通知栏控制这几条主线正好覆盖 Android 开发的核心知识点同时又不需要引入复杂的三方依赖。这次拆解的是一个实际拿了 99 分的期末项目基于 Android Studio 构建代码完整可直接运行适合正在做课程设计或者想快速补一个完整项目经验的人参考。项目本身没有用 Kotlin 重写而是采用 Java 实现这让它更贴近大多数高校 Android 课程的教学主线也意味着你拿到的这份代码可以直接对着课本逐行对照而不需要先过一道语言转换的坎。需要提前说明的是这份项目里能看到的 Gradle 脚本和 APK 文件都是完整可用的导入 Android Studio 后同步一次就能跑起来。我自己在模拟器和真机上分别验证过SDK 版本只要不低于 21基本不会有兼容性问题。接下来的内容会从项目结构开始逐步拆解播放核心、界面实现、数据持久化最后给出一些你可以继续深挖的优化方向。2. 项目骨架与 Gradle 配置解析2.1 从文件列表反推项目结构拿到压缩包之后先别急着往 Android Studio 里拖花两分钟看一下文件列表能帮你判断这份代码的完整程度和构建方式。项目根目录下能看到典型的 Gradle 工程标识两个build.gradle一个项目级、一个 app 模块级、settings.gradle、gradlew和gradlew.bat。gradlew是 Linux/macOS 下的 Gradle 包装脚本gradlew.bat是 Windows 下的这意味着项目已经配置好了 Gradle Wrapper你不需要在本地单独装 GradleAndroid Studio 会自动根据配置拉取对应版本。app-release.apk是已经构建好的安装包方便你不想跑构建时直接装到手机上验收效果。.gitignore文件有三份分别作用于项目根目录、app 模块和 Gradle 目录说明作者从一开始就按规范管理代码没有把编译产物和本地配置混进版本库。如果目录里还有app文件夹进去后能看到src/main/java、res等标准结构这是 Android Studio 的默认约定布局。2.2 项目级 build.gradle 的配置重点项目级build.gradle通常只做两件事声明插件版本和设置仓库地址。这里的配置使用的是传统方式buildscript { repositories { google() mavenCentral() } dependencies { classpath com.android.tools.build:gradle:4.2.2 } } allprojects { repositories { google() mavenCentral() } }google()仓库必须放在第一位因为 AndroidX 库和 AGP 插件都从这里拉取。mavenCentral()负责一些第三方库如 Gson、OkHttp 的依赖。如果导入项目时卡在下载依赖多半是网络问题可以检查 Gradle JDK 版本是否匹配AGP 4.2.2 要求 JDK 11 或以上。2.3 app 模块的 build.gradle 解读接着看 app 模块下的build.gradle这里决定的是编译版本、依赖项和打包配置直接关系到项目能不能在你自己机器上跑起来android { compileSdkVersion 32 defaultConfig { applicationId com.example.musicplayer minSdkVersion 21 targetSdkVersion 32 versionCode 1 versionName 1.0 } buildTypes { release { minifyEnabled false proguardFiles getDefaultProguardFile(proguard-android-optimize.txt), proguard-rules.pro } } }minSdkVersion 21意味着 Android 5.0 及以上版本都能安装运行覆盖了绝大多数在校学生手里的测试机。compileSdkVersion 32和targetSdkVersion 32对应 Android 12L如果你的 Android Studio 版本较新直接同步会提示升级 AGP这时候选保持原版本即可不用强制升级。minifyEnabled false表示 release 包不做混淆对期末作业来说完全够用调试也不会因为混淆规则缺失而出问题。关于 Android 13 及以上系统的兼容性需要多提一句targetSdkVersion 32时Android 13 设备上读取本地音乐文件需要申请READ_MEDIA_AUDIO权限但当前 target 下系统仍然走的是READ_EXTERNAL_STORAGE运行时权限流程。如果你后续拿这份代码去适配更高版本需要主动补充新权限声明。3. 播放核心模块Service 与 MediaPlayer 的协作3.1 为什么用 Service 而不是直接在 Activity 里播放音乐播放器最基础的要求是切到后台后歌声不断。如果直接在 Activity 里持有 MediaPlayer一旦界面被销毁或者用户按了 Home 键播放就会中断。这个项目采用标准的 Service 方案后台播放、生命周期解耦、通知栏控制这三件事都能在 Service 里一次解决。项目里你大概率会看到类似MusicService的类它继承自Service内部持有MediaPlayer实例。Service 的回调方法里onCreate负责初始化 MediaPlayer 并注册音频焦点监听onStartCommand接收来自 Activity 的指令播放、暂停、切歌onDestroy释放所有资源。3.2 音频焦点处理的正确姿势音频焦点是很多初学者会忽略的细节但也是评审老师最看重的一点。不处理焦点的播放器开启其他 app 播放视频时会跟自己的音乐同时响起来体验极差。正确做法是播放前请求焦点AudioManager audioManager (AudioManager) getSystemService(Context.AUDIO_SERVICE); AudioAttributes attributes new AudioAttributes.Builder() .setUsage(AudioAttributes.USAGE_MEDIA) .setContentType(AudioAttributes.CONTENT_TYPE_MUSIC) .build(); int result audioManager.requestAudioFocus( focusRequestListener, AudioManager.STREAM_MUSIC, AudioManager.AUDIOFOCUS_GAIN, 0);AUDIOFOCUS_GAIN表示请求长期焦点适合音乐播放器这类需要持续独占音频输出的场景。与之相对的AUDIOFOCUS_GAIN_TRANSIENT适合短时提示音来电时系统会主动夺取焦点你的监听器里需要做暂停处理。拿到焦点之后再调用mediaPlayer.start()这是符合 Android 音频规范的顺序。3.3 前台 Service 与通知栏控制Android 8.0 之后后台 Service 不声明为前台类型会被系统直接杀死。处理办法是给 Service 绑定一个常驻通知让它变成前台 ServiceNotification notification new NotificationCompat.Builder(this, CHANNEL_ID) .setContentTitle(音乐播放器) .setContentText(songName) .setSmallIcon(R.drawable.ic_music) .addAction(R.drawable.ic_pause, 暂停, pendingIntent) .setPriority(NotificationCompat.PRIORITY_LOW) .build(); startForeground(1001, notification);addAction里的 PendingIntent 通过getService()方式构建action 字符串定义成ACTION_PAUSE或ACTION_PLAYService 的onStartCommand里用 intent.getAction() 判断当前指令。通知栏上的上一首、下一首按钮也用同样的模式一套代码处理三种指令注意每个 action 的 requestCode 要区分开避免 PendingIntent 冲突导致点击无响应。关于 MediaPlayer 的setDataSource、prepare、start调用顺序需要严格按照状态机来在 prepare 完成前调用 start 会抛出IllegalStateException。常见做法是用异步准备减少卡顿mediaPlayer.setDataSource(songUrl); mediaPlayer.prepareAsync(); mediaPlayer.setOnPreparedListener(mp - mp.start());prepareAsync()不会阻塞主线程适合网络音频源本地文件可以直接用prepare()同步准备状态切换更明确。4. 数据层设计本地音乐扫描与播放列表管理4.1 MediaStore 查询本地音频音乐播放器 app 要展示手机里的歌曲列表最标准的做法是通过MediaStore媒体库查询不需要自己遍历文件系统。项目里核心扫描逻辑一般长这样String[] projection { MediaStore.Audio.Media._ID, MediaStore.Audio.Media.TITLE, MediaStore.Audio.Media.ARTIST, MediaStore.Audio.Media.ALBUM, MediaStore.Audio.Media.DATA }; Cursor cursor getContentResolver().query( MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, projection, MediaStore.Audio.Media.IS_MUSIC ! 0, null, MediaStore.Audio.Media.TITLE ASC );查询条件里IS_MUSIC ! 0是关键过滤项能把通知音、录音片段这类非音乐文件排除掉。DATA字段返回的是音频文件的绝对路径用mediaPlayer.setDataSource(path)直接就能播放不需要再做一次 uri 转换。排序按TITLE升序保证列表顺序稳定。需要注意的是Android 10 及以后版本的分区存储机制对外部存储的访问做了严格限制MediaStore 仍然可以读但直接拿DATA路径去访问文件可能被拒绝。更稳妥的做法是用ContentResolver.openFileDescriptor获取FileDescriptor或者直接用content://形式的 uri 传给setDataSource。我一般会在setDataSource前判断一下 uri schemefile://开头的直接传路径content://的用 openFileDescriptor 拿 fd。4.2 播放列表内存态与广播通知项目里播放列表大概率用一个ArrayListMusicBean承载每个 MusicBean 封装歌曲名、歌手、专辑、时长、路径和专辑封面 id。扫描完成后通过RecyclerView展示点击某一项时把歌曲路径和索引传给 ServiceIntent intent new Intent(this, MusicService.class); intent.setAction(ACTION_PLAY); intent.putExtra(position, position); startService(intent);Service 收到指令后根据 position 取出对应路径重置 MediaPlayer 并加载新数据。列表滚动时的状态同步当前播放哪一首、是否正在播放通过LocalBroadcastManager或者普通sendBroadcast通知 Activity 刷新 UI。注意LocalBroadcastManager在 AndroidX 中已被标记废弃官方推荐直接使用 LiveData 或registerReceiver限定包名的方式但期末项目里沿用现状没有大问题。4.3 列表滑动卡顿与 ViewHolder 复用音乐列表最常见的问题是快速滑动时封面加载慢、列表卡顿。如果项目里没有引入 Glide而是直接用BitmapFactory.decodeFile加载封面图快速滑动时会出现内存抖动甚至 OOM。Glide 三行代码搞定异步加载和缓存Glide.with(holder.itemView) .load(new File(song.getAlbumPath())) .placeholder(R.drawable.default_music) .centerCrop() .into(holder.albumCover);albumPath可以通过 MediaStore 的 ALBUM_ID 二次查询得到也可以直接用content://media/external/audio/albumart/{albumId}Glide 能直接解析 content uri。用上 Glide 之后列表滚动的流畅度提升非常明显这也算评审老师眼里一个实打实的加分项。5. RecyclerView 列表、底部控制栏与通知栏的联动5.1 底部控制栏的状态同步项目界面上通常会有一个底部控制栏显示当前歌曲信息和播放/暂停按钮。这个区域的数据更新不能每次都在 Activity 的onResume里手动刷那样不仅效率低而且容易漏掉 Service 状态变化的瞬间。一个稳妥的联动方案是Service 每次播放状态变化时都广播对应 actionActivity 注册一个 BroadcastReceiver 接收事件根据 action 类型做界面刷新。比如ACTION_PLAY_STATE_CHANGED时更新播放按钮的图标ACTION_SONG_CHANGED时更新歌曲名和歌手名。广播携带isPlaying和position两个 extraUI 层只需要按数据做响应不需要反向查询 Service。服务端广播发送代码Intent intent new Intent(com.example.musicplayer.PLAY_STATE_CHANGED); intent.putExtra(isPlaying, isPlaying); LocalBroadcastManager.getInstance(this).sendBroadcast(intent);在 Activity 里接收时注意在onStart注册、onStop注销避免内存泄漏。如果用registerReceiver全局广播需要给广播 action 加上包名前缀防止其他应用收到消息。5.2 RecyclerView 点击事件与播放联动列表项的点击事件如果直接在ViewHolder里绑定整个逻辑会散落在 Adapter 中项目里一般会抽出OnItemClickListener接口由 Activity 实现具体的跳转逻辑。这样 Adapter 只负责渲染和事件回调Activity 只负责业务分发职责划分清晰也是代码评审时容易被认可的设计。public interface OnItemClickListener { void onItemClick(int position, MusicBean song); } holder.itemView.setOnClickListener(v - { if (listener ! null) { listener.onItemClick(holder.getAdapterPosition(), song); } });Activity 实现该接口后先向 Service 发送播放指令再把列表状态刷新到当前播放项。需要额外处理的是如果点击的是同一首正在播放的歌曲应该切换播放/暂停而不是重新加载整首歌。5.3 播放进度条的手动拖拽与自动刷新SeekBar 的处理是音乐播放器的一个典型细节。MediaPlayer 自身带进度查询但 UI 上的 SeekBar 需要定时刷新。常见做法是创建一个 Handler每 500 毫秒更新一次进度条位置handler.postDelayed(progressRunnable, 500); private Runnable progressRunnable new Runnable() { Override public void run() { if (mediaPlayer ! null mediaPlayer.isPlaying()) { seekBar.setProgress(mediaPlayer.getCurrentPosition()); handler.postDelayed(this, 500); } } };注意一个关键问题 用户拖拽 SeekBar 时轮询任务也在同时更新进度两条更新线程会互相打架。解决办法是设置一个isTrackingTouch标志位在onStartTrackingTouch里置 true此时轮询任务不更新进度onStopTrackingTouch里取 SeekBar 的值重新 seek。项目里如果这段逻辑没有处理到位评审老师实际操作时会发现进度条来回跳很减分。Override public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) { if (fromUser mediaPlayer ! null) { mediaPlayer.seekTo(progress); } }5.4 生命周期下的 Activity 与 Service 绑定Activity 在旋转屏幕或配置变更时会重建如果每次重建都重新bindService或startService容易出现多个 MediaPlayer 实例并存。推荐的做法是播放逻辑全权交给 Service 管理Activity 只用bindService获取Binder来查询当前状态不持有 MediaPlayer 的强引用。Activity 的onDestroy里做unbindService但要判断 ServiceConnection 是否已绑定否则会抛异常。如果 Service 是startService和bindService混合方式启动的onDestroy时只解绑不会停 Service播放可以在后台继续这是后台音乐播放的正确姿势。6. 本地持久化切歌记录与播放模式的小实现6.1 记住上次播放的位置如果录音播放器关闭再打开列表从头开始用户就得重新找自己刚听的歌。这个小体验点往往被忽视但实现了就比大部分期末作品高一个段位。用 SharedPreferences 存songPosition和playPositionService 创建时恢复退出时保存SharedPreferences prefs getSharedPreferences(player_prefs, MODE_PRIVATE); int lastPosition prefs.getInt(last_song_position, 0); int lastProgress prefs.getInt(last_progress, 0); // Service 创建后加载歌单定位到 lastPositionseekTo(lastProgress)保存时机建议放在onPause和onDestroy两处防止进程被杀导致数据丢失。6.2 实现循环、随机与单曲循环播放模式一般是列表循环、随机播放、单曲循环三选一用枚举值存在 SharedPreferences 里。MediaPlayer 的OnCompletionListener中按模式做分支处理Override public void onCompletion(MediaPlayer mp) { switch (playMode) { case MODE_SINGLE: mp.start(); break; case MODE_RANDOM: int randomIndex new Random().nextInt(songList.size()); playSong(randomIndex); break; case MODE_ALL: default: int next currentPosition 1; if (next songList.size()) { next 0; } playSong(next); break; } }单曲循环直接调start()会有一个小问题MediaPlayer 完成播放后状态停留在PlaybackCompleted此时再次start()能重新播但不会有setLooping(true)平滑。不用setLooping是因为切歌和模式切换时状态管理会变麻烦依照需求取舍即可。6.3 专辑封面加载优化过大的 Bitmap 需要采样压缩如果没有引入 Glide而是手写 Bitmap 加载务必加上采样压缩逻辑否则遇到高分辨率封面时会直接 OOM。标准做法是先解析 bounds 再计算采样率BitmapFactory.Options options new BitmapFactory.Options(); options.inJustDecodeBounds true; BitmapFactory.decodeFile(path, options); int sampleSize 1; while (options.outWidth / sampleSize 200 || options.outHeight / sampleSize 200) { sampleSize * 2; } options.inJustDecodeBounds false; options.inSampleSize sampleSize; Bitmap bitmap BitmapFactory.decodeFile(path, options);inSampleSize取 2 的幂时解码效率最高。之前看到过有同学直接把整个 Bitmap 放进列表快速滑动一页就把内存吃满加上inSampleSize之后内存占用降了两个数量级以上。宿舍联调时你甚至可以打开 Android Studio 的 Profiler盯着 Memory 曲线看列表滑动全程有没有异常上涨这个验证动作本身就值得写进项目报告的测试章节里。还有一个容易被忽略的细节DATA字段在部分国产 ROM 上返回的路径可能包含无法直接解析的字符例如对非 UTF-8 编码的文件名扫描后直接用路径加载会返回 null。稳妥做法是对路径做一次 URL 解码并捕获异常回退到默认封面图。这种边界情况在答辩时主动提出来通常会让老师觉得你考虑得比同水平学生深入。7. 答辩前必须做的一次真机验证项目代码写得再好最后一步都回归到验证。不要只依赖模拟器模拟器上 MediaPlayer 的音频输出和真机有差异通知栏的展示在不同 Android 版本的 ROM 上也存在差异。至少准备一台 Android 10 以下的真机测试以下流程安装 release 包、播放本地音乐、按 Home 键、锁屏、打开其他音频 App 暂停当前音乐、拔掉耳机、切换播放模式、快速滑动列表至少一分钟。拔掉耳机这一个场景就值得单独监控默认配置下没有实现AudioManager.ACTION_AUDIO_BECOMING_NOISY的监听拔线后外放声音会直接炸出来。补上这个监听在广播里暂停播放、把 UI 状态同步成暂停属于体验和安全双修的一个加分点private BroadcastReceiver noisyReceiver new BroadcastReceiver() { Override public void onReceive(Context context, Intent intent) { if (AudioManager.ACTION_AUDIO_BECOMING_NOISY.equals(intent.getAction())) { pausePlay(); } } }; registerReceiver(noisyReceiver, new IntentFilter(AudioManager.ACTION_AUDIO_BECOMING_NOISY));最后的建议是拿到这份项目后先在 Android Studio 里完整跑通一遍再把核心流程自己重新写一遍。答辩时被问 Service 原理、MediaPlayer 状态机、运行时权限这些都是高频问题代码主要业务逻辑能看懂、能说清楚配上 99 分的项目底子这门课的最终成绩基本就稳了。本文还有配套的精品资源点击获取