Flutter/iOS 后台自动切歌与通知栏“下一首”稳定实现方法

前段时间在重构播放器内核时,我顺手把一类最烦的 iOS 播放问题彻底收了一遍:后台自动切歌不稳定、锁屏/通知栏点“下一首”偶发失灵,或者声音切了但元数据还停留在上一首

这个问题最恶心的地方不在于“完全坏掉”,而在于它经常是 80% 正常,20% 抽风

  • 前台切歌基本正常;
  • 进后台以后,播完自动下一首偶发不切;
  • 通知栏点下一首,有时能切,有时像没点到;
  • 更诡异的是,有时声音已经切过去了,但歌名、封面、按钮绑定的歌曲还是旧的。

涉及核心文件:

  • lib/core/services/audio_manager.dart
  • ios/Runner/AppDelegate.swift
  • lib/core/services/ios_carplay_service.dart

先说最终结论

这次修完之后,我对这个问题的总结非常明确:

iOS 后台自动切歌和通知栏“下一首”是否稳定,关键不在于你有没有写 skipToNext(),而在于你有没有把“切歌”当成一个完整的状态机来管理。

真正稳定的实现,至少要满足这几条:

  • 所有切歌入口统一收口:播放器按钮、通知栏、锁屏、CarPlay、自动切歌,最后都走同一条主链路。
  • 在线切歌必须串行化:任意时刻只能有一个活跃切歌任务。
  • 旧异步任务不能回写新状态:要有 token 防串写。
  • 切歌中再次点击不能简单吞掉:要记录“最后一次用户意图”,当前切歌结束后自动 drain。
  • 在线播放列表启动流程也要有独立 token:否则它会和手动切歌抢状态。
  • iOS 后台缓存切歌不要傻等 play() Future:它可能很晚才 resolve,但音频其实已经播了。
  • 通知栏的 queueIndex 和 mediaItem 不能完全相信播放器内部 index:必须优先使用你自己维护的当前索引。

下面按排查过程慢慢讲。

需注意,本文仅供技术性学习参考,不代表你的软件完全适用,具体问题具体分析,如果遇到问题,欢迎评论区留言交流。 -by Leguan

1. 问题现象:看起来像“偶发失灵”,本质是多条状态链路抢写

我最早观察到的现象有四类。

1.1 自动切歌偶发不触发

歌曲在前台播放完时大多能自动切下一首,但一旦切到后台,尤其是在线歌曲,就会出现:

  • 明明还有下一首;
  • 进度已经到结尾;
  • 但就是停在那里不走。

1.2 通知栏/锁屏点下一首偶发没反应

这个现象在“当前歌曲刚切完、或者正处于切歌中”时更明显:

  • 点一次 next,没反应;
  • 再点一次,又突然跳到后一首;
  • 有时还会出现“第二次操作覆盖第一次”的错觉。

1.3 声音切过去了,但元数据还是旧的

这是最迷惑人的一种。

用户主观体验是:

  • 耳朵听到已经是下一首;
  • 但锁屏显示还是上一首;
  • 播放页里某些区域也还没更新;
  • 有时暂停一下,信息又“自己好了”。

这类问题最容易让人误以为是 UI 层刷新 bug。

1.4 日志多数正确,但体验依然错

更坑的是,很多日志看起来都很健康:

  • targetIndex 是对的;
  • setAudioSource 是成功的;
  • play() 也发起了;
  • SWITCH success 也打出来了。

但用户体验仍然不稳定。

这通常意味着:你看到的不是“某一步完全失败”,而是多个异步流程竞争状态,最后谁晚回来谁覆盖谁。


2. 第一轮误判:以为只是“切歌函数写得不够严谨”

最开始我以为这是个很普通的切歌重入问题。

于是第一轮修法很朴素:

  • _isSwitchingOnlineTrack 锁;
  • 切歌中直接忽略新的 next/prev;
  • skipToNext() 加节流。

