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. 版本探针必须跟每轮修复绑定:避免“测错包”让排障退化为玄学。
最后修改:2026 年 03 月 05 日
如果觉得我的文章对你有用,请随意赞赏