前言
欢迎加入开源鸿蒙跨平台社区:https://openharmonycrossplatform.csdn.net
语音识别不只是"开始"那么简单,怎么结束同样重要。
flutter_speech提供了两种结束方式——stop和cancel,它们的语义完全不同:stop是"我说完了,给我结果",cancel是"算了不要了"。
这两个方法的代码都很短,加起来不到30行。
但别小看这30行代码,里面涉及的状态管理和边界场景处理是整个插件中最容易出bug的地方。
重复调用怎么办?引擎未初始化怎么办?正在识别中cancel了,结果回调还会触发吗?
我在测试阶段遇到了好几个和停止/取消相关的问题,今天把这些经验都整理出来。
💡本文对应源码:
FlutterSpeechPlugin.ets的stop方法(第237-248行)和cancel方法(第224-235行)。
/>
一、stop(finish)与cancel
核心区别
| 操作 | Dart层方法 | 原生层API | 语义 | 是否返回结果 |
|---|---|---|---|---|
| 停止 | _speech.stop() | asrEngine.finish(sessionId) | “我说完了” | ✅ 返回最终结果 |
| 取消 | _speech.cancel() | asrEngine.cancel(sessionId) | “不要了” | ❌ 不返回结果 |
1.2
行为差异
stop(finish):
用户说"今天天气怎么样"点击stop
返回最终识别结果:"今天天气怎么样"
├──
触发
cancel:
用户说"今天天气怎么样"点击cancel
可能触发(但无结果)
1.3
类比理解
把语音识别比作拍照:
- stop=
按下快门,等照片保存完成
- cancel=
关闭相机,不拍了
或者比作写邮件:
- stop=
点击"发送"
- cancel=
点击"丢弃草稿"
1.4
三平台的命名对比
| 操作 | Android | iOS | OpenHarmony |
|---|---|---|---|
| 正常停止 | stopListening() | endAudio() | finish(sessionId) |
| 取消 | cancel() | (task.cancel()) | cancel(sessionId) |
📌命名差异:Android叫
stopListening,OpenHarmony叫finish。虽然名字不同,但语义一致——都是"正常结束并获取结果"。
二、finish
方法:正常结束并获取最终结果
2.1
源码实现
privatestop(result:MethodResult):void{try{if(this.asrEngine&&this.isListening){this.asrEngine.finish(this.sessionId);this.isListening=false;}result.success(true);}catch(e){console.error(TAG,`stoperror:
${JSON.stringify(e)}`);result.success(true);}}2.2
逐行解析
第1行:if
(this.asrEngine
this.isListening)
- 双重检查:引擎存在且正在监听
- 如果引擎不存在或没在监听,直接跳过,返回success
第2行:this.asrEngine.finish(this.sessionId)
- 调用Core
Speech
Kit的
finish方法 - 传入sessionId指定要停止的会话
- 引擎会处理剩余音频并触发最终结果回调
第3行:this.isListening
=
false
- 立即将状态标记为"未监听"
- 不等回调触发再改状态,避免竞态条件
第4行:result.success(true)
- 无论是否执行了finish,都返回success
- 这是因为"停止"操作本身不应该失败——即使没在监听,停止也是合理的
2.3
为什么catch中也返回success
catch(e){console.error(TAG,`stoperror:
${JSON.stringify(e)}`);result.success(true);//错误了也返回success?
}这是一个设计决策:stop操作的语义是"请求停止",即使底层出了异常,从用户的角度来说"停止"这个动作已经完成了。
返回error会让Dart层的Future抛异常,可能导致UI出现不必要的错误提示。
🤔我的看法:这种处理方式有争议。
有人认为应该把错误传回去让调用方知道。
但在实际使用中,stop失败通常是因为引擎已经自己停了(比如VAD超时),这时候返回error反而会让用户困惑。
所以flutter_speech选择了"静默成功"的策略。
2.4
finish后的回调时序
调用finish后,引擎会触发以下回调:
asrEngine.finish(sessionId)├──
引擎处理剩余音频(可能需要几百毫秒)
├──
speech.onRecognitionComplete(text)
└──
(isListening已经是false,跳过)
⚠️注意:
finish是异步的——调用后不会立即触发回调。引擎需要时间处理剩余音频。
所以
result.success(true)是在回调之前返回的,Dart层收到success后还需要等待onRecognitionComplete回调才能拿到最终结果。
三、cancel
方法:立即中断不返回结果
3.1
源码实现
privatecancel(result:MethodResult):void{try{if(this.asrEngine&&this.isListening){this.asrEngine.cancel(this.sessionId);this.isListening=false;}result.success(true);}catch(e){console.error(TAG,`cancelerror:
${JSON.stringify(e)}`);result.success(true);}}3.2
与stop的代码差异
把两个方法放在一起对比:
//stop
this.asrEngine.finish(this.sessionId);//cancel
this.asrEngine.cancel(this.sessionId);//唯一的区别
代码结构完全一样,唯一的区别就是调用的API不同:finishvscancel。
3.3
cancel后的回调行为
asrEngine.cancel(sessionId)├──
(isListening已经是false,跳过)
└──
关键区别
cancel后不会触发onResult(isLast=true),所以Dart层不会收到speech.onRecognitionComplete事件。
这正是cancel的语义——“不要结果了”。
3.4
cancel的使用场景
| 场景 | 说明 |
|---|---|
| 用户点击"取消"按钮 | 用户主动放弃本次识别 |
| 切换语言后重新识别 | 先cancel旧的,再start新的 |
| 页面退出 | 离开语音识别页面时清理 |
| 防重入 | startListening中先cancel旧会话 |
四、isListening
isListening的生命周期
privateisListening:boolean=false;isListening在以下位置被修改:
| 位置 | 操作 | 新值 | 说明 |
|---|---|---|---|
| startListening | 开始识别 | true | 标记为监听中 |
| startListening(防重入) | cancel旧会话 | false true | 先false再true |
| stop | 停止识别 | false | 标记为未监听 |
| cancel | 取消识别 | false | 标记为未监听 |
| onResult(isLast=true) | 收到最终结果 | false | 识别自然结束 |
| onComplete | 会话完成 | false | 兜底处理 |
| onError | 发生错误 | false | 错误恢复 |
4.2
状态转换图
startListeningfalse
└────────────────────────────────────┘
4.3
防重入的实现
在startListening中:
if(this.isListening){this.asrEngine.cancel(this.sessionId);this.isListening=false;}这段代码确保了同一时间只有一个活跃的识别会话。
如果用户快速连续点击"开始"按钮,不会出现多个会话冲突。
用户快速点击两次"开始":第1次点击:
第2次点击(第1次还在识别中):
isListening
竞态条件分析
有一个潜在的竞态条件:stop方法将isListening设为false,但onResult(isLast=true)回调可能在之后触发,也会将isListening设为false。
//stop方法
this.asrEngine.finish(this.sessionId);this.isListening=false;//稍后,onResult回调触发
onResult(sessionId,result){if(result.isLast){plugin.isListening=false;//第2次设false(重复但无害)
}}
这种重复设置是无害的——false设两次还是false。
flutter_speech的设计选择了"宁可重复也不遗漏"的策略。
五、边界场景处理:重复调用、引擎未初始化
5.1
边界场景清单
场景 stop的行为 cancel的行为 正常识别中 finish+
不返回结果
未在识别(isListening=false) 跳过finish,返回success 跳过cancel,返回success 引擎未初始化(asrEngine=null) 跳过,返回success 跳过,返回success 连续调用两次stop 第1次正常,第2次跳过 第1次正常,第2次跳过 stop后立即cancel stop正常,cancel跳过 - cancel后立即stop cancel正常,stop跳过 -
5.2
引擎未初始化
if(this.asrEngine&&this.isListening){//只有引擎存在且正在监听时才执行
}result.success(true);//无论如何都返回success
如果用户在没有调用activate的情况下直接调用stop或cancel,asrEngine为null,条件不满足,直接返回success。
不会报错,也不会崩溃。
5.3isListening
返回success
第二次调用时,isListening已经是false,不会重复调用finish。
这是安全的。
5.4
stop和cancel交叉调用
stop()finish
返回success
先stop后cancel,cancel会被跳过。
反过来也一样。
这是正确的行为——已经停止了就不需要再取消。
六、stop/cancelDart
Dart层的调用
//停止识别
Futurestop()=>_channel.invokeMethod("speech.stop");//取消识别
Futurecancel()=>_channel.invokeMethod("speech.cancel");6.2
示例App中的使用
//停止按钮
ElevatedButton(onPressed:_isListening?()=>_speech.stop():null,child:Text('Stop'),),//取消按钮
ElevatedButton(onPressed:_isListening?()=>_speech.cancel():null,child:Text('Cancel'),),注意按钮的onPressed只在_isListening为true时才可点击。
这是UI层的防重入——如果没在识别,按钮是灰色的。
6.3
stop后的结果获取
stop后,Dart层通过recognitionCompleteHandler回调获取最终结果:
_speech.setRecognitionCompleteHandler((Stringtext){setState((){_transcription=text;_isListening=false;});});cancel后,recognitionCompleteHandler不会被调用,所以_transcription保持之前的值(部分结果或空字符串)。
6.4
完整的交互时序
stop场景:
Dart:_speech.stop()
onMethodCall("speech.stop")
Native:
channel.invokeMethod('speech.onSpeech',
text)
channel.invokeMethod('speech.onRecognitionComplete',
text)
recognitionCompleteHandler(text)
cancel场景:
Dart:_speech.cancel()
onMethodCall("speech.cancel")
Native:
(不会有后续回调)
七、与Android实现的对比
7.1
stop:
privatevoidstopListening(MethodChannel.Resultresult){try{if(speechRecognizer!=null&&isListening){speechRecognizer.stopListening();isListening=false;}result.success(true);}catch(Exceptione){result.success(true);}}OpenHarmony
stop:
privatestop(result:MethodResult):void{try{if(this.asrEngine&&this.isListening){this.asrEngine.finish(this.sessionId);this.isListening=false;}result.success(true);}catch(e){console.error(TAG,`stoperror:
${JSON.stringify(e)}`);result.success(true);}}7.2
差异点
| 差异 | Android | OpenHarmony |
|---|---|---|
| API名称 | stopListening() | finish(sessionId) |
| 需要sessionId | ❌不需要 | ✅ 需要 |
| 错误日志 | 无 | 有console.error |
| 代码结构 | 几乎一样 | 几乎一样 |
两个平台的实现高度相似,这说明flutter_speech的适配做得很好——保持了跨平台的一致性。
八、最佳实践与注意事项
8.1
何时用stop,何时用cancel
| 场景 | 推荐操作 | 原因 |
|---|---|---|
| 用户说完了,想要结果 | stop | 需要最终识别结果 |
| 用户想重新说 | cancel | 不需要当前结果 |
| 切换语言 | cancel | 旧语言的结果没用 |
| 页面退出 | cancel | 不需要结果了 |
| 超时处理 | stop | 尽量保留已识别的内容 |
| 错误恢复 | cancel | 清理状态重新开始 |
8.2
注意事项
- 不要在stop后立即startListening:finish是异步的,需要等onComplete回调后再开始新的识别
- cancel后可以立即startListening:cancel是立即生效的
- 不要忘记更新UI状态:stop/cancel后要更新按钮状态
- destroyEngine前先stop/cancel:确保识别已停止再销毁引擎
//正确的销毁顺序
if(this.isListening){this.asrEngine.cancel(this.sessionId);//先停止
}this.asrEngine.shutdown();//再销毁
总结
本文详细讲解了flutter_speech中语音识别的停止与取消:
- 语义区别:stop(finish)返回最终结果,cancel丢弃结果
- 实现结构:两个方法代码几乎一样,只是调用的API不同
- 状态管理:通过isListening标志防止重复操作和竞态条件
- 边界处理:引擎未初始化、未在监听、重复调用都能安全处理
- 错误策略:异常时也返回success,避免不必要的错误提示
下一篇我们讲引擎销毁与资源释放——destroyEngine方法的实现和资源管理的最佳实践。
如果这篇文章对你有帮助,欢迎点赞👍、收藏⭐、关注🔔,你的支持是我持续创作的动力!
/>
相关资源:
- Core
Speech
finish/cancel文档
- Android
SpeechRecognizer.stopListening
- Android
SpeechRecognizer.cancel
- flutter_speech
OpenHarmony源码
- 状态机设计模式
- Flutter
Platform
Channel通信
- 开源鸿蒙跨平台社区
- ArkTS异步编程指南


