简介:这是一份面向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 选型与生命周期:为什么课程设计不用 ExoPlayer
2.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() 之后进入 Prepared;start() 进入 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 = mutableListOf<Song>() 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 里要补上 foregroundServiceType:
<service android:name=".PlayService" android:exported="false" android:foregroundServiceType="mediaPlayback" />Android 14(targetSdk 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_AUDIO;Android 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:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32" /> <uses-permission android:name="android.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(): List<Song> { val songs = mutableListOf<Song>() 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 校验 |
| 伪装音频的图片 | 图片改名 .mp3 | MediaMetadataRetriever 校验 |
最后提醒一点:文件路径在 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: List<Song>, 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 以上先建 NotificationChannel:
private 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_MUTABLE,Android 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 给 Activity:
class PlayService : Service() { private val mainHandler = Handler(Looper.getMainLooper()) private var callback: PlayerCallback? = null inner class PlayBinder : Binder() { val service: PlayService get() = this@PlayService } 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 ) : ListAdapter<Song, 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.ItemCallback<Song>() { 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_NOISY:
private 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 更高效地判断前台服务有没有真正注册成功。调通这两条命令,再配合前面的音频焦点和耳机插拔事件,课设演示就不会在最关键的节点掉链子。
本文还有配套的精品资源,点击获取