看起来很合理,但很快发现两个副作用:

  • 用户连续点两下 next,第二下被吞了,体感上就是“按钮不灵”;
  • 下一次计算目标索引时,有时还是基于旧的 _currentIndex,结果方向也会错。

也就是说,只靠一个“正在切歌就 return”的入口锁,最多只是降低混乱,并没有真正解决竞争。


3. 第二轮误判:加了 token,为什么还会被旧状态拉回去?

随后我把在线切歌链路加上了 switchToken

这个思路本身是对的:

  • 每次切歌时递增 token;
  • resolve -> setAudioSource -> play -> commit 的各个阶段检查 token;
  • 发现 token 过期就立刻放弃,不允许旧任务再写 _currentIndex / mediaItem / queue

理论上这已经能挡住大部分“旧 Future 晚回来”的问题。

但实测仍然有一类异常:

  • 手动点 next 之后,音频已经切对了;
  • 几百毫秒后,UI 又被“拉回去”;
  • 日志里会混入 ONLINE_START success 一类晚到消息。

这时候我才意识到:切歌并不是唯一一条会写播放状态的链路。


4. 真正根因:不是一个 race,而是四条链路在竞争

最后梳理下来,真正互相竞争的其实是四条链路:

  1. 手动切歌链路
    skipToNext / skipToPrevious -> playAtIndex -> setAudioSource -> play -> commit
  2. 自动切歌链路
    ProcessingState.completed -> _handlePlaybackCompleted -> skipToNext -> playAtIndex
  3. 在线播放列表启动链路
    setOnlinePlaylist -> setAudioSource -> play -> commit
  4. 系统通知栏状态链路
    PlaybackEvent -> PlaybackState(queueIndex/mediaItem/controls) -> iOS Now Playing

只要这四条链路没有被统一收口,就一定会出现下面这些问题:

  • 某条旧链路晚回来回写状态;
  • 当前歌曲索引和系统通知栏索引脱节;
  • 音频已切到下一首,但系统仍显示上一首;
  • 背景场景下 play() Future 很慢,结果元数据提交被拖住。

所以后来我的修法也很明确了:

不再把“自动切歌”“通知栏 next”“播放器按钮 next”当成三个问题,而是统一看成“切歌状态机”的三个入口。

5. 最终方案:把所有入口统一收口到一条主链路

最终稳定下来的结构很简单:

播放器按钮 next / prev
锁屏、通知栏 next / prev
CarPlay next / prev
自动切歌 completed
  -> skipToNext() / skipToPrevious()
  -> playAtIndex(index)
  -> resolve / cache / setAudioSource / play
  -> commit currentIndex / mediaItem / queue / playbackState

关键点只有一句话:

自动切歌不要另写一套,通知栏点击也不要另写一套,最后都统一走 playAtIndex(index)

这样做的好处是:

  • 排查路径简单;
  • 修一次逻辑,所有入口一起受益;
  • 不会出现“前台按钮正常,通知栏异常,自动切歌又是另一套”的维护地狱。

6. 先解决自动切歌:完成事件只负责转发,不直接切 source

自动切歌这部分,我最后保留得非常克制。

监听 processingStateStream,状态进 completed 后,统一走 _handlePlaybackCompleted()

Future<void> _handlePlaybackCompleted() async {
  // Guard against duplicate completion notifications (can happen when
  // switching sources quickly) to avoid racing setAudioSource/play calls.
  if (_isHandlingCompletion) return;
  _isHandlingCompletion = true;
  try {
    if (_repeatMode == RepeatMode.one) {
      await seek(Duration.zero);
      await play();
    } else {
      await skipToNext();
    }
  } catch (e, st) {
    debugPrint('[AudioManager] _handlePlaybackCompleted error: $e');
    debugPrint('[AudioManager] _handlePlaybackCompleted stack: $st');
  } finally {
    _isHandlingCompletion = false;
  }
}

