2058 字
10 分钟

在线播放切歌“声音和信息不一致”:一次从入口补丁到状态链路重构的排查

在线播放切歌“声音和信息不一致”:一次从入口补丁到状态链路重构的排查#

这个 Bug 折腾了我一整个下午:

  • 在播放器里点上一首/下一首,声音切过去了
  • 但封面、歌名、评论按钮绑定的歌曲有时还是旧的;
  • 有时点一下暂停,信息又“突然正常”。

最容易复现的路径是:

  1. 点播放 A(正常)
  2. 切到 B(正常)
  3. 再切回 A(声音回去了,但 UI 还显示 B)

这不是单点 bug,而是几个并发问题叠在一起。

现象:日志看起来“多数正确”,但体验不稳定#

先看表面,很多日志都很“健康”:

  • targetIndex
  • SWITCH success
  • currentIndex/mediaItem 也经常对

但偶发时会出现两类关键信号:

  1. iOS cached source set ok 很快出现,但 iOS cached play ok 可能晚几秒甚至十几秒;
  2. 切歌过程中又出现 Online playlist started with ConcatenatingAudioSource 这种“启动流程晚到”日志。

这两类一叠加,就会造成:

  • 用户已经继续点了切歌;
  • 旧异步流程晚回来后又写状态;
  • UI 和音频出现错位,直到下一次播放状态事件(比如 pause)才被动刷新。

第一轮碰壁:只做“入口防重入”不够#

最开始做的是常规处理:

  • _isSwitchingOnlineTrack 锁;
  • 正在切歌时忽略新点击;
  • 加切歌节流。

这能减少乱点,但有两个副作用:

  • 用户点击被吞掉,体感“按钮不灵”;
  • 索引计算还基于旧 currentIndex,后续点击方向会错。

结论:只靠入口锁,治标不治本。

第二轮碰壁:只做 token 防串写,仍有漏网#

随后给 playAtIndex 加了 switchToken,防止旧切歌晚回来覆盖新状态。

这一步是对的,但仍有残留:

  • setOnlinePlaylist 自身也是异步链路;
  • 用户手动切歌后,旧的“启动在线播放列表”流程可能继续完成并回写状态。

于是出现了一个很迷惑的现象:

切歌明明成功了,过一会儿 UI 又被“拉回去”或延迟刷新。

真正根因:不是一个 race,是三条状态链路在竞争#

最终确认是三层并发竞争:

  1. 手动切歌链路skip -> playAtIndex -> setAudioSource/play -> commit
  2. 在线播放启动链路setOnlinePlaylist -> setAudioSource/play -> commit
  3. 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

// fields
int _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

@override
Future<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 必须尽量只吃一个状态源,避免同屏多个真相。

这次修完后,切歌日志终于从“看不懂谁覆盖谁”变成了“每次状态变化都有因果链”。后续再出类似问题,定位成本会低很多。

支持与分享

如果这篇文章对你有帮助,欢迎分享给更多人或赞助支持!

赞助
在线播放切歌“声音和信息不一致”:一次从入口补丁到状态链路重构的排查
https://www.ymxx.net/posts/online-switch-desync-fix/
作者
Leguan
发布于
2026-02-09
许可协议
CC BY-NC-SA 4.0

评论区

Profile Image of the Author
Leguan
Hello, I'm Leguan.
公告
欢迎来到我的博客!这是一则示例公告。
分类
标签
站点统计
文章
9
说说
11
分类
2
标签
15
总字数
11,776
运行时长
0
最后活动
0 天前

目录