Phase 2 详细实施计划:音乐库 + 基本体验
Context
Phase 1 已完成(基础播放功能可用)。Phase 2 的目标是让 app 像一个正经的音乐播放器一样浏览和管理音乐:引入 Room 数据库、按歌曲/专辑/艺术家/文件夹浏览、专辑封面、播放模式、搜索等。
依赖关系总览
1 | Week 1 (数据层): |
代码风格要求
- ViewModel 写在对应 Screen/Component 的同一文件内,定义在 composable 上方
- 所有注释使用中文
- 状态模式:
private val _state = MutableStateFlow(...)+val state = _state.asStateFlow() - DI:
@Singleton服务层,@HiltViewModelViewModel,@Inject constructor - Composable:
hiltViewModel(),collectAsStateWithLifecycle(),Scaffold+TopAppBar - 数据类默认值:中文(“未知艺术家”、“未知专辑”)
- 私有 composable 和工具函数放文件底部
- Entity 与 AudioFile 分离,通过 EntityMapper 转换
Week 1:Room 数据库 + 元数据解析
1.1 构建配置修改
gradle/libs.versions.toml — 添加:
1 | # [versions] 区域添加 |
app/build.gradle.kts — 添加:
1 | // android 块内添加 |
1.2 新建 data/entity/TrackEntity.kt
1 | package io.kyonqi.musicplayer.data.entity |
1.3 新建 data/entity/AlbumEntity.kt
1 | package io.kyonqi.musicplayer.data.entity |
1.4 新建 data/entity/ArtistEntity.kt
1 | package io.kyonqi.musicplayer.data.entity |
1.5 新建 data/dao/TrackDao.kt
1 | package io.kyonqi.musicplayer.data.dao |
1.6 新建 data/dao/AlbumDao.kt
1 | package io.kyonqi.musicplayer.data.dao |
1.7 新建 data/dao/ArtistDao.kt
1 | package io.kyonqi.musicplayer.data.dao |
1.8 新建 data/db/AppDatabase.kt
1 | package io.kyonqi.musicplayer.data.db |
Week 3 时将升级为 version 2,添加 PlaylistEntity + PlaylistTrackCrossRef
1.9 新建 di/DatabaseModule.kt
1 | package io.kyonqi.musicplayer.di |
1.10 新建 data/mapper/EntityMapper.kt
1 | package io.kyonqi.musicplayer.data.mapper |
1.11 新建 scanner/MetadataReader.kt
1 | package io.kyonqi.musicplayer.scanner |
1.12 新建 scanner/LibraryScanner.kt
1 | package io.kyonqi.musicplayer.scanner |
FileScanner.kt保留不动,等 Week 2 替换完成后可删除。
Week 2:专辑封面 + 浏览 UI
2.1 新建 ui/component/AlbumArt.kt
1 | package io.kyonqi.musicplayer.ui.component |
2.2 新建 ui/screen/LibraryScreen.kt
替代 FileBrowseScreen 作为主入口。
1 | package io.kyonqi.musicplayer.ui.screen |
2.3 新建 ui/screen/SongListScreen.kt
1 | package io.kyonqi.musicplayer.ui.screen |
2.4 新建 ui/screen/AlbumListScreen.kt
1 | package io.kyonqi.musicplayer.ui.screen |
2.5 新建 ui/screen/AlbumDetailScreen.kt
1 | package io.kyonqi.musicplayer.ui.screen |
2.6 新建 ui/screen/ArtistListScreen.kt
1 | package io.kyonqi.musicplayer.ui.screen |
2.7 新建 ui/screen/ArtistDetailScreen.kt
1 | package io.kyonqi.musicplayer.ui.screen |
2.8 新建 ui/screen/FolderBrowserScreen.kt
1 | package io.kyonqi.musicplayer.ui.screen |
2.9 修改 ui/navigation/AppNavigation.kt
1 |
|
FileBrowseScreen.kt不再路由,可删除或保留。
Week 3:播放队列 + 播放模式
3.1 新建 player/RepeatMode.kt
1 | package io.kyonqi.musicplayer.player |
3.2 修改 player/PlaybackState.kt
添加两个字段:
1 | data class PlaybackState( |
3.3 修改 player/PlayerController.kt
接口添加两个方法:
1 | // 切换循环模式 (OFF → ALL → ONE → OFF) |
3.4 修改 player/ExoPlayerController.kt
添加实现:
1 | override fun toggleRepeatMode() { |
3.5 新建 ui/screen/QueueScreen.kt
1 | package io.kyonqi.musicplayer.ui.screen |
3.6 修改 ui/screen/NowPlayingScreen.kt
-
ViewModel 添加:
1
2fun toggleRepeatMode() = player.toggleRepeatMode()
fun toggleShuffle() = player.toggleShuffle() -
Screen 参数添加
onOpenQueue: () -> Unit -
封面区域替换为 AlbumArt 组件:
1
2
3Card(modifier = Modifier.size(200.dp), ...) {
AlbumArt(uri = state.currentTrack?.uri, modifier = Modifier.fillMaxSize(), size = 200.dp)
} -
播放控制栏下方添加循环/随机/队列按钮行:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceEvenly) {
// 随机播放
IconButton(onClick = { viewModel.toggleShuffle() }) {
Icon(Icons.Default.Shuffle, "随机播放",
tint = if (state.shuffleEnabled) primary else onSurfaceVariant)
}
// 循环模式(三态图标)
IconButton(onClick = { viewModel.toggleRepeatMode() }) {
Icon(
when (state.repeatMode) {
RepeatMode.ONE -> Icons.Default.RepeatOne
else -> Icons.Default.Repeat
}, "循环模式",
tint = if (state.repeatMode != RepeatMode.OFF) primary else onSurfaceVariant)
}
// 队列按钮
IconButton(onClick = onOpenQueue) {
Icon(Icons.Default.QueueMusic, "播放队列")
}
}
3.7 新建 data/entity/PlaylistEntity.kt
1 | package io.kyonqi.musicplayer.data.entity |
3.8 新建 data/entity/PlaylistTrackCrossRef.kt
1 | package io.kyonqi.musicplayer.data.entity |
3.9 新建 data/dao/PlaylistDao.kt
1 | package io.kyonqi.musicplayer.data.dao |
3.10 修改 data/db/AppDatabase.kt (version 2)
1 |
|
3.11 修改 di/DatabaseModule.kt
添加:
1 | // 提供播放列表 DAO |
3.12 新建 ui/screen/PlaylistScreen.kt
1 | package io.kyonqi.musicplayer.ui.screen |
3.13 新建 ui/screen/PlaylistDetailScreen.kt
1 | package io.kyonqi.musicplayer.ui.screen |
Week 4:打磨
4.1 修改 ui/component/MiniPlayer.kt
-
MusicNote 图标替换为 AlbumArt:
1
2
3Card(modifier = Modifier.size(40.dp)) {
AlbumArt(uri = track.uri, size = 40.dp)
} -
播放/暂停按钮后添加下一首按钮:
1
2
3IconButton(onClick = { viewModel.next() }) {
Icon(Icons.Default.SkipNext, contentDescription = "下一首")
} -
ViewModel 添加:
1
fun next() = player.next()
4.2 新建 ui/screen/SearchScreen.kt
1 | package io.kyonqi.musicplayer.ui.screen |
文件清单总览
新建 (27 个文件)
| 周 | 文件路径(相对 io/kyonqi/musicplayer/) |
|---|---|
| 1 | data/entity/TrackEntity.kt |
| 1 | data/entity/AlbumEntity.kt |
| 1 | data/entity/ArtistEntity.kt |
| 1 | data/dao/TrackDao.kt |
| 1 | data/dao/AlbumDao.kt |
| 1 | data/dao/ArtistDao.kt |
| 1 | data/db/AppDatabase.kt |
| 1 | data/mapper/EntityMapper.kt |
| 1 | di/DatabaseModule.kt |
| 1 | scanner/MetadataReader.kt |
| 1 | scanner/LibraryScanner.kt |
| 2 | ui/component/AlbumArt.kt |
| 2 | ui/screen/LibraryScreen.kt |
| 2 | ui/screen/SongListScreen.kt |
| 2 | ui/screen/AlbumListScreen.kt |
| 2 | ui/screen/AlbumDetailScreen.kt |
| 2 | ui/screen/ArtistListScreen.kt |
| 2 | ui/screen/ArtistDetailScreen.kt |
| 2 | ui/screen/FolderBrowserScreen.kt |
| 3 | player/RepeatMode.kt |
| 3 | ui/screen/QueueScreen.kt |
| 3 | data/entity/PlaylistEntity.kt |
| 3 | data/entity/PlaylistTrackCrossRef.kt |
| 3 | data/dao/PlaylistDao.kt |
| 3 | ui/screen/PlaylistScreen.kt |
| 3 | ui/screen/PlaylistDetailScreen.kt |
| 4 | ui/screen/SearchScreen.kt |
修改 (10 个文件)
| 周 | 文件路径 | 修改内容 |
|---|---|---|
| 1 | gradle/libs.versions.toml |
+Room 版本和库 |
| 1 | app/build.gradle.kts |
+Room 依赖 + schema export |
| 2 | ui/navigation/AppNavigation.kt |
起始路由→library, +所有新路由 |
| 3 | player/PlaybackState.kt |
+repeatMode, +shuffleEnabled |
| 3 | player/PlayerController.kt |
+toggleRepeatMode(), +toggleShuffle() |
| 3 | player/ExoPlayerController.kt |
实现 repeat/shuffle |
| 3 | ui/screen/NowPlayingScreen.kt |
+封面+循环/随机/队列按钮 |
| 3 | data/db/AppDatabase.kt |
version 2, +Playlist entities, +autoMigration |
| 3 | di/DatabaseModule.kt |
+PlaylistDao provider |
| 4 | ui/component/MiniPlayer.kt |
AlbumArt + 下一首按钮 |
废弃 (1 个文件)
| 文件路径 | 说明 |
|---|---|
ui/screen/FileBrowseScreen.kt |
被 LibraryScreen 替代,不再路由 |
验证方式
每周完成后在模拟器上验证:
Week 1: 启动 app → 权限授予 → Logcat 确认 LibraryScanner 完成扫描 → 通过 App Inspection (Database Inspector) 确认 Room 表有数据
Week 2: 启动 app → 看到 LibraryScreen 4 个 Tab → 切换歌曲/专辑/艺术家/文件夹 → 专辑详情/艺术家详情能进入 → 封面显示
Week 3: 播放歌曲 → 循环/随机按钮状态切换 → 打开队列页 → 点击队列切歌 → 创建播放列表 → 添加歌曲
Week 4: MiniPlayer 显示封面+下一首 → 搜索能找到歌曲/专辑/艺术家 → 刷新按钮触发增量扫描
关键架构决策
-
Entity vs Domain 模型分离:
TrackEntity是数据库表示,AudioFile保持为 UI/播放器模型,通过EntityMapper桥接。避免 Room 注解泄漏到播放器层。 -
专辑标识:使用 (name, artist) 组合标识专辑,而非 MediaStore album ID(重新扫描后可能变化)。
AlbumEntity.albumId是 Room 自动生成的主键,用于导航路由。 -
Tab 实现:4 个 Tab(歌曲/专辑/艺术家/文件夹)作为 LibraryScreen 内部的 composable 切换,不是独立的 NavHost 路由。仅详情页(AlbumDetail/ArtistDetail)是导航路由。
-
播放列表入口:从 LibraryScreen 的 TopAppBar 菜单或独立入口进入,保持 4-Tab 布局不变。
-
扫描策略:全量扫描清库重建,增量扫描对比 URI 差异。分批插入(每 50 条)提高性能。
说些什么吧!