这里的重点不是代码长短,而是职责边界:

  • _handlePlaybackCompleted() 不直接做 URL 解析;
  • 不直接 set source;
  • 不直接提交新歌曲元数据;
  • 它只负责把“播放完成”这个事件,转成一次标准的 skipToNext()

这一步非常重要。因为一旦自动切歌另走一套私有逻辑,你后面就一定会出现:

  • 手动 next 修好了;
  • 但自动 next 还是会有旧 bug。

7. 再解决通知栏/锁屏 next:原生只桥接,Flutter 统一处理

我这边的做法是让原生 iOS 尽量“薄”。

7.1 Swift 侧不写业务逻辑,只桥接命令

AppDelegate.swift 里基本就是这样:

func skipToNext(completion: @escaping (Result<Void, Error>) -> Void) {
  invokeVoid("skipToNext", completion: completion)
}

func skipToPrevious(completion: @escaping (Result<Void, Error>) -> Void) {
  invokeVoid("skipToPrevious", completion: completion)
}

也就是说:

  • 原生层不负责计算该跳到哪一首;
  • 也不负责切 source;
  • 所有核心逻辑仍然收敛到 Flutter 侧。

7.2 Flutter 里的媒体按钮也统一走 skip 方法

click() 这一层也不做特殊逻辑,直接转发:

@override
Future<void> click([MediaButton button = MediaButton.media]) async {
  _logNotificationDebug('click() requested button=${button.name}');
  switch (button) {
    case MediaButton.media:
      if (_player.playing) {
        await pause();
      } else {
        await play();
      }
      break;
    case MediaButton.next:
      await skipToNext();
      break;
    case MediaButton.previous:
      await skipToPrevious();
      break;
  }
  _logNotificationDebug('click() finished button=${button.name}');
}

这样一来,“播放器里的 next”和“通知栏点 next”就没有分叉了。


8. 真正的核心:在线切歌状态机必须同时解决 3 件事

在线切歌比本地切歌麻烦很多,因为它天然有异步阶段:

  • 可能命中缓存;
  • 可能要拿预加载 URL;
  • 可能要重新 resolve;
  • 可能还夹着歌词、封面、元数据更新。

所以我最后把在线切歌状态机稳定下来,依赖的是这几个字段:

bool _isSwitchingOnlineTrack = false;
DateTime? _onlineTrackSwitchStartedAt;
int _onlineSwitchToken = 0;
int? _pendingOnlineSwitchIndex;
int? _queuedOnlineSwitchIndex;

它们分别解决不同问题:

  • _isSwitchingOnlineTrack:当前是否有切歌任务在跑。
  • _pendingOnlineSwitchIndex:正在执行的目标 index。
  • _queuedOnlineSwitchIndex:用户切歌过程中又点了一次,最后想去哪里。
  • _onlineSwitchToken:旧任务晚回来时,是否还有资格写状态。

8.1 skipToNext:切歌中不忽略,而是入队

这是最关键的一步之一。

以前很多实现会写成:

if (_isSwitching) return;

这会让用户的第二次点击直接消失。

我的做法改成了“按 pending index 计算 + 只保留最后一次用户意图”:

