解析鸿蒙 SpeechRecognitionPlugin:从权限申请到识别回调的完整链路

适合谁看

  • 想读懂 SpeechRecognitionPlugin.ets 鸿蒙插件代码的开发者

  • 想自己写鸿蒙 Flutter 原生插件的人

  • 想看一个鸿蒙 Core Speech Kit 最小闭环的人

  • 想理解 pendingResultRecognitionListener 怎么配合的人

问题背景

鸿蒙语音识别插件最容易写坏的地方不在"调不调得起来",而在:

  • 权限在哪申请 --- 鸿蒙的 ohos.permission.MICROPHONE 需要 module.json5 声明 + 运行期 requestPermissionsFromUser 双重保障

  • 引擎什么时候创建和销毁 --- Core Speech Kit 的 speechRecognizer 引擎是重量级资源,持有不当会导致泄漏

  • 回调什么时候回传 Flutter --- RecognitionListeneronStartonResultonCompleteonError 多个回调,如果每个都回传,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;
  }
}

插件对外只暴露 startListeningstopListening。这意味着权限申请、引擎创建、监听器注册这些细节全部封闭在插件内部,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 秒,引擎自动判定说完了,触发 onResultisLast

  • 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 权限声明

鸿蒙侧实现总结

这份鸿蒙插件最值得借鉴的地方有三个:

  1. pendingResult 绑定一次请求 --- Flutter 侧的 Future<String> 和鸿蒙侧的 MethodResult 通过 pendingResult 完美对接,识别结果在回调中异步回传

  2. sessionId 统一管理识别会话 --- 所有 finish()startListening() 都用同一个 sessionId,避免会话混乱

  3. 监听器里集中做成功、完成、失败的收口 --- 三个出口统一 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 没有在所有出口置空 --- 成功路径置空了,但 onCompleteonError 路径忘了,导致 pendingResult 被复用时行为异常

  • onCompleteonResult 都回传结果 --- 两个回调都调了 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 行的干净接口

相关推荐
祭曦念1 天前
【共创季稿事节】鸿蒙ArkTS布局实战_Column交叉轴对齐
华为·harmonyos
古德new1 天前
鸿蒙PC迁移:Anki Qt 记忆卡片工具鸿蒙PC适配全记录
qt·华为·harmonyos
TMT星球1 天前
创梦天地《地铁跑酷》携手鸿蒙 深化全场景生态共建
华为·harmonyos
枫叶丹41 天前
【HarmonyOS 6.0】MDM Kit 新特性:PC/2in1设备无锁屏密码重启自动解锁能力详解
开发语言·华为·harmonyos
Davina_yu1 天前
数据持久化(2):RelationalStore关系型数据库(SQLite)操作(14)
harmonyos·鸿蒙·鸿蒙系统
不良使1 天前
鸿蒙PC迁移:使用Electron`logseq-master-ohos` 鸿蒙适配全记录
jvm·electron·harmonyos
枫叶丹41 天前
【HarmonyOS 6.0】MDM Kit:PC/2in1设备用户行为限制策略详解
开发语言·华为·harmonyos
SuperHeroWu71 天前
【HarmonyOS 7】鸿蒙应用 AI Coding 工具链 DevEco Code 到 DevEco CLI
人工智能·华为·ai编程·harmonyos·cli·code
祭曦念1 天前
【共创季稿事节】鸿蒙原生 ArkTS 布局深度解析:Column 主轴对齐之 flex-start / center / flex-end 全解
华为·harmonyos
Davina_yu1 天前
环境变量管理:Environment与LocalStorage的应用场景(23)
harmonyos·鸿蒙·鸿蒙系统