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 / 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) 上。
这段逻辑里,我最后确认必须处理好三件事:
- 如果在线播放列表启动流程还没结束,先取消旧启动会话。
- 如果当前已经在切歌,不再直接执行,而是记录 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,都会一下子变得非常具体,也非常好修。