ARTICLE DETAIL

资讯详情

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

Android本地音乐播放器开发实战:权限申请与MediaPlayer播放详解

Android本地音乐播放器开发实战:权限申请与MediaPlayer播放详解 简介这是一份面向Android初学者的轻量级本地音乐播放器实战项目适用于安卓应用开发入门学习与课程实验。项目仅含单页面UI基于Android Studio 3.1.4构建通过MediaPlayer API读取模拟器SD卡中音频文件支持歌曲列表点击播放、暂停/继续等基础控制功能代码结构清晰、逻辑简洁便于理解Android四大组件、文件IO及媒体播放核心流程。压缩包共1118个文件涵盖21个Java源码、16个XML布局与配置文件、116个JSON资源、86个JAR依赖库及大量编译产物如class、dex、flat文件整体大小为11.61MB目录组织规范便于快速定位主Activity、Adapter及权限配置模块。目前已有1129人下载学习配套源码可直接导入运行无需额外配置是掌握Android基础控件、生命周期管理与本地存储访问的理想练手案例。 说实话Android开发入门练手项目虽然多但多数要么太简单计算器、待办事项要么一上来就要搞网络请求和数据库把新手直接劝退。本地音乐播放器是我觉得难度刚好卡在中间的那一类它要用到运行时权限申请、系统媒体库查询、列表渲染、状态同步、多媒体播放一个项目串起了Android开发里好几块硬核知识点。这篇文章我按实际开发顺序来写从工程搭建到播放逻辑全流程拆解代码可以直接照抄跑通同时每个关键步骤我都会解释为什么这么做。1. 项目整体设计与思路拆解1.1 这个项目到底在写什么本地音乐播放器通俗点说就是把你手机存储里的音频文件读取出来以列表形式展示在界面上点击任意一首就能播放。它不涉及网络、不涉及数据库所有数据都来自系统自己的媒体库MediaStore播放则由Android官方提供的MediaPlayer组件完成。就这么一个看起来“平平无奇”的功能背后涉及的Android知识点其实相当扎实运行时权限申请机制、ContentResolver查询系统媒体库、RecyclerView列表展示与点击事件、MediaPlayer状态机管理和资源释放、UI状态与播放状态的同步。如果你是刚学完Android基础组件和布局想要一个综合练习项目这个项目非常合适。它对代码量要求不高核心逻辑集中在一个Activity里就能搞定但又不会像计算器那样写完毫无成就感。我见过不少学习者做完这个项目后对Activity生命周期、权限机制、列表适配器的理解都上了一个台阶因为它逼着你在真实场景里去处理这些问题而不是背概念。1.2 方案选型先跑通核心闭环再谈架构市面上很多教程一上来就让新手用Service做后台播放、加通知栏控制、处理音频焦点这些概念叠加在一起很容易让初学者迷失在“框架”里连最基础的播放逻辑都没搞清楚。我的建议是第一版把逻辑全部放在Activity里用官方自带的MediaPlayer组件先跑通“读取列表——点击播放——暂停/切换/拖进度”这条最核心的闭环。为什么不推荐一开始就用ExoPlayerExoPlayer确实功能更强但那是面向流媒体、自适应码率、DLNA这类复杂场景的。对于播放一个本地MP3MediaPlayer的性能和API复杂度都刚刚好。学会MediaPlayer的状态机后你去理解ExoPlayer会更轻松毕竟很多概念是相通的。等核心功能稳定了你完全可以再把它迁移到Service里、加上通知栏和音频焦点处理那时候你会感谢自己先在Activity里把播放逻辑理清楚过一遍。2. 环境准备与项目骨架搭建2.1 版本选择与Gradle同步加速写这个项目需要的环境并不复杂一台安装了常见版本Android Studio的电脑就行。我这边用的是Android Studio Koala2024.1.1Gradle版本8.5以上JDK 17。如果你还没装Android Studio直接到官网下载安装包一路默认选项即可安装过程中会一并配置好Android SDK和模拟器组件。一个卡住很多新手的点是Gradle同步慢。首次打开项目时Gradle需要下载大量依赖在国内网络环境下经常一卡就是半小时。解决办法是在项目的settings.gradle.kts里加上阿里云镜像仓库放在google()和mavenCentral()之前pluginManagement { repositories { maven { url uri(https://maven.aliyun.com/repository/public) } maven { url uri(https://maven.aliyun.com/repository/google) } maven { url uri(https://maven.aliyun.com/repository/gradle-plugin) } google() mavenCentral() gradlePluginPortal() } }这样同步速度会有明显提升。另外SDK版本的建议compileSdk用33targetSdk也用33。这个选择很关键因为Android 13API 33起系统把媒体读取权限从READ_EXTERNAL_STORAGE拆分成了更细粒度的READ_MEDIA_AUDIO。如果你的targetSdk是33以上但代码里还只申请旧的存储权限就会出现“权限明明给了但就是查不到音乐”的问题这一点后面会详细展开。反过来targetSdk也不建议定得太低新版本模拟器和上架市场对低targetSdk的应用限制会越来越多。2.2 新建项目与依赖配置打开Android Studio选择“New Project”模板选“Empty Views Activity”。注意是Views Activity不是Compose。虽然Compose现在是官方主推但这个项目重点在理解Android传统开发流程而且网上能找到的参考资料也是View体系居多。语言我建议直接选Kotlin如果你还在用Java下面代码的逻辑照抄也能跑通只是语法差异。包名可以写成com.example.musicplayer这类格式不影响功能。项目创建好后需要给模块级的build.gradle.kts添加RecyclerView依赖同时建议把Material库也加上因为后面的布局控件会用到。内容如下dependencies { implementation(androidx.core:core-ktx:1.12.0) implementation(androidx.appcompat:appcompat:1.6.1) implementation(com.google.android.material:material:1.11.0) implementation(androidx.constraintlayout:constraintlayout:2.1.4) implementation(androidx.recyclerview:recyclerview:1.3.2) }添加依赖后Android Studio会自动触发Gradle Sync等同步完成就说明工程骨架没问题了。3. 读取本地音乐权限申请与内容查询3.1 运行时权限申请机制从Android 6.0API 23开始系统引入了运行时权限机制。像读取存储、定位、相机这类“危险权限”应用安装后不会直接授予必须在运行时弹窗向用户申请用户点允许后应用才有权限访问。这个机制的核心目的是保护用户隐私。作为开发者我们必须实现一整套“申请——回调——处理拒绝”的逻辑。具体到我们这个项目根据系统版本不同要申请的权限不一样Android版本需要申请的权限Android 12及以下API ≤ 32READ_EXTERNAL_STORAGE读取外部存储Android 13及以上API ≥ 33READ_MEDIA_AUDIO读取音频文件传统写法是requestPermissions配onRequestPermissionsResult但现在官方推荐用ActivityResultLauncher代码更简洁也和生命周期绑定得更好。核心实现如下private lateinit var permissionLauncher: ActivityResultLauncherString 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 } permissionLauncher registerForActivityResult( ActivityResultContracts.RequestPermission() ) { granted - if (granted) { loadMusicList() } else { Toast.makeText(this, 没有权限无法读取本地音乐, Toast.LENGTH_SHORT).show() } } permissionLauncher.launch(permission) }注意Build.VERSION.SDK_INT Build.VERSION_CODES.TIRAMISU这个判断它表示Android 13及以上版本。这个兼容判断必须写否则在Android 13手机上申请旧权限会完全无效。需要在onCreate里调用申请逻辑并且要注意一个常见坑如果用户已经拒绝过一次权限再次调用launch时会直接走失败回调不会弹窗。更严谨的流程是判断shouldShowRequestPermissionRationale()给用户解释为什么需要权限然后引导去设置页开启。这个兜底我们放在后面的问题排查章节展开。3.2 用MediaStore查询本地音乐权限拿到后核心工作就是查询系统媒体库。MediaStore是Android系统内置的媒体数据库统一管理设备上的图片、视频、音频文件。我们通过ContentResolver.query()去查MediaStore.Audio.Media.EXTERNAL_CONTENT_URI这张表就能拿到设备上所有音乐文件的标题、歌手、时长、路径等信息。查询代码如下private fun loadMusicList() { val musicList mutableListOfMusic() 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 val sortOrder ${MediaStore.Audio.Media.TITLE} ASC contentResolver.query( MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, projection, selection, null, sortOrder )?.use { cursor - val idColumn cursor.getColumnIndexOrThrow(MediaStore.Audio.Media._ID) val titleColumn cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.TITLE) val artistColumn cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.ARTIST) val durationColumn cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.DURATION) val dataColumn cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.DATA) while (cursor.moveToNext()) { val id cursor.getLong(idColumn) val title cursor.getString(titleColumn) val artist cursor.getString(artistColumn) val duration cursor.getLong(durationColumn) val path cursor.getString(dataColumn) if (duration 0) { musicList.add(Music(id, title, artist, duration, path)) } } } musicAdapter.submitList(musicList) }几个关键点解释一下projection是你要查询的字段列表相当于SQL里的列名。不查询不必要的字段能减少内存占用。selection这里用了IS_MUSIC ! 0这个条件能把录音、铃声、通知音等非音乐音频过滤掉一部分。通过duration 0做二次过滤因为有些系统的录音文件时长字段可能是0强行展示会让列表出现无意义的条目。use { }是Kotlin扩展函数会自动关闭Cursor避免资源泄漏。这个习惯一定要养成Cursor不关闭会导致内存泄漏严重时会报CursorWindow相关异常。sortOrder按歌名排序你也可以改成按添加时间或歌手排序MediaStore.Audio.Media.ADDED DESC就是一个常用选项。模拟器测试时的常见问题是模拟器里什么都没有查出来是空列表再正常不过。你可以直接把自己电脑上的MP3文件拖拽到模拟器窗口Android Studio会自动把文件拷贝到模拟器的Download目录系统媒体扫描器会自动识别。过几秒重新点加载歌曲就会出现在列表里。3.3 音乐数据实体类Music是一个纯数据类承载列表展示和播放所需的所有字段data class Music( val id: Long, val title: String, val artist: String, val duration: Long, val path: String, val albumId: Long 0 )albumId现在用不到但如果你后续想用Glide加载专辑封面这个字段可以直接派上用场。path是文件绝对路径播放时setDataSource()需要它。duration单位是毫秒展示时再转换成mm:ss格式。4. 音乐列表界面RecyclerView与适配器4.1 主界面布局设计界面采用经典的“顶部标题栏 中间列表 底部播放控制栏”三段式结构。根布局用ConstraintLayout顶部放一个标题TextView中间是RecyclerView底部是一个垂直方向的LinearLayout用来放SeekBar、当前歌曲名和三个控制按钮。主布局核心结构androidx.constraintlayout.widget.ConstraintLayout ... TextView android:idid/tvTitle android:layout_widthmatch_parent android:layout_heightwrap_content android:text本地音乐 android:textSize20sp android:padding16dp app:layout_constraintTop_toTopOfparent / androidx.recyclerview.widget.RecyclerView android:idid/rvMusicList android:layout_width0dp android:layout_height0dp app:layout_constraintTop_toBottomOfid/tvTitle app:layout_constraintBottom_toTopOfid/bottomBar / LinearLayout android:idid/bottomBar android:layout_widthmatch_parent android:layout_heightwrap_content android:orientationvertical android:padding12dp app:layout_constraintBottom_toBottomOfparent SeekBar android:idid/seekBar android:layout_widthmatch_parent android:layout_heightwrap_content / TextView android:idid/tvCurrentMusic android:layout_widthmatch_parent android:layout_heightwrap_content android:text暂无播放 android:singleLinetrue / LinearLayout android:layout_widthmatch_parent android:layout_heightwrap_content android:orientationhorizontal android:gravitycenter Button android:idid/btnPrev android:layout_widthwrap_content android:layout_heightwrap_content android:text上一首 / Button android:idid/btnPlayPause android:layout_widthwrap_content android:layout_heightwrap_content android:text播放 / Button android:idid/btnNext android:layout_widthwrap_content android:layout_heightwrap_content android:text下一首 / /LinearLayout /LinearLayout /androidx.constraintlayout.widget.ConstraintLayout这里底部的bottomBar我用的是LinearLayout而不是在ConstraintLayout里用多个控件约束到底部原因很简单用LinearLayout包裹后布局层次更清晰调整控制按钮的间距也更方便。RecyclerView的0dp宽高配合约束让它自动填充剩余空间这是ConstraintLayout最常用的“填充”技巧。4.2 list_item布局与适配器实现列表item布局我建议保持简洁就三行信息歌名、歌手名、时长。不需要显示专辑名也不需要封面图信息越多新手反而越难处理加载失败的情况。行高用wrap_content加上内边距保证不同字体缩放下都能正常显示LinearLayout xmlns:androidhttp://schemas.android.com/apk/res/android android:layout_widthmatch_parent android:layout_heightwrap_content android:orientationhorizontal android:gravitycenter_vertical android:paddingHorizontal16dp android:paddingVertical12dp LinearLayout android:layout_width0dp android:layout_heightwrap_content android:layout_weight1 android:orientationvertical TextView android:idid/tvMusicTitle android:layout_widthmatch_parent android:layout_heightwrap_content android:textSize16sp android:textColorandroid:color/black android:maxLines1 android:ellipsizeend / TextView android:idid/tvMusicArtist android:layout_widthmatch_parent android:layout_heightwrap_content android:textSize13sp android:textColorandroid:color/darker_gray android:maxLines1 android:ellipsizeend / /LinearLayout TextView android:idid/tvMusicDuration android:layout_widthwrap_content android:layout_heightwrap_content android:textSize13sp android:textColorandroid:color/darker_gray / /LinearLayout歌名和歌手名都加了maxLines1和ellipsizeend防止长文件名把布局撑破。MusicAdapter继承自RecyclerView.Adapter实现起来并不复杂。这里把item点击事件的处理方式单独说一下现在主流写法是直接在适配器里暴露一个Lambda变量Activity里直接赋值监听比传统OnItemClickListener接口更简洁代码量也少。class MusicAdapter( private val list: MutableListMusic mutableListOf() ) : RecyclerView.AdapterMusicAdapter.MusicViewHolder() { var onItemClick: ((Int) - Unit)? null private var currentPlayPosition -1 inner class MusicViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { val tvTitle: TextView itemView.findViewById(R.id.tvMusicTitle) val tvArtist: TextView itemView.findViewById(R.id.tvMusicArtist) val tvDuration: TextView itemView.findViewById(R.id.tvMusicDuration) } override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): MusicViewHolder { val view LayoutInflater.from(parent.context) .inflate(R.layout.item_music, parent, false) return MusicViewHolder(view) } override fun onBindViewHolder(holder: MusicViewHolder, position: Int) { val music list[position] holder.tvTitle.text music.title holder.tvArtist.text music.artist holder.tvDuration.text formatDuration(music.duration) holder.itemView.isSelected position currentPlayPosition holder.itemView.setOnClickListener { onItemClick?.invoke(holder.bindingAdapterPosition) } } override fun getItemCount() list.size fun updateList(newList: ListMusic) { list.clear() list.addAll(newList) notifyDataSetChanged() } fun setCurrentPlayPosition(position: Int) { currentPlayPosition position notifyDataSetChanged() } }这里有一个细节值得注意在点击回调里我用的不是onBindViewHolder参数里的position而是holder.bindingAdapterPosition。原因是RecyclerView在数据集变化、动画执行期间onBindViewHolder的position参数可能是过期的而bindingAdapterPosition能拿到当前真正对应的数据位置。这种小细节在快速点击列表项时特别重要。isSelected配合item布局根节点上的android:background选择器就能实现当前播放项的高亮效果比手动改字体颜色要优雅得多。不过这个属于锦上添花的功能入门阶段可以后加。4.3 时长格式化工具方法时长字段从MediaStore取出来是毫秒值比如一首3分20秒的歌得到的是200000毫秒。这个数值不能直接显示需要转成mm:ss格式。写一个工具方法fun formatDuration(durationMs: Long): String { if (durationMs 0) return 00:00 val totalSeconds durationMs / 1000 val minutes totalSeconds / 60 val seconds totalSeconds % 60 return String.format(%02d:%02d, minutes, seconds) }这个方法建议放在单独的文件里比如Utils.kt。后面播放进度显示还要用到一个地方维护比在适配器和Activity里各写一份要干净得多。5. MediaPlayer核心播放逻辑5.1 状态机与播放方法封装MediaPlayer本质是一个状态机调用方法时必须符合它的状态转换规则否则会抛出IllegalStateException。为什么这是新手最容易踩的坑因为播放器不像普通类你随手调一个方法可能是合法的也可能是非法的编译器不会帮你检查。为了彻底规避这个问题最有效的办法是每次播放新歌曲都走同一套固定流程private fun playMusic(position: Int) { if (musicList.isEmpty()) return currentPlayPosition position val music musicList[position] tvCurrentMusic.text music.title try { mediaPlayer?.reset() mediaPlayer?.setDataSource(music.path) mediaPlayer?.setOnPreparedListener { mp - mp.start() updatePlayButton(true) startProgressUpdate() } mediaPlayer?.setOnCompletionListener { playNext() } mediaPlayer?.prepareAsync() } catch (e: Exception) { Log.e(MusicPlayer, 播放失败: ${e.message}) } }这套流程的顺序是固定的reset()回到空闲状态——setDataSource()设置数据源——prepareAsync()异步准备——onPrepared回调里start()。只要每一步都遵循这个顺序就基本不会踩到状态机的坑。有人会问为什么不用prepare()而用prepareAsync()因为prepare()是同步操作会阻塞主线程。如果音频文件很大或者文件系统响应慢主线程卡住会导致界面无响应严重时触发ANR弹窗。prepareAsync()则不会阻塞准备完成后通过回调通知主线程。当然本地小文件用prepare()可能也感觉不到卡顿但养成写异步准备的习惯很重要尤其是以后处理网络音频时这个经验直接复用。reset()之后调用setDataSource()之前如果数据源路径非法、文件被删除会抛出异常。这也是为什么整个流程用try-catch包起来防止应用崩溃。实际开发中列表里的歌曲可能在扫描之后被用户删除所以每次播放都要有容错。5.2 播放进度更新与SeekBar联动播放器开始播放后UI上的进度条需要跟随播放状态实时刷新。最传统也最稳定的方式是用Handler加Runnable每隔500毫秒更新一次。这个间隔是我实测下来比较合适的值——太快了增加无谓刷新开销太慢了进度条看起来一顿一顿。private val handler Handler(Looper.getMainLooper()) private val progressRunnable object : Runnable { override fun run() { mediaPlayer?.let { mp - if (mp.isPlaying) { seekBar.progress mp.currentPosition seekBar.max mp.duration tvCurrentTime.text formatDuration(mp.currentPosition.toLong()) } handler.postDelayed(this, 500) } } } private fun startProgressUpdate() { handler.removeCallbacks(progressRunnable) handler.post(progressRunnable) }注意这里启动前先removeCallbacks防止重复启动多个循环导致进度条刷新加速。这个细节很多人会忽略。SeekBar拖动这块有个比较隐蔽的问题用户在拖进度条的时候播放器还在继续播放Handler也在不断刷新进度条两边会互相“打架”表现为进度条跳来跳去。解决办法是加一个isDragging标志位拖动开始时暂停Handler的刷新拖动结束后根据SeekBar的当前值调用seekTo跳转再恢复刷新seekBar.setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener { override fun onProgressChanged(seekBar: SeekBar?, progress: Int, fromUser: Boolean) { if (fromUser) { tvCurrentTime.text formatDuration(progress.toLong()) } } override fun onStartTrackingTouch(seekBar: SeekBar?) { isDragging true } override fun onStopTrackingTouch(seekBar: SeekBar?) { isDragging false mediaPlayer?.seekTo(seekBar?.progress ?: 0) } })onProgressChanged里判断fromUser很重要因为程序自动更新进度条时也会回调这个方法如果不判断就会出现“进度条自动刷新时不断重设文本、让用户感觉界面卡顿”的情况。5.3 播放下一首与循环策略一首歌播放完毕后的行为是由setOnCompletionListener回调控制的。我在里面调用playNext()实现切换到下一首的逻辑private fun playNext() { if (musicList.isEmpty()) return val nextPosition (currentPlayPosition 1) % musicList.size playMusic(nextPosition) } private fun playPrev() { if (musicList.isEmpty()) return val prevPosition if (currentPlayPosition - 1 0) { musicList.size - 1 } else { currentPlayPosition - 1 } playMusic(prevPosition) }这里用了取模运算实现列表循环播放到最后一首再点下一首会回到第一首到第一首点上一首会跳到最后一首。这个逻辑简单且不影响理解。5.4 生命周期管理与资源释放这是很多初学者最容易忽视的部分。MediaPlayer持有底层音频解码资源如果不及时释放会导致音频持续播放、内存泄漏甚至影响其他应用的声音播放。在Activity的onDestroy里必须做好清理工作override fun onDestroy() { super.onDestroy() handler.removeCallbacksAndMessages(null) mediaPlayer?.release() mediaPlayer null }removeCallbacksAndMessages(null)会把Handler上的所有消息清掉防止Activity销毁后Runnable还在后台跑导致内存泄漏。release()释放MediaPlayer占用的底层资源置空引用则是为了防止后续误用已释放的对象。这里还要提一个新手容易踩的坑旋转屏幕。默认情况下Activity旋转会销毁重建如果你的音乐正在播放旋转一下屏幕MediaPlayer被release了播放就会中断。最省事的临时方案是在AndroidManifest.xml里给Activity加上android:configChangesorientation|screenSize让Activity自己处理配置变化而不重建activity android:name.MainActivity android:exportedtrue android:configChangesorientation|screenSize ... /activity这个方案只适合入门阶段。长期来看更优雅的方案是通过ViewModel持有MediaPlayer旋屏时数据不丢失播放不中断再进一步就是把播放放到Service里实现真正的后台播放。这些升级方向等核心功能跑通后再去研究方向是明确的。6. 常见问题与排查技巧实录6.1 权限都给了还是扫不到音乐这个现象在开发中非常常见明明权限弹窗点了允许代码也执行了查询列表就是空的。排查优先级从高到低如下排查项说明权限是否正确targetSdk≥33必须申请READ_MEDIA_AUDIO只申请READ_EXTERNAL_STORAGE在Android 13上无效设备是否有音乐文件模拟器默认没有需要手动拖拽文件进去真机用系统文件管理器确认查询条件是否过滤太严IS_MUSIC ! 0会把一部分录音、语音文件过滤掉如果测试文件本身不是标准音频格式也可能被过滤是否有系统媒体扫描延迟刚拷贝进去的文件需要等待媒体扫描器识别部分国产手机系统扫描还不及时如果文件已经存在但一直扫不到可以尝试手动触发媒体扫描MediaScannerConnection.scanFile( this, arrayOf(filePath), arrayOf(audio/mpeg) ) { _, _ - runOnUiThread { loadMusicList() } }体验阶段的建议是代码里加一个下拉刷新或者一个“重新扫描”按钮这样调试时不用反复重启应用来触发查询。6.2 MediaPlayer各种异常初学阶段最常碰到的是IllegalStateException几乎都是因为方法调用顺序和MediaPlayer状态不匹配。例如没有prepare就start没有reset就再次setDataSource在Released状态调用start等等。要根治这个问题就是把播放流程统一封装成5.1节那样的固定顺序调用方不需要关心内部状态流转只需要调用playMusic(position)。另一个常见问题是prepareAsync还没回调onPrepared用户又快速点了另一首歌此时reset()会打断正在进行的准备流程导致后一个播放请求异常。解决办法是在代码里加一个准备中的标志位private var isPreparing false private fun playMusic(position: Int) { ... isPreparing true mediaPlayer?.setOnPreparedListener { mp - isPreparing false mp.start() ... } }点击播放时先判断是否在准备中如果是就直接忽略本次点击。这个防护虽然简单但能避免掉绝大多数快速点击场景下的播放异常。6.3 播放列表与UI状态不同步播放列表与UI状态不同步典型表现为列表项高亮位置不对、播放按钮图标混乱、底部栏歌曲标题和实际播放的不一致。这些问题的根本原因是状态分散保存在多个地方没有统一锚点。我的建议是始终以currentPlayPosition作为唯一状态锚点。列表适配器的高亮、底部栏的歌曲标题、播放按钮的图标全部从这一个索引推导。当切换歌曲时先更新currentPlayPosition再同步刷新所有UI组件顺序不能乱private fun updateUIForCurrentMusic() { if (currentPlayPosition in musicList.indices) { val music musicList[currentPlayPosition] tvCurrentMusic.text music.title musicAdapter.setCurrentPlayPosition(currentPlayPosition) } updatePlayButton(mediaPlayer?.isPlaying true) }还有一个实战小技巧列表点击事件里如果点击的position和当前正在播放的position相同不要从头重新播放而是直接切换播放/暂停状态。否则用户点一下正在播放的歌就从头开始体验很糟糕musicAdapter.onItemClick { position - if (position currentPlayPosition) { togglePlayPause() } else { playMusic(position) } }6.4 其他值得留意的坑权限被拒绝后的处理需要做兜底。如果用户点了拒绝再次申请时系统不会再弹窗而是直接走失败回调。这时候应该弹Toast解释并提供跳转到系统设置页的入口。跳转代码val intent Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS) intent.data Uri.parse(package:$packageName) startActivity(intent)音频焦点Audio Focus在这个入门项目里不强制做但你应该了解它的存在。当你的音乐在播放这时候来了一条微信语音系统会打断你的播放如果你不处理会出现音乐继续放、微信语音也响的混乱情况或者音乐被系统暂停但UI没有同步更新。这属于播放器体验优化的重要议题基础功能跑通后建议去了解一下AudioManager.requestAudioFocus的用法。最后提一下Gradle和AGP版本匹配问题。新版Android Studio创建的项目默认使用较新的AGP版本如果你本地的SDK没有对应组件首次构建会下载大量文件速度慢很正常配好镜像源后耐心等待即可。如果Gradle版本和AGP版本不匹配构建时会有明确报错按提示修改版本号就能解决。写到这里项目的核心实现已经完整走了一遍。如果按这个流程做下来实现效果是打开App弹出权限申请同意后看到本地音乐列表点击任意一首歌底部显示歌曲信息SeekBar开始走动三个按钮可以切换上下曲和播放暂停。我自己在带新手做这个项目时还有一个额外建议把自己手机里真实存在的各种格式音频都拷进去测一遍MP3、FLAC、M4A都试试因为不同格式在MediaPlayer上的兼容性表现不完全一样能提前暴露不少播放异常。等这个版本跑稳了再去加通知栏控制、专辑封面、睡眠定时这些功能每一步都有清晰的方向。本文还有配套的精品资源点击获取
返回列表