@override
Future<void> skipToNext() async {
  if (_playlist.isEmpty) return;
  final now = DateTime.now();
  var baseIndex = (_isOnlinePlaylist && _isSwitchingOnlineTrack)
      ? (_pendingOnlineSwitchIndex ?? _currentIndex)
      : _currentIndex;

  if (_isOnlinePlaylist && _isSwitchingOnlineTrack) {
    final startedAt = _onlineTrackSwitchStartedAt;
    final isTimedOut = startedAt != null &&
        now.difference(startedAt) > _onlineSwitchLockTimeout;
    if (isTimedOut) {
      debugPrint('[AudioManager] online switch lock timed out, reset lock');
      _isSwitchingOnlineTrack = false;
      _onlineTrackSwitchStartedAt = null;
      _pendingOnlineSwitchIndex = null;
      baseIndex = _currentIndex;
    } else {
      final queuedIndex = _resolveNextIndex(
        baseIndex: baseIndex,
        reshuffleOnWrap: true,
      );
      if (queuedIndex < 0) return;
      debugPrint(
          '[AudioManager][CMD] skipToNext currentIndex=$_currentIndex baseIndex=$baseIndex pending=$_pendingOnlineSwitchIndex queued=$_queuedOnlineSwitchIndex playerIndex=${_player.currentIndex} pos=${_player.position.inMilliseconds}ms currentTitle=${currentSong?.title}');
      _queuedOnlineSwitchIndex = queuedIndex;
      debugPrint(
          '[AudioManager][CMD] skipToNext queued targetIndex=$queuedIndex queued=$_queuedOnlineSwitchIndex baseIndex=$baseIndex');
      return;
    }
  }

  final nextIndex = _resolveNextIndex(
    baseIndex: baseIndex,
    reshuffleOnWrap: true,
  );
  if (nextIndex < 0) return;

  if (_lastSkipToNextAt != null &&
      now.difference(_lastSkipToNextAt!) < _skipToNextThrottle) {
    debugPrint('[AudioManager] skipToNext throttled');
    return;
  }
  _lastSkipToNextAt = now;

  await playAtIndex(nextIndex);
}

这一段的意义是:

  • 当前切歌没完成时,新的点击不会立即执行;
  • 但也不会被吞掉;
  • 当前切歌结束后,会自动接管 _queuedOnlineSwitchIndex

用户体感会从“按钮不灵”变成“虽然忙,但会接着响应我最后一次操作”。


9. playAtIndex:这才是整个系统真正的中心

所有修复最终都落在 playAtIndex(int index) 上。

这段逻辑里,我最后确认必须处理好三件事:

  1. 如果在线播放列表启动流程还没结束,先取消旧启动会话。
  2. 如果当前已经在切歌,不再直接执行,而是记录 queued target。
  3. 真正执行切歌时,整个过程都要受 switchToken 保护。

核心代码如下:

Future<void> playAtIndex(int index) async {
  if (index < 0 || index >= _playlist.length) return;

  // Cancel any ongoing fallback recovery for a previous track.
  _fallbackManager.cancelCurrentFallback();

  // Handle online playlist - need to resolve URL and create new audio source
  if (_isOnlinePlaylist && _onlineSongList != null && _urlResolver != null) {
    _onlineRecoveryShouldResumePlayback = true;
    final boardSong = _onlineSongList![index];
    if (_isSettingOnlinePlaylist) {
      final canceledToken = _onlinePlaylistSessionToken;
      _onlinePlaylistSessionToken++;
      _isSettingOnlinePlaylist = false;
      debugPrint(
          '[AudioManager][SWITCH] cancel pending online start token=$canceledToken by manual switch target=$index');
    }
    if (_isSwitchingOnlineTrack) {
      _queuedOnlineSwitchIndex = index;
      _pendingOnlineSwitchIndex = index;
      _updatePlaybackState();
      debugPrint(
          '[AudioManager][SWITCH] switching in progress, queue target index=$index queued=$_queuedOnlineSwitchIndex pending=$_pendingOnlineSwitchIndex current=$_currentIndex');
      return;
    }
    _isSwitchingOnlineTrack = true;
    _onlineTrackSwitchStartedAt = DateTime.now();
    _pendingOnlineSwitchIndex = index;
    _updatePlaybackState();
    final switchToken = ++_onlineSwitchToken;

    try {
      // ... resolve / cached source / network source / play

      if (switchToken != _onlineSwitchToken) {
        debugPrint(
            '[AudioManager][SWITCH] stale network switch ignored token=$switchToken latest=$_onlineSwitchToken index=$index');
        return;
      }

      _currentIndex = index;
      _updateNowPlayingMediaItem(mediaItems[index], force: true);
      _updatePlaybackState();
    } finally {
      if (switchToken == _onlineSwitchToken) {
        _isSwitchingOnlineTrack = false;
        _onlineTrackSwitchStartedAt = null;
        _pendingOnlineSwitchIndex = null;

        final queuedIndex = _queuedOnlineSwitchIndex;
        if (queuedIndex != null && queuedIndex != _currentIndex) {
          _queuedOnlineSwitchIndex = null;
          debugPrint(
              '[AudioManager][SWITCH] drain queued switch queued=$queuedIndex current=$_currentIndex token=$switchToken');
          unawaited(playAtIndex(queuedIndex));
        } else {
          _queuedOnlineSwitchIndex = null;
        }
      }
    }
  }
}

