09-Flutter 鸿蒙实战 09:语音讲故事与音频播放器
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,就要按同样的通道名响应 speak 和 stop。
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 实现,鸿蒙侧插件注册必须成功。

播放状态来源
播放器状态不要自己猜
音频播放不是按钮切换那么简单。播放可能失败,可能被系统打断,可能播放完成,也可能还没有拿到时长。项目使用 onPlayerStateChanged、onPositionChanged、onDurationChanged、onPlayerComplete 监听真实状态。
_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 是一个纯展示组件。它接收 loading、loaded、playing、position、duration、error,但不直接创建播放器。真正的音频逻辑在详情页状态里。
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` | 两套播放来源不要互相覆盖状态 |
音频功能要重点测生命周期。第一次播放成功只能说明接口和插件可用,返回页面、切换故事、播放完成、加载失败才是真正容易出问题的地方。
更多推荐


所有评论(0)