09. Flutter 鸿蒙实战 09语音讲故事与音频播放器用 audioplayers 播放高清语音同时保留本地讲述兜底能力。语音功能的两层能力故事详情页里有“讲故事”功能。项目同时用了两类能力一类是audioplayers播放后端生成的音频另一类是MethodChannel(talking_album/local_tts)触发本地语音讲述。前者音质更稳定后者可以作为即时反馈。本地通道封装在lib/core/speech/local_story_speaker.dartclassLocalStorySpeaker{LocalStorySpeaker._(){_channel.setMethodCallHandler((call)async{switch(call.method){caseonDone:onComplete?.call();return;caseonError:onError?.call(call.arguments?.toString()??系统语音播放失败);return;}});}staticfinalLocalStorySpeakerinstanceLocalStorySpeaker._();staticconst_channelMethodChannel(talking_album/local_tts);}这段 Dart 侧代码已经定义了通道名和回调处理。鸿蒙侧如果要实现本地 TTS就要按同样的通道名响应speak和stop。AudioPlayer 生命周期故事详情页持有一个AudioPlayerfinalAudioPlayer_audioPlayerAudioPlayer();finalListStreamSubscriptiondynamic_audioSubscriptions[];初始化时监听播放状态、进度、时长和完成事件_audioSubscriptions.add(_audioPlayer.onPlayerStateChanged.listen((state){if(mounted!_localSpeechActive){setState(()_audioPlayingstatePlayerState.playing);}}));_audioSubscriptions.add(_audioPlayer.onPositionChanged.listen((position){if(mounted)setState(()_audioPositionposition);}));播放器状态不能只靠按钮点击判断。真实播放可能失败、完成、被系统中断所以要监听播放器事件。释放资源详情页销毁时要取消订阅、停止本地朗读、清理回调、释放播放器overridevoiddispose(){for(finalsubscriptionin_audioSubscriptions){subscription.cancel();}_animationPollTimer?.cancel();LocalStorySpeaker.instance.stop();LocalStorySpeaker.instance.onCompletenull;LocalStorySpeaker.instance.onErrornull;_audioPlayer.dispose();super.dispose();}音频资源是移动端很容易漏的地方。离开故事详情后如果不释放隐藏页面可能继续占用播放器或继续发出声音。播放器 UI播放器 UI 独立成StoryAudioPlayerclassStoryAudioPlayerextendsStatelessWidget{constStoryAudioPlayer({super.key,requiredthis.hasContent,requiredthis.displayDuration,requiredthis.loading,requiredthis.loaded,requiredthis.playing,requiredthis.position,requiredthis.duration,requiredthis.error,requiredthis.onToggle,requiredthis.onSeek,});}这个组件只接收状态和回调不直接请求接口也不直接管理播放器。页面负责业务组件负责展示。进度条拖动只有在音频已加载且未播放时可用Slider(value:value,max:max,onChanged:!loaded||playing?null:(next)onSeek(Duration(milliseconds:next.round())),)鸿蒙插件依赖pubspec.yaml使用了 OpenHarmony 适配的 audioplayersaudioplayers:git:url:https://gitee.com/openharmony-sig/flutter_audioplayers.gitref:br_audioplayers-v6.1.0_ohospath:packages/audioplayersGeneratedPluginRegistrant.ets中也注册了AudioplayersPlugin。这说明音频能力不是纯 Dart 实现鸿蒙侧插件注册必须成功。播放状态来源播放器状态不要自己猜音频播放不是按钮切换那么简单。播放可能失败可能被系统打断可能播放完成也可能还没有拿到时长。项目使用onPlayerStateChanged、onPositionChanged、onDurationChanged、onPlayerComplete监听真实状态。_audioSubscriptions.add(_audioPlayer.onDurationChanged.listen((duration){if(mounted)setState(()_audioDurationduration);}));播放器 UI 的状态应该来自播放器事件而不是来自按钮点击。按钮只代表用户意图事件才代表真实播放状态。本地讲述的兜底意义LocalStorySpeaker通过 MethodChannel 调本地能力。即使高清语音还在准备也可以先让系统语音读当前故事。这个设计让“讲故事”按钮响应更快也给后端音频生成留出时间。音频事件和本地讲述播放状态必须来自播放器事件音频播放按钮只能表达用户意图不能代表真实播放状态。用户点了播放音频可能还在加载用户点了暂停播放器可能已经自然结束。项目里通过AudioPlayer的事件流更新状态而不是靠按钮点击后直接改 UI。_audioSubscriptions.add(_audioPlayer.onPlayerStateChanged.listen((state){if(mounted!_localSpeechActive){setState(()_audioPlayingstatePlayerState.playing);}}));_audioSubscriptions.add(_audioPlayer.onPositionChanged.listen((position){if(mounted)setState(()_audioPositionposition);}));_audioSubscriptions.add(_audioPlayer.onDurationChanged.listen((duration){if(mounted)setState(()_audioDurationduration);}));这样 UI 上的播放、暂停、进度条都跟播放器真实状态一致。网络音频加载慢、播放完成、拖动进度时页面不会出现按钮状态和声音不一致的问题。资源释放不能省详情页离开时必须取消订阅、停止本地讲述、释放播放器。否则会出现页面关了但声音还在、回调继续 setState、内存占用增长等问题。overridevoiddispose(){for(finalsubscriptionin_audioSubscriptions){subscription.cancel();}_animationPollTimer?.cancel();LocalStorySpeaker.instance.stop();LocalStorySpeaker.instance.onCompletenull;LocalStorySpeaker.instance.onErrornull;_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没有则解析展示时长。finaltotalduration.inMilliseconds0?duration:_parseDisplayDuration();这个细节能避免音频刚进入页面时进度条完全没有长度。等播放器拿到真实 duration 后再用真实值覆盖。远端音频和本地讲述不能同时抢状态项目用_localSpeechActive区分本地讲述状态。播放器事件回调里会判断这个状态避免远端播放器事件覆盖本地讲述 UI。if(mounted!_localSpeechActive){setState(()_audioPlayingstatePlayerState.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 | 两套播放来源不要互相覆盖状态 |音频功能要重点测生命周期。第一次播放成功只能说明接口和插件可用返回页面、切换故事、播放完成、加载失败才是真正容易出问题的地方。
09-Flutter 鸿蒙实战 09:语音讲故事与音频播放器
09. Flutter 鸿蒙实战 09语音讲故事与音频播放器用 audioplayers 播放高清语音同时保留本地讲述兜底能力。语音功能的两层能力故事详情页里有“讲故事”功能。项目同时用了两类能力一类是audioplayers播放后端生成的音频另一类是MethodChannel(talking_album/local_tts)触发本地语音讲述。前者音质更稳定后者可以作为即时反馈。本地通道封装在lib/core/speech/local_story_speaker.dartclassLocalStorySpeaker{LocalStorySpeaker._(){_channel.setMethodCallHandler((call)async{switch(call.method){caseonDone:onComplete?.call();return;caseonError:onError?.call(call.arguments?.toString()??系统语音播放失败);return;}});}staticfinalLocalStorySpeakerinstanceLocalStorySpeaker._();staticconst_channelMethodChannel(talking_album/local_tts);}这段 Dart 侧代码已经定义了通道名和回调处理。鸿蒙侧如果要实现本地 TTS就要按同样的通道名响应speak和stop。AudioPlayer 生命周期故事详情页持有一个AudioPlayerfinalAudioPlayer_audioPlayerAudioPlayer();finalListStreamSubscriptiondynamic_audioSubscriptions[];初始化时监听播放状态、进度、时长和完成事件_audioSubscriptions.add(_audioPlayer.onPlayerStateChanged.listen((state){if(mounted!_localSpeechActive){setState(()_audioPlayingstatePlayerState.playing);}}));_audioSubscriptions.add(_audioPlayer.onPositionChanged.listen((position){if(mounted)setState(()_audioPositionposition);}));播放器状态不能只靠按钮点击判断。真实播放可能失败、完成、被系统中断所以要监听播放器事件。释放资源详情页销毁时要取消订阅、停止本地朗读、清理回调、释放播放器overridevoiddispose(){for(finalsubscriptionin_audioSubscriptions){subscription.cancel();}_animationPollTimer?.cancel();LocalStorySpeaker.instance.stop();LocalStorySpeaker.instance.onCompletenull;LocalStorySpeaker.instance.onErrornull;_audioPlayer.dispose();super.dispose();}音频资源是移动端很容易漏的地方。离开故事详情后如果不释放隐藏页面可能继续占用播放器或继续发出声音。播放器 UI播放器 UI 独立成StoryAudioPlayerclassStoryAudioPlayerextendsStatelessWidget{constStoryAudioPlayer({super.key,requiredthis.hasContent,requiredthis.displayDuration,requiredthis.loading,requiredthis.loaded,requiredthis.playing,requiredthis.position,requiredthis.duration,requiredthis.error,requiredthis.onToggle,requiredthis.onSeek,});}这个组件只接收状态和回调不直接请求接口也不直接管理播放器。页面负责业务组件负责展示。进度条拖动只有在音频已加载且未播放时可用Slider(value:value,max:max,onChanged:!loaded||playing?null:(next)onSeek(Duration(milliseconds:next.round())),)鸿蒙插件依赖pubspec.yaml使用了 OpenHarmony 适配的 audioplayersaudioplayers:git:url:https://gitee.com/openharmony-sig/flutter_audioplayers.gitref:br_audioplayers-v6.1.0_ohospath:packages/audioplayersGeneratedPluginRegistrant.ets中也注册了AudioplayersPlugin。这说明音频能力不是纯 Dart 实现鸿蒙侧插件注册必须成功。播放状态来源播放器状态不要自己猜音频播放不是按钮切换那么简单。播放可能失败可能被系统打断可能播放完成也可能还没有拿到时长。项目使用onPlayerStateChanged、onPositionChanged、onDurationChanged、onPlayerComplete监听真实状态。_audioSubscriptions.add(_audioPlayer.onDurationChanged.listen((duration){if(mounted)setState(()_audioDurationduration);}));播放器 UI 的状态应该来自播放器事件而不是来自按钮点击。按钮只代表用户意图事件才代表真实播放状态。本地讲述的兜底意义LocalStorySpeaker通过 MethodChannel 调本地能力。即使高清语音还在准备也可以先让系统语音读当前故事。这个设计让“讲故事”按钮响应更快也给后端音频生成留出时间。音频事件和本地讲述播放状态必须来自播放器事件音频播放按钮只能表达用户意图不能代表真实播放状态。用户点了播放音频可能还在加载用户点了暂停播放器可能已经自然结束。项目里通过AudioPlayer的事件流更新状态而不是靠按钮点击后直接改 UI。_audioSubscriptions.add(_audioPlayer.onPlayerStateChanged.listen((state){if(mounted!_localSpeechActive){setState(()_audioPlayingstatePlayerState.playing);}}));_audioSubscriptions.add(_audioPlayer.onPositionChanged.listen((position){if(mounted)setState(()_audioPositionposition);}));_audioSubscriptions.add(_audioPlayer.onDurationChanged.listen((duration){if(mounted)setState(()_audioDurationduration);}));这样 UI 上的播放、暂停、进度条都跟播放器真实状态一致。网络音频加载慢、播放完成、拖动进度时页面不会出现按钮状态和声音不一致的问题。资源释放不能省详情页离开时必须取消订阅、停止本地讲述、释放播放器。否则会出现页面关了但声音还在、回调继续 setState、内存占用增长等问题。overridevoiddispose(){for(finalsubscriptionin_audioSubscriptions){subscription.cancel();}_animationPollTimer?.cancel();LocalStorySpeaker.instance.stop();LocalStorySpeaker.instance.onCompletenull;LocalStorySpeaker.instance.onErrornull;_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没有则解析展示时长。finaltotalduration.inMilliseconds0?duration:_parseDisplayDuration();这个细节能避免音频刚进入页面时进度条完全没有长度。等播放器拿到真实 duration 后再用真实值覆盖。远端音频和本地讲述不能同时抢状态项目用_localSpeechActive区分本地讲述状态。播放器事件回调里会判断这个状态避免远端播放器事件覆盖本地讲述 UI。if(mounted!_localSpeechActive){setState(()_audioPlayingstatePlayerState.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 | 两套播放来源不要互相覆盖状态 |音频功能要重点测生命周期。第一次播放成功只能说明接口和插件可用返回页面、切换故事、播放完成、加载失败才是真正容易出问题的地方。