这一段基本把问题全部收口了。

它解决的是三个最现实的 bug:

  • 旧 start 会话晚到回写
  • 旧切歌任务晚到回写
  • 切歌中用户再次点击被吞掉

10. iOS 后台最关键的坑:缓存切歌时,不要等 play() Future 返回

这一步是我觉得最“值钱”的结论。

日志里我反复看到这种现象:

  • iOS cached source set ok 很快出现;
  • await _player.play() 可能几秒,甚至十几秒后才返回;
  • 更离谱的是,有时音频已经播了,play() Future 还没 resolve。

如果你这时的代码顺序是:

await _player.setAudioSource(source);
await _player.play();
_currentIndex = index;
mediaItem.add(...);

那就等于把“元数据切换”绑死在 play() Future 的完成时机上。

在 iOS 后台场景下,这个绑定非常危险。

所以我最后改成了:

if (Platform.isIOS) {
  debugPrint(
      '[AudioManager] iOS: Full audio reset for background track switch');
  try {
    // Stop completely first
    await _player.stop();
    await Future<void>.delayed(const Duration(milliseconds: 50));

    // Re-activate audio session
    final session = await AudioSession.instance;
    await session.setActive(true);

    final fileUri = Uri.file(cachedAudio);
    final audioSource = _buildOnlineProgressiveSource(
      fileUri,
      song: updatedSong,
      duration: Duration(milliseconds: updatedSong.duration),
    );
    await _setOnlineSingleSourceForSwitch(
      audioSource,
      playlistIndex: index,
      reason: 'switch_ios_cached:index=$index',
    );
    debugPrint(
        '[AudioManager][SWITCH] iOS cached source set ok index=$index');

    final playFuture = _player.play();
    unawaited(playFuture.then((_) {
      debugPrint(
          '[AudioManager][SWITCH] iOS cached play completed token=$switchToken index=$index');
    }).catchError((Object e, StackTrace st) {
      debugPrint(
          '[AudioManager][SWITCH] iOS cached play failed token=$switchToken index=$index error=$e');
    }));
    debugPrint(
        '[AudioManager][SWITCH] iOS cached play requested token=$switchToken index=$index');
    if (switchToken != _onlineSwitchToken) {
      debugPrint(
          '[AudioManager][SWITCH] stale iOS cached switch ignored token=$switchToken latest=$_onlineSwitchToken index=$index');
      return;
    }
  } catch (e) {
    debugPrint(
        '[AudioManager][SWITCH] iOS background audio reset failed index=$index error=$e');
    rethrow;
  }
}

这里真正关键的不是“加了 stop()”或者“加了 50ms delay”。

真正关键的是这句:

final playFuture = _player.play();
unawaited(playFuture)

换句话说:

  • play() 要发起;
  • 状态提交不要被 play() Future 的完成时机绑架

这一步改完之后,iOS 后台“声音已经切了,但系统元数据还没切”的问题明显少了很多。


11. 只修切歌还不够:在线播放列表启动流程也要防“晚到回写”

前面说过,playAtIndex 不是唯一会写播放状态的链路。

如果你的项目也有 setOnlinePlaylist(...) 这种“加载列表并自动播第一首”的入口,那它本身也必须带 token。

我这边是这样处理的:

final sessionToken = ++_onlinePlaylistSessionToken;

