从 PRD 到上线:一个纯本地 Android 音乐播放器的完整开发复盘(Kotlin + Media3)
本文是个人项目的开发复盘。项目是一个纯本地、不联网、不上传的安卓音乐播放器,从写 PRD、任务拆解到 v1.0 → v1.3 四个版本迭代,全部用「AI 辅助开发 + 真机自测」完成。文中所有账号、路径、签名信息已脱敏。
一、为什么要做这个项目
市面上播放器要么塞满广告和推荐流,要么强依赖网络。我想要的是一个非常干净的工具:
- 打开就能看到本地音乐,点开就播
- 锁屏、通知栏、耳机线控都能控制
- 没有网络权限------不可能偷偷上传任何东西
于是先写了 PRD(15 段结构、FR-01~17 逐条编号 + GWT 验收标准),再拆解技术任务,然后按版本迭代。事实证明这套「先文档后编码」的流程哪怕对个人小项目也非常值得------后面会讲为什么。
技术选型 :Kotlin + Jetpack Media3(ExoPlayer + MediaSession)+ Room,minSdk 33(Android 13,用 READ_MEDIA_AUDIO 细粒度权限)。
二、版本规划:小步快跑
| 版本 | 范围 | 核心价值 |
|---|---|---|
| v1.0 | 扫描 + 播放 + 后台 + 系统控制 + 搜索排序 | 基础闭环 |
| v1.1 | 循环/单曲/随机 + 自建播放列表(Room) | 高频基础能力 |
| v1.2 | 本地歌词(lrc + 内嵌)+ 均衡器 | 体验增强 |
| v1.3 | 专辑/歌手视图 + 倍速 + 睡眠定时器 | 日常刚需 |
每个版本都是「规划锁定 → 开发 → 真机自测清单逐项打勾」,不写需求边界模糊的代码。
三、整体架构
scss
┌─ UI 层 ──────────────────────────────┐
│ MainActivity(权限+导航) │
│ SongsFragment / PlayerActivity │
│ PlaylistsFragment / SettingsFragment │
└──────────────┬───────────────────────┘
│ MediaController(跨进程会话客户端)
┌──────────────▼───────────────────────┐
│ PlaybackService(MediaSessionService)│
│ ├ ExoPlayer(焦点/耳机拔出/倍速) │
│ ├ MediaSession(通知栏/锁屏/线控) │
│ └ EffectsManager(Equalizer) │
└──────────────┬───────────────────────┘
┌──────────────▼───────────────────────┐
│ 数据层 │
│ ├ MusicRepository(MediaStore+缓存) │
│ ├ Room(playlist / playlist_item) │
│ └ LrcParser / LyricsProvider / Prefs │
└───────────────────────────────────────┘
关键决策:UI 层只和 PlayerClient(MediaController 封装)说话,永远不直接碰 ExoPlayer。这样 Activity 怎么生生死死,后台播放都不受影响,而且通知栏/锁屏控制天然免费。
四、核心实现拆解
1. 媒体扫描:MediaStore + 缓存 + 增量刷新
只查 IS_MUSIC != 0 的条目,首启先读私有目录的 JSON 缓存(<1s 出列表),后台静默全量校验,再注册 ContentObserver 监听媒体库变化做增量刷新:
kotlin
app.contentResolver.registerContentObserver(
MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, true,
object : ContentObserver(handler) {
override fun onChange(selfChange: Boolean, uri: Uri?) {
handler.removeCallbacks(pending) // 3s 防抖
handler.postDelayed(pending, 3000)
}
}
)
2. 后台播放:Media3 把最难的部分全包了
前台服务 + 通知栏 + 锁屏 + 耳机线控 + 音频焦点,这些「系统级脏活」Media3 全部自动处理,真正要写的只有配置:
kotlin
val player = ExoPlayer.Builder(this)
.setAudioAttributes(attrs, /* handleAudioFocus = */ true) // 焦点自动抢占/恢复
.setHandleAudioBecomingNoisy(true) // 耳机拔出自动暂停
.setWakeMode(C.WAKE_MODE_LOCAL) // 播放时保持 CPU 唤醒
.build()
mediaSession = MediaSession.Builder(this, player).build()
代价是三行配置对应三个必踩的坑,见下文踩坑记录。
3. 播放模式:把状态机抽成纯函数
顺序/列表循环/单曲循环直接映射 ExoPlayer 的 repeatMode,Shuffle 用 shuffleModeEnabled。但「4 模式 × Shuffle」组合的下一首逻辑容易写崩,所以抽了个纯函数对象做回归测试(8 个组合用例):
kotlin
fun computeNext(index: Int, size: Int, repeatMode: Int,
shuffle: Boolean, playedInRound: MutableSet<Int>): Int
运行时由 ExoPlayer 承载,纯函数版作为同口径参考实现跑单测------改逻辑先跑测试,不用每次装真机。
4. 播放列表:song_key 而不是 URI
Room 两张表 playlist / playlist_item,条目存的是 MediaStore _id(song_key)而不是 content URI------文件被移动/重建索引后 URI 会失效,_id 稳定得多,配合「失效项标记 + 一键清理」兜底。
5. 歌词:本地 lrc + 内嵌,全容错
- 同目录同名
.lrc(多个冲突取修改时间最新的) - 内嵌歌词字段是
@hide常量,公开 SDK 引用不到,用反射拿值、拿不到就当没有 - 编码探测:BOM → UTF-8 → 出现乱码替换符回退 GBK
- 时间轴容错:排序 + 去重 + 负值丢弃,解析挂了就降级「暂无歌词」,绝不影响播放
6. 均衡器:绑定 audioSessionId + 能力降级
kotlin
// 播放会话建立时
Equalizer(0, player.audioSessionId)
// 会话变化时
override fun onAudioSessionIdChanged(id: Int) { EffectsManager.attach(id, prefs) }
// 服务销毁时必须 release,避免音频资源泄漏
预设统一用增益数组实现(按索引重采样到设备实际频段数),不依赖厂商内置预设名------不同 ROM 内置预设列表差异很大。
五、踩坑记录(本文最有价值的部分)
以下坑全部真实发生,按发现顺序排列。
坑 1:镜像源配了不生效,还在从官方下载
gradle-wrapper.properties 改成了国内镜像,构建时却还在下载官方 -src.zip。排查发现是 IDE 用自己的引导逻辑接管了 wrapper (项目缺 gradle-wrapper.jar 时)。教训:wrapper 相关问题先确认「这次构建到底读的是哪份配置、哪个 jar」。
坑 2:Invalid token LOCALIZED ------ 查询排序直接崩溃
arduino
// ❌ MediaProvider 不认识这个 token,整个查询抛 IllegalArgumentException
"TITLE COLLATE LOCALIZED ASC"
// ✅ 普通 ASC,拼音排序放内存里用 Collator 做
"TITLE ASC"
MediaStore 查询走 ContentProvider 到系统服务端执行,服务端 SQLite 不支持本地化排序器。排序需求在客户端做,查询里只写最朴素的 SQL。
坑 3:点击播放秒崩 SecurityException: WAKE_LOCK
用了 setWakeMode(C.WAKE_MODE_LOCAL) 却没在 Manifest 声明 android.permission.WAKE_LOCK。这是 ExoPlayer 文档明确要求的配套权限,普通权限、安装即授予,但漏了就是真机上一触即崩。
坑 4:style 里没写 layout_width,inflate 直接炸
多个控件只写了 style="@style/SettingRow",style 里没定义布局尺寸,运行时 You must supply a layout_width attribute。修法是在 style 中补 android:layout_width/height(style 携带 layout 属性是生效的),一次修所有引用处。
坑 5:用 visibility 状态当业务标志位,首页一片黑
防重复初始化写成了 if (contentGroup.visibility == VISIBLE) return,但该容器在 XML 里默认就是 VISIBLE ,首次启动直接短路返回,Fragment 根本没被添加。视图状态 ≠ 业务状态,业务状态用显式布尔字段。
坑 6:lifecycleScope.launch { } 里的 this 不是 Activity
launch 的块接收者是 CoroutineScope,嵌套 lambda 里裸写 this 拿到的是 Scope 而不是 Context,Toast.makeText(this, ...) 编译都过不了。协程作用域内一律用 this@Activity 或 applicationContext。
坑 7:厂商 ROM 的音效能力探测失败
在部分 ROM 上,未播放时用临时 Equalizer(0, 0) 探测能力会被 AudioFlinger 拒绝。正确姿势是把「创建失败」当成正常的探测结果:降级为隐藏音效入口 + 提示「设备不支持」,播放会话建立后再重新绑定。
坑 8:日志沉默的 bug 最难查
扫描为空时界面无任何反馈、logcat 一片安静。后来给整条链路加了分层数量诊断日志(total_audio / is_music / filtered_rows),配合手动重扫按钮 + Toast 反馈,一分钟定位到坑 2。自用项目也要在关键路径埋「分层数据日志」,出问题时能立刻定位是权限、数据源还是过滤条件的问题。
六、经验总结
- 先写 PRD 再写代码:FR 编号 + GWT 验收标准让每个功能都有明确的「完成定义」,AI 辅助生成代码时也极大地减少了沟通损耗;
- 权限最小化是卖点也是约束 :只有
READ_MEDIA_AUDIO+ 前台服务 + 通知 +WAKE_LOCK,每个权限都能说出用途; - 系统级能力交给系统框架:MediaSession 生态(通知/锁屏/线控/焦点/蓝牙)自研成本极高且永远有兼容性长尾;
- 真机自测清单逐项打勾:每版一份,MUUI/国产 ROM 的行为差异基本都在清单里暴露;
- 统计只存本地不上传:本地日志文件 + 设置页查看,既满足隐私洁癖又服务了排障。
七、结语
这个项目从 PRD 定稿到四个版本功能全部跑通,累计不到一周的业余时间。最大的感受是:现代 Android 开发的「地基」已经非常厚了------Media3 把播放器最难的部分标准化,剩下的其实是把需求拆清楚、把每个坑都真机踩一遍。
如果这篇文章对你有帮助,欢迎点赞收藏;踩坑列表如果有你遇到过的同款,评论区聊聊。