适合谁看
-
想读懂
SpeechRecognitionPlugin.ets鸿蒙插件代码的开发者 -
想自己写鸿蒙 Flutter 原生插件的人
-
想看一个鸿蒙 Core Speech Kit 最小闭环的人
-
想理解
pendingResult和RecognitionListener怎么配合的人
问题背景
鸿蒙语音识别插件最容易写坏的地方不在"调不调得起来",而在:
-
权限在哪申请 --- 鸿蒙的
ohos.permission.MICROPHONE需要module.json5声明 + 运行期requestPermissionsFromUser双重保障 -
引擎什么时候创建和销毁 --- Core Speech Kit 的
speechRecognizer引擎是重量级资源,持有不当会导致泄漏 -
回调什么时候回传 Flutter ---
RecognitionListener有onStart、onResult、onComplete、onError多个回调,如果每个都回传,Flutter 侧就会混乱 -
出错时如何及时清理状态 --- 鸿蒙引擎出错后如果不
shutdown(),下一次调用可能直接失败
如果这些点没有固定下来,插件代码很快就会失控------要么内存泄漏,要么 pendingResult 被重复调用导致 Flutter 侧报错。
项目中的真实场景
食界探味的实现集中在两个文件:
-
app/ohos/entry/src/main/ets/plugins/SpeechRecognitionPlugin.ets--- 鸿蒙侧插件(194 行) -
app/lib/core/platform/speech_recognition_channel.dart--- Flutter 侧封装(19 行)
Flutter 侧只有 19 行,说明复杂度主要压在了鸿蒙 ArkTS 插件里。这个比例本身就是一个信号:好的跨端设计应该让鸿蒙侧兜住所有系统交互细节,Flutter 侧只做业务决策。
核心实现
一、插件类结构:两个接口,一个职责
import {
FlutterPlugin,
FlutterPluginBinding,
MethodCall,
MethodCallHandler,
MethodChannel,
MethodResult
} from '@ohos/flutter_ohos';
import { speechRecognizer } from '@kit.CoreSpeechKit';
import { BusinessError } from '@kit.BasicServicesKit';
import { abilityAccessCtrl, Permissions } from '@kit.AbilityKit';
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;
}
插件实现了两个接口:
-
FlutterPlugin--- 管理插件的生命周期(绑定/解绑引擎) -
MethodCallHandler--- 处理 Flutter 侧发来的方法调用
四个内部状态变量各司其职:
| 变量 | 类型 | 作用 |
|---|---|---|
channel |
MethodChannel |
与 Flutter 通信的通道 |
asrEngine |
SpeechRecognitionEngine |
鸿蒙 Core Speech Kit 识别引擎实例 |
sessionId |
string |
识别会话标识,固定为 '10000' |
pendingResult |
MethodResult |
悬挂的 Flutter 回调,贯穿整个识别生命周期 |
其中 pendingResult 是整个插件最关键的设计。Flutter 侧的 startListening() 返回一个 Future<String>,这个 Future 在鸿蒙侧对应的就是 pendingResult。识别没结束时它一直挂着,直到拿到最终结果才通过 success() 解除。
二、生命周期:绑定 channel 和回收引擎
onAttachedToEngine(binding: FlutterPluginBinding): void {
this.channel = new MethodChannel(
binding.getBinaryMessenger(),
'com.foodvoyage.speech_recognition'
);
this.channel.setMethodCallHandler(this);
}
onDetachedFromEngine(binding: FlutterPluginBinding): void {
if (this.channel) {
this.channel.setMethodCallHandler(null);
}
this.shutdownEngine(); // 引擎销毁时强制清理
}
通道名 com.foodvoyage.speech_recognition 必须和 Flutter 侧的 MethodChannel('com.foodvoyage.speech_recognition') 完全一致,否则两边通信不上。
onDetachedFromEngine 里的 shutdownEngine() 很关键------当 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): Promise<void> {
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);
}
}
这个顺序非常重要:先权限、再引擎、再监听、最后开始。
为什么是这个顺序?
-
如果先建引擎再申请权限,权限被拒后引擎已经创建了,白浪费资源
-
如果先注册监听器再建引擎,监听器绑不到引擎上会报空指针
-
如果权限申请失败,
pendingResult必须置空,否则onComplete兜底逻辑会往一个已经被 error 的 result 上再调 success
五、权限申请:鸿蒙的双重保障
private async requestMicrophonePermission(): Promise<boolean> {
try {
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
);
} catch (err) {
console.error(TAG, `requestPermission failed: ${JSON.stringify(err)}`);
return false;
}
}
鸿蒙的权限机制和 Android 类似但更严格:
-
module.json5里声明ohos.permission.MICROPHONE是"我有权使用" -
运行期
requestPermissionsFromUser才是真正弹窗问用户"允不允许"
如果只做了声明、没做运行期申请,鸿蒙系统会直接拒绝麦克风访问,不会弹窗。
对应的 module.json5 配置:
{
"name": "ohos.permission.MICROPHONE",
"reason": "$string:mic_reason",
"usedScene": {
"abilities": ["EntryAbility"],
"when": "inuse" // 只在使用期间申请,比 always 更友好
}
}
六、引擎创建:Core Speech Kit 的初始化参数
private createEngine(): Promise<void> {
return new Promise((resolve, reject) => {
const extraParam: Record<string, 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 |
在线模式(精度更高,需要网络) |
locate |
CN |
地区设置为中国 |
recognizerMode |
short |
短语音模式,适合按住说话 |
这里用 Promise 包装了回调式的 createEngine API,这样在 handleStartListening 里就能用 await 串联后续步骤。
七、监听器:只在最终结果时回传 Flutter
private setupListener(): void {
if (!this.asrEngine) return;
const listener: speechRecognizer.RecognitionListener = {
onStart: (sessionId: string, eventMessage: string) => {
console.info(TAG, `onStart sessionId: ${sessionId}, msg: ${eventMessage}`);
},
onEvent: (sessionId: string, eventCode: number, eventMessage: string) => {
console.info(TAG, `onEvent sessionId: ${sessionId}, code: ${eventCode}`);
},
onResult: (sessionId: string, result: speechRecognizer.SpeechRecognitionResult) => {
console.info(TAG, `onResult: ${JSON.stringify(result)}`);
if (result.isLast && this.pendingResult) {
this.pendingResult.success(result.result);
this.pendingResult = null;
this.shutdownEngine();
}
},
onComplete: (sessionId: string, eventMessage: string) => {
console.info(TAG, `onComplete sessionId: ${sessionId}`);
if (this.pendingResult) {
this.pendingResult.success('');
this.pendingResult = null;
}
this.shutdownEngine();
},
onError: (sessionId: string, errorCode: number, errorMessage: string) => {
console.error(TAG, `onError code: ${errorCode}, msg: ${errorMessage}`);
if (this.pendingResult) {
this.pendingResult.error('ASR_ERROR', errorMessage, null);
this.pendingResult = null;
}
this.shutdownEngine();
}
};
this.asrEngine.setListener(listener);
}
Core Speech Kit 的 RecognitionListener 有五个回调,但真正回传 Flutter 的时机非常克制:
-
onStart--- 只打日志,不回传 -
onEvent--- 只打日志,不回传 -
onResult--- 只有result.isLast时才success()回传最终文本 -
onComplete--- 兜底:如果onResult没拿到isLast,这里也要把pendingResult收掉(返回空字符串) -
onError--- 出错时error()回传,同时回收
为什么 onResult 里要做 isLast 判断?因为 Core Speech Kit 在识别过程中会多次调用 onResult,每次带一段中间结果(partial result)。如果不判断 isLast,就会把中间片段也 success() 给 Flutter,但 MethodResult 只能调用一次------第二次调用会直接崩溃。
八、开始监听:音频参数配置
private startListening(): void {
if (!this.asrEngine) return;
const audioParam: speechRecognizer.AudioInfo = {
audioType: 'pcm',
sampleRate: 16000,
soundChannel: 1,
sampleBit: 16
};
const extraParam: Record<string, Object> = {
'recognitionMode': 0,
'vadBegin': 2000, // 静音检测:开始说话前等待 2 秒
'vadEnd': 3000, // 静音检测:说完话后等待 3 秒自动结束
'maxAudioDuration': 20000 // 最长录音 20 秒
};
const recognizerParams: speechRecognizer.StartParams = {
sessionId: this.sessionId,
audioInfo: audioParam,
extraParams: extraParam
};
this.asrEngine.startListening(recognizerParams);
}
音频参数说明:
-
pcm+16000Hz+ 单声道 +16bit--- 鸿蒙 Core Speech Kit 推荐的音频格式 -
vadBegin: 2000--- VAD(Voice Activity Detection)会在用户开始说话前静默等待 2 秒 -
vadEnd: 3000--- 用户停止说话后静默 3 秒,引擎自动判定说完了,触发onResult的isLast -
maxAudioDuration: 20000--- 保护性限制,避免无限录音
九、停止识别:触发引擎结束
private handleStopListening(result: MethodResult): void {
try {
if (this.asrEngine) {
this.asrEngine.finish(this.sessionId);
}
result.success(null);
} catch (err) {
const error = err as BusinessError;
result.error('ASR_ERROR', `停止识别失败: ${error.message}`, null);
}
}
finish() 通知引擎"用户已经说完",引擎随后会通过 onResult 回调返回最终识别结果。
注意一个关键细节:stopListening 本身不回传识别文本------它返回的是 success(null)。真正的识别文本仍然是通过之前的 pendingResult.success() 回传的。这是因为 Flutter 侧的 startListening() 和 stopListening() 是两个独立的 MethodChannel 调用,startListening 的 Future 一直在等 pendingResult,而 stopListening 只是触发了引擎的结束流程。
十、引擎清理:所有出口统一 shutdown
private shutdownEngine(): void {
try {
if (this.asrEngine) {
this.asrEngine.shutdown();
this.asrEngine = null;
console.info(TAG, 'Engine shutdown');
}
} catch (err) {
console.error(TAG, `shutdown error: ${JSON.stringify(err)}`);
}
}
三个出口都调用 shutdownEngine():
onResult (isLast) ──▶ success(text) ──▶ shutdownEngine()
onComplete ──▶ success('') ──▶ shutdownEngine()
onError ──▶ error(...) ──▶ shutdownEngine()
这种"用完即弃"的策略对短语音输入场景非常合适。每次识别结束后引擎都被销毁,下一次调用 startListening 时重新创建。虽然多了一次初始化开销,但避免了引擎长期持有导致的资源泄漏和状态混乱------这在鸿蒙设备上尤为重要,因为鸿蒙对后台资源管理比 Android 更严格。
关键代码位置
-
app/ohos/entry/src/main/ets/plugins/SpeechRecognitionPlugin.ets--- 鸿蒙侧完整插件实现 -
app/lib/core/platform/speech_recognition_channel.dart--- Flutter 侧 Channel 封装 -
app/ohos/entry/src/main/module.json5---ohos.permission.MICROPHONE权限声明
鸿蒙侧实现总结
这份鸿蒙插件最值得借鉴的地方有三个:
-
pendingResult绑定一次请求 --- Flutter 侧的Future<String>和鸿蒙侧的MethodResult通过pendingResult完美对接,识别结果在回调中异步回传 -
sessionId统一管理识别会话 --- 所有finish()、startListening()都用同一个sessionId,避免会话混乱 -
监听器里集中做成功、完成、失败的收口 --- 三个出口统一
shutdownEngine()+pendingResult = null,确保不会泄漏
这使得插件内部状态比"页面一直等事件"更容易维护。
Flutter 侧实现
Flutter 侧之所以可以写得很薄,是因为鸿蒙插件已经把复杂度兜住了:
class SpeechRecognitionChannel {
static const _channel = MethodChannel('com.foodvoyage.speech_recognition');
static Future<String> startListening({String language = 'zh-CN'}) async {
final result = await _channel.invokeMethod<String>(
'startListening',
{'language': language},
);
return result ?? '';
}
static Future<void> stopListening() async {
await _channel.invokeMethod<void>('stopListening');
}
}
startListening() 最终只暴露出一个返回字符串的 Future,页面层不需要理解鸿蒙的权限状态、引擎生命周期和识别事件细节。这就是跨端分层设计的价值:鸿蒙插件把 Core Speech Kit 的复杂性封装成一个干净的同步接口。
常见坑
-
pendingResult没有在所有出口置空 --- 成功路径置空了,但onComplete和onError路径忘了,导致pendingResult被复用时行为异常 -
onComplete和onResult都回传结果 --- 两个回调都调了success(),但MethodResult只能用一次,第二次调用直接崩溃 -
onResult不做isLast判断 --- 中间片段也success()给 Flutter,同样导致MethodResult重复调用 -
停止识别只调用
finish,却不做shutdownEngine--- 引擎虽然结束了识别但实例还活着,下次createEngine可能冲突 -
在插件
onDetachedFromEngine时没有shutdownEngine()--- Flutter 引擎销毁后鸿蒙识别引擎仍在运行,造成资源泄漏 -
只声明了
module.json5权限,运行期没有requestPermissionsFromUser--- 鸿蒙系统会直接拒绝访问,不弹窗 -
stopListening期望直接拿到识别文本 --- 搞混了两个 MethodChannel 调用的返回值,真正的文本走的是pendingResult
可复用模板
鸿蒙插件启动流程模板
private async handleStartListening(call: MethodCall, result: MethodResult): Promise<void> {
this.pendingResult = result;
// 1. 权限
const hasPermission = await this.requestMicrophonePermission();
if (!hasPermission) {
this.pendingResult = null;
result.error('PERMISSION_DENIED', '麦克风权限被拒绝', null);
return;
}
// 2. 引擎 → 3. 监听 → 4. 开始
try {
await this.createEngine();
this.setupListener();
this.startListening();
} catch (err) {
this.pendingResult = null;
result.error('ASR_ERROR', '启动失败', null);
}
}
鸿蒙监听器核心模板(三出口统一清理)
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(); // ← 成功出口
}
},
onComplete: (sessionId, eventMessage) => {
if (this.pendingResult) {
this.pendingResult.success('');
this.pendingResult = null;
}
this.shutdownEngine(); // ← 兜底出口
},
onError: (sessionId, code, msg) => {
if (this.pendingResult) {
this.pendingResult.error('ASR_ERROR', msg, null);
this.pendingResult = null;
}
this.shutdownEngine(); // ← 错误出口
}
};
this.asrEngine.setListener(listener);
}
鸿蒙 module.json5 权限声明模板
{
"name": "ohos.permission.MICROPHONE",
"reason": "$string:mic_reason",
"usedScene": {
"abilities": ["EntryAbility"],
"when": "inuse"
}
}
本篇总结
-
鸿蒙
SpeechRecognitionPlugin的核心不是 API 数量,而是调用顺序:先权限、再引擎、再监听、最后清理 -
pendingResult是连接 Flutter Future 和鸿蒙异步回调的桥梁,必须在所有出口(成功、完成、错误)统一置空 -
RecognitionListener的五个回调中,只有onResult(isLast=true)才真正回传识别文本,其余只打日志 -
引擎的"用完即弃"策略(每次识别后
shutdownEngine)在鸿蒙设备上比长期持有更稳定 -
把复杂度留在鸿蒙 ArkTS 插件里,Flutter 侧就能保持 19 行的干净接口