await _player.setAudioSource(
  _onlineConcatenatingSource!,
  initialIndex: 0,
);
if (sessionToken != _onlinePlaylistSessionToken) {
  debugPrint(
      '[AudioManager][ONLINE_START] stale after setAudioSource token=$sessionToken latest=$_onlinePlaylistSessionToken ignored');
  return;
}

_updateNowPlayingMediaItem(mediaItems[_currentIndex], force: true);
_updatePlaybackState();
if (autoPlay) {
  await play();
  if (sessionToken != _onlinePlaylistSessionToken) {
    debugPrint(
        '[AudioManager][ONLINE_START] stale after play token=$sessionToken latest=$_onlinePlaylistSessionToken ignored');
    return;
  }
}

这样做的意义是:

  • 如果用户在在线播放列表启动过程中手动切歌;
  • 旧启动流程即使晚回来,也会因为 token 过期被直接丢弃;
  • 不会再把当前状态“拉回初始化那首歌”。

很多“明明切歌成功,过一会儿又回去了”的问题,本质都在这里。


12. 通知栏为什么会显示错歌?因为系统的 queueIndex 未必可信

这一步是很多人容易忽略的。

audio_service 最终给 iOS Now Playing 的,不只是“播放/暂停状态”,还包括:

  • 当前的 mediaItem
  • 当前的 queueIndex
  • 对应的 controls

如果 queueIndex 落后,或者 mediaItem 更新滞后,系统通知栏就会出现明显错位。

我最后做了两件事。

12.1 queueIndex 优先使用自己维护的 _currentIndex

PlaybackState _transformEvent(PlaybackEvent event) {
  return PlaybackState(
    controls: [
      MediaControl.skipToPrevious,
      Platform.isAndroid
          ? playPauseControl
          : (_player.playing ? MediaControl.pause : MediaControl.play),
      MediaControl.skipToNext,
    ],
    systemActions: const {
      MediaAction.play,
      MediaAction.pause,
      MediaAction.playPause,
      MediaAction.seek,
      MediaAction.seekForward,
      MediaAction.seekBackward,
      MediaAction.skipToNext,
      MediaAction.skipToPrevious,
      MediaAction.setShuffleMode,
      MediaAction.setRepeatMode,
    },
    androidCompactActionIndices: const [0, 1, 2],
    processingState: const {
      ProcessingState.idle: AudioProcessingState.loading,
      ProcessingState.loading: AudioProcessingState.loading,
      ProcessingState.buffering: AudioProcessingState.buffering,
      ProcessingState.ready: AudioProcessingState.ready,
      ProcessingState.completed: AudioProcessingState.completed,
    }[_player.processingState]!,
    playing: _player.playing,
    updatePosition: _player.position,
    bufferedPosition: _player.bufferedPosition,
    speed: _player.speed,
    queueIndex: _currentIndex >= 0 ? _currentIndex : event.currentIndex,
  );
}

这句:

queueIndex: _currentIndex >= 0 ? _currentIndex : event.currentIndex

非常关键。

因为在某些在线场景里,播放器内部的 event.currentIndex 并不等于你业务上的当前歌曲索引。

12.2 切歌成功后主动推送新的 mediaItem

我没有完全依赖播放器事件自己同步,而是在切歌成功后主动调用:

_updateNowPlayingMediaItem(mediaItems[index], force: true);

这样做的好处是:

  • 一旦业务层已经确认“当前歌就是这首”;
  • 就立即把它推给系统;
  • 不再被动等待底层事件什么时候更新到位。

这一步对锁屏元数据一致性非常重要。


13. 一个容易忽略的细节:idle 不一定应该映射成系统 idle

我这里还顺手修了一个很隐蔽的问题。

在某些切歌瞬间,播放器会短暂进入 ProcessingState.idle。如果这时你直接把它映射成系统的 AudioProcessingState.idle,iOS 可能会认为当前 Now Playing 会话已经结束。

所以最终我在系统状态映射里故意做了这个处理:

