4. 播放状态监听与UI状态映射:Player.Listener、状态枚举、Compose State转换

播放器底层状态变化(缓冲、准备、结束等)需要实时反映到界面。本章讲解如何通过 Player.Listener 监听播放器事件,将 ExoPlayer 内部状态枚举映射为 Compose 可观测的 State,驱动 UI 自动重组。

4.1 核心概念

Player.Listener 是 ExoPlayer 提供的事件回调接口,包含 onPlaybackStateChanged、onIsPlayingChanged 等方法。

播放器内置的状态枚举 Player.State 定义如下:

状态常量值含义
Player.STATE_IDLE1播放器空闲/未初始化
Player.STATE_BUFFERING2正在缓冲数据
Player.STATE_READY3可播放或暂停(数据充足)
Player.STATE_ENDED4播放完毕

Compose 中使用 mutableStateOf 包装状态,当状态值改变时触发读取该状态的 Composable 重组。

4.2 实现流程

  1. 创建 ExoPlayer 实例。
  2. 实现 Player.Listener 接口,重写 onPlaybackStateChanged 和 onIsPlayingChanged。
  3. 在 ViewModel(或持有播放器的类)中声明 mutableStateOf 变量。
  4. 在 Listener 回调中更新这些 State 变量。
  5. 在 Composable 中通过 collectAsState() 或直接读取 ViewModel 的 State 属性,驱动 UI 更新。
  6. 在 DisposableEffect 中注册/取消注册 Listener,避免内存泄漏。
关键设计原则: 播放器状态应始终从单一可信源(ExoPlayer)出发,经过 ViewModel 的 State 转换后流向 UI。不要在 Composable 中直接持有 ExoPlayer 引用。

4.3 完整代码示例

定义用于 UI 的状态类:

data class PlayerUiState(
    val playbackState: Int = Player.STATE_IDLE,
    val isPlaying: Boolean = false,
    val bufferedPercentage: Int = 0
)

ViewModel 中管理播放器与状态:

class PlayerViewModel : ViewModel() {

    private val player = ExoPlayer.Builder(getApplication()).build()

    private val _uiState = mutableStateOf(PlayerUiState())
    val uiState: State<PlayerUiState> = _uiState

    private val listener = object : Player.Listener {
        override fun onPlaybackStateChanged(playbackState: Int) {
            _uiState.value = _uiState.value.copy(playbackState = playbackState)
        }

        override fun onIsPlayingChanged(isPlaying: Boolean) {
            _uiState.value = _uiState.value.copy(isPlaying = isPlaying)
        }

        override fun onPlaybackParametersChanged(parameters: PlaybackParameters) {
            // 可额外处理倍速等状态
        }
    }

    init {
        player.addListener(listener)
    }

    override fun onCleared() {
        player.removeListener(listener)
        player.release()
        super.onCleared()
    }

    fun play() { player.play() }
    fun pause() { player.pause() }
}

Composable 中消费状态:

@Composable
fun PlayerScreen(viewModel: PlayerViewModel = viewModel()) {
    val uiState by viewModel.uiState

    Column(modifier = Modifier.fillMaxSize()) {
        // 根据 playbackState 显示不同视图
        when (uiState.playbackState) {
            Player.STATE_BUFFERING -> Text("缓冲中…")
            Player.STATE_READY -> {
                if (uiState.isPlaying) {
                    Text("正在播放")
                } else {
                    Text("已暂停")
                }
            }
            Player.STATE_ENDED -> Text("播放结束")
            else -> Text("等待初始化")
        }

        Button(onClick = {
            if (uiState.isPlaying) viewModel.pause() else viewModel.play()
        }) {
            Text(if (uiState.isPlaying) "暂停" else "播放")
        }
    }
}
Tip: by 委托的写法 val uiState by viewModel.uiState 会自动解包 State,当值变化时重组当前 Composable。也可以直接使用 collectAsState(),但 ViewModel 中已定义为 State 时无需再 collect。

4.4 状态转换关系图

下图展示从 ExoPlayer 底层状态到 UI 组件的完整数据流:

播放状态 → Compose UI 映射数据流 ExoPlayer Player.Listener onPlaybackStateChanged ViewModel mutableStateOf() 状态改变 更新 State State 读取 Composable 重组 当 State 变化时 UI 更新 进度条/按钮/文字 读取 读取 触发重组 🔑 关键链路: ExoPlayer 底层事件 → Listener 回调 → mutableStateOf 更新 → Compose 自动重组 UI ⚠️ 注意:Listener 必须在 onCleared() 或 DisposableEffect 中移除,避免泄露。

4.5 常见错误

错误场景原因正确做法
UI 不更新Listener 回调未更新 MutableState,或读取的 State 不是 Compose 可观测确保使用 mutableStateOf,并在 Composable 中用 by 或 collectAsState() 读取
内存泄漏未在 ViewModel.onCleared() 或 DisposableEffect 中 removeListener始终成对注册/移除,推荐在 ViewModel 的 init/onCleared 中处理
播放状态错乱多个地方同时修改同一个 mutableStateOf,导致竞争状态仅由 Listener 更新,UI 只读不写;使用 copy() 避免直接修改值
onPlaybackStateChanged 频繁触发缓冲过程中不断切换 BUFFERING ↔ READYUI 端可加入防抖动逻辑(如 debounce),但映射层保持原样
性能提醒: 不要在每个 Listener 回调中执行重量级操作(如文件 I/O、复杂计算)。状态更新应在几十微秒内完成。若需要将状态合入 Navigation 或数据库,考虑使用 SnapshotFlow 转为 Flow 后异步处理。

4.6 优化建议

  • 使用 derivedStateOf 从多个基础状态派生出 UI 需要的复合状态,避免不必要的重组。
  • 对于频繁变化的进度(比如 currentPosition),考虑在 Compose 中使用 animateFloatAsState 平滑过渡,而不是直接映射整数位置。
  • 测试时可用 PlaybackTestListener 模拟各种状态序列,验证 UI 是否按预期显示。
  • 在调试阶段,可以在 Listener 中增加日志输出,确认回调顺序:Log.d("Player", "State: $playbackState")。

本章完成之后,你已经掌握了播放器状态与 Compose UI 之间的桥梁搭建。下一阶段将基于此设计自定义播放控制器控件。