27、实战项目三:音乐播放器(媒体播放 + 手势控制 + 自定义布局 + 后台服务)
终于到了第三个实战项目。说实话,音乐播放器是移动开发里非常经典的一个练手项目。它不像计算器那样简单,也不像电商那样复杂,但恰好能把 Jetpack Compose 里最核心的几个能力串起来——媒体播放、手势交互、自定义布局、还有后台服务。
我个人习惯把这类项目叫做「中型实战」。为什么?因为它涉及了 UI 层、业务逻辑层、还有系统服务层。你想想看,一个完整的播放器,用户点一下播放按钮,音乐要响起来;切到后台,音乐不能停;锁屏了,还得能控制。这些在传统 View 系统里写起来挺麻烦的,但在 Compose 里,反而能写出更优雅的代码。
27.1 项目整体架构
先搭架子。我建议把项目分成三层:
- UI 层:Compose 页面,负责展示封面、进度条、控制按钮
- Service 层:前台服务,负责真正的音频播放
- ViewModel 层:连接 UI 和 Service,管理播放状态
嗯,这里要注意一点:千万不要在 Compose 里直接操作 MediaPlayer。我在项目中遇到过,有人把 MediaPlayer 实例放在 Composable 函数里,结果重组时反复创建销毁,音频卡成狗。正确的做法是把播放逻辑封装到 Service 里,通过 Binder 或者广播来通信。
核心原则:Compose 只负责 UI 渲染,不负责业务逻辑。播放状态通过 ViewModel 的 StateFlow 下发到 UI 层。
下面这张图展示了整体的数据流向:
27.2 媒体播放核心实现
播放器最核心的东西,说白了就是 MediaPlayer 的管理。我建议用 MediaSessionCompat 来做,因为它天然支持锁屏控制和蓝牙按键。不过这里有个坑——MediaPlayer 必须在子线程创建和释放。
我曾经在项目里直接在 Service 的 onStartCommand 里 new MediaPlayer,结果在某些低端机上 ANR 了。后来改成用 HandlerThread 来处理,稳得很。
来看核心代码:
class MusicService : Service() {
private val handlerThread = HandlerThread("MusicPlayerThread")
private lateinit var mediaPlayer: MediaPlayer
private lateinit var mediaSession: MediaSessionCompat
override fun onCreate() {
super.onCreate()
handlerThread.start()
// 在子线程初始化 MediaPlayer
Handler(handlerThread.looper).post {
mediaPlayer = MediaPlayer().apply {
setWakeMode(applicationContext, PowerManager.PARTIAL_WAKE_LOCK)
setAudioAttributes(
AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_MEDIA)
.setContentType(AudioAttributes.CONTENT_TYPE_MUSIC)
.build()
)
}
}
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
when (intent?.action) {
ACTION_PLAY -> play(intent.getStringExtra(EXTRA_URL) ?: return START_NOT_STICKY)
ACTION_PAUSE -> pause()
ACTION_RESUME -> resume()
ACTION_SEEK -> seekTo(intent.getIntExtra(EXTRA_POSITION, 0))
}
return START_STICKY
}
}
小技巧:使用 START_STICKY 而不是 START_NOT_STICKY,这样即使用户划掉 App,Service 也会自动重启继续播放。但要注意,如果用户主动停止播放,记得调 stopSelf()。
27.3 手势控制:滑动切歌与音量调节
手势控制是提升用户体验的关键。在 Compose 里,我们可以用 pointerInput 修饰符来监听手势。我个人比较喜欢用 detectHorizontalDragGestures 和 detectVerticalDragGestures 来分别处理左右滑动和上下滑动。
你想想看,用户正在听歌,想切到下一首,手指在封面区域往右一划——这个交互比点按钮爽多了。但这里有个细节:要区分点击和滑动。如果用户只是想点一下暂停,结果误触发了切歌,那就尴尬了。
我的做法是:记录手指按下和抬起的坐标差,如果水平位移超过 100dp 才触发切歌,否则视为点击。
@Composable
fun GestureControlArea(
onSwipeLeft: () -> Unit, // 上一首
onSwipeRight: () -> Unit, // 下一首
onSwipeUp: () -> Unit, // 音量+
onSwipeDown: () -> Unit, // 音量-
onTap: () -> Unit, // 暂停/播放
content: @Composable () -> Unit
) {
var offsetX by remember { mutableFloatStateOf(0f) }
var offsetY by remember { mutableFloatStateOf(0f) }
val threshold = with(LocalDensity.current) { 100.dp.toPx() }
Box(
modifier = Modifier
.fillMaxSize()
.pointerInput(Unit) {
detectTapGestures(onTap = { onTap() })
}
.pointerInput(Unit) {
detectHorizontalDragGestures(
onDragEnd = {
if (offsetX > threshold) onSwipeRight()
else if (offsetX < -threshold) onSwipeLeft()
offsetX = 0f
},
onHorizontalDrag = { _, dragAmount ->
offsetX += dragAmount
}
)
}
.pointerInput(Unit) {
detectVerticalDragGestures(
onDragEnd = {
if (offsetY > threshold) onSwipeDown()
else if (offsetY < -threshold) onSwipeUp()
offsetY = 0f
},
onVerticalDrag = { _, dragAmount ->
offsetY += dragAmount
}
)
}
) {
content()
}
}
注意:多个 pointerInput 修饰符会按顺序消费事件。如果先写了 detectTapGestures,它可能会拦截掉拖动手势的起始事件。我建议把拖动手势放在前面,点击放在后面,或者用 awaitPointerEventScope 手动处理事件分发。
27.4 自定义布局:唱片机转盘效果
这个项目里最炫酷的部分,就是那个旋转的唱片机封面。在 Compose 里实现自定义布局,核心是 Layout 可组合项。我们需要重写 MeasurePolicy,手动测量和摆放子元素。
唱片机的布局其实就两层:
- 底层:一个圆形的黑胶唱片背景(带同心圆纹理)
- 上层:专辑封面图片,旋转动画
嗯,这里要注意旋转的中心点。默认的 rotate 修饰符是以元素左上角为轴心旋转的,但我们需要以图片中心为轴心。所以得用 graphicsLayer 配合 pivotX 和 pivotY。
@Composable
fun VinylRecord(
coverUrl: String,
isPlaying: Boolean,
modifier: Modifier = Modifier
) {
val infiniteTransition = rememberInfiniteTransition(label = "rotation")
val rotation by infiniteTransition.animateFloat(
initialValue = 0f,
targetValue = 360f,
animationSpec = infiniteRepeatable(
animation = tween(durationMillis = 8000, easing = LinearEasing),
repeatMode = RepeatMode.Restart
),
label = "recordRotation"
)
Box(
modifier = modifier
.aspectRatio(1f)
.clip(CircleShape)
.background(Color.DarkGray)
) {
// 黑胶唱片纹理(自定义绘制)
Canvas(modifier = Modifier.fillMaxSize()) {
val center = Offset(size.width / 2, size.height / 2)
val radius = size.minDimension / 2
// 画同心圆
for (i in 0..10) {
val r = radius * (1f - i * 0.08f)
drawCircle(
color = if (i % 2 == 0) Color(0xFF333333) else Color(0xFF222222),
radius = r,
center = center
)
}
// 中心标签
drawCircle(color = Color.Red, radius = radius * 0.08f, center = center)
}
// 专辑封面(带旋转)
AsyncImage(
model = coverUrl,
contentDescription = "专辑封面",
modifier = Modifier
.fillMaxSize(0.6f)
.align(Alignment.Center)
.graphicsLayer {
rotationZ = if (isPlaying) rotation else 0f
pivotX = size.width / 2f
pivotY = size.height / 2f
}
.clip(CircleShape)
)
}
}
27.5 后台服务与前台通知
Android 8.0 以后,后台 Service 的限制越来越严。想让音乐在后台继续播放,必须启动前台 Service,并显示一个通知。这个通知不只是为了合规,它也是用户控制播放的入口——通知栏上的暂停、切歌按钮。
我建议用 MediaStyle 通知样式,它自带播放控制按钮,而且兼容锁屏界面。这里有个容易踩的坑:通知渠道的优先级。如果你把通知渠道设为 IMPORTANCE_LOW,通知栏不会弹出声音,但锁屏控制可能失效。我一般设为 IMPORTANCE_HIGH,然后在通知内容里把声音关掉。
private fun createNotification(): Notification {
val playPauseIntent = PendingIntent.getService(
this,
0,
Intent(this, MusicService::class.java).apply {
action = if (isPlaying) ACTION_PAUSE else ACTION_RESUME
},
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
val notificationBuilder = NotificationCompat.Builder(this, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_music_note)
.setContentTitle(currentSong?.title ?: "未知歌曲")
.setContentText(currentSong?.artist ?: "未知艺术家")
.setLargeIcon(currentSong?.coverBitmap)
.setStyle(
androidx.media.app.NotificationCompat.MediaStyle()
.setMediaSession(mediaSession.sessionToken)
.setShowActionsInCompactView(0, 1, 2) // 在紧凑视图中显示三个按钮
.setShowCancelButton(true)
.setCancelButtonIntent(
PendingIntent.getService(
this, 0,
Intent(this, MusicService::class.java).apply { action = ACTION_STOP },
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
)
)
.addAction(R.drawable.ic_skip_previous, "上一首", previousIntent)
.addAction(
if (isPlaying) R.drawable.ic_pause else R.drawable.ic_play,
if (isPlaying) "暂停" else "播放",
playPauseIntent
)
.addAction(R.drawable.ic_skip_next, "下一首", nextIntent)
.setOngoing(true)
.setPriority(NotificationCompat.PRIORITY_HIGH)
return notificationBuilder.build()
}
经验之谈:通知里的 PendingIntent 一定要加 FLAG_IMMUTABLE 标志,否则在 Android 12+ 上会崩溃。另外,setShowActionsInCompactView 的参数是按钮索引,最多三个,多了不显示。
27.6 状态同步:ViewModel 与 Service 的通信
最后一块拼图,就是让 UI 层知道 Service 里发生了什么。我推荐用 Binder 模式 + StateFlow。Service 里维护一个 MutableStateFlow<PlayerState>,ViewModel 通过 Binder 获取这个 Flow,然后 collect。
为什么不用广播?广播太重了,而且容易漏掉消息。StateFlow 是粘性的,即使 UI 在后台错过了某个状态更新,重新订阅时也能拿到最新值。
// Service 端
class MusicService : Service() {
private val _playerState = MutableStateFlow(PlayerState.IDLE)
val playerState: StateFlow<PlayerState> = _playerState.asStateFlow()
inner class MusicBinder : Binder() {
fun getStateFlow(): StateFlow<PlayerState> = playerState
}
private val binder = MusicBinder()
override fun onBind(intent: Intent?): IBinder = binder
}
// ViewModel 端
class PlayerViewModel : ViewModel() {
private val _uiState = MutableStateFlow(UiState())
val uiState: StateFlow<UiState> = _uiState.asStateFlow()
fun bindService(context: Context) {
val intent = Intent(context, MusicService::class.java)
context.bindService(intent, serviceConnection, Context.BIND_AUTO_CREATE)
}
private val serviceConnection = object : ServiceConnection {
override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
val binder = service as MusicService.MusicBinder
viewModelScope.launch {
binder.stateFlow.collect { state ->
_uiState.update { it.copy(playerState = state) }
}
}
}
}
}
说实话,这个模式写起来有点啰嗦,但胜在稳定。我在好几个生产项目里都用这个方案,没出过问题。
27.7 避坑总结
最后,我把做这个项目时踩过的坑列一下,希望能帮你省点时间:
| 问题 | 原因 | 解决方案 |
|---|---|---|
| MediaPlayer 在主线程创建导致 ANR | MediaPlayer 的 prepare 是同步操作,可能耗时 | 使用 HandlerThread 或 Coroutine 的 Dispatchers.IO |
| 手势滑动和点击冲突 | pointerInput 修饰符顺序不当 | 先处理拖拽,再处理点击;或使用 awaitPointerEventScope 手动分发 |
| 通知栏按钮点击无响应 | PendingIntent 缺少 FLAG_IMMUTABLE | Android 12+ 必须加 FLAG_IMMUTABLE |
| 后台播放被系统杀死 | 没有启动前台 Service | 在 onStartCommand 中调 startForeground() |
| 旋转动画卡顿 | 在 Compose 中频繁重组 | 使用 graphicsLayer 而不是 Modifier.rotate,避免重组 |
嗯,这个项目做完,你基本上就把 Jetpack Compose 里最常用的几个能力都练到了。媒体播放、手势交互、自定义布局、后台服务——每一个都是实际开发中绕不开的点。我个人觉得,能独立完成这个项目,出去面试中高级 Android 岗位,至少 Compose 这一块是稳的。
核心收获:Compose 不是银弹,但它让 UI 层的代码变得极其简洁。真正的复杂度在 Service 和 ViewModel 那一层。把这两层写稳了,UI 层怎么改都不怕。
公众号:蓝海资料掘金营,微信deep3321