Android/Flyme 通知栏“暂停后继续播放无响应”技术复盘:根因、关键代码与最终稳定方案

1258 字
6 分钟
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

问题路径:

  1. 通知栏点暂停;
  2. 再点继续播放;
  3. 部分机型无反应。

附带异常:有些通知样式会出现一个方形按钮(本质是 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() requestedT0
  • play() future resolvedT0 + 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 可能走:

  • click
  • playFrom*
  • 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+2014
  • probe=AUDIO_MANAGER_BUILD_2026_02_27_NOTIFDBG_V7_PREPARE_B14

关键验证链路(通知栏):

  • click(media) -> pause() 成功
  • 再次 click(media) -> play() dispatch -> playing=true 成功
  • 多轮 pause/play 成功
  • click(next) 切歌成功

这说明问题已经从“偶发不可控”转为“可复现可观测且已稳定修复”。


9. 经验总结(面向工程)#

  1. 先观测,后猜测:跨 ROM 问题没有日志就没有真相。
  2. 明确 Future 语义play() 的 Future 不等于“开始播放成功”。
  3. 命令链路收敛click/playFrom/prepare/customAction 必须兜底到统一恢复路径。
  4. 通知动作尽量稳定:Android 上 playPause 常比动态切 play/pause 更兼容。
  5. 版本探针必须跟每轮修复绑定:避免“测错包”让排障退化为玄学。

支持与分享

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

打赏
Android/Flyme 通知栏“暂停后继续播放无响应”技术复盘:根因、关键代码与最终稳定方案
https://www.ymxx.net/posts/android-flyme-notification-playback-resume-fix/
作者
Leguan
发布于
2026-03-05
许可协议
CC BY-NC-SA 4.0
相关文章智能推荐
1
Flutter/iOS 后台自动切歌与通知栏手动“下一首”稳定实现方法
开发日志Flutter/iOS 后台自动切歌与通知栏“下一首”稳定实现方法前段时间在重构播放器内核时,我顺手把一类最烦的 iOS 播放问题彻底收了一遍:后台自动切歌不稳定、锁屏/通知栏点“下一首”偶发失...
2
重装后收藏/喜欢 歌曲不见了:一次「ID 稳定性 + 数据迁移」的修复记录
开发日志Flutter 本地音乐播放器在重装/升级后出现“收藏还在,但喜欢列表空了”的问题。本文记录从日志定位到根因(songId 不稳定)以及如何通过 canonical path + 迁移把用户数据救回来。
3
在线播放切歌“声音和信息不一致”:一次从入口补丁到状态链路重构的排查
开发日志记录一次 Flutter 音乐播放器在线切歌错位问题:音频已切到下一首,但封面/歌名滞后,甚至要点暂停才刷新。包含踩坑方案、失败原因和最终稳定修复。
4
播放页默认封面切换闪烁(技术细节版):从资源猜测到动画结构稳定性的完整修复
开发日志技术细节版复盘:记录播放器在“无封面歌曲”场景下切换歌词页时闪烁的问题,包含日志观察、错误方案、最终代码改动与可复用排查模板。
5
iOS 本地 FLAC 拖动进度条不准:一次「日志正确但耳朵不对」的排查
开发日志最近在重写一个音乐播放器软件,拖动进度条时歌曲的实际播放进度和进度条显示的不一致,记录一下 Bug 的修复过程。
随机文章随机推荐

评论区

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

文章目录