09. Flutter 鸿蒙实战 09:语音讲故事与音频播放器

在这里插入图片描述

用 audioplayers 播放高清语音,同时保留本地讲述兜底能力。

语音功能的两层能力

故事详情页里有“讲故事”功能。项目同时用了两类能力:一类是 audioplayers 播放后端生成的音频,另一类是 MethodChannel('talking_album/local_tts') 触发本地语音讲述。前者音质更稳定,后者可以作为即时反馈。

本地通道封装在 lib/core/speech/local_story_speaker.dart

class LocalStorySpeaker {
  LocalStorySpeaker._() {
    _channel.setMethodCallHandler((call) async {
      switch (call.method) {
        case 'onDone':
          onComplete?.call();
          return;
        case 'onError':
          onError?.call(call.arguments?.toString() ?? '系统语音播放失败');
          return;
      }
    });
  }

  static final LocalStorySpeaker instance = LocalStorySpeaker._();
  static const _channel = MethodChannel('talking_album/local_tts');
}

这段 Dart 侧代码已经定义了通道名和回调处理。鸿蒙侧如果要实现本地 TTS,就要按同样的通道名响应 speakstop

AudioPlayer 生命周期

故事详情页持有一个 AudioPlayer

final AudioPlayer _audioPlayer = AudioPlayer();
final List<StreamSubscription<dynamic>> _audioSubscriptions = [];

初始化时监听播放状态、进度、时长和完成事件:

_audioSubscriptions.add(_audioPlayer.onPlayerStateChanged.listen((state) {
  if (mounted && !_localSpeechActive) {
    setState(() => _audioPlaying = state == PlayerState.playing);
  }
}));
_audioSubscriptions.add(_audioPlayer.onPositionChanged.listen((position) {
  if (mounted) setState(() => _audioPosition = position);
}));

播放器状态不能只靠按钮点击判断。真实播放可能失败、完成、被系统中断,所以要监听播放器事件。

释放资源

详情页销毁时要取消订阅、停止本地朗读、清理回调、释放播放器:


void dispose() {
  for (final subscription in _audioSubscriptions) {
    subscription.cancel();
  }
  _animationPollTimer?.cancel();
  LocalStorySpeaker.instance.stop();
  LocalStorySpeaker.instance.onComplete = null;
  LocalStorySpeaker.instance.onError = null;
  _audioPlayer.dispose();
  super.dispose();
}

音频资源是移动端很容易漏的地方。离开故事详情后如果不释放,隐藏页面可能继续占用播放器或继续发出声音。

播放器 UI

播放器 UI 独立成 StoryAudioPlayer

class StoryAudioPlayer extends StatelessWidget {
  const StoryAudioPlayer({
    super.key,
    required this.hasContent,
    required this.displayDuration,
    required this.loading,
    required this.loaded,
    required this.playing,
    required this.position,
    required this.duration,
    required this.error,
    required this.onToggle,
    required this.onSeek,
  });
}

这个组件只接收状态和回调,不直接请求接口,也不直接管理播放器。页面负责业务,组件负责展示。

进度条拖动只有在音频已加载且未播放时可用:

Slider(
  value: value,
  max: max,
  onChanged: !loaded || playing
      ? null
      : (next) => onSeek(Duration(milliseconds: next.round())),
)

鸿蒙插件依赖

pubspec.yaml 使用了 OpenHarmony 适配的 audioplayers:

audioplayers:
  git:
    url: https://gitee.com/openharmony-sig/flutter_audioplayers.git
    ref: br_audioplayers-v6.1.0_ohos
    path: packages/audioplayers

GeneratedPluginRegistrant.ets 中也注册了 AudioplayersPlugin。这说明音频能力不是纯 Dart 实现,鸿蒙侧插件注册必须成功。

在这里插入图片描述

播放状态来源

播放器状态不要自己猜

音频播放不是按钮切换那么简单。播放可能失败,可能被系统打断,可能播放完成,也可能还没有拿到时长。项目使用 onPlayerStateChangedonPositionChangedonDurationChangedonPlayerComplete 监听真实状态。

_audioSubscriptions.add(_audioPlayer.onDurationChanged.listen((duration) {
  if (mounted) setState(() => _audioDuration = duration);
}));

播放器 UI 的状态应该来自播放器事件,而不是来自按钮点击。按钮只代表用户意图,事件才代表真实播放状态。

本地讲述的兜底意义

LocalStorySpeaker 通过 MethodChannel 调本地能力。即使高清语音还在准备,也可以先让系统语音读当前故事。这个设计让“讲故事”按钮响应更快,也给后端音频生成留出时间。

音频事件和本地讲述

播放状态必须来自播放器事件

音频播放按钮只能表达用户意图,不能代表真实播放状态。用户点了播放,音频可能还在加载;用户点了暂停,播放器可能已经自然结束。项目里通过 AudioPlayer 的事件流更新状态,而不是靠按钮点击后直接改 UI。

_audioSubscriptions.add(_audioPlayer.onPlayerStateChanged.listen((state) {
  if (mounted && !_localSpeechActive) {
    setState(() => _audioPlaying = state == PlayerState.playing);
  }
}));
_audioSubscriptions.add(_audioPlayer.onPositionChanged.listen((position) {
  if (mounted) setState(() => _audioPosition = position);
}));
_audioSubscriptions.add(_audioPlayer.onDurationChanged.listen((duration) {
  if (mounted) setState(() => _audioDuration = duration);
}));

这样 UI 上的播放、暂停、进度条都跟播放器真实状态一致。网络音频加载慢、播放完成、拖动进度时,页面不会出现按钮状态和声音不一致的问题。

