适合谁看

  • 正在设计鸿蒙 Flutter 语音识别接口的人

  • 觉得"只保留一个 start 就够了"的开发者

  • 想做"按住说话、松手停止"交互的人

  • 想理解 startListening 的 Future 和 stopListening 的触发关系的人

问题背景

语音识别天然带有状态。在鸿蒙 Core Speech Kit 里,一次识别的生命周期是这样的:

未开始 ──start──▶ 正在监听 ──finish/自动──▶ 引擎回调 ──▶ 已结束
                    │                           │
                    │                           ├── onResult(isLast) → 回传文本
                    │                           ├── onComplete → 兜底回传
                    │                           └── onError → 回传错误
                    │
                    └──stop──▶ finish(sessionId) ──▶ 触发上面的回调

如果只有一个 start,会出现两个问题:

  • 页面无法主动结束 — 用户说完话了,但引擎还在等(vadEnd: 3000 的静音超时还没到),体验很迟钝

  • 异步回传搞混start 返回的 Future 到底什么时候 resolve?是 start 的时候还是识别结束的时候?

只有把 startListeningstopListening 拆开,页面才能真正掌控交互节奏。

项目中的真实场景

食界探味的 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 里异常被静默吞掉了——因为停止只是一个收尾动作,它失败不应该影响用户体验。真正的结果走的是 startVoiceInputstartListening() 的 Future。

五、引擎生命周期的显式收口

startListeningstopListening 的分离,也让鸿蒙引擎的生命周期管理更清晰:

  • 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(麦克风按钮变色、显示"正在聆听...")。结果回来后状态自动流转到 parsingrespondingidle,全程不需要页面手动管理。

如果把"什么时候停"强塞给鸿蒙原生侧(比如只用 vadEnd 自动停止),页面就失去了控制权——用户说完话后还要等 3 秒静音超时才能拿到结果,体验很迟钝。

常见坑

  • 只有 start 没有 stop — 页面无法主动结束输入,用户说完话还得等 VAD 超时(vadEnd: 3000

  • 页面退出时忘记调用 stop — 鸿蒙引擎还在录音,pendingResult 还悬挂着,造成状态残留和资源泄漏

  • stop 接口存在,但页面不维护"是否正在识别"的状态 — 用户连点两次 start,导致引擎冲突

  • 把"自动完成"和"用户主动停止"混为一种交互 — 自动完成走 onComplete 兜底,主动停止走 onResult(isLast),两个回调的处理逻辑不一样

  • 期望 stopListening 直接返回识别文本 — 搞混了两个 MethodChannel 调用的返回值,文本走的是 startListeningpendingResult

  • 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);
  }
}

本篇总结

  • startListeningstopListening 分开,核心价值是交互控制权回到页面手里——用户按下开始、松手停止,而不是等引擎自己判断

  • 两个方法之间有一条隐含的异步链:start 的 Future 一直悬挂,stop 触发 finishfinish 触发 onResultonResult 通过 pendingResult.success() 把结果还给 start 的 Future

  • stopListening 返回 void 不返回文本——文本走的是 startListeningpendingResult,搞混了就拿不到结果

  • 这种拆分既利于鸿蒙引擎的生命周期管理(重启动、轻停止),也利于 Flutter 页面的状态同步(按下切 listening、结果回来切 parsing

  • 真实的鸿蒙 Flutter 项目里,语音输入一定是状态能力,不是单次函数调用

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