简介:这款安卓音乐播放器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,你不需要在本地单独装 Gradle,Android 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 绑定一个常驻通知,让它变成前台 Service:
Notification 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_PLAY,Service 的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 scheme,file://开头的直接传路径,content://的用 openFileDescriptor 拿 fd。
4.2 播放列表内存态与广播通知
项目里播放列表大概率用一个ArrayList<MusicBean>承载,每个 MusicBean 封装歌曲名、歌手、专辑、时长、路径和专辑封面 id。扫描完成后通过RecyclerView展示,点击某一项时把歌曲路径和索引传给 Service:
Intent 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 每次播放状态变化时都广播对应 action,Activity 注册一个 BroadcastReceiver 接收事件,根据 action 类型做界面刷新。比如ACTION_PLAY_STATE_CHANGED时更新播放按钮的图标,ACTION_SONG_CHANGED时更新歌曲名和歌手名。广播携带isPlaying和position两个 extra,UI 层只需要按数据做响应,不需要反向查询 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和playPosition,Service 创建时恢复,退出时保存:
SharedPreferences prefs = getSharedPreferences("player_prefs", MODE_PRIVATE); int lastPosition = prefs.getInt("last_song_position", 0); int lastProgress = prefs.getInt("last_progress", 0); // Service 创建后加载歌单,定位到 lastPosition,seekTo(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 分的项目底子,这门课的最终成绩基本就稳了。
本文还有配套的精品资源,点击获取