资源释放不能省

详情页离开时必须取消订阅、停止本地讲述、释放播放器。否则会出现页面关了但声音还在、回调继续 setState、内存占用增长等问题。


void dispose() {
  for (final subscription in _audioSubscriptions) {
    subscription.cancel();
  }
  _animationPollTimer?.cancel();
  LocalStorySpeaker.instance.stop();
  LocalStorySpeaker.instance.onComplete = null;
  LocalStorySpeaker.instance.onError = null;
  _audioPlayer.dispose();
  super.dispose();
}

移动端音频功能很容易留下隐藏问题。只测“能不能播放”不够,还要测返回页面、切换故事、锁屏前后、重新进入详情这些路径。

本地讲述的价值

项目同时保留远端音频和本地讲述能力。远端音频负责更自然的声音效果,本地讲述负责兜底。当远端音频准备中或失败时,用户仍然能听到故事内容。

远端音频:质量更高,但依赖网络和服务端生成。
本地讲述:效果有限,但启动快、依赖少、适合兜底。

这个设计能提升可用性。语音功能不应该因为高清音频暂时失败就完全不可用,尤其故事正文已经在本地时,本地朗读能让功能闭环。

播放器 UI 的边界

StoryAudioPlayer 是一个纯展示组件。它接收 loadingloadedplayingpositiondurationerror,但不直接创建播放器。真正的音频逻辑在详情页状态里。

StoryAudioPlayer(
  hasContent: story.content.trim().isNotEmpty,
  displayDuration: story.duration,
  loading: _audioLoading,
  loaded: _audioLoaded,
  playing: _audioPlaying,
  position: _audioPosition,
  duration: _audioDuration,
  error: _audioError,
  onToggle: _toggleAudio,
  onSeek: _seekAudio,
)

这样组件更容易复用,也更容易测试。UI 组件只根据入参渲染,业务组件负责资源加载、播放控制和错误处理。

音频功能验证路径

1. 故事正文为空时,播放按钮不可用并显示提示。
2. 点击播放后按钮状态随播放器事件变化。
3. 播放完成后进度回到起点。
4. 拖动进度条后能从目标位置播放。
5. 离开详情页后音频停止,重新进入不会叠加播放。
6. 远端音频失败时显示错误,本地讲述仍能停止和恢复状态。

这些场景能覆盖播放器生命周期。音频类 Bug 往往不是第一次播放出现,而是在返回、重进、连续切换时暴露。

音频播放排查

Slider 不能在播放中随意拖动

当前播放器组件在播放中禁用拖动,只在音频加载完成且未播放时允许 seek。

Slider(
  value: value,
  max: max,
  onChanged: !loaded || playing
      ? null
      : (next) => onSeek(Duration(milliseconds: next.round())),
)

这样做能减少播放器状态竞争。播放中拖动当然也能实现,但要处理 seek 后继续播放、缓冲状态、进度回调跳变等细节。当前项目先保证稳定,再逐步增强交互。

显示时长要有兜底解析

故事数据里有 displayDuration,播放器真实 duration 可能还没拿到。组件会优先使用播放器 duration,没有则解析展示时长。

final total =
    duration.inMilliseconds > 0 ? duration : _parseDisplayDuration();

这个细节能避免音频刚进入页面时进度条完全没有长度。等播放器拿到真实 duration 后,再用真实值覆盖。

远端音频和本地讲述不能同时抢状态

项目用 _localSpeechActive 区分本地讲述状态。播放器事件回调里会判断这个状态,避免远端播放器事件覆盖本地讲述 UI。

if (mounted && !_localSpeechActive) {
  setState(() => _audioPlaying = state == PlayerState.playing);
}

如果没有这个判断,本地讲述启动后,远端播放器的状态变化可能把按钮又改回暂停或播放,造成 UI 混乱。两个播放来源必须有清晰优先级。

音频错误不要阻断详情页

音频失败只影响播放器区域。故事正文、图片、编辑、删除都应该继续可用。

音频加载失败:显示播放器错误,保留正文。
本地讲述失败:停止本地状态,展示错误。
播放完成:重置播放状态和进度。
页面销毁:停止所有声音并释放资源。

这种局部失败处理能保证详情页稳定。音频是增强能力,不应该拖垮故事详情主流程。

语音播放问题定位表

现象 优先检查 对应文件 处理方向
按钮显示播放但实际没声音 是否监听播放器状态 story_detail_page.dart onPlayerStateChanged 更新 UI
离开详情后仍有声音 dispose 是否释放资源 story_detail_page.dart 取消订阅、停止本地讲述、dispose 播放器
进度条时长不对 duration 是否拿到 story_audio_player.dart 真实 duration 优先,展示时长兜底
本地讲述和远端音频状态冲突 _localSpeechActive 是否隔离 story_detail_page.dart 两套播放来源不要互相覆盖状态

音频功能要重点测生命周期。第一次播放成功只能说明接口和插件可用,返回页面、切换故事、播放完成、加载失败才是真正容易出问题的地方。

音频缓存的扩展方式

当前重点是播放状态和资源释放。如果要继续优化体验,可以给已生成语音增加本地缓存,但缓存 key 必须和故事正文绑定。

cacheKey = storyId + contentHash + voiceType

正文变化后,旧音频不能继续复用;声音类型变化后,也不能复用另一种声音。这个 key 设计能避免“页面文字已经变了,播放还是旧语音”的问题。
ge.dart` | 两套播放来源不要互相覆盖状态 |

音频功能要重点测生命周期。第一次播放成功只能说明接口和插件可用,返回页面、切换故事、播放完成、加载失败才是真正容易出问题的地方。

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