ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Android Media3音乐播放器2.0:适配Android 12+的后台播放与Scoped Storage实战

Android Media3音乐播放器2.0:适配Android 12+的后台播放与Scoped Storage实战 简介本资源是面向Android初学者的音乐播放器开发实战项目聚焦移动应用开发核心技能训练特别适合课程设计、大作业及自学实践。作为1.0版本的深度升级版2.0版本新增上一首/下一首功能、采用个性化按钮UI设计并为全部关键逻辑补充详尽中文注释显著提升代码可读性与学习效率。压缩包共474个文件涵盖156个flat资源文件含界面布局与素材、118个json配置、28个png图标、22个xml布局与权限声明、8个java源码文件及7个mp3示例音频整体达275.02MB结构完整、模块清晰便于逐层理解Activity生命周期、MediaPlayer控制逻辑与UI事件响应机制。目前已有5247人学习下载提供开箱即用的可运行工程含Gradle构建脚本、调试APK及完整资源目录助新手快速掌握Android Studio开发全流程。1. 为什么一个“Android Studio 音乐播放器 2.0”值得重做一遍不是所有音乐播放器 Demo 都叫 2.0 —— 它不是版本号堆砌而是对 Android 原生音频链路一次系统性补全。很多初学者用 MediaPlayer 写完基础播放后卡在进度条拖动失灵、后台播放被系统杀掉、横竖屏切换时状态丢失、点播多个文件后内存暴涨、甚至连本地 MP3 文件路径都读不到尤其 Android 10 Scoped Storage 下。这些不是“小问题”而是 Android 音频开发中真实存在的断层API 演进没跟上文档更新权限模型变了但示例代码还停留在READ_EXTERNAL_STORAGEService被弃用却没人讲清楚ForegroundService怎么配 Notification ChannelMediaSession的回调逻辑和 UI 同步机制也常被一笔带过。本篇聚焦「可运行、可调试、可上线」的最小闭环用 Android Studio Flamingo2022.2.1及以上 AGP 8.1 androidx.media3:media3-session:1.2.1构建一个真正能响应系统媒体键、支持后台持续播放、适配 Android 12~14 的音乐播放器。它不依赖任何第三方 SDK所有逻辑直连 Android 原生音频栈小白照着敲能跑通老手能从中拎出AudioFocus处理边界、ExoPlayer自定义LoadControl、MediaSessionConnector状态同步等关键路径。2. 从零搭建可后台播放的 Media3 播放核心替代已废弃的 MediaPlayer2.1 为什么必须放弃 MediaPlayer转向 Media3MediaPlayer在 Android 12API 31起被标记为Deprecated其根本缺陷在于无法与系统媒体会话MediaSession深度集成不支持动态音轨切换如 AAC/FLAC 自适应、无内置缓冲策略控制、且setDataSource()对 Content URI 支持极差。而androidx.media3是 Google 官方推荐的下一代媒体库核心组件ExoPlayer提供细粒度生命周期控制、自定义解码器链、网络带宽自适应ABR更重要的是MediaSession和MediaSessionService已深度耦合能原生响应蓝牙耳机按键、系统通知栏控制、语音助手指令。项目升级到 2.0 的第一要义就是将播放引擎从MediaPlayer迁移至ExoPlayer并用MediaSessionService替代传统Service。2.2 在 Android Studio 中配置 Media3 依赖与权限在模块级build.gradle注意是app/build.gradle非项目根目录中添加以下依赖使用implementation勿用api// app/build.gradle android { namespace com.example.musicplayer compileSdk 34 defaultConfig { applicationId com.example.musicplayer minSdk 21 targetSdk 34 // 必须 ≥31 才能使用 MediaSessionService versionCode 20 versionName 2.0 } } dependencies { implementation androidx.core:core-ktx:1.12.0 implementation androidx.appcompat:appcompat:1.6.1 implementation com.google.android.material:material:1.10.0 // Media3 核心依赖2024 年主流稳定版 implementation androidx.media3:media3-common:1.2.1 implementation androidx.media3:media3-exoplayer:1.2.1 implementation androidx.media3:media3-session:1.2.1 implementation androidx.media3:media3-ui:1.2.1 // 提供 MediaControllerWidget 等 UI 组件 // Kotlin 协程支持避免主线程阻塞 implementation org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3 }提示media3-session是 2.0 版本的核心差异点——它让播放器不再是孤立 App而是系统媒体生态中的“一员”。若跳过此依赖将无法在锁屏界面显示控制按钮也无法通过adb shell cmd media_session查看会话状态。同时在AndroidManifest.xml中声明必要权限与服务!-- AndroidManifest.xml -- uses-permission android:nameandroid.permission.FOREGROUND_SERVICE / uses-permission android:nameandroid.permission.POST_NOTIFICATIONS / uses-permission android:nameandroid.permission.READ_MEDIA_AUDIO / !-- Android 13 替代 READ_EXTERNAL_STORAGE -- application ... !-- 媒体会话服务必须声明 android:exportedtrue 且含 SERVICE 类别 -- service android:name.service.MusicService android:exportedtrue android:foregroundServiceTypemediaPlayback intent-filter action android:nameandroidx.media3.session.MediaSessionService / /intent-filter /service !-- 前台服务所需的通知渠道Android 8.0 强制 -- meta-data android:nameandroid.app.default_notification_channel_id android:valuemusic_playback / /application2.3 实现 MediaSessionService让播放器“活”在系统里创建MusicService.kt继承MediaSessionService非Service// app/src/main/java/com/example/musicplayer/service/MusicService.kt class MusicService : MediaSessionService() { private lateinit var mediaSession: MediaSession private lateinit var player: ExoPlayer override fun onCreate() { super.onCreate() // 1. 初始化 ExoPlayer启用硬件解码、设置缓冲策略 player ExoPlayer.Builder(this) .setTrackSelector(DefaultTrackSelector(this)) .setLoadControl( DefaultLoadControl.Builder() .setBufferDurationsMs( 25_000, // min buffer (25s) 50_000, // max buffer (50s) 2500, // playback after rebuffer (2.5s) 5000 // rebuffer delay (5s) ) .build() ) .build() // 2. 创建 MediaSession 并绑定 Player mediaSession MediaSession.Builder(this, player) .setId(music_session) .setCallback(object : MediaSession.Callback() { override fun onPlay(session: MediaSession, mediaItem: MediaItem?, extras: Bundle?) { player.playWhenReady true } override fun onPause(session: MediaSession) { player.playWhenReady false } override fun onSkipToNext(session: MediaSession) { player.seekToNext() } override fun onSkipToPrevious(session: MediaSession) { player.seekToPrevious() } }) .build() // 3. 设置前台服务通知必须在 startForeground() 前调用 setSessionToken(mediaSession.token) } override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaSession? { return mediaSession } override fun onDestroy() { mediaSession.release() player.release() super.onDestroy() } }参数说明DefaultLoadControl的bufferDurationsMs四个参数分别控制最小缓冲时长防卡顿、最大缓冲时长省内存、播放恢复阈值缓冲够了就播、重新缓冲延迟网络抖动时等待时间。2.0 版本将默认值从10s/50s/2.5s/5s调整为更激进的25s/50s/2.5s/5s兼顾流畅性与低延迟。若目标设备内存紧张可将minBufferMs降至15_000。2.4 验证 MediaSession 是否生效三步终端命令检测在 Android Studio Terminal 中执行以下命令确认会话已注册# 1. 查看当前活跃的媒体会话 adb shell cmd media_session list-sessions # 2. 查看指定会话详细信息输出应包含 statePLAYING 或 statePAUSED adb shell cmd media_session session --package com.example.musicplayer --user 0 # 3. 发送播放指令无需启动 App直接测试服务响应 adb shell cmd media_session dispatch --package com.example.musicplayer play若list-sessions返回空检查MusicService是否在AndroidManifest.xml中正确声明androidx.media3.session.MediaSessionServiceaction若dispatch play无反应确认onPlay()回调中是否设置了player.playWhenReady true。3. 构建响应式 UI 层用 MediaControllerWidget 同步播放状态3.1 Activity 中初始化 MediaController 并绑定 Session在MainActivity.kt中不再直接操作ExoPlayer而是通过MediaController间接控制// app/src/main/java/com/example/musicplayer/MainActivity.kt class MainActivity : AppCompatActivity() { private lateinit var binding: ActivityMainBinding private lateinit var controller: MediaController override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) binding ActivityMainBinding.inflate(layoutInflater) setContentView(binding.root) // 1. 连接 MediaSessionService自动处理跨进程通信 val sessionToken MediaSessionService.getSessionToken(this, MusicService::class.java) controller MediaController.Builder(this, sessionToken).build() // 2. 将控制器绑定到 UI 组件自动同步状态 binding.playButton.setOnClickListener { controller.play() } binding.pauseButton.setOnClickListener { controller.pause() } binding.nextButton.setOnClickListener { controller.skipToNext() } binding.prevButton.setOnClickListener { controller.skipToPrevious() } // 3. 监听播放状态变化UI 更新入口 controller.addMediaSessionCallback(object : MediaController.Callback() { override fun onIsPlayingChanged(controller: MediaController) { updatePlayButtonState() } override fun onPlaybackState(controller: MediaController) { updateProgressAndPosition() } }) } private fun updatePlayButtonState() { binding.playButton.isEnabled controller.isPlayable binding.playButton.text if (controller.isPlaying) ⏸️ 暂停 else ▶️ 播放 } private fun updateProgressAndPosition() { val position controller.playbackState?.position ?: 0L val duration controller.playbackState?.duration ?: 0L binding.progressBar.max duration.toInt() binding.progressBar.progress position.toInt() } override fun onDestroy() { controller.release() super.onDestroy() } }注意MediaController是跨进程代理对象它不持有ExoPlayer实例所有操作均通过 Binder 调用MusicService中的MediaSession。这意味着即使MainActivity被系统回收只要MusicService在前台运行播放就不会中断。3.2 使用 MediaControllerWidget 实现进度条拖动media3-ui提供PlayerControlView和ProgressBar的增强版MediaControllerWidget但需手动处理拖动事件!-- res/layout/activity_main.xml -- LinearLayout xmlns:androidhttp://schemas.android.com/apk/res/android android:orientationvertical android:layout_widthmatch_parent android:layout_heightmatch_parent com.google.android.exoplayer2.ui.PlayerControlView android:idid/playerView android:layout_widthmatch_parent android:layout_heightwrap_content / !-- 自定义进度条支持拖动 -- SeekBar android:idid/progressBar android:layout_widthmatch_parent android:layout_heightwrap_content android:max1000 / /LinearLayout在MainActivity.kt中添加拖动监听// 续写 onCreate() 中的初始化逻辑 binding.progressBar.setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener { override fun onProgressChanged(seekBar: SeekBar?, progress: Int, fromUser: Boolean) { if (fromUser controller.playbackState?.duration ! null) { val newPosition (progress * controller.playbackState.duration) / 1000 controller.seekTo(newPosition) } } override fun onStartTrackingTouch(seekBar: SeekBar?) {} override fun onStopTrackingTouch(seekBar: SeekBar?) {} })关键逻辑SeekBar的max设为1000是为避免浮点精度丢失实际位置换算公式为(progress / 1000) × duration。fromUser true是防止controller主动更新进度时触发反向拖动。3.3 处理 Scoped Storage安全读取本地音频文件Android 10 强制应用使用分区存储Scoped Storage不能直接访问Environment.getExternalStorageDirectory()。2.0 版本必须改用MediaStore查询音频// 在 MainActivity 中添加音频扫描方法 private fun loadLocalAudioFiles() { val projection arrayOf( MediaStore.Audio.Media._ID, MediaStore.Audio.Media.TITLE, MediaStore.Audio.Media.ARTIST, MediaStore.Audio.Media.DURATION, MediaStore.Audio.Media.DATA // 仅用于兼容旧 API新 API 推荐用 Content URI ) val cursor contentResolver.query( MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, projection, null, null, ${MediaStore.Audio.Media.TITLE} ASC ) cursor?.use { val idColumn it.getColumnIndexOrThrow(MediaStore.Audio.Media._ID) val titleColumn it.getColumnIndexOrThrow(MediaStore.Audio.Media.TITLE) val artistColumn it.getColumnIndexOrThrow(MediaStore.Audio.Media.ARTIST) val durationColumn it.getColumnIndexOrThrow(MediaStore.Audio.Media.DURATION) while (it.moveToNext()) { val id it.getLong(idColumn) val title it.getString(titleColumn) ?: 未知标题 val artist it.getString(artistColumn) ?: 未知艺术家 val duration it.getLong(durationColumn) // 构建 MediaItem关键使用 Content URI非 file:// 路径 val uri ContentUris.withAppendedId( MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, id ) val mediaItem MediaItem.Builder() .setUri(uri) .setMediaId($id) .setMediaMetadata( MediaMetadata.Builder() .setTitle(title) .setArtist(artist) .setDurationMs(duration) .build() ) .build() // 添加到播放队列此处简化为单曲播放实际可用 player.setMediaItems(list) controller.setMediaItem(mediaItem) } } }参数说明ContentUris.withAppendedId()生成的 URI 形如content://media/external/audio/media/12345系统自动授予临时读取权限无需申请READ_MEDIA_AUDIO运行时权限但需在 Manifest 中声明。若目标 Android 版本 33仍需动态申请READ_EXTERNAL_STORAGE。4. 解决 2.0 版本高频崩溃与卡顿AudioFocus 与内存泄漏防护4.1 正确申请 AudioFocus避免被系统静音当其他 App如电话、导航请求音频焦点时若未正确处理AUDIOFOCUS_LOSS_TRANSIENT会导致播放器无声且无提示。在MusicService.kt的onCreate()后添加焦点监听// MusicService.kt 中 onCreate() 末尾追加 private fun initAudioFocus() { val audioManager getSystemService(Context.AUDIO_SERVICE) as AudioManager val focusRequest AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN) .setOnAudioFocusChangeListener { focusChange - when (focusChange) { AudioManager.AUDIOFOCUS_LOSS - { // 永久失去焦点停止播放释放资源 player.playWhenReady false mediaSession.setActive(false) } AudioManager.AUDIOFOCUS_LOSS_TRANSIENT - { // 短暂失去暂停等待恢复 player.playWhenReady false } AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK - { // 可降低音量继续播放如通知音 player.volume 0.2f } AudioManager.AUDIOFOCUS_GAIN - { // 重新获得焦点恢复播放与音量 player.playWhenReady true player.volume 1.0f } } } .build() audioManager.requestAudioFocus(focusRequest) }注意AUDIOFOCUS_LOSS必须调用mediaSession.setActive(false)否则系统认为会话仍活跃可能触发 ANR。AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK不应暂停播放仅调低音量这是 2.0 版本提升用户体验的关键细节。4.2 防止 ExoPlayer 内存泄漏Lifecycle-Aware 释放ExoPlayer实例若在Activity销毁后未释放会持有Context导致内存泄漏。MediaSessionService本身具备生命周期感知能力但需确保player和mediaSession在onDestroy()中彻底释放// MusicService.kt 中 onDestroy() 方法已存在此处强调关键点 override fun onDestroy() { // 必须按顺序释放先释放 Session再释放 Player mediaSession.release() // 此操作会自动解除与 player 的绑定 player.release() // 此操作会清空所有缓冲区和解码器 super.onDestroy() }验证方法在 Android Studio Profiler 中开启 Memory 分析反复启动/关闭播放器观察ExoPlayerImpl实例数是否归零。若持续增长说明player.release()未被执行。4.3 优化后台播放保活ForegroundService 通知配置ForegroundService要求必须显示通知否则 Android 9 会强制停止服务。在MusicService.kt的onCreate()中添加通知构建逻辑// MusicService.kt onCreate() 中 player 初始化后追加 private fun createNotificationChannel() { if (Build.VERSION.SDK_INT Build.VERSION_CODES.O) { val channel NotificationChannel( music_playback, 音乐播放, NotificationManager.IMPORTANCE_LOW ).apply { description 显示播放控制按钮 setShowBadge(false) // 隐藏角标 } val manager getSystemService(NotificationManager::class.java) manager.createNotificationChannel(channel) } } private fun startForegroundNotification() { val intent Intent(this, MainActivity::class.java).apply { flags Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK } val pendingIntent PendingIntent.getActivity( this, 0, intent, PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT ) val notification NotificationCompat.Builder(this, music_playback) .setContentTitle(音乐播放器 2.0) .setContentText(正在播放...) .setSmallIcon(R.drawable.ic_music_note) .setContentIntent(pendingIntent) .setOngoing(true) // 关键标记为持续运行用户无法清除 .addAction(R.drawable.ic_skip_previous, 上一首, getPendingIntent(prev)) .addAction(R.drawable.ic_play, 播放/暂停, getPendingIntent(play)) .addAction(R.drawable.ic_skip_next, 下一首, getPendingIntent(next)) .build() startForeground(1, notification) } private fun getPendingIntent(action: String): PendingIntent { val intent Intent(this, MusicService::class.java).apply { action action } return PendingIntent.getService( this, action.hashCode(), intent, PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT ) }参数说明setOngoing(true)是保活核心它使通知不可滑动清除FLAG_IMMUTABLE是 Android 12 强制要求避免 PendingIntent 被篡改getPendingIntent()中action.hashCode()作为 requestCode确保每个 Action 有唯一标识。5. 进阶技巧用 MediaSessionConnector 实现播放列表无缝切换5.1 为什么需要 MediaSessionConnectorMediaSession默认只管理单个ExoPlayer实例当需实现“播放列表循环”、“随机播放”、“跳过已播放曲目”时需手动维护player.setMediaItems()并同步MediaSession的MediaMetadata。MediaSessionConnector是 Media3 提供的高级封装它自动将ExoPlayer的MediaItem切换、PlaybackException、Timeline变化映射到MediaSession的标准行为。5.2 集成 MediaSessionConnector 替代手动 Callback在MusicService.kt的onCreate()中替换原有MediaSession.Builder逻辑// MusicService.kt onCreate() 中删除原 mediaSession 初始化代码替换为 private lateinit var connector: MediaSessionConnector override fun onCreate() { super.onCreate() player ExoPlayer.Builder(this).build() // 1. 创建 MediaSession不设 Callback mediaSession MediaSession.Builder(this, player) .setId(music_session) .build() // 2. 创建 Connector 并绑定 Player 与 Session connector MediaSessionConnector(mediaSession) connector.setPlayer(player) // 3. 注册自定义播放逻辑替代原 onPlay/onPause connector.setPlaybackPreparer(object : PlaybackPreparer() { override fun onPrepareFromMediaId( mediaId: String, notify: Boolean, extras: Bundle? ) { // 根据 mediaId 加载对应 MediaItem如从数据库查 val mediaItem getMediaItemById(mediaId) player.setMediaItem(mediaItem) if (notify) player.prepare() } }) // 4. 启动前台服务 createNotificationChannel() startForegroundNotification() }5.3 实现播放列表管理支持 shuffle 与 repeat在MusicService.kt中添加播放列表工具方法private fun getMediaItemById(mediaId: String): MediaItem { // 示例从预置列表中查找实际应从 Room 数据库或 ContentProvider 加载 return when (mediaId) { song_001 - buildMediaItem(content://media/external/audio/media/1001, 晴天, 周杰伦) song_002 - buildMediaItem(content://media/external/audio/media/1002, 七里香, 周杰伦) else - buildMediaItem(content://media/external/audio/media/1001, 晴天, 周杰伦) } } private fun buildMediaItem(uri: String, title: String, artist: String): MediaItem { return MediaItem.Builder() .setUri(uri) .setMediaId(uri.hashCode().toString()) .setMediaMetadata( MediaMetadata.Builder() .setTitle(title) .setArtist(artist) .build() ) .build() } // 在 Controller 端触发播放如 MainActivity 中 fun playPlaylist(songIds: ListString) { val mediaItems songIds.map { getMediaItemById(it) } player.setMediaItems(mediaItems) player.prepare() player.seekToDefaultPosition() // 从第一首开始 }关键优势MediaSessionConnector自动处理player.seekToNext()时的MediaSession状态同步无需手动调用mediaSession.setMetadata()当player抛出PlaybackException它会自动触发MediaSession的错误回调便于前端展示 Toast。这是 2.0 版本区别于 1.0 的核心工程实践——用官方抽象层替代手工状态同步降低出错概率。最后一步确保MusicService在AndroidManifest.xml中的android:foregroundServiceType属性值为mediaPlayback这是 Android 12 系统识别媒体服务的唯一标识缺一不可。本文还有配套的精品资源点击获取
返回列表