Flutter/iOS 后台自动切歌与通知栏手动“下一首”稳定实现方法
Flutter/iOS 后台自动切歌与通知栏“下一首”稳定实现方法
前段时间在重构播放器内核时,我顺手把一类最烦的 iOS 播放问题彻底收了一遍:后台自动切歌不稳定、锁屏/通知栏点“下一首”偶发失灵,或者声音切了但元数据还停留在上一首。
这个问题最恶心的地方不在于“完全坏掉”,而在于它经常是 80% 正常,20% 抽风:
- 前台切歌基本正常;
- 进后台以后,播完自动下一首偶发不切;
- 通知栏点下一首,有时能切,有时像没点到;
- 更诡异的是,有时声音已经切过去了,但歌名、封面、按钮绑定的歌曲还是旧的。
涉及核心文件:
lib/core/services/audio_manager.dartios/Runner/AppDelegate.swiftlib/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,而是四条链路在竞争
最后梳理下来,真正互相竞争的其实是四条链路:
- 手动切歌链路
skipToNext / skipToPrevious -> playAtIndex -> setAudioSource -> play -> commit - 自动切歌链路
ProcessingState.completed -> _handlePlaybackCompleted -> skipToNext -> playAtIndex - 在线播放列表启动链路
setOnlinePlaylist -> setAudioSource -> play -> commit - 系统通知栏状态链路
PlaybackEvent -> PlaybackState(queueIndex/mediaItem/controls) -> iOS Now Playing
只要这四条链路没有被统一收口,就一定会出现下面这些问题:
- 某条旧链路晚回来回写状态;
- 当前歌曲索引和系统通知栏索引脱节;
- 音频已切到下一首,但系统仍显示上一首;
- 背景场景下
play()Future 很慢,结果元数据提交被拖住。
所以后来我的修法也很明确了:
不再把“自动切歌”“通知栏 next”“播放器按钮 next”当成三个问题,而是统一看成“切歌状态机”的三个入口。
5. 最终方案:把所有入口统一收口到一条主链路
最终稳定下来的结构很简单:
播放器按钮 next / prev锁屏、通知栏 next / prevCarPlay 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() 这一层也不做特殊逻辑,直接转发:
@overrideFuture<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 计算 + 只保留最后一次用户意图”:
@overrideFuture<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) 上。
这段逻辑里,我最后确认必须处理好三件事:
- 如果在线播放列表启动流程还没结束,先取消旧启动会话。
- 如果当前已经在切歌,不再直接执行,而是记录 queued target。
- 真正执行切歌时,整个过程都要受
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 okiOS cached play requestedstale ... ignoreddrain 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,而是:
- 你的自动切歌和手动切歌是不是同一条主链路;
- 你的在线切歌是不是有
token + queued target; - 你的通知栏
queueIndex和mediaItem是不是由业务层真实当前歌曲驱动。
把这三件事处理好,很多“看起来很玄学”的 iOS 后台播放 bug,都会一下子变得非常具体,也非常好修。
支持与分享
如果这篇文章对你有帮助,欢迎分享给更多人或打赏支持!

