鸿蒙 + Flutter 如何把 AI 助手嵌进应用页面里——以食界探味为
适合谁看想在鸿蒙端把 AI 助手接进 Flutter 应用的人不想把 AI 功能做成孤立聊天页的开发者想理解鸿蒙原生能力语音识别、TTS如何通过 Platform Channel 被 Flutter AI 页面消费的人想理解页面、协调器、工具调用怎么串起来的人问题背景很多 AI 页面一开始都很好做放一个输入框调一下模型展示返回文本但到了真实项目里很快就会出现更难的问题用户从别的页面带着问题进来怎么办AI 回复和业务卡片要不要一起展示页面退出时语音播报要不要停对话状态、语音状态和页面状态怎么配合鸿蒙端的语音识别和 TTS 能力怎么被 Flutter 页面调用Android/iOS 各有一套鸿蒙也有一套权限申请、引擎生命周期管理在鸿蒙侧怎么处理这也是为什么能不能聊天并不是关键关键是 AI 助手怎么被嵌进原有应用结构里——尤其是鸿蒙跨语言桥接这一层。项目中的真实场景食界探味当前的 AI 助手相关代码主要在Flutter 侧AI 页面 协调器app/lib/features/ai_assistant/screens/ai_assistant_screen.dart— AI 对话页面app/lib/core/ai/ai_explore_coordinator.dart— AI 流程编排协调器app/lib/core/ai/agent_service.dart— 模型调用统一封装app/lib/core/ai/models/ai_session_state.dart— 会话状态模型app/lib/core/ai/tools/— 工具层搜索菜品、菜品详情、随机推荐等Flutter 侧Platform Channel 封装app/lib/core/platform/speech_recognition_channel.dart— 语音识别通道app/lib/core/platform/text_to_speech_channel.dart— TTS 通道鸿蒙侧原生插件app/ohos/entry/src/main/ets/plugins/SpeechRecognitionPlugin.ets— 鸿蒙语音识别插件app/ohos/entry/src/main/ets/plugins/TextToSpeechPlugin.ets— 鸿蒙 TTS 插件路由入口app/lib/app.dart同时它还会从别的页面被带起比如搜索页把 query 带到/ai-assistant菜品详情页带着推荐类似吃法的问题进来探索页有 AI 入口卡片这说明当前 AI 助手不是单点功能而是已经进入了应用路由和页面流转主线。核心实现先说结论食界探味的 AI 助手不是页面里直接调模型而是Flutter 页面 协调器 AgentService 工具调用 鸿蒙原生语音能力 菜品卡片渲染这一整条组合链。一、鸿蒙侧原生能力是怎么暴露给 Flutter 的在讲 Flutter 页面之前先把鸿蒙侧的基础打好。AI 助手最依赖的两个鸿蒙原生能力是语音识别和文本转语音它们都来自鸿蒙的kit.CoreSpeechKit。1.1 语音识别插件SpeechRecognitionPlugin.ets鸿蒙侧的语音识别插件实现了FlutterPlugin接口通过MethodChannel与 Flutter 通信// app/ohos/entry/src/main/ets/plugins/SpeechRecognitionPlugin.ets import { speechRecognizer } from kit.CoreSpeechKit; import { abilityAccessCtrl, Permissions } from kit.AbilityKit; export default class SpeechRecognitionPlugin implements FlutterPlugin, MethodCallHandler { private channel: MethodChannel | null null; private asrEngine: speechRecognizer.SpeechRecognitionEngine | null null; onAttachedToEngine(binding: FlutterPluginBinding): void { this.channel new MethodChannel( binding.getBinaryMessenger(), com.foodvoyage.speech_recognition ); this.channel.setMethodCallHandler(this); }关键流程权限申请— 鸿蒙必须先动态申请麦克风权限private async requestMicrophonePermission(): Promiseboolean { const atManager abilityAccessCtrl.createAtManager(); const permissions: Permissions[] [ohos.permission.MICROPHONE]; const context getContext(this); const grantResult await atManager.requestPermissionsFromUser(context, permissions); return grantResult.authResults.every( status status abilityAccessCtrl.GrantStatus.PERMISSION_GRANTED ); }创建识别引擎— 使用在线模式语言设为zh-CNprivate createEngine(): Promisevoid { return new Promise((resolve, reject) { const initParams: speechRecognizer.CreateEngineParams { language: zh-CN, online: 1, extraParams: { locate: CN, recognizerMode: short } }; speechRecognizer.createEngine(initParams, (err, engine) { if (!err) { this.asrEngine engine; resolve(); } else { reject(err); } }); }); }开始识别— 设置监听器后发起识别private startListening(): void { if (!this.asrEngine) return; const audioParam: speechRecognizer.AudioInfo { audioType: pcm, sampleRate: 16000, soundChannel: 1, sampleBit: 16 }; const recognizerParams: speechRecognizer.StartParams { sessionId: this.sessionId, audioInfo: audioParam, extraParams: { recognitionMode: 0, vadBegin: 2000, vadEnd: 3000, maxAudioDuration: 20000 } }; this.asrEngine.startListening(recognizerParams); }结果回传— 识别到最终结果时通过 MethodChannel 把文本传回 FlutteronResult: (sessionId, result) { if (result.isLast this.pendingResult) { this.pendingResult.success(result.result); // 回传给 Flutter this.pendingResult null; this.shutdownEngine(); } }引擎清理— 识别完成后立即 shutdown避免资源泄漏private shutdownEngine(): void { if (this.asrEngine) { this.asrEngine.shutdown(); this.asrEngine null; } }1.2 TTS 插件TextToSpeechPlugin.etsTTS 插件同样实现了FlutterPlugin使用鸿蒙的textToSpeech能力// app/ohos/entry/src/main/ets/plugins/TextToSpeechPlugin.ets import { textToSpeech } from kit.CoreSpeechKit; export default class TextToSpeechPlugin implements FlutterPlugin, MethodCallHandler { private ttsEngine: textToSpeech.TextToSpeechEngine | null null; onAttachedToEngine(binding: FlutterPluginBinding): void { this.channel new MethodChannel( binding.getBinaryMessenger(), com.foodvoyage.text_to_speech ); this.channel.setMethodCallHandler(this); }TTS 的创建和播报private createEngine(): Promisevoid { return new Promise((resolve, reject) { if (this.ttsEngine) { resolve(); return; } const initParams: textToSpeech.CreateEngineParams { language: zh-CN, person: 0, online: 1, extraParams: { style: interaction-broadcast, locate: CN, name: EngineName } }; textToSpeech.createEngine(initParams, (err, engine) { if (!err) { this.ttsEngine engine; resolve(); } else { reject(err); } }); }); }播报时设置回调监听播报完成后回传结果给 Flutterprivate setupListenerAndSpeak(text: string): void { if (!this.ttsEngine) return; const speakListener: textToSpeech.SpeakListener { onStart: (requestId, response) { /* 开始 */ }, onComplete: (requestId, response) { if (this.pendingResult) { this.pendingResult.success(null); // 通知 Flutter 播报完成 this.pendingResult null; } }, onStop: (requestId, response) { /* 停止 */ }, onData: (requestId, audio, response) { /* 音频数据 */ }, onError: (requestId, errorCode, errorMessage) { if (this.pendingResult) { this.pendingResult.error(TTS_ERROR, errorMessage, null); this.pendingResult null; } } }; this.ttsEngine.setListener(speakListener); this.ttsEngine.speak(text, { requestId: tts_${Date.now()}, extraParams: { queueMode: 0, speed: 1, volume: 2, pitch: 1, languageContext: zh-CN, audioType: pcm, soundChannel: 3, playType: 1 } }); }1.3 Flutter 侧的通道封装鸿蒙原生插件注册好之后Flutter 侧只需要通过MethodChannel调用即可// app/lib/core/platform/speech_recognition_channel.dart class SpeechRecognitionChannel { static const _channel MethodChannel(com.foodvoyage.speech_recognition); static FutureString startListening({String language zh-CN}) async { final result await _channel.invokeMethodString( startListening, {language: language}, ); return result ?? ; } static Futurevoid stopListening() async { await _channel.invokeMethodvoid(stopListening); } }// app/lib/core/platform/text_to_speech_channel.dart class TextToSpeechChannel { static const _channel MethodChannel(com.foodvoyage.text_to_speech); static Futurevoid speak(String text) async { await _channel.invokeMethodvoid(speak, {text: text}); } static Futurevoid stop() async { await _channel.invokeMethodvoid(stop); } }这里的关键设计是Flutter 侧的 Channel 封装是平台无关的。同一套SpeechRecognitionChannel/TextToSpeechChannel在鸿蒙端走鸿蒙的 CoreSpeechKit在 Android 端走 Android SpeechRecognizer在 iOS 端走 AVSpeechSynthesizer。AI 页面完全不感知底层平台差异。二、路由层先给 AI 助手一个正式入口在app/lib/app.dart里当前已经有GoRoute( path: /ai-assistant, redirect: (context, state) { if (!AppConfig.enableAi) { return /explore; } return null; }, builder: (context, state) { final query state.uri.queryParameters[q]; return AiAssistantScreen(initialQuery: query); }, ),这一步非常关键。因为它说明 AI 助手在产品里不是弹窗式实验能力而是正式页面入口。更重要的是它还支持initialQuery参数意味着 AI 助手页面不是只能靠用户手打输入开始而是可以承接外部页面传来的问题。三、多入口如何把问题带进 AI 助手食界探味的 AI 助手不是只有一个入口而是从多个页面都能自然地进入搜索页当搜索无结果时引导用户用 AI 探索// app/lib/features/search/screens/search_screen.dart if (AppConfig.enableAi _looksLikeNaturalLanguage(state.query)) _AiSearchHint( query: state.query, onTap: () context.push( /ai-assistant?q${Uri.encodeComponent(state.query)}, ), ),这里有个智能判断_looksLikeNaturalLanguage检查搜索词是否像自然语言而不是食材关键词。如果是今晚想吃点辣的这类话搜索页会提示试试 AI 探味助手点击后带着原始 query 跳转到 AI 助手。菜品详情页带着推荐类似吃法的问题进来// app/lib/features/dish_detail/screens/dish_detail_screen.dart onSimilar: () context.push( /ai-assistant?q${Uri.encodeComponent( 帮我推荐和${dish.name}类似的${dish.ingredientName}吃法 )}, ),用户在看一道菜时可以点击类似吃法按钮系统会自动构造一个精准的问题带进 AI 助手。这种带着上下文进入 AI的体验比空聊好得多。探索页AI 入口卡片// app/lib/features/explore/screens/explore_screen.dart onTap: () context.push(/ai-assistant),探索页有一个 AI 入口卡片点击后直接进入 AI 助手页面。四、页面层本身负责的是对话体验容器回到ai_assistant_screen.dart你会发现它真正承担的是页面体验组织而不是 AI 推理本身。这个页面主要做了几件事class _AiAssistantScreenState extends ConsumerStateAiAssistantScreen { final List_ChatEntry _history []; // 对话历史 String? _lastStreamingText; // 流式输出缓冲 bool _isSpeaking false; // 语音播报状态 // 提交问题 → 委托给协调器 void _handleSubmit(String text) { setState(() { _history.add(_ChatEntry(isUser: true, text: text)); _lastStreamingText null; }); ref.read(aiExploreCoordinatorProvider.notifier).submitQuery(text); }页面上真正的组件层也已经拆出来了AiInputBar— 底部输入栏文本 语音按钮AiMessageBubble— 消息气泡支持流式动画AiDishCardList— 菜品卡片列表AiDishCard— 单个菜品卡片可点击跳转详情这说明它不是一个把所有逻辑塞进单文件的聊天页而是一个已经开始组件化的 AI 对话页面。页面还有一个重要的初始 query 处理逻辑// 当有初始查询时自动提交 if (widget.initialQuery ! null widget.initialQuery!.isNotEmpty !_hasSubmittedInitial) { _hasSubmittedInitial true; WidgetsBinding.instance.addPostFrameCallback((_) { _handleSubmit(widget.initialQuery!); }); }这段代码让 AI 助手页面从其他页面带着问题进来时能够自动开始回答而不是等用户再手动输入一次。五、AI 页面不是直接调模型而是先调协调器在这套结构里页面真正依赖的是final sessionState ref.watch(aiExploreCoordinatorProvider); final coordinator ref.read(aiExploreCoordinatorProvider.notifier);也就是说页面层提交问题时走的是_handleSubmit(text)coordinator.submitQuery(text)而不是页面里直接写模型调用、工具执行、状态机处理。这一步的价值很大因为它把页面展示职责和AI 流程编排职责分开了。六、协调器才是 AI 助手的真正工作台app/lib/core/ai/ai_explore_coordinator.dart是这套 AI 页面真正的主心骨。它负责的事情包括class AiExploreCoordinator extends StateNotifierAiSessionState { // 初始化 Agent带真实业务工具 void _ensureAgent() { _agentService.createAgent( systemPrompt: _systemPrompt, tools: [ SearchDishesTool(_foodRepository, onDishesFound: _onDishesFound), GetDishDetailTool(_foodRepository), GetRandomDishTool(_foodRepository, onDishesFound: _onDishesFound), GetDishesByIngredientTool(_foodRepository, onDishesFound: _onDishesFound), ], enableAutoToolExecution: true, maxMessages: 30, ); }协调器把 4 个业务工具注册到了 Agent 里工具功能SearchDishesTool根据关键词搜索菜品GetDishDetailTool获取某道菜的详细信息GetRandomDishTool随机推荐一道菜GetDishesByIngredientTool按食材查同食材的其他吃法协调器的流式对话处理Futurevoid submitQuery(String text) async { state state.copyWith( status: AiSessionStatus.parsing, inputText: text, streamingText: , ); _ensureAgent(); final buffer StringBuffer(); await _agentService.chatWithToolsStream( message: text, onContent: (chunk) { buffer.write(chunk); state state.copyWith( status: AiSessionStatus.responding, streamingText: buffer.toString(), ); }, onToolCall: (toolCall) { state state.copyWith(status: AiSessionStatus.searching); }, onComplete: (full) { state state.copyWith( status: AiSessionStatus.idle, streamingText: full, ); }, ); }协调器还直接接上了鸿蒙的语音能力// 语音输入 → 走鸿蒙 SpeechRecognitionPlugin Futurevoid startVoiceInput() async { state state.copyWith(status: AiSessionStatus.listening); final text await SpeechRecognitionChannel.startListening(); if (text.isNotEmpty) { await submitQuery(text); } } // TTS 播报 → 走鸿蒙 TextToSpeechPlugin自动清理 Markdown 格式 Futurevoid speakText(String text) async { final cleaned _stripForTts(text); await TextToSpeechChannel.speak(cleaned); }这里有个细节_stripForTts函数会清理 AI 回复中的 Markdown 格式、emoji、表格符号等确保鸿蒙 TTS 引擎播报出来的声音是干净的自然语言。如果只看页面你会误以为这是一个聊天 UI。但只要看到协调器就会发现它其实是AI 交互状态机 工具调用编排器 语音输入输出协调器。七、AgentService 负责把模型能力收成统一接口再往下走AgentService负责的是更底层的模型交互class AgentService { AIAgent? _currentAgent; AIAgent createAgent({ required String systemPrompt, ListTool? tools, int maxMessages 50, bool enableAutoToolExecution false, }) { final agent AIAgent( provider: _provider, config: AIAgentConfig( systemPrompt: systemPrompt, enableAutoToolExecution: enableAutoToolExecution, ), memoryManager: ConversationMemory(maxMessages: maxMessages), ); if (tools ! null) { for (final tool in tools) { agent.addTool(tool); } } _currentAgent agent; return agent; }也就是说页面不直接碰 Provider协调器也不直接碰底层 Provider。中间先由AgentService把 agent 创建、memoryManager、工具注册、流式输出这些底层能力统一收了起来。八、AI 助手真正的差异化在于文本 菜品卡片一起出现这套页面结构特别适合食界探味的原因不只是它能对话而是它没有把 AI 回复只做成一段文字。在协调器里工具调用拿到的菜品结果会通过回调更新到状态里void _onDishesFound(ListDish dishes) { state state.copyWith(matchedDishes: dishes); }页面层最终通过AiDishCardList把这些结果变成真正可点击的菜品卡片。这意味着 AI 在这里承担的并不是替代页面而是生成推荐语义 驱动已有业务卡片展示。这比纯聊天页稳得多。九、语音输入输出为什么也能无缝嵌进页面这一点也很关键。协调器已经直接接上了鸿蒙的语音能力SpeechRecognitionChannel.startListening()→ 鸿蒙speechRecognizerTextToSpeechChannel.speak(...)→ 鸿蒙textToSpeech所以页面层最终能得到的是AiInputBar( onSubmit: _handleSubmit, onVoiceStart: () coordinator.startVoiceInput(), onVoiceEnd: () coordinator.stopVoiceInput(), isListening: sessionState.status AiSessionStatus.listening, )输入栏的语音按钮采用按住说话交互按下时触发startVoiceInput()松开时触发stopVoiceInput()。鸿蒙侧会自动处理 VAD语音端点检测用户停止说话后自动结束识别。语音播报方面页面层通过_toggleSpeak控制void _toggleSpeak(String text) async { if (_isSpeaking) { await TextToSpeechChannel.stop(); // 鸿蒙 TTS 停止 setState(() _isSpeaking false); } else { setState(() _isSpeaking true); await TextToSpeechChannel.speak(text); // 鸿蒙 TTS 播报 } }更重要的是页面退出时会自动停止 TTSoverride void dispose() { if (_isSpeaking) { TextToSpeechChannel.stop().catchError((_) {}); } super.dispose(); }这保证了用户退出 AI 页面后不会出现声音还在后台播的问题。状态机AI 会话的完整生命周期食界探味的 AI 助手使用了一套清晰的状态机enum AiSessionStatus { idle, // 空闲 listening, // 正在语音识别 parsing, // 正在理解用户意图 searching, // 正在搜索菜品工具调用中 responding, // 正在流式生成回复 speaking, // 正在 TTS 播报 error, // 出错 }状态流转idle → listening用户按住语音按钮 listening → parsing语音识别完成文本提交 parsing → searching模型决定调用工具 searching → responding工具结果返回模型生成回复 responding → idle回复完成 idle → speaking用户点击播报按钮 speaking → idle播报完成或手动停止 任何状态 → error出错 error → parsing用户点击重试页面根据当前状态展示不同的 UIswitch (sessionState.status) { case AiSessionStatus.listening: return AiMessageBubble(text: 正在聆听..., isStreaming: true); case AiSessionStatus.parsing: return AiMessageBubble(text: 正在理解你的需求..., isStreaming: true); case AiSessionStatus.searching: return AiMessageBubble(text: 正在探索全球美食..., isStreaming: true); case AiSessionStatus.responding: return AiMessageBubble(text: sessionState.streamingText, isStreaming: true); default: return SizedBox.shrink(); }关键代码位置文件作用app/lib/app.dart路由配置AI 助手入口app/lib/features/ai_assistant/screens/ai_assistant_screen.dartAI 对话页面app/lib/features/ai_assistant/widgets/ai_input_bar.dart底部输入栏文本语音app/lib/features/ai_assistant/widgets/ai_message_bubble.dart消息气泡app/lib/features/ai_assistant/widgets/ai_dish_card_list.dart菜品卡片列表app/lib/core/ai/ai_explore_coordinator.dartAI 流程编排协调器app/lib/core/ai/agent_service.dart模型调用统一封装app/lib/core/ai/models/ai_session_state.dart会话状态模型app/lib/core/ai/tools/工具层app/lib/core/platform/speech_recognition_channel.dart语音识别通道app/lib/core/platform/text_to_speech_channel.dartTTS 通道app/ohos/entry/src/main/ets/plugins/SpeechRecognitionPlugin.ets鸿蒙语音识别插件app/ohos/entry/src/main/ets/plugins/TextToSpeechPlugin.ets鸿蒙 TTS 插件鸿蒙侧与 Flutter 侧的协作关系从整体架构看AI 助手的双端协作可以这样理解┌─────────────────────────────────────────────────┐ │ Flutter 侧 │ │ │ │ AiAssistantScreen (页面层) │ │ │ │ │ ▼ │ │ AiExploreCoordinator (协调器) │ │ │ │ │ │ │ ▼ ▼ ▼ │ │ AgentService SpeechChannel TtsChannel │ │ │ │ │ │ │ ▼ │ │ │ │ 工具层/模型层 │ │ │ │ │ │ │ ├──────────────────┼───────────┼────────────────────┤ │ MethodChannel │ │ │ │ │ │ ├──────────────────┼───────────┼────────────────────┤ │ 鸿蒙侧 │ │ │ │ │ │ SpeechRecognitionPlugin TextToSpeechPlugin │ │ │ │ │ │ CoreSpeechKit CoreSpeechKit │ └─────────────────────────────────────────────────┘核心要点AI 推理完全在 Flutter 侧完成— 通过 AgentService 调用云端模型鸿蒙侧不参与 AI 推理语音能力完全由鸿蒙侧提供— 通过 CoreSpeechKit 的 speechRecognizer 和 textToSpeechFlutter 侧只看到统一的 Channel 接口— 不感知底层是鸿蒙还是 Android鸿蒙插件需要管理引擎生命周期— 创建、使用、shutdown避免资源泄漏鸿蒙插件需要处理权限— 麦克风权限必须在鸿蒙侧动态申请常见坑页面里直接调模型导致 UI、状态和工具调用混成一团 → 一定要抽出协调器AI 回复只做文本不接业务卡片→ 利用工具回调把菜品数据带回页面层从别的页面进 AI 助手时没有设计初始 query 入口→ 用initialQuery参数承接上下文页面退出时不处理 TTS 停止导致体验很差 → 在dispose()里调TextToSpeechChannel.stop()鸿蒙引擎不 shutdown导致内存泄漏 → 每次识别/TTS 完成后必须调shutdown()鸿蒙麦克风权限未申请导致语音识别直接失败 → 在startListening前先requestMicrophonePermissionTTS 播报时把 Markdown 格式一起读出来→ 协调器里_stripForTts先清理再播报可复用模板如果你要在自己的鸿蒙 Flutter 项目里做类似的 AI 助手嵌入可以参考这个结构页面层AiAssistantScreen ├─ 输入栏文本 语音按钮 ├─ 消息列表用户消息 AI 回复 菜品卡片 └─ 错误提示条 协调器AiExploreCoordinator ├─ 状态机idle → parsing → searching → responding → idle ├─ Agent 管理创建、重置、工具注册 ├─ 流式对话chatWithToolsStream ├─ 语音输入startVoiceInput → SpeechRecognitionChannel └─ TTS 播报speakText → TextToSpeechChannel 模型服务AgentService ├─ createAgentsystemPrompt tools ├─ chatWithToolsStream流式 工具回调 └─ chatWithTools非流式 工具层Tools ├─ SearchDishesTool ├─ GetDishDetailTool ├─ GetRandomDishTool └─ GetDishesByIngredientTool 鸿蒙原生插件 ├─ SpeechRecognitionPluginCoreSpeechKit speechRecognizer └─ TextToSpeechPluginCoreSpeechKit textToSpeechFlutter 侧协调器的 Riverpod Provider 模板final aiExploreCoordinatorProvider StateNotifierProvider.autoDisposeAiExploreCoordinator, AiSessionState( (ref) { final agentService ref.watch(agentServiceProvider); final foodRepo ref.watch(foodRepositoryProvider); return AiExploreCoordinator( agentService: agentService, foodRepository: foodRepo, ); });页面中使用协调器的模板final sessionState ref.watch(aiExploreCoordinatorProvider); final coordinator ref.read(aiExploreCoordinatorProvider.notifier); // 提交问题 coordinator.submitQuery(text); // 语音输入 coordinator.startVoiceInput(); // TTS 播报 coordinator.speakText(text); // 重置会话 coordinator.reset();本篇总结食界探味的 AI 助手之所以能自然嵌进鸿蒙 Flutter 页面里关键不在有一个聊天页面而在于它已经形成了鸿蒙原生能力层— 语音识别和 TTS 通过 CoreSpeechKit 提供经由 MethodChannel 暴露给 Flutter正式路由入口— 支持initialQuery可从搜索页、详情页、探索页多入口进入协调器状态层— 管理 AI 会话生命周期、工具调用编排、语音输入输出模型服务层— AgentService 统一封装模型调用细节工具调用层— 4 个业务工具让 AI 能检索真实菜品数据业务卡片回填层— AI 推荐直接驱动菜品卡片展示不只是一段文字这套结构让 AI 助手不再是孤立演示页而是真正进入了应用主体验链路——在鸿蒙设备上用户可以用语音和 AI 聊美食AI 推荐的菜品卡片可以直接点击查看详情整个过程流畅自然鸿蒙原生能力和 Flutter UI 无缝协作。