Android/Flyme 通知栏“暂停后继续播放无响应”技术复盘:根因、关键代码与最终稳定方案
Android/Flyme 通知栏“暂停后继续播放无响应”技术复盘:根因、关键代码与最终稳定方案
这篇偏技术细节,重点放在:问题如何被误导、如何用日志证明、最终是怎么改代码稳住的。
涉及核心文件:lib/core/services/audio_manager.dart
1. 问题现象与复现
复现环境:
- Android:Flyme 12.6.0.0A
- App:1.0.5+2010~1.0.5+2014 逐版验证
- 构建:
flutter build apk --release --split-per-abi
问题路径:
- 通知栏点暂停;
- 再点继续播放;
- 部分机型无反应。
附带异常:有些通知样式会出现一个方形按钮(本质是 stop/custom action 显示路径差异)。
2. 第一阶段:先清表层问题
2.1 去掉 stop 按钮,固定 3 个控制位
先把通知栏按钮收敛为三键:prev / play-pause / next。
controls: [ MediaControl.skipToPrevious, if (_player.playing) MediaControl.pause else MediaControl.play, MediaControl.skipToNext,],androidCompactActionIndices: const [0, 1, 2],同时移除 MediaControl.stop,避免 ROM 显示方形动作位导致误触 stop。
2.2 click() 不再依赖 playbackState.playing
BaseAudioHandler.click() 默认根据 playbackState 判断切换,某些时刻可能滞后。改成看 _player.playing:
@overrideFuture<void> click([MediaButton button = MediaButton.media]) async { 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; }}2.3 onNotificationDeleted() 不再 stop
默认实现会 stop(),在 Flyme 上可能导致暂停状态下通知被系统清理后队列丢失。
@overrideFuture<void> onNotificationDeleted() async { if (_player.playing) { await pause(); } else { _savePlaybackState(); }}这一步避免了 idx=-1 / playlist=0 这类“状态被清空”的问题。
3. 第二阶段:建立可观测性(先证明再改)
为了避免“你测的不是我改的包”,增加双版本锚点:
- 启动版本:
STARTUP_VERSION app=...+build - 构建探针:
BUILD_PROBE ...
static const String buildProbe = 'AUDIO_MANAGER_BUILD_2026_02_27_NOTIFDBG_V7_PREPARE_B14';
debugPrint('[AudioManager][BUILD_PROBE] $buildProbe');并加 NOTIF_DBG 全链路日志:
click() requested ...play() requested ...play() dispatch ...play() post-check ...play() future resolved ...
这样就能看清楚“到底有没有收到系统命令”“命令收到后有没有真正进入播放”。
4. 真正根因:await _player.play() 语义误用
旧代码核心问题是把 await _player.play() 当成“播放立即开始”的同步点。
4.1 旧写法(有风险)
@overrideFuture<void> play() async { await _ensureAudioSessionConfigured(); await session.setActive(true); await _player.play(); // 这里会被长时间阻塞 // 后续状态提交逻辑被拖延}4.2 日志证据
实际日志里经常出现:
play() requested在T0play() future resolved在T0 + 8s / 30s / 39s- 且 resolve 发生时可能已经 pause 了
这说明 play() Future 更多是底层生命周期结束点,不适合作为 UI/通知链路的“立即成功”判定点。
5. 核心修复:改成“快速派发 + 异步观察”
5.1 新增派发器 _dispatchPlayerPlay
void _dispatchPlayerPlay(String reason) { _logNotificationDebug('play() dispatch reason=$reason'); final startedAt = DateTime.now(); unawaited( _player.play().then((_) { final elapsedMs = DateTime.now().difference(startedAt).inMilliseconds; _logNotificationDebug( 'play() future resolved reason=$reason elapsed=${elapsedMs}ms', ); }).catchError((Object e, StackTrace st) { debugPrint('[AudioManager] play() failed reason=$reason: $e'); }), ); unawaited(Future<void>.delayed(const Duration(milliseconds: 180), () { _logNotificationDebug('play() post-check reason=$reason'); }));}5.2 play() 改造
@overrideFuture<void> play() async { await _ensureAudioSessionConfigured(); final session = await AudioSession.instance; await session.setActive(true);
if (_player.playing) return;
final processing = _player.processingState; if (processing == ProcessingState.completed) { await _player.seek(Duration.zero); _dispatchPlayerPlay('completed_seek0'); return; }
if (processing == ProcessingState.idle) { if (_isOnlinePlaylist) { await _recoverOnlinePlayback('play_from_idle'); return; } if (_concatenatingSource != null && _playlist.isNotEmpty) { final targetIndex = _currentIndex.clamp(0, _playlist.length - 1); _currentIndex = targetIndex; await _player.setAudioSource(_concatenatingSource!, initialIndex: targetIndex); _dispatchPlayerPlay('idle_local_reset'); return; } }
_dispatchPlayerPlay('primary');}关键点:play() 现在是“命令立即下发”,不再被底层 Future 完成时间绑架。
6. Flyme 兼容层:把所有入口收敛到同一恢复链路
在 Android MediaSession 中,ROM 可能走:
clickplayFrom*prepareFrom*customAction
因此新增统一入口:
Future<void> _resumeFromExternalCommand(String command) async { _logNotificationDebug('$command received'); if (_player.playing) return; if (!_ensureManagedIndexForExternalResume(command)) return; await play();}并将这些方法全部接入:
@overrideFuture<void> prepare() async => _resumeFromExternalCommand('prepare()');
@overrideFuture<void> prepareFromMediaId(String mediaId, [Map<String, dynamic>? extras]) async => _resumeFromExternalCommand('prepareFromMediaId() mediaId=$mediaId extras=$extras');
@overrideFuture<void> playFromMediaId(String mediaId, [Map<String, dynamic>? extras]) async => _resumeFromExternalCommand('playFromMediaId() mediaId=$mediaId extras=$extras');索引自愈
bool _ensureManagedIndexForExternalResume(String source) { if (_playlist.isEmpty) return false; if (_currentIndex >= 0 && _currentIndex < _playlist.length) return true;
int? recoveredIndex; if (_isOnlinePlaylist) { final playerIndex = _player.currentIndex; if (playerIndex != null && playerIndex >= 0 && playerIndex < _onlinePlayerToPlaylistIndex.length) { final mappedIndex = _onlinePlayerToPlaylistIndex[playerIndex]; if (mappedIndex >= 0 && mappedIndex < _playlist.length) { recoveredIndex = mappedIndex; } } }
recoveredIndex ??= _player.currentIndex; recoveredIndex = (recoveredIndex == null || recoveredIndex < 0 || recoveredIndex >= _playlist.length) ? 0 : recoveredIndex;
_currentIndex = recoveredIndex; _updatePlaybackState(); return true;}7. 最终稳定点:Android 中间键改成单一 playPause
为了规避 Flyme 在 pause -> play 动作切换过程中的 PendingIntent 差异,中间控制改为 playPause,只切图标。
final playPauseControl = MediaControl( androidIcon: _player.playing ? 'drawable/audio_service_pause' : 'drawable/audio_service_play_arrow', label: _player.playing ? 'Pause' : 'Play', action: MediaAction.playPause,);
controls: [ MediaControl.skipToPrevious, Platform.isAndroid ? playPauseControl : (_player.playing ? MediaControl.pause : MediaControl.play), MediaControl.skipToNext,],最终日志从 controls=[...,play,...] 变成 controls=[...,playPause,...],并在 Flyme 上稳定。
8. 最终验证(B14)
最终验证版本:
app=1.0.5+2014probe=AUDIO_MANAGER_BUILD_2026_02_27_NOTIFDBG_V7_PREPARE_B14
关键验证链路(通知栏):
click(media) -> pause()成功- 再次
click(media) -> play() dispatch -> playing=true成功 - 多轮 pause/play 成功
click(next)切歌成功
这说明问题已经从“偶发不可控”转为“可复现可观测且已稳定修复”。
9. 经验总结(面向工程)
- 先观测,后猜测:跨 ROM 问题没有日志就没有真相。
- 明确 Future 语义:
play()的 Future 不等于“开始播放成功”。 - 命令链路收敛:
click/playFrom/prepare/customAction必须兜底到统一恢复路径。 - 通知动作尽量稳定:Android 上
playPause常比动态切play/pause更兼容。 - 版本探针必须跟每轮修复绑定:避免“测错包”让排障退化为玄学。
支持与分享
如果这篇文章对你有帮助,欢迎分享给更多人或打赏支持!

