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

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

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,都会一下子变得非常具体,也非常好修。

支持与分享

如果这篇文章对你有帮助,欢迎分享给更多人或打赏支持!

打赏
Flutter/iOS 后台自动切歌与通知栏手动“下一首”稳定实现方法
https://blog.leguans.cn/posts/Flutter-iOS-skipToNext-fix
作者
leguan
发布于
2026-04-23
许可协议
CC BY-NC-SA 4.0
相关文章智能推荐
1
Android/Flyme 通知栏“暂停后继续播放无响应”技术复盘:根因、关键代码与最终稳定方案
开发日志一次真实线上故障的技术复盘:Flyme 机型通知栏暂停后无法继续播放。包含时序根因、关键代码改动、日志证据、兼容策略与验证结果。
2
在线播放切歌“声音和信息不一致”:一次从入口补丁到状态链路重构的排查
开发日志记录一次 Flutter 音乐播放器在线切歌错位问题:音频已切到下一首,但封面/歌名滞后,甚至要点暂停才刷新。包含踩坑方案、失败原因和最终稳定修复。
3
重装后收藏/喜欢 歌曲不见了:一次「ID 稳定性 + 数据迁移」的修复记录
开发日志Flutter 本地音乐播放器在重装/升级后出现“收藏还在,但喜欢列表空了”的问题。本文记录从日志定位到根因(songId 不稳定)以及如何通过 canonical path + 迁移把用户数据救回来。
4
播放页默认封面切换闪烁(技术细节版):从资源猜测到动画结构稳定性的完整修复
开发日志技术细节版复盘:记录播放器在“无封面歌曲”场景下切换歌词页时闪烁的问题,包含日志观察、错误方案、最终代码改动与可复用排查模板。
5
[LiveContainer] IOS无限制安装应用教程
搞七捻三前言最近体验了 LiveContainer/LiveContainer,体验很不错,之前的安装教程很繁琐,要装好多东西才能配置好,最近LiveContainer更新了,本想按原来老方式更新,但我...
随机文章随机推荐

评论区

Profile Image of the Author
Leguan
Hello, I'm Leguan.
公告
欢迎来到我的博客!这是一则示例公告。
音乐
封面

音乐

暂未播放

0:000:00
暂无歌词
分类
标签
站点统计
文章
15
说说
21
分类
3
标签
23
总字数
23,182
运行时长
0
最后活动
0 天前
站点信息
构建平台
Unknown CI
博客版本
Firefly v6.13.3
文章许可
CC BY-NC-SA 4.0

文章目录