跳转到正文
月明星稀
返回

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

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

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

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

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

涉及核心文件:


先说最终结论

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

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

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

下面按排查过程慢慢讲。

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


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

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

1.1 自动切歌偶发不触发

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

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

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

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

这是最迷惑人的一种。

用户主观体验是:

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

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

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

但用户体验仍然不稳定。

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


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

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

于是第一轮修法很朴素:

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

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


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

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

这个思路本身是对的:

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

但实测仍然有一类异常:

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


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

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

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

不再把“自动切歌”“通知栏 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;
  }
}

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

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


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)
}

也就是说:

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 件事

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

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

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

它们分别解决不同问题:

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);
}

这一段的意义是:

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


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:


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

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

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

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

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)

换句话说:

这一步改完之后,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;
  }
}

这样做的意义是:

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


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

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

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

如果 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. 复测时我主要盯哪些日志

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

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

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

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

[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. 小结

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

你一开始会以为它是:

但真正的根因其实是:

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

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

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

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

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

文章目录

分享这篇文章:

相关文章


上一篇
Swift:iOS 控制中心媒体播放状态与软件内不同步的解决办法和排查思路
下一篇
我的音乐App-SollinPlayer,被Apple审核员判“涉黄”了

评论

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