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 层。

下面这张图展示了整体的数据流向:

用户点击/手势 Compose UI 层 ViewModel 前台 Service MediaPlayer StateFlow 状态下发

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 修饰符来监听手势。我个人比较喜欢用 detectHorizontalDragGesturesdetectVerticalDragGestures 来分别处理左右滑动和上下滑动。

你想想看,用户正在听歌,想切到下一首,手指在封面区域往右一划——这个交互比点按钮爽多了。但这里有个细节:要区分点击和滑动。如果用户只是想点一下暂停,结果误触发了切歌,那就尴尬了。

我的做法是:记录手指按下和抬起的坐标差,如果水平位移超过 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 配合 pivotXpivotY

@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