任务四 显示当前歌曲和播放状态

点击列表中的歌曲已经能够播放,但底部播放栏仍显示占位歌名和默认图标。接下来使用 PlaybackState 保存播放信息,通过 StateFlow 提供给 PlayerFragment,让播放栏显示当前歌曲的名称、封面和播放状态。

一、任务目标

任务结束时,页面将能够:

  • 收到服务发布的当前歌曲和播放状态。
  • 随之刷新歌曲名称、专辑封面和播放/暂停图标。
  • 只在 Fragment 视图存活期间收集状态,页面销毁后不再更新控件。

二、设计播放状态

1. 创建 PlaybackState

在 model 包中新建 PlaybackState.kt,用一个数据类集中描述页面需要的播放信息。将状态模型与服务分开,服务负责发布状态,数据类负责定义状态内容。

app/src/main/java/com/sziit/musicplayer/model/PlaybackState.kt
package com.sziit.musicplayer.model
data class PlaybackState(
val isPlaying: Boolean = false,
val currentPosition: Int = 0,
val duration: Int = 0,
val currentSong: Song? = null
)

四个字段各管一件事:isPlaying 决定播放图标,currentSong 提供歌曲名称和封面;duration 在播放器准备完成时写入,currentPosition 到下一任务才定时更新,这两个时间字段先占位。所有字段都带默认值,服务刚创建、还没播放任何歌曲时,也能发出一个完整状态。

2. 创建可变状态流

在 MusicService 中导入 StateFlow,并增加一个私有可变流和一个公开只读流。StateFlow 由初始工程的 kotlinx-coroutines-android 依赖提供,不需要添加新依赖。

app/src/main/java/com/sziit/musicplayer/service/MusicService.kt
import com.sziit.musicplayer.model.PlaybackState
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
class MusicService : Service() {
// ... 省略已有成员。
// 服务内部修改状态,页面通过只读流接收状态。
private val _playbackState = MutableStateFlow(PlaybackState())
val playbackState: StateFlow<PlaybackState> = _playbackState.asStateFlow()
// ... 省略服务中的其他方法。
}

服务内部用 _playbackState 修改状态,对外只暴露只读的 playbackState。页面能看不能改,播放器状态只有一个修改入口。

想一想

为什么要用 _playbackState 保存可修改的状态流,同时只向页面提供 playbackState?如果把可修改的状态流直接交给页面,会有什么影响?

三、在 MusicService 中发布状态

1. 播放准备完成时发布歌曲信息

找到任务二写好的 setOnPreparedListener,在播放器启动后发布当前歌曲和播放状态。

app/src/main/java/com/sziit/musicplayer/service/MusicService.kt
class MusicService : Service() {
// ... 省略其他成员和方法。
private fun playImpl(index: Int) {
// ... 省略歌曲校验及播放器数据源设置。
player.setOnPreparedListener { mediaPlayer ->
currentIndex = index
mediaPlayer.start()
// 播放器准备完成后,发布当前歌曲及播放状态。
_playbackState.value = PlaybackState(
isPlaying = true,
currentSong = song,
duration = mediaPlayer.duration.coerceAtLeast(0)
)
}
}
}

总时长要等播放器准备完成后才能读到,所以在回调里把当前歌曲、播放状态和 duration 一次性设置好。

2. 歌曲播放完成时更新状态

在同一个 playImpl() 方法中,为播放器添加完成监听。歌曲自然结束后,将播放状态改为停止,播放栏图标随之恢复。

app/src/main/java/com/sziit/musicplayer/service/MusicService.kt
// ... 省略 package 声明和其他导入。
import kotlinx.coroutines.flow.update
class MusicService : Service() {
// ... 省略其他成员和方法。
private fun playImpl(index: Int) {
// ... 省略歌曲校验、数据源设置和准备完成监听。
player.setOnCompletionListener {
_playbackState.update { it.copy(isPlaying = false) }
}
}
}

copy() 只更新播放状态,当前歌曲和总时长会保留。

四、收集状态并更新页面

1. 添加 Glide 依赖

打开 app/build.gradle.kts,在 dependencies 中加入 Glide 依赖。

