鸿蒙语音识别为什么要区分 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
startListening 权限申请、引擎创建、监听器注册、开始录音 Future<String> stop 触发 onResult 后
stopListening 调用 finish(sessionId) 结束会话 Future<void> 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 项目里,语音输入一定是状态能力,不是单次函数调用

相关推荐
Georgewu3 小时前
【HarmonyOS 7】 HarmonyOS 应用 Skill创建和编排详解
harmonyos
Georgewu5 小时前
【HarmonyOS 7】 应用 Agent 接入云 A2A 与端 A2A 的实践详解
harmonyos
幻灭行度7 小时前
基于昇腾 910B NPU 部署 SenseVoiceSmall 语音识别服务
人工智能·语音识别
马剑威(威哥爱编程)10 小时前
【共创稿事节】HarmonyOS 7 Agent 开发实战 05:流式输出做打字机对话
华为·harmonyos
O。O蛋黄酥啊11 小时前
实时语音 Agent 开发完全指南:从 ASR/TTS 到 WebRTC/VAD 的链路拆解
大模型·webrtc·语音识别·tts·asr
AI备忘录12 小时前
(二十五)华为华三锐捷迈普思科 ERPS 环网保护配置命令(G.8032 五厂商对照)
运维·服务器·网络·网络协议·华为
OH_TPC14 小时前
【鸿蒙优选三方库】@ohos/coap:让鸿蒙设备说上 IoT 的母语
物联网·华为·harmonyos·鸿蒙
程序猿追15 小时前
渐变中间那一格怎么算?用 colormath 做个鸿蒙调色器
华为·harmonyos
程序猿追15 小时前
在 HarmonyOS 6 上做了张 3D 翻转卡片,正面背面来回翻
3d·华为·harmonyos
小π军16 小时前
安卓、iOS、鸿蒙三端的理解
android·ios·harmonyos