适合谁看
正在设计鸿蒙 Flutter 语音识别接口的人
觉得"只保留一个 start 就够了"的开发者
想做"按住说话、松手停止"交互的人
想理解 startListening 的 Future 和 stopListening 的触发关系的人
问题背景
语音识别天然带有状态。在鸿蒙 Core Speech Kit 里,一次识别的生命周期是这样的:
未开始 ──start──▶ 正在监听 ──finish/自动──▶ 引擎回调 ──▶ 已结束 │ │ │ ├── onResult(isLast) → 回传文本 │ ├── onComplete → 兜底回传 │ └── onError → 回传错误 │ └──stop──▶ finish(sessionId) ──▶ 触发上面的回调如果只有一个start,会出现两个问题:
页面无法主动结束— 用户说完话了,但引擎还在等(
vadEnd: 3000的静音超时还没到),体验很迟钝异步回传搞混—
start返回的 Future 到底什么时候 resolve?是 start 的时候还是识别结束的时候?
只有把startListening和stopListening拆开,页面才能真正掌控交互节奏。
项目中的真实场景
食界探味的 AI 助手页面用"按住说话、松手停止"的交互模式。用户手指按下时开始识别,松手时结束识别——这天然需要两个独立的控制点。
涉及的代码:
app/lib/core/platform/speech_recognition_channel.dart— Flutter 侧两个方法app/ohos/entry/src/main/ets/plugins/SpeechRecognitionPlugin.ets— 鸿蒙侧两个 handlerapp/lib/core/ai/ai_explore_coordinator.dart— 协调器串联两个动作app/lib/features/ai_assistant/screens/ai_assistant_screen.dart— 页面手势交互
两个动作的时序关系
用户手指按下 用户手指松手 │ │ ▼ ▼ onPanDown onPanEnd │ │ ▼ ▼ startVoiceInput() stopVoiceInput() │ │ ▼ ▼ SpeechRecognitionChannel SpeechRecognitionChannel .startListening() .stopListening() │ │ ▼ ▼ (鸿蒙侧) (鸿蒙侧) handleStartListening handleStopListening 权限→引擎→监听→开始 asrEngine.finish(sessionId) │ │ │ ▼ │ finish 触发引擎回调 │ │ │ ▼ │ onResult(isLast=true) │ │ ▼ ▼ pendingResult ──────────▶ success("今晚想吃牛肉") (Future 一直悬挂着) │ ▼ Flutter Future<String> resolve │ ▼ coordinator 拿到文本 → submitQuery()关键理解:startListening()的 Future 一直悬挂着,直到stopListening()触发的onResult回调通过pendingResult.success()才把它 resolve 掉。两个方法配合完成一次识别。
核心实现
一、交互控制权在页面手里
开始识别和停止识别由完全不同的用户手势触发:
// ai_assistant_screen.dart 里的语音按钮 GestureDetector( onPanDown: (_) => onVoiceStart(), // 手指按下 → 开始 onPanEnd: (_) => onVoiceEnd(), // 手指松手 → 停止 onPanCancel: () => onVoiceEnd(), // 手势被打断 → 停止 child: Container( child: const Text('按住说话'), ), )停止不是开始的附属动作,而是独立的交互控制点。可能触发停止的场景包括:
用户松手— 最主要的场景
手势被打断—
onPanCancel,比如系统通知弹出来了页面切换— 用户在说话过程中导航走了
业务超时— 录音超过
maxAudioDuration: 20000(20 秒),引擎自动结束
如果只有一个start,这些场景都无法处理。
二、两个方法各自的职责
Flutter 侧(speech_recognition_channel.dart):
class SpeechRecognitionChannel { static const _channel = MethodChannel('com.foodvoyage.speech_recognition'); /// 开始识别,返回最终文本(注意:不是马上返回,要等 stop 之后) static Future<String> startListening({String language = 'zh-CN'}) async { final result = await _channel.invokeMethod<String>( 'startListening', {'language': language}, ); return result ?? ''; } /// 停止识别(返回 void,不返回识别文本) static Future<void> stopListening() async { await _channel.invokeMethod<void>('stopListening'); } }鸿蒙侧(SpeechRecognitionPlugin.ets):
onMethodCall(call: MethodCall, result: MethodResult): void { switch (call.method) { case 'startListening': this.handleStartListening(call, result); // 权限→引擎→监听→开始 break; case 'stopListening': this.handleStopListening(result); // finish → 触发回调 break; default: result.notImplemented(); break; } }两个方法分工明确:
方法 | 鸿蒙侧职责 | 返回值 | 何时 resolve |
|---|---|---|---|
| 权限申请、引擎创建、监听器注册、开始录音 |
| stop 触发 |
| 调用 |
| finish 调用完成后立即 |
三、stopListening 不返回识别文本
这是一个容易搞混的点。stopListening的鸿蒙侧实现:
private handleStopListening(result: MethodResult): void { try { if (this.asrEngine) { this.asrEngine.finish(this.sessionId); // 通知引擎"用户说完了" } result.success(null); // ← 返回的是 null,不是识别文本 } catch (err) { const error = err as BusinessError; result.error('ASR_ERROR', `停止识别失败: ${error.message}`, null); } }finish()做的事情是通知鸿蒙引擎"用户已经说完话了",引擎随后会通过onResult回调返回最终识别结果。但这个结果是通过pendingResult.success()回传给startListening那个 Future 的,而不是通过stopListening的返回值。
startListening() ──▶ pendingResult = result (Future 悬挂) │ stopListening() ──▶ finish(sessionId) (触发引擎结束) │ onResult(isLast) (引擎回调) │ pendingResult.success(text) (Future resolve)stopListening返回success(null)只是告诉 Flutter "停止指令已执行",不是识别结果。
四、协调器里的 mounted 保护
因为startListening()是一个长时间悬挂的 Future(从按下到松手可能持续好几秒),协调器必须处理用户中途退出页面的情况:
Future<void> startVoiceInput() async { if (!mounted) return; state = state.copyWith(status: AiSessionStatus.listening); try { // 这个 await 会一直挂到 stopListening 触发 onResult 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: '语音识别出错,请手动输入'); } } Future<void> stopVoiceInput() async { try { await SpeechRecognitionChannel.stopListening(); } catch (_) {} // 停止失败静默吞掉,不影响主流程 }注意stopVoiceInput里异常被静默吞掉了——因为停止只是一个收尾动作,它失败不应该影响用户体验。真正的结果走的是startVoiceInput里startListening()的 Future。
五、引擎生命周期的显式收口
startListening和stopListening的分离,也让鸿蒙引擎的生命周期管理更清晰:
startListening负责创建引擎(createEngine)stopListening负责结束会话(finish)onResult/onComplete/onError负责销毁引擎(shutdownEngine)
createEngine ←── startListening │ startListening(引擎API) ←── 开始录音 │ finish(sessionId) ←── stopListening │ onResult(isLast) ←── 引擎回调 │ shutdownEngine ←── 回调里统一清理每个阶段都有明确的入口和出口,不会出现"引擎创建了但没销毁"的泄漏。
六、为后续扩展留空间
即使当前项目只回传最终文本(isLast时的完整结果),把开始和结束分开也为后续扩展留出了空间:
实时中间结果— 在
onResult里不判断isLast,把中间片段通过 EventChannel 推给 Flutter自动停止策略— 在
startListening里配好vadEnd: 3000,用户说完 3 秒后引擎自动finish,不需要手动 stop长按说话上限— 通过
maxAudioDuration: 20000限制最长 20 秒,超过自动结束多段识别— start/stop 循环调用,实现"说一段、提交一段"的分段输入
关键代码位置
app/lib/core/platform/speech_recognition_channel.dart— Flutter 侧startListening/stopListeningapp/ohos/entry/src/main/ets/plugins/SpeechRecognitionPlugin.ets— 鸿蒙侧handleStartListening/handleStopListeningapp/lib/core/ai/ai_explore_coordinator.dart— 协调器startVoiceInput/stopVoiceInputapp/lib/features/ai_assistant/screens/ai_assistant_screen.dart— 页面手势onPanDown/onPanEnd
鸿蒙侧实现
鸿蒙侧拆分后,每个 handler 的职责非常单一:
// startListening:四步串联 private async handleStartListening(call, result): Promise<void> { this.pendingResult = result; await this.requestMicrophonePermission(); // 1. 权限 await this.createEngine(); // 2. 引擎 this.setupListener(); // 3. 监听器 this.startListening(); // 4. 开始录音 } // stopListening:一步触发 private handleStopListening(result): void { this.asrEngine.finish(this.sessionId); // 触发引擎结束 → onResult result.success(null); }start是重操作(权限+引擎+监听+录音),stop是轻操作(一行finish)。这种"重启动、轻停止"的设计在鸿蒙设备上很合理——启动时做好所有准备工作,停止时只发一个信号。
引擎的销毁不在stopListening里做,而是在onResult/onComplete/onError回调里统一执行shutdownEngine()。这是因为finish()是异步的——调完之后引擎还要处理最后的音频帧,等onResult回调来了才真正结束。
Flutter 侧实现
Flutter 侧受益更大。因为有了两个独立方法,页面可以非常自然地实现"按住说话":
// 页面只需关注手势 GestureDetector( onPanDown: (_) => coordinator.startVoiceInput(), // 按下开始 onPanEnd: (_) => coordinator.stopVoiceInput(), // 松手停止 onPanCancel: () => coordinator.stopVoiceInput(), // 打断也停止 ) // 协调器只需关注结果 Future<void> startVoiceInput() async { state = state.copyWith(status: AiSessionStatus.listening); // UI 显示"正在听" final text = await SpeechRecognitionChannel.startListening(); // 悬挂等结果 await submitQuery(text); // 拿到文本自动提交给 AI }状态同步也很自然——startVoiceInput把状态切到listening,页面通过 Riverpod 的ref.watch自动更新 UI(麦克风按钮变色、显示"正在聆听...")。结果回来后状态自动流转到parsing→responding→idle,全程不需要页面手动管理。
如果把"什么时候停"强塞给鸿蒙原生侧(比如只用vadEnd自动停止),页面就失去了控制权——用户说完话后还要等 3 秒静音超时才能拿到结果,体验很迟钝。
常见坑
只有
start没有stop— 页面无法主动结束输入,用户说完话还得等 VAD 超时(vadEnd: 3000)页面退出时忘记调用
stop— 鸿蒙引擎还在录音,pendingResult还悬挂着,造成状态残留和资源泄漏stop接口存在,但页面不维护"是否正在识别"的状态— 用户连点两次 start,导致引擎冲突把"自动完成"和"用户主动停止"混为一种交互— 自动完成走
onComplete兜底,主动停止走onResult(isLast),两个回调的处理逻辑不一样期望
stopListening直接返回识别文本— 搞混了两个 MethodChannel 调用的返回值,文本走的是startListening的pendingResultstopVoiceInput里抛异常影响主流程— 停止只是一个收尾动作,异常应该静默吞掉startListening的 Future resolve 后不做mounted检查— 用户在识别期间退出页面,coordinator 已 dispose,更新 state 会报StateError
可复用模板
Flutter 侧 Channel 模板
class SpeechRecognitionChannel { static const _channel = MethodChannel('com.yourapp.speech_recognition'); /// 开始识别,Future 悬挂到 stop 触发的 onResult 后才 resolve static Future<String> startListening({String language = 'zh-CN'}) async { final result = await _channel.invokeMethod<String>( 'startListening', {'language': language}, ); return result ?? ''; } /// 停止识别,返回 void(不返回识别文本) static Future<void> stopListening() async { await _channel.invokeMethod<void>('stopListening'); } }Flutter 侧"按住说话"交互模板
GestureDetector( onPanDown: (_) => coordinator.startVoiceInput(), onPanEnd: (_) => coordinator.stopVoiceInput(), onPanCancel: () => coordinator.stopVoiceInput(), child: YourButtonWidget(), ) // 协调器 Future<void> 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: '识别出错'); } } Future<void> stopVoiceInput() async { try { await SpeechRecognitionChannel.stopListening(); } catch (_) {} // 静默吞掉 }鸿蒙侧 handler 模板
// start:重操作(权限→引擎→监听→录音) private async handleStartListening(call, result): Promise<void> { this.pendingResult = result; // 权限、引擎、监听、开始... } // stop:轻操作(finish 触发回调) private handleStopListening(result: MethodResult): void { try { if (this.asrEngine) { this.asrEngine.finish(this.sessionId); // 触发 onResult } result.success(null); // 不返回文本,文本走 pendingResult } catch (err) { result.error('ASR_ERROR', '停止失败', null); } }本篇总结
startListening和stopListening分开,核心价值是交互控制权回到页面手里——用户按下开始、松手停止,而不是等引擎自己判断两个方法之间有一条隐含的异步链:
start的 Future 一直悬挂,stop触发finish,finish触发onResult,onResult通过pendingResult.success()把结果还给start的 FuturestopListening返回 void 不返回文本——文本走的是startListening的pendingResult,搞混了就拿不到结果这种拆分既利于鸿蒙引擎的生命周期管理(重启动、轻停止),也利于 Flutter 页面的状态同步(按下切
listening、结果回来切parsing)真实的鸿蒙 Flutter 项目里,语音输入一定是状态能力,不是单次函数调用