在线播放切歌“声音和信息不一致”:一次从入口补丁到状态链路重构的排查
在线播放切歌“声音和信息不一致”:一次从入口补丁到状态链路重构的排查
这个 Bug 折腾了我一整个下午:
- 在播放器里点上一首/下一首,声音切过去了;
- 但封面、歌名、评论按钮绑定的歌曲有时还是旧的;
- 有时点一下暂停,信息又“突然正常”。
最容易复现的路径是:
- 点播放 A(正常)
- 切到 B(正常)
- 再切回 A(声音回去了,但 UI 还显示 B)
这不是单点 bug,而是几个并发问题叠在一起。
现象:日志看起来“多数正确”,但体验不稳定
先看表面,很多日志都很“健康”:
targetIndex对SWITCH success对currentIndex/mediaItem也经常对
但偶发时会出现两类关键信号:
iOS cached source set ok很快出现,但iOS cached play ok可能晚几秒甚至十几秒;- 切歌过程中又出现
Online playlist started with ConcatenatingAudioSource这种“启动流程晚到”日志。
这两类一叠加,就会造成:
- 用户已经继续点了切歌;
- 旧异步流程晚回来后又写状态;
- UI 和音频出现错位,直到下一次播放状态事件(比如 pause)才被动刷新。
第一轮碰壁:只做“入口防重入”不够
最开始做的是常规处理:
- 加
_isSwitchingOnlineTrack锁; - 正在切歌时忽略新点击;
- 加切歌节流。
这能减少乱点,但有两个副作用:
- 用户点击被吞掉,体感“按钮不灵”;
- 索引计算还基于旧
currentIndex,后续点击方向会错。
结论:只靠入口锁,治标不治本。
第二轮碰壁:只做 token 防串写,仍有漏网
随后给 playAtIndex 加了 switchToken,防止旧切歌晚回来覆盖新状态。
这一步是对的,但仍有残留:
setOnlinePlaylist自身也是异步链路;- 用户手动切歌后,旧的“启动在线播放列表”流程可能继续完成并回写状态。
于是出现了一个很迷惑的现象:
切歌明明成功了,过一会儿 UI 又被“拉回去”或延迟刷新。
真正根因:不是一个 race,是三条状态链路在竞争
最终确认是三层并发竞争:
- 手动切歌链路(
skip -> playAtIndex -> setAudioSource/play -> commit) - 在线播放启动链路(
setOnlinePlaylist -> setAudioSource/play -> commit) - UI 数据源链路(一部分组件读 stream state,一部分直接读
audioManager.currentSong)
只要这三条不统一,某个链路晚到就可能覆盖另一个链路,导致“声音和信息不同步”。
最终方案(分层修复)
1)切歌链路:token + 排队,不再简单 ignore
在 AudioManager.playAtIndex 的在线分支里:
- 每次切歌递增
switchToken; - 在
setSource/play/commit前后都检查 token; - 过期任务直接
stale ignored,不允许写_currentIndex/mediaItem/queue。
同时增加了“切歌排队”而不是“切歌忽略”:
- 切歌进行中收到新的 next/prev,不丢弃,写入
_queuedOnlineSwitchIndex; - 当前切歌完成后自动 drain 队列,执行最后一次用户意图。
这样既避免了串写,又保留了交互连续性。
2)在线播放启动链路:增加 session token,防晚到回写
给 setOnlinePlaylist 单独加了 online start session token:
- 启动时记录
sessionToken; setAudioSource后、play后都校验是否过期;- 过期则打印
stale ... ignored并退出,不再提交状态。
再加一条关键策略:
- 用户手动切歌时,主动取消 pending 的 online start session。
这一步直接解决了“切歌中又出现 Online playlist started 并污染状态”的问题。
3)iOS 缓存切歌:不再阻塞等待 play() Future 完成
日志里反复出现:
iOS cached source set ok很快- 但
await _player.play()可能很久才返回
如果等它返回再提交 UI,信息会明显滞后。
所以改成:
- 发起
play()请求(异步监听成功/失败日志); - 状态提交不再被
play()Future 阻塞。
这让 UI 不会因为 iOS 的慢返回而“卡住旧歌信息”。
4)UI 层收口:统一从 playbackState 读当前歌曲
播放器页面之前有双数据源:
- 有些组件读
playbackState.currentSong - 有些组件直接读
audioManager.currentSong
这会导致同一帧内不同组件看的是不同快照。
最终把播放页关键区域收口到同一来源:
- 额外控制区按钮(评论/喜欢/加入/队列)
- 歌词当前 song id
- 队列高亮当前歌曲
统一由 AudioPlaybackState 驱动,避免“局部刷新好了,局部还旧”的视觉撕裂。
为什么“点暂停就会刷新”
这个现象很有迷惑性,其实是线索:
- 点暂停会触发一轮新的播放状态事件(
playing=false); - UI 依赖的 stream 被强制推进一次;
- 之前没对齐的局部状态在这次重建里碰巧对齐了。
所以“暂停能修好”不是修复,而是状态链路竞争被下一次事件掩盖。
最终验证(复测路径)
重点复测了这几条:
- A -> B -> A(最容易复现)
- 连续快速上一首/下一首
- 在线缓存命中切歌
- 切歌与在线播放列表启动并发
关键日志特征变为:
- 旧流程:
stale ... ignored - 手动切歌会取消旧 start:
cancel pending online start ... - 切歌中点击:
queued targetIndex=... - 切歌完成自动接管:
drain queued switch ...
最终表现恢复稳定:音频与歌名/封面/功能按钮绑定一致,不再需要“点暂停触发刷新”。
关键代码(节选)
下面贴的是这次修复里最关键的几段代码,都是“去竞态”的核心点。
A. 在线切歌 token 防串写
文件:lib/core/services/audio_manager.dart
// fieldsint _onlineSwitchToken = 0;int? _pendingOnlineSwitchIndex;int? _queuedOnlineSwitchIndex;
Future<void> playAtIndex(int index) async { if (_isOnlinePlaylist && _onlineSongList != null && _urlResolver != null) { if (_isSwitchingOnlineTrack) { _queuedOnlineSwitchIndex = index; debugPrint( '[AudioManager][SWITCH] switching in progress, queue target index=$index', ); return; }
_isSwitchingOnlineTrack = true; _pendingOnlineSwitchIndex = index; final switchToken = ++_onlineSwitchToken;
try { // ... resolve / set source / play
if (switchToken != _onlineSwitchToken) { debugPrint('[AudioManager][SWITCH] stale switch ignored token=$switchToken'); return; }
_currentIndex = index; mediaItem.add(queue.value[index]); _updatePlaybackState(); } finally { if (switchToken == _onlineSwitchToken) { _isSwitchingOnlineTrack = false; _pendingOnlineSwitchIndex = null;
final queued = _queuedOnlineSwitchIndex; if (queued != null && queued != _currentIndex) { _queuedOnlineSwitchIndex = null; unawaited(playAtIndex(queued)); } else { _queuedOnlineSwitchIndex = null; } } } }}B. 切歌期间不忽略点击,改为“按 pending index 计算 + 入队”
文件:lib/core/services/audio_manager.dart
@overrideFuture<void> skipToNext() async { final baseIndex = (_isOnlinePlaylist && _isSwitchingOnlineTrack) ? (_pendingOnlineSwitchIndex ?? _currentIndex) : _currentIndex;
final nextIndex = baseIndex < _playlist.length - 1 ? baseIndex + 1 : 0;
if (_isOnlinePlaylist && _isSwitchingOnlineTrack) { _queuedOnlineSwitchIndex = nextIndex; debugPrint('[AudioManager][CMD] skipToNext queued targetIndex=$nextIndex'); return; }
await playAtIndex(nextIndex);}C. 在线播放启动流程加 session token,防“晚到回写”
文件:lib/core/services/audio_manager.dart
int _onlinePlaylistSessionToken = 0;bool _isSettingOnlinePlaylist = false;
Future<void> setOnlinePlaylist(...) async { final sessionToken = ++_onlinePlaylistSessionToken; _isSettingOnlinePlaylist = true;
try { await _player.setAudioSource(_onlineConcatenatingSource!, initialIndex: 0); if (sessionToken != _onlinePlaylistSessionToken) return;
mediaItem.add(mediaItems[_currentIndex]); _updatePlaybackState(); await play(); if (sessionToken != _onlinePlaylistSessionToken) return; } finally { if (sessionToken == _onlinePlaylistSessionToken) { _isSettingOnlinePlaylist = false; } }}
// 手动切歌时,取消旧启动会话if (_isSettingOnlinePlaylist) { _onlinePlaylistSessionToken++; _isSettingOnlinePlaylist = false;}D. iOS 缓存切歌不再阻塞等 play() Future
文件:lib/core/services/audio_manager.dart
await _setOnlineSingleSource(audioSource, playlistIndex: index);debugPrint('[AudioManager][SWITCH] iOS cached source set ok index=$index');
// 不 await,避免 iOS 后台场景 play() 返回很慢导致 UI 卡旧状态final playFuture = _player.play();unawaited(playFuture.then((_) { debugPrint('[AudioManager][SWITCH] iOS cached play completed index=$index');}));
debugPrint('[AudioManager][SWITCH] iOS cached play requested index=$index');
// 继续提交 currentIndex / mediaItem / playbackState_currentIndex = index;mediaItem.add(queue.value[index]);_updatePlaybackState();E. 播放页统一从 playbackState 读当前歌曲
文件:lib/features/player/presentation/screens/player_screen.dart
Widget _buildAdditionalControls( BuildContext context, AudioPlaybackState state,) { final currentSong = state.currentSong; // 不再读 audioManager.currentSong // ...}
Widget _buildLyricsSection(BuildContext context, AudioPlaybackState state) { final currentSongId = state.currentSong?.id; // ...}
void _showPlayQueueSheet(BuildContext context, AudioPlaybackState state) { int currentIndex = state.currentIndex ?? -1; // fallback 再按 state.currentSong 匹配}这几段加起来,才真正把“音频切了、UI没切”这种不一致压下去。
小结
这个问题最大的坑是:
你以为是“切歌函数有 bug”,其实是“多条异步状态通道没有收口”。
经验总结:
- 音频播放器的稳定性,核心不在某个 API,而在状态流是否单一、可判定、可取消;
- “忽略点击”通常只是临时止血,真正可用的是“排队 + 去重 + 过期丢弃”;
- UI 必须尽量只吃一个状态源,避免同屏多个真相。
这次修完后,切歌日志终于从“看不懂谁覆盖谁”变成了“每次状态变化都有因果链”。后续再出类似问题,定位成本会低很多。
支持与分享
如果这篇文章对你有帮助,欢迎分享给更多人或赞助支持!