跳转到正文
月明星稀
返回

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

约 5 分钟阅读开发日志加载中浏览量

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

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

最容易复现的路径是:

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

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

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

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

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

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

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

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

最开始做的是常规处理:

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

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

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

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

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

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

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

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

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

  1. 手动切歌链路skip -> playAtIndex -> setAudioSource/play -> commit
  2. 在线播放启动链路setOnlinePlaylist -> setAudioSource/play -> commit
  3. UI 数据源链路(一部分组件读 stream state,一部分直接读 audioManager.currentSong

只要这三条不统一,某个链路晚到就可能覆盖另一个链路,导致“声音和信息不同步”。

最终方案(分层修复)

1)切歌链路:token + 排队,不再简单 ignore

AudioManager.playAtIndex 的在线分支里:

同时增加了“切歌排队”而不是“切歌忽略”:

这样既避免了串写,又保留了交互连续性。

2)在线播放启动链路:增加 session token,防晚到回写

setOnlinePlaylist 单独加了 online start session token

再加一条关键策略:

这一步直接解决了“切歌中又出现 Online playlist started 并污染状态”的问题。

3)iOS 缓存切歌:不再阻塞等待 play() Future 完成

日志里反复出现:

如果等它返回再提交 UI,信息会明显滞后。

所以改成:

这让 UI 不会因为 iOS 的慢返回而“卡住旧歌信息”。

4)UI 层收口:统一从 playbackState 读当前歌曲

播放器页面之前有双数据源:

这会导致同一帧内不同组件看的是不同快照。

最终把播放页关键区域收口到同一来源:

统一由 AudioPlaybackState 驱动,避免“局部刷新好了,局部还旧”的视觉撕裂。

为什么“点暂停就会刷新”

这个现象很有迷惑性,其实是线索:

所以“暂停能修好”不是修复,而是状态链路竞争被下一次事件掩盖

最终验证(复测路径)

重点复测了这几条:

关键日志特征变为:

最终表现恢复稳定:音频与歌名/封面/功能按钮绑定一致,不再需要“点暂停触发刷新”。

关键代码(节选)

下面贴的是这次修复里最关键的几段代码,都是“去竞态”的核心点。

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”,其实是“多条异步状态通道没有收口”。

经验总结:

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

文章目录

分享这篇文章:

相关文章


上一篇
播放页默认封面切换闪烁(技术细节版):从资源猜测到动画结构稳定性的完整修复
下一篇
重装后收藏/喜欢 歌曲不见了:一次「ID 稳定性 + 数据迁移」的修复记录

评论

欢迎留下你的想法。请友善交流,也可以顺手分享此刻的感受。