在 Flutter 鸿蒙项目里接入语音识别的完整思路

发布时间:2026/6/13 17:22:11

在 Flutter 鸿蒙项目里接入语音识别的完整思路 适合谁看想在 Flutter 鸿蒙项目里接入语音识别的人已经会写MethodChannel但还没把语音链路跑通的人想理解权限、引擎、回调和页面状态如何配合的人想把语音识别和 AI 对话串联起来的开发者问题背景语音识别表面上像一个按钮能力实际上是一条横跨三层的完整链路Flutter 页面层要能发起开始和停止还要在正在听时给用户视觉反馈Flutter 协调层要把识别结果拿回来后决定下一步填入输入框直接发给 AI鸿蒙原生层要先拿到麦克风权限再创建识别引擎注册回调最后在结束时回收资源如果这几层没有提前分开最后就会变成能识别但不好用的功能——要么权限没声明导致闪退要么引擎没清理导致第二次调用失败要么页面状态和识别状态混在一起导致 UI 闪烁。项目中的真实场景食界探味的语音识别服务于 AI 探味助手。用户在 AI 助手页面可以按住说话松手后识别结果自动提交给 AI整个体验就像和语音助手对话一样自然。这条链路涉及的代码分布在五层app/lib/features/ai_assistant/screens/ai_assistant_screen.dart ← 页面层UI 交互 app/lib/core/ai/ai_explore_coordinator.dart ← 协调层状态编排 app/lib/core/ai/models/ai_session_state.dart ← 状态模型 app/lib/core/platform/speech_recognition_channel.dart ← 平台通道Flutter 侧 app/ohos/entry/src/main/ets/plugins/SpeechRecognitionPlugin.ets ← 鸿蒙插件ArkTS 侧 app/ohos/entry/src/main/module.json5 ← 工程配置权限声明页面层并不直接碰 Core Speech Kit甚至不直接调 channel而是统一通过AiExploreCoordinator编排。整体架构一览┌─────────────────────────────────────────────────────────────┐ │ Flutter (Dart) │ │ │ │ AiAssistantScreen │ │ ┌──────────────┐ ┌─────────────────────────────────┐ │ │ │ 按住说话按钮 │───▶│ AiExploreCoordinator │ │ │ │ 松手停止 │ │ startVoiceInput() │ │ │ └──────────────┘ │ stopVoiceInput() │ │ │ │ 拿到文本 → submitQuery() │ │ │ └──────────┬──────────────────────┘ │ │ │ │ │ ┌──────────▼──────────────────────┐ │ │ │ SpeechRecognitionChannel │ │ │ │ startListening() → FutureStr │ │ │ │ stopListening() → Futurevoid │ │ │ └──────────┬──────────────────────┘ │ │ ────────────────────────────────┼───────────────────────────│ │ MethodChannel(com.foodvoyage.speech_recognition) │ ────────────────────────────────┼───────────────────────────│ │ HarmonyOS (ArkTS) │ │ ┌──────────▼──────────────────────┐ │ │ │ SpeechRecognitionPlugin │ │ │ │ 1. requestMicrophonePermission │ │ │ │ 2. createEngine() │ │ │ │ 3. setupListener() │ │ │ │ 4. startListening() │ │ │ │ 5. onResult → success(text) │ │ │ │ 6. shutdownEngine() │ │ │ └──────────┬──────────────────────┘ │ │ │ │ │ ┌──────────▼──────────────────────┐ │ │ │ Core Speech Kit │ │ │ │ speechRecognizer.createEngine │ │ │ │ RecognitionListener 回调 │ │ │ └─────────────────────────────────┘ │ └─────────────────────────────────────────────────────────────┘核心实现整条链路的时序是这样的Flutter 页面检测到用户按下语音按钮调用coordinator.startVoiceInput()协调器把状态切到listening同时调用SpeechRecognitionChannel.startListening()ArkTS 插件收到调用先申请ohos.permission.MICROPHONE权限通过后创建speechRecognizer引擎引擎注册监听器开始监听音频用户松手页面调用coordinator.stopVoiceInput()→stopListening()插件调用asrEngine.finish()触发引擎回调onResult回调里拿到最终文本通过pendingResult.success()回传 Flutter协调器拿到文本自动调用submitQuery()提交给 AI页面状态从listening切到parsing→searching→responding→idle这套设计里最重要的一点是把识别本身和识别后的业务动作分开。语音识别层只负责把文本拿回来至于要不要直接发给 AI、要不要展示在输入框里应该由 Flutter 业务层决定。状态流转识别过程涉及的核心状态定义在ai_session_state.dart里enum AiSessionStatus { idle, // 空闲 listening, // 正在聆听语音识别中 parsing, // 正在理解用户需求 searching, // 正在搜索菜品 responding, // AI 正在回复 speaking, // TTS 播报中 error, // 出错 }语音识别相关的状态流转idle ──(按下说话)──▶ listening ──(拿到文本)──▶ parsing ──▶ searching ──▶ responding ──▶ idle │ ▲ │──(未听清/出错)──▶ error ──(重试)──────────────────────────────────────┘ │ └──(用户松手)──▶ finish 识别 ──▶ onResult 回传页面层只需要 watch 状态变化就能驱动 UI不需要自己维护识别生命周期。关键代码位置app/lib/core/platform/speech_recognition_channel.dart— Flutter 侧 MethodChannel 封装app/lib/core/ai/ai_explore_coordinator.dart— 协调器串联语音输入和 AI 对话app/lib/core/ai/models/ai_session_state.dart— 会话状态枚举和模型app/lib/features/ai_assistant/screens/ai_assistant_screen.dart— AI 助手页面 UIapp/ohos/entry/src/main/ets/plugins/SpeechRecognitionPlugin.ets— 鸿蒙侧识别插件app/ohos/entry/src/main/module.json5— 麦克风权限声明鸿蒙侧实现鸿蒙侧的重点在SpeechRecognitionPlugin.ets。整个插件围绕一个核心思路一次识别请求对应一次引擎生命周期。插件内部状态export default class SpeechRecognitionPlugin implements FlutterPlugin, MethodCallHandler { private channel: MethodChannel | null null; private asrEngine: speechRecognizer.SpeechRecognitionEngine | null null; private sessionId: string 10000; private pendingResult: MethodResult | null null; // 关键悬挂的 MethodResult }pendingResult是整个插件的灵魂。Flutter 侧startListening()是一个Future它在 ArkTS 侧对应的就是这个MethodResult。识别没完成时它一直挂着直到onResult回调拿到最终文本才通过success()把结果还给 Flutter。方法入口只暴露两个onMethodCall(call: MethodCall, result: MethodResult): void { switch (call.method) { case startListening: this.handleStartListening(call, result); break; case stopListening: this.handleStopListening(result); break; default: result.notImplemented(); break; } }插件对外只暴露startListening和stopListening这意味着权限申请、引擎创建、监听器注册这些细节全部封闭在插件内部Flutter 侧完全不需要感知。启动流程权限 → 引擎 → 监听 → 开始private async handleStartListening(call: MethodCall, result: MethodResult): Promisevoid { this.pendingResult result; // 第一步申请麦克风权限 const hasPermission await this.requestMicrophonePermission(); if (!hasPermission) { this.pendingResult null; result.error(PERMISSION_DENIED, 麦克风权限被拒绝, null); return; } // 第二步创建引擎 // 第三步注册监听器 // 第四步开始监听 try { await this.createEngine(); this.setupListener(); this.startListening(); } catch (err) { this.pendingResult null; const error err as BusinessError; result.error(ASR_ERROR, 语音识别启动失败: ${error.message}, null); } }这个顺序很重要先权限、再引擎、再监听、最后开始。如果引擎创建失败了权限已经申请过不会重复弹窗如果权限被拒了引擎根本不会被创建避免浪费资源。权限申请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 ); }注意运行期申请权限和工程里声明权限是两件事。module.json5里声明的是我需要这个权限而这里requestPermissionsFromUser才是真正弹窗问用户。引擎创建参数private createEngine(): Promisevoid { return new Promise((resolve, reject) { const extraParam: Recordstring, Object { locate: CN, recognizerMode: short }; const initParams: speechRecognizer.CreateEngineParams { language: zh-CN, online: 1, extraParams: extraParam }; speechRecognizer.createEngine(initParams, (err, engine) { if (!err) { this.asrEngine engine; resolve(); } else { reject(err); } }); }); }关键参数说明language: zh-CN— 中文识别online: 1— 使用在线识别精度更高recognizerMode: short— 短语音模式适合按住说话的场景监听器只在最终结果时回传private setupListener(): void { const listener: speechRecognizer.RecognitionListener { onStart: (sessionId, eventMessage) { console.info(TAG, onStart sessionId: ${sessionId}); }, onResult: (sessionId, result) { // 只有最终结果才回传 Flutter if (result.isLast this.pendingResult) { this.pendingResult.success(result.result); this.pendingResult null; this.shutdownEngine(); // 立即回收 } }, onComplete: (sessionId, eventMessage) { // 兜底如果 onResult 没拿到 isLast这里也要收口 if (this.pendingResult) { this.pendingResult.success(); this.pendingResult null; } this.shutdownEngine(); }, onError: (sessionId, errorCode, errorMessage) { if (this.pendingResult) { this.pendingResult.error(ASR_ERROR, errorMessage, null); this.pendingResult null; } this.shutdownEngine(); } }; this.asrEngine.setListener(listener); }这里最关键的设计是只在result.isLast时才回传。中间的识别片段partial results全部忽略Flutter 侧拿到的就是一个干净的最终字符串。这比把所有中间片段都推给 Flutter 要简单得多——页面不需要维护正在显示部分识别结果的逻辑。三个出口统一清理无论成功、完成还是出错最后都会调用shutdownEngine()private shutdownEngine(): void { if (this.asrEngine) { this.asrEngine.shutdown(); this.asrEngine null; } }这意味着每次识别结束后引擎都会被销毁。下一次调用startListening时会重新创建。这种用完即弃的策略对于短语音输入场景非常合适——避免了引擎长期持有导致的资源泄漏和状态混乱。停止识别private handleStopListening(result: MethodResult): void { if (this.asrEngine) { this.asrEngine.finish(this.sessionId); } result.success(null); }finish()会通知引擎用户已经说完引擎随后会通过onResult回调返回最终识别结果。注意stopListening本身不回传识别文本——它只是触发引擎结束真正的文本还是通过pendingResult.success()回传的。工程配置兜底module.json5里必须声明麦克风权限{ name: ohos.permission.MICROPHONE, reason: $string:mic_reason, usedScene: { abilities: [EntryAbility], when: inuse } }when: inuse表示只在应用使用期间申请这比always更容易被用户接受也符合鸿蒙的隐私规范。Flutter 侧实现Flutter 侧的设计原则是保持接口极薄复杂度全部留给鸿蒙插件。平台通道封装speech_recognition_channel.dart只有 19 行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); } }startListening()是一个同步式的 Future 接口——调用方一行代码拿到最终文本完全不需要理解回调和监听器的存在。协调器串联语音输入和 AI 对话AiExploreCoordinator是语音识别和 AI 之间的桥梁。它做的事情很简单/// 语音输入 Futurevoid startVoiceInput() async { if (!mounted) return; state state.copyWith( status: AiSessionStatus.listening, errorMessage: null, ); try { final text await SpeechRecognitionChannel.startListening(); if (!mounted) return; if (text.isEmpty) { state state.copyWith( status: AiSessionStatus.error, errorMessage: 未听清请再说一次, ); return; } // 拿到文本后自动提交给 AI await submitQuery(text); } catch (e) { AppLogger.error([AI助手] 语音识别出错: $e); if (!mounted) return; state state.copyWith( status: AiSessionStatus.error, errorMessage: 语音识别出错请手动输入, ); } } /// 停止语音输入 Futurevoid stopVoiceInput() async { try { await SpeechRecognitionChannel.stopListening(); } catch (_) {} }这里有三层异常处理识别结果为空→ 提示未听清请再说一次状态回 error识别过程异常→ 提示语音识别出错请手动输入降级到文字输入停止时异常→ 静默吞掉因为停止只是一个收尾动作不应该影响流程还有一个mounted检查贯穿全程——因为startListening()是异步的用户可能在识别过程中就退出了页面此时 coordinator 已经被 dispose所有状态更新都会被跳过。页面层按住说话松手停止在ai_assistant_screen.dart里语音按钮用的是GestureDetector的 pan 手势GestureDetector( onPanDown: (_) onVoiceStart(), // 按下 → 开始识别 onPanEnd: (_) onVoiceEnd(), // 松手 → 停止识别 onPanCancel: () onVoiceEnd(), // 手势取消 → 停止识别 child: Container( // ... 按钮 UI child: const Text(按住说话), ), )对应的回调onVoiceStart: () coordinator.startVoiceInput(), onVoiceEnd: () coordinator.stopVoiceInput(),页面不需要关心识别引擎的细节它只需要知道两件事按下时调startVoiceInput()松手时调stopVoiceInput()剩下的状态变化listening→parsing→responding→idle全部通过 Riverpod 的ref.watch(aiExploreCoordinatorProvider)自动驱动 UI 更新。在输入栏里isListening状态会改变按钮的外观AiInputBar( onVoiceStart: () coordinator.startVoiceInput(), onVoiceEnd: () coordinator.stopVoiceInput(), isListening: sessionState.status AiSessionStatus.listening, // ... )这就是整个语音识别链路在页面层的全部代码——没有一行涉及引擎、权限或回调。常见坑只申请权限不声明权限— 运行期requestPermissionsFromUser调了但module.json5里没有写ohos.permission.MICROPHONE结果权限申请直接失败中间结果和最终结果混在一起回传—onResult里不做isLast判断每次回调都success()导致 Flutter 侧收到多次结果或pendingResult被重复调用页面层直接依赖识别引擎— 把MethodChannel调用散落在多个 widget 里后续想替换实现比如换成第三方 SDK时无处下手识别失败后不清理引擎—onError里只报了错但没有shutdownEngine()导致引擎一直挂着下次createEngine可能冲突pendingResult没有在所有出口置空— 成功路径置空了但onComplete和onError路径忘了导致pendingResult被复用时行为异常异步过程中不做mounted检查— 用户在识别期间退出页面coordinator 被 dispose 后还在更新 state导致StateError停止识别时期望直接拿到结果—stopListening()返回的是 void真正的识别文本是通过之前的pendingResult.success()回传的搞混了就会拿不到文本可复用模板Flutter 侧 Channel 封装import package:flutter/services.dart; class SpeechRecognitionChannel { static const _channel MethodChannel(com.yourapp.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); } }Flutter 侧协调器调用Futurevoid startVoiceInput() async { if (!mounted) return; state state.copyWith(status: AiSessionStatus.listening); try { final text await SpeechRecognitionChannel.startListening(); if (!mounted) return; if (text.isEmpty) { state state.copyWith(status: AiSessionStatus.error, errorMessage: 未听清); return; } await submitQuery(text); // 由业务层决定后续行为 } catch (e) { if (!mounted) return; state state.copyWith(status: AiSessionStatus.error, errorMessage: 识别出错); } }ArkTS 侧插件骨架private async handleStartListening(call: MethodCall, result: MethodResult): Promisevoid { this.pendingResult result; const hasPermission await this.requestMicrophonePermission(); if (!hasPermission) { this.pendingResult null; result.error(PERMISSION_DENIED, 麦克风权限被拒绝, null); return; } try { await this.createEngine(); this.setupListener(); this.startListening(); } catch (err) { this.pendingResult null; result.error(ASR_ERROR, 启动失败, null); } } // 监听器核心只在 isLast 时回传所有出口都清理 private setupListener(): void { const listener: speechRecognizer.RecognitionListener { onResult: (sessionId, result) { if (result.isLast this.pendingResult) { this.pendingResult.success(result.result); this.pendingResult null; this.shutdownEngine(); } }, onError: (sessionId, code, msg) { if (this.pendingResult) { this.pendingResult.error(ASR_ERROR, msg, null); this.pendingResult null; } this.shutdownEngine(); }, // onComplete 兜底... }; this.asrEngine.setListener(listener); }工程配置// module.json5 requestPermissions: [ { name: ohos.permission.MICROPHONE, reason: $string:mic_reason, usedScene: { abilities: [EntryAbility], when: inuse } } ]本篇总结语音识别不是一个按钮能力而是一条横跨 Flutter 和鸿蒙双端的完整调用链鸿蒙侧负责权限申请、引擎创建、监听器注册、结果回传和资源回收复杂度全部封闭在插件内部Flutter 侧负责交互决策和状态编排通过极薄的 Channel 封装暴露同步式接口最关键的设计决策有三点①pendingResult把一次请求和一次回传绑定② 只在isLast时回传最终文本③ 所有出口统一shutdownEngine()先把这条链路的边界划清再接 AI 或搜索这类业务整体会稳很多

相关新闻