简介:基于 Android Studio 开发的音乐播放器 APP 源码包,面向刚开始接触 Android 开发的初学者,以及正在准备毕业设计、期末大作业的高校学生;参考该项目可理清多媒体播放、界面设计与设备交互等核心问题的实现思路。压缩包共收录 375 个文件,其中 116 个 Java 文件承担播放控制与业务逻辑,74 个 XML 文件负责界面布局和资源配置,161 张 PNG 图片提供图标与视觉素材,另含可直接安装的 APK、Gradle 构建脚本及打包产物,整体约 4.06MB,目录划分清晰,便于按源码、资源和配置文件分类查阅。目前已有 201 人学习下载。借助该源码,不仅可以快速了解 Android Studio 工程中 Java、XML 与图片资源的协作方式,还能通过随包 APK 直接体验运行效果,对照源码梳理媒体加载、页面展示、事件响应等开发链路;Gradle 配置与打包文件同时支持导入开发环境后二次编译和修改,方便针对课程设计或项目复现进行个性化调整,整体是一份完整度较高的 Android 多媒体工程参考。
1. 从源码包到能上手的音乐播放器:Android Studio 开发的第一步
一份“基于 Android Studio 开发的音乐播放器 APP 源码”拿到手,第一件事不是急着点 Run,而是先想清楚这套源码到底覆盖了哪些环节:本地音乐扫描、播放器内核、后台播放、通知栏控制,以及和系统组件之间的生命周期对接。源码包的价值在于给你一个能直接编译的工程骨架,但真正的难点从来不是构建一个播放器实例,而是换歌、锁屏、来电、拔出耳机这些边界行为发生时,播放器如何不崩溃、不闪退、不让用户觉得“这个 app 有问题”。这篇文章按我接手这类工程的习惯做法,把环境配置、内核选型、MediaStore 查询、前台服务与通知栏控制、音频焦点这几个关键模块拆开讲透,适合正在学 Android 应用开发的人,也适合想拿开源项目二次改造的开发者。
2. Android Studio 环境与播放内核选型:Media3 还是 MediaPlayer
2.1 源码包落地的第一步:环境版本与 Gradle 配置
拿到源码包先用 Android Studio 打开工程,等 Gradle 同步之前,先看根目录的 settings.gradle 和 build.gradle,确认 AGP、Gradle 和 JDK 三者版本是否配套。以 2025 年稳定的做法看,AGP 8.2 以上对应 Gradle 8.2 以上,JDK 要求 17;老工程如果还停在 AGP 7.x,建议直接去官网下载新版本的 Android Studio,不用单独配 JDK,直接用 Android Studio 自带的 embedJDK,省得 JAVA_HOME 出乱子。
国内拉依赖慢是绕不开的问题,我一般会在 settings.gradle 的 pluginManagement 和 dependencyResolutionManagement 两个块里补上阿里云镜像,顺序放在 google() 前面,这样 Gradle 同步快很多:
pluginManagement { repositories { maven { url 'https://maven.aliyun.com/repository/google' } maven { url 'https://maven.aliyun.com/repository/public' } maven { url 'https://maven.aliyun.com/repository/gradle-plugin' } google() mavenCentral() } } dependencyResolutionManagement { repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) repositories { maven { url 'https://maven.aliyun.com/repository/google' } maven { url 'https://maven.aliyun.com/repository/public' } google() mavenCentral() } }镜像源只影响依赖拉取速度,不会改变任何 API 行为。Gradle 同步报错时先看是不是网络拉包失败,再看是不是版本冲突,Media3 全家桶版本号不一致是最常见的冲突来源。刚装好 Android Studio 的人想把界面换成中文,在 Settings 的 Plugins 面板搜 Chinese 装完重启即可,这只改 IDE 界面,不影响编译参数和源码内容,与工程配置无关。
2.2 播放内核选型:MediaPlayer、ExoPlayer 与 Media3
选播放内核决定了后面所有代码的写法。老教程大量使用 MediaPlayer + Service 的组合,Handler 手动切歌、手动持锁、手动维护播放列表,代码越堆越难维护。MediaPlayer 对流媒体、无缝衔接、音效处理支持很弱,扩展示例极少。ExoPlayer 从 AndroidX 时代并入 Media3 之后,成为 Google 官方主推的播放器方案,本地与在线音视频都能播,还自带音效、倍速、DASH/HLS 支持。
| 维度 | MediaPlayer | Media3 (ExoPlayer) |
|---|---|---|
| 本地音频 | 支持 | 支持 |
| 在线流媒体 | 基本不支持 | 原生支持 HLS/DASH |
| 后台播放封装 | 手写 Service | MediaSessionService 自带 |
| 通知栏控制 | 手写 Notification | 默认提供 MediaNotification |
| 维护状态 | 官方更新极少 | Media 仓库持续演进 |
| 学习成本 | 低 | 中 |
结论对源码包改造影响很大:如果源码里接的是 MediaPlayer,后续往通知栏、音频焦点、锁屏控制这些方向改造,工程量接近重写;如果接的是 Media3,直接在现有骨架上加歌单和在线音频会顺很多。对一套想长期迭代的音乐播放器源码包,播放内核这块的决策基本可以照搬 Media3 方案。
2.3 在 build.gradle 里引入 Media3 依赖
模块级 build.gradle 最低引入三件套,我一般按需再加 media3-datasource 和 media3-decoder:
android { compileSdk 35 defaultConfig { minSdk 24 targetSdk 35 } } dependencies { implementation("androidx.media3:media3-exoplayer:1.4.1") implementation("androidx.media3:media3-session:1.4.1") implementation("androidx.media3:media3-ui:1.4.1") }compileSdk 和 targetSdk 建议拉齐到本机已安装 SDK 的最高稳定版本,minSdk 保持 24,能覆盖市面上绝大多数存量设备。media3-exoplayer 是播放器本体,media3-session 负责把播放能力桥接给通知栏、蓝牙按键和锁屏页,media3-ui 提供默认的 PlayerView,省掉自绘进度条和时间显示。注意这三个依赖的版本号必须完全一致,不然会报 “All com.android.support libraries must use the exact same version specification” 这一类编译错误。
3. 扫描本机音乐:MediaStore 查询与权限适配
3.1 权限声明:Android 13 前后的差异
源码包里最常见的坑,是权限声明停留在 READ_EXTERNAL_STORAGE。Android 13(API 33)把音频权限拆成了 READ_MEDIA_AUDIO,targetSdk 33 及以上只识别后者,Android 12 及以下的设备走旧权限模型。在 AndroidManifest.xml 里要同时声明两套,才能保证各个版本都能扫描到本地音乐。
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32" /> <uses-permission android:name="android.permission.READ_MEDIA_AUDIO" />maxSdkVersion 这行很关键,targetSdk 35 构建时如果带着不加限制的 READ_EXTERNAL_STORAGE,Google Play 会警告,部分厂商 ROM 也会弹隐私提示。跑播放功能之前,还要补前台服务权限和通知权限,缺一个都会在后续启动前台服务时崩:
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" /> <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />FOREGROUND_SERVICE_MEDIA_PLAYBACK 是 Android 14 新增的前台服务类型声明,不加这一条,在 Android 14 设备上启动播放服务会直接抛 ForegroundServiceTypeNotAllowedException,后面第 4 章还会提到对应代码写法。
3.2 用 ContentResolver 查询 MediaStore.Audio.Media
本地音乐列表的正确来源是 MediaStore,而不是直接遍历目录找 mp3 文件。系统负责维护媒体索引,应用只管向 ContentResolver 要数据。查询时把需要的字段放进 projection,不要用 Cursor 列下标直接取值,API 变更或字段位置调整时代码就崩了。
fun queryLocalAudio(context: Context): List<AudioItem> { val collection = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { MediaStore.Audio.Media.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY) } else { MediaStore.Audio.Media.EXTERNAL_CONTENT_URI } val projection = arrayOf( MediaStore.Audio.Media._ID, MediaStore.Audio.Media.TITLE, MediaStore.Audio.Media.ARTIST, MediaStore.Audio.Media.ALBUM_ID, MediaStore.Audio.Media.DURATION ) val selection = "${MediaStore.Audio.Media.IS_MUSIC} != 0" val sortOrder = "${MediaStore.Audio.Media.TITLE} ASC" val list = mutableListOf<AudioItem>() context.contentResolver.query( collection, projection, selection, null, sortOrder )?.use { cursor -> val idCol = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media._ID) val titleCol = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.TITLE) val artistCol = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.ARTIST) val albumCol = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.ALBUM_ID) val durationCol = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.DURATION) while (cursor.moveToNext()) { list += AudioItem( id = cursor.getLong(idCol), title = cursor.getString(titleCol), artist = cursor.getString(artistCol), albumId = cursor.getLong(albumCol), duration = cursor.getLong(durationCol) ) } } return list }collection 的获取在 Android 10(API 29)以上用 getContentUri 加 VOLUME_EXTERNAL_PRIMARY,拿到的才是主外置存储;旧版本用 EXTERNAL_CONTENT_URI。selection 里 IS_MUSIC != 0 会过滤掉一部分录音和系统提示音,但不够彻底,想只显示“真正的音乐”,还要结合 DURATION 大于 30 秒这类条件再筛一层。这个查询建议放到协程的 IO 线程执行,本地歌曲上千首时主线程直接查会卡顿明显。
3.3 运行时权限申请与空列表排查
动态申请权限用 Activity Result API,不要再写 startActivityForResult 那套旧模板,registerForActivityResult 是当前稳定的回调方式。
private val audioPermissionLauncher = registerForActivityResult(ActivityResultContracts.RequestPermission()) { granted -> if (granted) { loadAudioList() } else { showPermissionTip() } } private fun requestAudioPermission() { val permission = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { Manifest.permission.READ_MEDIA_AUDIO } else { Manifest.permission.READ_EXTERNAL_STORAGE } audioPermissionLauncher.launch(permission) }权限名的选择按 API 33 切分:TIRAMISU 以上用新权限,33 以下用旧权限。查询结果为空时排错顺序固定是这样:先在系统设置里确认权限真的授予了(部分系统有“仅允许本次”这类一次性授权选项);再确认设备是否处于 USB 文件传输模式,MTP 连接期间部分 ROM 会暂停媒体索引更新;最后检查 manifest 里是否同时声明了旧权限且带 maxSdkVersion。还有一个大家容易踩的误用,是把 MediaStore 的 DATA 列当文件路径直接 new File,Android 10 及以上文件重定向机制会让这个路径读不到内容,正确的做法是拿 _ID 拼出 content://media/external/audio/media/ID 这样的 Uri 交给播放器打开。
4. 后台播放实现:前台服务与通知栏控制
4.1 Service 生命周期与 Android 8/14 适配
普通 Service 在后台待一会儿就会被系统回收,音乐播放必须依赖前台服务。Android 8 之后后台不允许直接 startService,必须走 startForegroundService(),且服务启动后 5 秒内要调 startForeground 并附上通知,否则直接抛 “did not then call Service.startForeground()”。Android 14 又加了前台服务类型限制:类型为 mediaPlayback 的服务,内部必须存在活跃的 MediaSession。
提示:Android 14 设备上播放服务一启动就崩,先看 Logcat 里有没有 ForegroundServiceTypeNotAllowedException,八成是 service 声明里少了 android:foregroundServiceType="mediaPlayback"。
Manifest 声明服务时要把类型带上:
<service android:name=".PlaybackService" android:exported="false" android:foregroundServiceType="mediaPlayback" />实现上强烈建议直接用 Media3 的 MediaSessionService 而不是裸写 Service。MediaSessionService 内部已经处理了前台状态、通知创建和控制器回调,省掉一堆样板代码:
class PlaybackService : MediaSessionService() { private var mediaSession: MediaSession? = null override fun onCreate() { super.onCreate() val player = ExoPlayer.Builder(this).build() mediaSession = MediaSession.Builder(this, player) .setCallback(sessionCallback) .build() } override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaSession? { return mediaSession } override fun onTaskRemoved(rootIntent: Intent?) { val player = mediaSession?.player if (player == null || !player.playWhenReady || player.mediaItemCount == 0) { stopSelf() } } override fun onDestroy() { mediaSession?.run { player.release() release() } mediaSession = null super.onDestroy() } }onTaskRemoved 里做停止策略:用户划掉最近任务后,如果还在播放就继续保留前台服务,如果没在播放就 stopSelf,避免留下杀不死的后台进程。MediaSessionService 会通过 DefaultMediaNotificationProvider 在播放状态变化时自动生成通知并保持前台服务存活,开发者不用手动调 startForeground 里那个 notification 参数填个空通知。
4.2 MediaSession 与通知栏控制消息
MediaSession 承载播放状态和播控命令,通知栏的上一首、下一首、播放、暂停按钮走的就是会话回调。需要监听哪个命令,就在 Callback 里实现对应方法:
private val sessionCallback = object : MediaSession.Callback { override fun onPlay() { player.play() // 从暂停或就绪状态恢复播放 } override fun onPause() { player.pause() // 暂停并保留当前播放位置 } override fun onSkipToNext() { player.seekToNextMediaItem() // 跳到队列下一项 } override fun onSeekTo(positionMs: Long) { player.seekTo(positionMs) // 拖动进度条 } }通知栏、蓝牙耳机的 AVRCP 命令、锁屏页这些外部控制器,最后都会汇聚到这几个回调方法里,所以不要在回调里重复做状态判断,播放器内部已经处理。通知栏能正常显示封面、标题和进度条,前提是 MediaItem 构建时把 mediaMetadata 填完整:
val mediaItem = MediaItem.Builder() .setUri(contentUri) // content:// 形式,避免直接用文件路径 .setMediaMetadata( MediaMetadata.Builder() .setTitle(item.title) .setArtist(item.artist) .setArtworkUri(albumArtUri) // 没有封面可以不加 .build() ) .build()setMediaMetadata 是整条播控链路的信息源头,通知栏、锁屏、蓝牙设备都从这取元数据。只 setUri 不设元数据,锁屏界面就会显示“未知曲目”。如果源码里出现自定义 PendingIntent 去替换通知栏按钮的行为,建议优先拍掉改用 Media3 默认逻辑,自定义点击事件容易和系统媒体控制面板起冲突。
4.3 播放队列与自动切歌的接法
播放队列挂在 player 上,不要塞进 Service 的静态变量里,进程被系统重建后队列会丢。正确做法是把列表和起始索引传给 player 的 setMediaItems:
val player = mediaSession!!.player player.setMediaItems( audioList.mapIndexed { index, item -> MediaItem.Builder() .setMediaId("$index") .setUri(item.uri) .setMediaMetadata( MediaMetadata.Builder() .setTitle(item.title) .setArtist(item.artist) .build() ) .build() }, startIndex, // 从列表第几首开始播放 0L // 起始播放位置,单位毫秒 ) player.playWhenReady = true player.prepare()第二个参数 startIndex 是初始播放下标,第三个参数是起始进度毫秒,一般传 0。播放完最后一首是自动停还是循环,由 Player.RepeatMode 决定,UI 层设置后 Service 里会自动同步。监听播放结束推荐用 Player.Listener 的 onMediaItemTransition,判断 transitionReason 是否为 MEDIA_ITEM_TRANSITION_REASON_PLAYLIST_ENDED,而不是监听 STATE_ENDED 后手动切歌,后者在列表切换、播放失败重试时容易重复触发。
5. 音频焦点、耳机事件与联调验证
5.1 音频焦点:避免“听着听着被掐掉”
播放前申请音频焦点,Android 8 以上的标准做法是构建 AudioFocusRequest,传入媒体属性的 AudioAttributes,然后注册焦点变化监听:
private fun requestAudioFocus() { val audioManager = getSystemService(Context.AUDIO_SERVICE) as AudioManager if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { val focusRequest = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN) .setAudioAttributes( AudioAttributes.Builder() .setUsage(AudioAttributes.USAGE_MEDIA) .setContentType(AudioAttributes.CONTENT_TYPE_MUSIC) .build() ) .setOnAudioFocusChangeListener { focusChange -> when (focusChange) { AudioManager.AUDIOFOCUS_LOSS -> player.pause() AudioManager.AUDIOFOCUS_LOSS_TRANSIENT -> player.pause() AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK -> player.volume = 0.3f } } .build() audioManager.requestAudioFocus(focusRequest) } }AUDIOFOCUS_LOSS 是长期失去焦点,比如另一个应用要持续播放,这时要暂停并准备释放资源;AUDIOFOCUS_LOSS_TRANSIENT 是来电、语音助手这类临时打断,暂停等后续重新获得焦点再恢复;CAN_DUCK 表示系统允许压低音量继续播放,恢复时要记得把音量调回来。音频焦点跟“后台播放会不会被杀”无关,它只管声音竞争。
5.2 让播放器对外界中断有反应:耳机拔出与来电
耳机拔出后系统会发 ACTION_AUDIO_BECOMING_NOISY 广播,需要动态注册接收器,收到后立刻暂停播放,防止耳机里的声音突然转到外放。来电场景大部分由音频焦点的 LOSS_TRANSIENT 覆盖,但蓝牙断开或耳机拔出不会触发焦点回调:
private val noisyReceiver = object : BroadcastReceiver() { override fun onReceive(context: Context, receiver: Intent) { if (intent.action == AudioManager.ACTION_AUDIO_BECOMING_NOISY) { player.pause() // 拔出耳机或蓝牙断开时暂停 } } } private fun registerNoisyReceiver() { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { registerReceiver( noisyReceiver, IntentFilter(AudioManager.ACTION_AUDIO_BECOMING_NOISY), Context.RECEIVER_NOT_EXPORTED ) } else { registerReceiver(noisyReceiver, IntentFilter(AudioManager.ACTION_AUDIO_BECOMING_NOISY)) } }Android 13 及以上动态注册广播必须指定 RECEIVER_EXPORTED 或 RECEIVER_NOT_EXPORTED,系统广播类型建议用 NOT_EXPORTED,避免其他应用往你的接收器里塞假事件。
5.3 联调验证清单与 adb 查看命令
最后收尾时按这个清单逐项验证,比盲改代码效率高:前台服务是否存活、通知栏是否由 Media3 接管、音频焦点回调是否真实触发。查看前台服务状态用 adb 一条命令就能确认:
| 验证点 | 操作 | 预期结果 |
|---|---|---|
| 前台服务存活 | 播放时执行 adb shell dumpsys activity services | PlaybackService 处于 foreground 状态 |
| 通知栏控制 | 下拉通知栏点暂停、下一首 | 播放状态及时变化 |
| 锁屏播放 | 锁屏后从锁屏卡片切歌 | MediaSession 元数据更新 |
| 耳机拔出 | 播放中拔出耳机 | 播放立即暂停 |
| 来电打断 | 播放中模拟来电 | 暂停且来电挂断后不自动恢复 |
| 权限空列表 | 首次安装点击扫描 | 弹出权限申请且不崩溃 |
联调这一步别省,很多源码包拿到手改了一堆通知和焦点代码,真机一跑还是会在锁屏或来电时出问题。重点盯三处:MediaSessionService 是否被系统判定为前台运行、通知栏是否由 Media3 接管而不是自己塞的 Notification、音频焦点回调在拔耳机和来电话时是否真实触发。用 dumpsys 命令配合上面的操作记录,一次就能定位到具体环节。
本文还有配套的精品资源,点击获取