尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Android音乐播放器课设从零到答辩:MediaPlayer前台服务与MediaStore全解

Android音乐播放器课设从零到答辩:MediaPlayer前台服务与MediaStore全解 简介这是一份面向Android课程设计、期末作业及毕业设计的音乐播放器完整项目源码以Java编写适合初学Android开发的学生参考。项目在Android Studio中构建功能覆盖注册登录、本地音乐扫描、歌曲搜索、列表播放、暂停/上一曲/下一曲控制以及个人信息浏览和密码修改业务闭环完整可直接编译运行并在此基础上二次开发。资源包共390个文件压缩后仅2.24MB其中41个Java文件承载核心逻辑168个xml文件对应界面布局及Android配置153个png和12张jpg构成视觉资源另有gradle构建脚本、jar依赖库及gradlew等工具文件目录结构清晰便于快速定位代码。目前已有414人学习下载。通过该源码可以直观理解音乐播放器的界面搭建、事件响应、MediaPlayer调用以及本地音乐扫描等关键实现对期末答辩、课程设计报告撰写或毕业设计演示都有实用价值。1. 这个音乐播放器课设不只是一个 Activity“实现一个音乐播放器”往往是 Android 期末作业里最受欢迎的题目但大多数第一次拿这个题的人会把代码堆在一个 MainActivity 里一个 ListView、一个 MediaPlayer、一个 SeekBar点一下放一下。答辩时老师问“退到后台还能不能继续放”“来电时会不会和铃声抢声音”“为什么 Android 13 上读不到歌曲”就会卡壳。一个能拿出手的课设实质是“MediaStore 扫描 MediaPlayer 前台服务 通知栏控制 RecyclerView 状态同步”的完整链路。下面按这条链路拆开讲附可直接抄的 Kotlin 代码、参数说明和边界处理适合当 Android 期末作业、AndroidStudio 毕业设计也适合拿来做架构练习。2. MediaPlayer 选型与生命周期为什么课程设计不用 ExoPlayer2.1 MediaPlayer 状态机是崩溃第一来源课程设计场景下MediaPlayer 是常规选择。它是 Android 原生多媒体框架里最直接的音频播放器API 简单本地音频播放不需要额外依赖ExoPlayer 的优势在自适应码率流媒体、DASH/HLS、DRM这些在本地歌曲列表里用不上反而引入大量依赖和回调增加答辩时被追问的成本。但仅仅示例 new MediaPlayer() 然后反复调用 prepare()/start()真正的崩溃点会集中在状态机调用错误上。MediaPlayer 的合法状态包括 Idle、Initialized、Prepared、Started、Paused、Stopped、PlaybackCompleted、End。setDataSource() 之前是 Idle调 prepare() 之后进入 Preparedstart() 进入 Started。常见错误是在 Started 状态再调 prepare()或在 Stopped 状态直接 start()都会抛 IllegalStateException。稳妥的顺序是reset() - setDataSource(path) - prepare() - start()。本地文件用同步 prepare 就行网络文件必须用 prepareAsync() 配合 OnPreparedListener否则主线程卡死。try { mediaPlayer?.reset() mediaPlayer?.setDataSource(song.path) mediaPlayer?.setAudioAttributes( AudioAttributes.Builder() .setContentType(AudioAttributes.CONTENT_TYPE_MUSIC) .setUsage(AudioAttributes.USAGE_MEDIA) .build() ) mediaPlayer?.prepare() mediaPlayer?.start() } catch (e: Exception) { Log.e(PlayService, play failed: ${song.path}, e) }这段代码里的 setAudioAttributes 容易被漏掉。不设置时播放器使用默认音频属性在音频焦点竞争和声音路由上表现不稳定显式声明 USAGE_MEDIA 后系统才能正确参与音频焦点分配。prepare() 是同步阻塞调用本地音乐文件一般几十毫秒内完成文件损坏、路径不可读时会抛 IOException由 catch 兜住。2.2 startForegroundService通知栏不是装饰是生命周期约束播放器进入后台还继续出声必须依托前台服务。Android 8 以后后台应用不能再自由 startService播放音乐这一类用户可感知任务要用 startForegroundService()并且服务在启动后 5 秒内必须调用 startForeground()否则系统抛 ForegroundServiceDidNotStartInTimeException。class PlayService : Service() { private var mediaPlayer: MediaPlayer? null private val songList mutableListOfSong() private var currentIndex 0 override fun onCreate() { super.onCreate() mediaPlayer MediaPlayer() createNotificationChannel() } override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { when (intent?.action) { ACTION_PLAY - play() ACTION_PAUSE - pause() ACTION_NEXT - playAt(currentIndex 1) ACTION_PREV - playAt(currentIndex - 1) ACTION_STOP - stopSelf() } return START_NOT_STICKY } private fun play() { if (songList.isEmpty()) return playAt(currentIndex) // playAt 内部会调用 startForeground() } override fun onDestroy() { mediaPlayer?.release() mediaPlayer null super.onDestroy() } companion object { const val CHANNEL_ID music_play_channel const val NOTIFICATION_ID 1001 const val ACTION_PLAY com.example.musicplayer.PLAY const val ACTION_PAUSE com.example.musicplayer.PAUSE const val ACTION_NEXT com.example.musicplayer.NEXT const val ACTION_PREV com.example.musicplayer.PREV const val ACTION_STOP com.example.musicplayer.STOP } }onStartCommand 返回 START_NOT_STICKY服务被系统杀死后不会自动重建适合音乐播放器因为被杀说明系统内存紧张自动恢复反而会继续占资源如果希望进程被回收后能恢复播放可以改 START_STICKY但要在 onStartCommand 里判空 intent 并手动恢复歌单工作量和崩溃风险都会增加。onDestroy 里必须 release()否则下次启动创建新的 MediaPlayer 会累积底层资源。播放音乐服务在 AndroidManifest.xml 里要补上 foregroundServiceTypeservice android:name.PlayService android:exportedfalse android:foregroundServiceTypemediaPlayback /Android 14targetSdk 34开始启动前台服务而不声明类型会直接报 MissingForegroundServiceTypeException。exported 一定设 false课程设计里不需要给别的应用调这个服务。2.3 音频焦点来电时不暂停就会和铃声混在一起不处理音频焦点播放器在来电、导航播报和别的音乐 App 启动时会和系统同时出声这也是答辩高频追问点。常见做法是播放前用 AudioManager.requestAudioFocus() 申请焦点再通过 OnAudioFocusChangeListener 响应焦点变化。private val audioManager by lazy { getSystemService(Context.AUDIO_SERVICE) as AudioManager } private val focusListener AudioManager.OnAudioFocusChangeListener { change - when (change) { AudioManager.AUDIOFOCUS_LOSS - pause() AudioManager.AUDIOFOCUS_LOSS_TRANSIENT - pause() AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK - { mediaPlayer?.setVolume(0.3f, 0.3f) } AudioManager.AUDIOFOCUS_GAIN - { mediaPlayer?.setVolume(1f, 1f) mediaPlayer?.start() } } } private fun requestFocus(): Boolean { val result audioManager.requestAudioFocus( focusListener, AudioManager.STREAM_MUSIC, AudioManager.AUDIOFOCUS_GAIN ) return result AudioManager.AUDIOFOCUS_REQUEST_GRANTED } private fun abandonFocus() { audioManager.abandonAudioFocus(focusListener) }requestAudioFocus 的三个参数依次是焦点变化监听器、音频流类型、请求的焦点模式。AUDIOFOCUS_GAIN 表示长时间持有焦点适合正常播放AUDIOFOCUS_GAIN_TRANSIENT 表示短暂播放提示音当前播放器应当暂停AUDIOFOCUS_GAIN_TRANSIENT_MAY_DUCK 表示允许降音量共存。回调里等对方释放焦点后收到 AUDIOFOCUS_GAIN 时要恢复音量和播放状态。这里有个课设里常见的误用把 pause() 写在 LOSS 回调里重新点击播放按钮却无法恢复因为没有在 play() 里重新 requestFocus()。正确的做法是在 play() 开头先调用 requestFocus()判断返回值再继续播放。焦点事件含义建议处理AUDIOFOCUS_LOSS长时间失去焦点暂停并释放焦点不自动恢复AUDIOFOCUS_LOSS_TRANSIENT短暂失去来电暂停焦点恢复后再继续AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK其他应用正在发声降低音量不暂停AUDIOFOCUS_GAIN重新获得焦点恢复音量和播放状态注意targetSdk 31 及以上请求 POST_NOTIFICATIONS 权限后才能显示前台服务通知漏掉这个权限前台服务也能跑但通知栏看不到UI 上会误以为播放器没启动。3. MediaStore 扫描与去重Android 10 分区存储下的歌曲列表3.1 权限检查与运行时请求Android 13 用 READ_MEDIA_AUDIO从 Android 6 开始读写外部存储属于危险权限需要运行时申请。Android 13 又把音频、视频、图片三个权限拆开读取音乐用 READ_MEDIA_AUDIOAndroid 12 及以下仍然用 READ_EXTERNAL_STORAGE。兼容写法如下private fun hasAudioPermission(): Boolean { return if (Build.VERSION.SDK_INT Build.VERSION_CODES.TIRAMISU) { ContextCompat.checkSelfPermission( this, Manifest.permission.READ_MEDIA_AUDIO ) PackageManager.PERMISSION_GRANTED } else { ContextCompat.checkSelfPermission( this, Manifest.permission.READ_EXTERNAL_STORAGE ) PackageManager.PERMISSION_GRANTED } } 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 } ActivityCompat.requestPermissions(this, arrayOf(permission), REQUEST_AUDIO_PERMISSION) }在 MainActivity 的 onResume 里检查权限没有授权就弹请求用户拒绝后RecyclerView 显示空列表并给出“请在设置里开启音乐权限”的提示。AndroidManifest.xml 里两个权限都声明低版本用 maxSdkVersion 限制避免在新版系统上重复弹出uses-permission android:nameandroid.permission.READ_EXTERNAL_STORAGE android:maxSdkVersion32 / uses-permission android:nameandroid.permission.READ_MEDIA_AUDIO /注意一个细节Android 11 的“所有文件访问”权限 MANAGE_EXTERNAL_STORAGE 并不能替代 READ_MEDIA_AUDIO它属于特殊权限需要跳设置页单独授予。课设只读取媒体库申请它就属于过度申请答辩时容易被追问不推荐。3.2 查询 MediaStore 并去重selection 和 distinctBy 一起用MediaStore 是系统媒体库的 ContentProvider 入口用 contentResolver.query() 查询。下面是一个能直接用的扫描函数data class Song( val id: Long, val title: String, val artist: String, val duration: Long, val path: String ) private fun querySongs(): ListSong { val songs mutableListOfSong() val projection arrayOf( MediaStore.Audio.Media._ID, MediaStore.Audio.Media.TITLE, MediaStore.Audio.Media.ARTIST, MediaStore.Audio.Media.DURATION, MediaStore.Audio.Media.DATA ) val selection ${MediaStore.Audio.Media.IS_MUSIC} ! 0 AND ${MediaStore.Audio.Media.DURATION} 30000 contentResolver.query( MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, projection, selection, null, ${MediaStore.Audio.Media.TITLE} ASC )?.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 durationCol cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.DURATION) val dataCol cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.DATA) while (cursor.moveToNext()) { val path cursor.getString(dataCol) ?: continue if (isValidMusicPath(path)) { songs.add( Song( id cursor.getLong(idCol), title cursor.getString(titleCol) ?: 未知歌曲, artist cursor.getString(artistCol) ?: 未知艺术家, duration cursor.getLong(durationCol), path path ) ) } } } return songs.distinctBy { it.title to it.artist to it.duration } }这段逻辑有四个要点。projection 是查询返回的列不要用 SELECT *显式声明列能减少 binder 传输量selection 的 IS_MUSIC 标志位会把系统铃声、录音排除在歌曲列表外DURATION 单位是毫秒 30000 表示过滤 30 秒以下的通知音排序条件直接拼进 sortOrder 参数让 MediaStore 排序而不是在 Kotlin 里 sortBy。cursor.use 会自动关闭 cursor避免资源泄漏。distinctBy 的键是“标题歌手时长”三元组比只按 title 去重更稳因为不同专辑里允许出现同名歌曲。3.3 过滤 FileProvider 导出的假音乐/android/data 路径不能要真机扫描时会发现列表里混入一些没有封面、时长几百毫秒的“歌曲”它们的路径多半带 /android/data/。这是因为微信、百度网盘等应用把临时音频放在自己私有外部目录系统媒体扫描器偶尔会收录而这类文件在 Android 11 以后受分区存储限制本应用根本无权读取点击播放会直接抛异常。过滤函数如下private val FAKE_MUSIC_EXTENSIONS listOf(.jpg, .jpeg, .png, .gif, .webp) private fun isValidMusicPath(path: String): Boolean { val lower path.lowercase() if (lower.contains(/android/data/) || lower.contains(/data/data/)) { return false } if (FAKE_MUSIC_EXTENSIONS.any { lower.endsWith(it) }) { return false } return lower.endsWith(.mp3) || lower.endsWith(.wav) || lower.endsWith(.flac) || lower.endsWith(.m4a) || lower.endsWith(.aac) || lower.endsWith(.ogg) || lower.endsWith(.opus) }文件名伪装成 mp3 的图片仍会漏进来。如果课程设计有时间可以在后台线程用 MediaMetadataRetriever 校验 MIME 类型和时长private fun isValidAudioByMetadata(path: String): Boolean { return try { val retriever MediaMetadataRetriever() retriever.setDataSource(path) val mime retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_MIMETYPE) val duration retriever.extractMetadata( MediaMetadataRetriever.METADATA_KEY_DURATION )?.toLongOrNull() ?: 0L retriever.release() mime?.startsWith(audio/) true duration 1000 } catch (e: Exception) { false } }MediaMetadataRetriever.setDataSource() 对每个文件都要解析文件头几百首歌时耗时明显必须在协程或线程池里跑扫完再一次性提交到 RecyclerView。常见假音频来源特征路径处理方式微信工作文件/storage/emulated/0/Android/data/com.tencent.wework/路径过滤掉 /android/data/百度应用缓存/storage/emulated/0/Android/data/com.baidu.searchbox/同上剪贴板音频片段/data/data/ 下临时文件路径过滤加 MIME 校验伪装音频的图片图片改名 .mp3MediaMetadataRetriever 校验最后提醒一点文件路径在 Android 10 及以上不再保证可靠官方推荐用 ContentResolver.openFileDescriptor(Uri) 读取内容。课设里可以直接用 DATA 列路径喂给 MediaPlayer但要在 README 里备注这个知识点答辩时主动讲出来比被老师问到再承认要加分。4. PlayService 与 UI 解耦通知栏控制、状态回调和列表联动4.1 用 Intent action 分发控制指令播放服务要同时被 Activity 和通知栏按钮唤起最直接的方式是 startForegroundService(Intent(action))。Activity、通知按钮都向同一个 Service 发指令Service 内部根据 action 分发UI 只依赖回调接收状态不直接持有 MediaPlayer。object PlayActions { const val ACTION_PLAY com.example.musicplayer.PLAY const val ACTION_PAUSE com.example.musicplayer.PAUSE const val ACTION_TOGGLE com.example.musicplayer.TOGGLE const val ACTION_NEXT com.example.musicplayer.NEXT const val ACTION_PREV com.example.musicplayer.PREV } fun PlayService.startPlay(context: Context, songs: ListSong, index: Int) { val intent Intent(context, PlayService::class.java).apply { action PlayActions.ACTION_PLAY putExtra(songs, ArrayList(songs)) putExtra(index, index) } context.startForegroundService(intent) }startForegroundService 与 startService 的区别在于前者向系统承诺服务会转成前台服务系统给一段窗口期来调用 startForeground()。如果在 onStartCommand 里没有调用5 秒后必然崩溃。歌单通过 Intent 传递 ArrayList 时请保证 Song 实现 Serializable如果 Song 是 Parcelable 则用 putParcelableArrayListExtra。上千首歌的数据用 Intent 传输会接近 Binder 的 1MB 上限稳妥做法是把歌单存在 Service 内部Activity 只传歌曲路径或索引。4.2 通知栏控制NotificationChannel、PendingIntent 与 MediaStyle前台服务必须马上有通知。Android 8 以上先建 NotificationChannelprivate fun createNotificationChannel() { val channel NotificationChannel( CHANNEL_ID, 音乐播放, NotificationManager.IMPORTANCE_LOW ) val manager getSystemService(NotificationManager::class.java) manager.createNotificationChannel(channel) }IMPORTANCE_LOW 对应低打扰级别通知出现在通知栏但不弹横幅适合播放器常驻IMPORTANCE_HIGH 会响铃不要用在音乐播放上。随后 buildNotification 给通知加“上一首、播放/暂停、下一首”三个动作private fun buildNotification(song: Song): Notification { val pendingIntentFlags PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE val contentIntent PendingIntent.getActivity( this, 0, Intent(this, MainActivity::class.java), pendingIntentFlags ) val prevIntent PendingIntent.getService( this, 1, Intent(this, PlayService::class.java).setAction(PlayActions.ACTION_PREV), pendingIntentFlags ) val toggleIntent PendingIntent.getService( this, 2, Intent(this, PlayService::class.java).setAction(PlayActions.ACTION_TOGGLE), pendingIntentFlags ) val nextIntent PendingIntent.getService( this, 3, Intent(this, PlayService::class.java).setAction(PlayActions.ACTION_NEXT), pendingIntentFlags ) return NotificationCompat.Builder(this, CHANNEL_ID) .setSmallIcon(R.drawable.ic_music_note) .setContentTitle(song.title) .setContentText(song.artist) .setContentIntent(contentIntent) .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) .setPriority(NotificationCompat.PRIORITY_LOW) .addAction(0, 上一首, prevIntent) .addAction(0, 播放/暂停, toggleIntent) .addAction(0, 下一首, nextIntent) .setStyle( NotificationCompat.MediaStyle() .setShowActionsInCompactView(0, 1, 2) .setShowCancelButton(true) ) .build() }PendingIntent 的 FLAG 组合需要重点说明。targetSdk 31 及以上创建 PendingIntent 必须显式加 FLAG_IMMUTABLE 或 FLAG_MUTABLEAndroid 12 会强制要求FLAG_UPDATE_CURRENT 保证多次创建同一 requestCode 的 PendingIntent 时更新携带的数据而不是新增一条。addAction 第一个参数 icon 传 0 时部分系统版本不显示按钮图标所以要准备 ic_previous、ic_play、ic_next 三个向量图标setShowActionsInCompactView(0,1,2) 决定锁屏界面上哪些按钮可见。PendingIntent 标志作用课设使用建议FLAG_UPDATE_CURRENT更新已存在 PendingIntent 的数据每个按钮都加上FLAG_IMMUTABLE创建后不可修改通知栏按钮建议加FLAG_MUTABLE可被替换或被内部填充只有做精确闹钟或自定义跳转时才用4.3 状态回调接口加 Handler 替代静态广播MainActivity 需要知道“切歌了、暂停了、进度变了”。常见做法是定义 PlayerCallback 接口Service 在播放状态变化时通过主线程 handler post 回调。interface PlayerCallback { fun onSongChanged(index: Int) fun onPlayStateChanged(isPlaying: Boolean) }Service 暴露 Binder 给 Activityclass PlayService : Service() { private val mainHandler Handler(Looper.getMainLooper()) private var callback: PlayerCallback? null inner class PlayBinder : Binder() { val service: PlayService get() thisPlayService } override fun onBind(intent: Intent?): IBinder PlayBinder() fun setCallback(cb: PlayerCallback?) { callback cb } private fun notifySongChanged(index: Int) { mainHandler.post { callback?.onSongChanged(index) } } private fun notifyPlayStateChanged(playing: Boolean) { mainHandler.post { callback?.onPlayStateChanged(playing) } } }Activity 在 onStart 里 bindService在 onStop 里把回调置空并 unbindService。不要在 onDestroy 才置空Activity 的 onDestroy 不一定执行。handler.post 把回调切到主线程避免业务逻辑跑在 Binder 线程。注意回调里不要写 fragment 切换逻辑Service 可能有多条回调排队界面不可见时回调直接 return否则会出现“通知栏已切歌、界面回到前台却显示旧歌”的错位。5. RecyclerView 列表与 SeekBar 联动播放状态不同步的修正5.1 ListAdapter 与 DiffUtil 让列表刷新不闪屏音乐列表用 RecyclerView 时别用 notifyDataSetChanged() 全量刷新。每次 MediaStore 扫描结果回来或点击切歌时整个列表会重建滚动位置和闪烁效果都很差。ListAdapter 自带的 DiffUtil 会计算新旧差异做最小范围刷新class SongAdapter( private val onClick: (Int) - Unit ) : ListAdapterSong, SongAdapter.Vh(DIFF) { var currentPlayId: Long -1L override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): Vh { val binding ItemSongBinding.inflate( LayoutInflater.from(parent.context), parent, false ) return Vh(binding) } override fun onBindViewHolder(holder: Vh, position: Int) { holder.bind(getItem(position)) } inner class Vh(private val binding: ItemSongBinding) : RecyclerView.ViewHolder(binding.root) { fun bind(song: Song) { binding.tvTitle.text song.title binding.tvArtist.text song.artist binding.ivPlaying.isVisible song.id currentPlayId binding.root.setOnClickListener { val pos bindingAdapterPosition if (pos ! RecyclerView.NO_POSITION) { onClick(pos) } } } } companion object { private val DIFF object : DiffUtil.ItemCallbackSong() { override fun areItemsTheSame(oldItem: Song, newItem: Song): Boolean oldItem.id newItem.id override fun areContentsTheSame(oldItem: Song, newItem: Song): Boolean oldItem newItem } } }areItemsTheSame 用 id 判断是不是同一首歌areContentsTheSame 比较内容有没有变化。只有内容变化时 onBindViewHolder 才执行标题歌手没变时不会重新绑定滚动时整体不闪。currentPlayId 变化时要手动调 notifyItemChanged(oldIndex) 和 notifyItemChanged(newIndex)否则两行的播放状态图标不联动。5.2 SeekBar 进度刷新Handler 500ms 一次SeekBar 进度直接从 MediaPlayer.currentPosition 拉取但不能在 UI 线程高频轮询。常见做法是 Handler 每 500 毫秒 post 一次更新private val progressHandler Handler(Looper.getMainLooper()) private val progressRunnable object : Runnable { override fun run() { val pos try { mediaPlayer?.currentPosition ?: 0L } catch (e: IllegalStateException) { -1L } if (mediaPlayer?.isPlaying true pos 0) { binding.seekBar.progress pos.toInt() binding.tvCurrent.text formatDuration(pos) } if (mediaPlayer?.isPlaying true) { progressHandler.postDelayed(this, 500) } } } private fun startProgressLoop() { progressHandler.removeCallbacks(progressRunnable) progressHandler.post(progressRunnable) } private fun stopProgressLoop() { progressHandler.removeCallbacks(progressRunnable) }currentPosition 在 MediaPlayer 处于错误状态时会抛 IllegalStateException用 try-catch 包住避免崩溃。500ms 的刷新频率对音乐播放来说观感连续又不会造成 MessageQueue 过载只有做波形图或频谱才需要 100ms普通课设不需要。5.3 列表播放状态错误的完整修正顺序常见的错误表现点第二首歌列表高亮变了通知栏还是第一首或者音乐已经暂停列表行还在闪动。原因通常是 UI 各自维护状态没有统一数据源。需要在 Service 里集中状态fun playAt(index: Int) { if (index 0 || index songList.size) return currentIndex index val song songList[currentIndex] try { mediaPlayer?.reset() mediaPlayer?.setDataSource(song.path) mediaPlayer?.prepare() mediaPlayer?.start() startForeground(NOTIFICATION_ID, buildNotification(song)) notifySongChanged(currentIndex) notifyPlayStateChanged(true) startProgressLoop() } catch (e: Exception) { notifyPlayStateChanged(false) Log.e(PlayService, playAt failed: ${e.message}) } }执行顺序是先更新 Service 内部状态再更新通知栏最后回调给 UI 更新 currentPlayId 和图标。不要把 start() 放在回调之后否则 UI 显示播放中但 MediaPlayer 抛异常失败状态又会闪回。三种刷新方式对比如下刷新方式触发范围表现适用时机notifyDataSetChanged()全部 item重绘闪烁、滚动回顶不建议ListAdapter.submitList()有差异的 item局部更新、动画平滑扫描结果变更notifyItemChanged()单个 item最小范围操作播放行状态变化6. 答辩前要调通的边界耳机拔插、音频焦点与进程回收6.1 耳机拔插处理拔耳机自动暂停是播放器标配。Android 8 以后清单文件里静态注册隐式广播大部分失效要动态注册监听 AudioManager.ACTION_AUDIO_BECOMING_NOISYprivate val noisyReceiver object : BroadcastReceiver() { override fun onReceive(context: Context?, intent: Intent?) { if (intent?.action AudioManager.ACTION_AUDIO_BECOMING_NOISY) { pause() } } } override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { registerReceiver( noisyReceiver, IntentFilter(AudioManager.ACTION_AUDIO_BECOMING_NOISY) ) return START_NOT_STICKY } override fun onDestroy() { unregisterReceiver(noisyReceiver) super.onDestroy() }监听器要注册在 onCreate 或 onStartCommand不要在局部函数里注册否则 onReceive 还没触发就失效。拔耳机后如果不清除音频焦点再插回耳机时音乐不会自动恢复这是和系统行为一致的但要在 UI 上把按钮置为暂停态避免用户误判。6.2 进程回收与通知栏残留的验证验证整套链路是否健壮可以用 adb 模拟两个场景。先在一台 Android 10 以上模拟器装包把测试歌放进 Music 目录触发扫描adb push test.mp3 /sdcard/Music/ adb shell cmd media scan随后在 App 里播放按 Home 回桌面确认通知栏出现再强制杀进程看通知栏表现adb shell am kill com.example.musicplayer如果播放停止且通知栏消失说明前台服务被系统回收、onDestroy 正常释放了 MediaPlayer这是标准行为。再补一个技巧adb shell dumpsys activity services com.example.musicplayer能直接看到服务进程是否存活、是否处于 foreground 状态比逐行翻 Logcat 更高效地判断前台服务有没有真正注册成功。调通这两条命令再配合前面的音频焦点和耳机插拔事件课设演示就不会在最关键的节点掉链子。本文还有配套的精品资源点击获取
返回列表