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:
@override
Future<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 上可能导致暂停状态下通知被系统清理后队列丢失。
@override
Future<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 旧写法(有风险)
@override
Future<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() 改造
@override
Future<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();
}并将这些方法全部接入:
@override
Future<void> prepare() async => _resumeFromExternalCommand('prepare()');
@override
Future<void> prepareFromMediaId(String mediaId, [Map<String, dynamic>? extras])
async => _resumeFromExternalCommand('prepareFromMediaId() mediaId=$mediaId extras=$extras');
@override
Future<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更兼容。 - 版本探针必须跟每轮修复绑定:避免“测错包”让排障退化为玄学。