processingState: const {
  // NOTE: Map idle→loading (not idle) to prevent iOS from killing the
  // Now Playing session during track transitions.
  ProcessingState.idle: AudioProcessingState.loading,
  ProcessingState.loading: AudioProcessingState.loading,
  ProcessingState.buffering: AudioProcessingState.buffering,
  ProcessingState.ready: AudioProcessingState.ready,
  ProcessingState.completed: AudioProcessingState.completed,
}[_player.processingState]!,

这个改动不大,但对 iOS 后台切歌过程的稳定性是有帮助的。

因为从系统视角看,切歌瞬间更接近“正在 loading 下一首”,而不是“播放会话结束了”。


14. 复测时我主要盯哪些日志

这类问题如果没有日志,基本只能靠猜。

我后来重点盯的是这些信号:

  • cancel pending online start token=...
  • switching in progress, queue target index=...
  • iOS cached source set ok
  • iOS cached play requested
  • stale ... ignored
  • drain queued switch queued=...

如果这些日志顺序是健康的,通常状态链路就是对的。

一个比较理想的切歌日志序列,大概会长这样:

[AudioManager][CMD] skipToNext targetIndex=12
[AudioManager][SWITCH] cancel pending online start token=7 by manual switch target=12
[AudioManager][SWITCH] start token=21 index=12 current=11 title=...
[AudioManager][SWITCH] iOS cached source set ok index=12
[AudioManager][SWITCH] iOS cached play requested token=21 index=12
[AudioManager][SYNC] switched(cached) currentIndex=12 ...

如果用户在切歌中又点了一次 next,还会看到:

[AudioManager][CMD] skipToNext queued targetIndex=13
[AudioManager][SWITCH] drain queued switch queued=13 current=12 token=21

这个“drain queued switch”非常关键,它代表第二次点击没有丢。


15. 本方法要点

15.1 自动切歌最终调用 skipToNext()

不要自己另写一套自动切歌逻辑。

15.2 在线切歌增加这四个状态字段

bool _isSwitchingOnlineTrack = false;
DateTime? _onlineTrackSwitchStartedAt;
int _onlineSwitchToken = 0;
int? _pendingOnlineSwitchIndex;
int? _queuedOnlineSwitchIndex;

15.3 切歌中不要简单 return,要记录 queued target

否则按钮会“像坏了一样”。

15.4 如果有在线播放列表初始化流程,也必须带 session token

否则旧启动流程会回写状态。

15.5 iOS 后台缓存切歌时,不要等 await player.play()

这是解决“声音切了但元数据还没切”的关键之一。

15.6 queueIndex 优先用自己维护的业务索引

不要完全依赖 event.currentIndex


16. 小结

回头看,这次问题最有意思的地方是:

你一开始会以为它是:

  • 某个按钮监听没接对;
  • 某次 skipToNext() 没执行;
  • 或者某个 UI 刷新晚了。

但真正的根因其实是:

播放器、通知栏、自动切歌、在线播放启动,这几条链路都能改同一份状态,但之前没有统一的切歌状态机去收口它们。

这次最终稳定下来,靠的不是某个神奇 hack,而是把职责重新拉直了:

  • 入口统一;
  • 切歌串行;
  • 旧任务失效;
  • 用户意图排队;
  • 系统状态由业务索引主导;
  • iOS 后台的 play() 慢返回不再拖住元数据提交。

如果你在做 Flutter 音乐播放器,卡在 iOS 后台自动切歌或通知栏 next 这类问题上,我最建议先检查的,不是 UI,而是:

  1. 你的自动切歌和手动切歌是不是同一条主链路;
  2. 你的在线切歌是不是有 token + queued target
  3. 你的通知栏 queueIndexmediaItem 是不是由业务层真实当前歌曲驱动。

把这三件事处理好,很多“看起来很玄学”的 iOS 后台播放 bug,都会一下子变得非常具体,也非常好修。

最后修改:2026 年 04 月 23 日
如果觉得我的文章对你有用,请随意赞赏