app/build.gradle.kts
dependencies {
// ... 省略已有依赖。
implementation("com.github.bumptech.glide:glide:4.16.0")
}

保存文件并等待 Gradle 同步完成。

2. 更新歌曲名称和封面

添加 updateSongInfo()。歌曲为空时不更新控件,避免服务刚创建时访问空对象。网络封面加载期间,Glide 先显示布局中已有的 ic_launcher_foreground 默认图,加载完成后再显示专辑封面。

app/src/main/java/com/sziit/musicplayer/ui/fragment/PlayerFragment.kt
// ... 省略 package 声明和已有导入。
import com.bumptech.glide.Glide
import com.sziit.musicplayer.R
import com.sziit.musicplayer.model.Song
class PlayerFragment : Fragment() {
// ... 省略已有字段和其他方法。
private fun updateSongInfo(song: Song?) {
// 服务尚未选中歌曲时,不更新歌曲信息。
song ?: return
binding.tvSongTitle.text = song.name
Glide.with(this)
.load(song.imagePath)
.placeholder(R.drawable.ic_launcher_foreground)
.into(binding.imgPic)
}
}

3. 更新播放按钮图标

updatePlayState() 根据服务状态在播放图标 ic_start 和暂停图标 ic_stop 之间切换。

app/src/main/java/com/sziit/musicplayer/ui/fragment/PlayerFragment.kt
// ... 省略 package 声明和已有导入。
class PlayerFragment : Fragment() {
// ... 省略已有字段和其他方法。
private fun updatePlayState(isPlaying: Boolean) {
// 播放时显示暂停图标,停止时显示播放图标。
val icon = if (isPlaying) {
R.drawable.ic_stop
} else {
R.drawable.ic_start
}
binding.btnPlay.setBackgroundResource(icon)
}
}

4. 使用视图生命周期收集状态

在 PlayerFragment 中添加收集方法。viewLifecycleOwner.lifecycleScope 会在 Fragment 视图销毁时自动取消协程,不会出现页面已经销毁还在更新控件的情况。lifecycleScope 来自初始工程的 lifecycle-runtime-ktx,launch 和 collectLatest 来自 kotlinx-coroutines-android,都不需要新增依赖。

app/src/main/java/com/sziit/musicplayer/ui/fragment/PlayerFragment.kt
// ... 省略 package 声明和已有导入。
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
class PlayerFragment : Fragment() {
// ... 省略已有字段和其他方法。
private fun observePlaybackState() {
viewLifecycleOwner.lifecycleScope.launch {
musicService?.playbackState?.collectLatest { state ->
updateSongInfo(state.currentSong)
updatePlayState(state.isPlaying)
}
}
}
}

collectLatest 收到新状态后处理最新值。页面不需要自己保存“当前歌曲”或“是否播放”的副本,一切以服务发布的状态为准。

5. 在服务连接成功后开始收集

最后,在 PlayerFragment 的服务连接回调中调用 observePlaybackState()。服务绑定成功后,页面即可开始接收播放状态。

app/src/main/java/com/sziit/musicplayer/ui/fragment/PlayerFragment.kt
class PlayerFragment : Fragment() {
// ... 省略已有字段。
private val serviceConnection = object : ServiceConnection {
override fun onServiceConnected(name: ComponentName, service: IBinder) {
musicService = (service as MusicService.MusicBinder).getService()
observePlaybackState()
}
// ... 省略已经完成的 onServiceDisconnected()。
}
}

歌曲开始播放后,播放栏信息和图标由服务发布的状态驱动。暂停、继续和切歌操作会在下一任务接入。

6. 验证状态同步

运行应用,点击歌曲开始播放,检查歌曲名称、专辑封面和播放图标是否随状态更新;播放自然结束后,图标应恢复。再点击其他歌曲,或离开后返回播放器页面,确认显示内容仍与服务状态一致。

歌曲信息与播放状态同步效果

若歌曲名称已更新但封面没有显示,检查 Glide 依赖是否已加入并完成 Gradle 同步。

任务小结

MusicService 用 PlaybackState 保存播放信息,并通过 StateFlow 提供给 PlayerFragment。播放栏收到状态后更新歌曲名称、封面和播放图标,视图销毁时停止收集。