鸿蒙语音识别为什么要区分 startListening 和 stopListening
适合谁看
-
正在设计鸿蒙 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— 鸿蒙侧两个 handler -
app/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/stopListening -
app/ohos/entry/src/main/ets/plugins/SpeechRecognitionPlugin.ets— 鸿蒙侧handleStartListening/handleStopListening -
app/lib/core/ai/ai_explore_coordinator.dart— 协调器startVoiceInput/stopVoiceInput -
app/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的pendingResult -
stopVoiceInput里抛异常影响主流程 — 停止只是一个收尾动作,异常应该静默吞掉 -
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的 Future -
stopListening返回 void 不返回文本——文本走的是startListening的pendingResult,搞混了就拿不到结果 -
这种拆分既利于鸿蒙引擎的生命周期管理(重启动、轻停止),也利于 Flutter 页面的状态同步(按下切
listening、结果回来切parsing) -
真实的鸿蒙 Flutter 项目里,语音输入一定是状态能力,不是单次函数调用
更多推荐



所有评论(0)