前言

欢迎加入开源鸿蒙跨平台社区:https://openharmonycrossplatform.csdn.net

前面十几篇我们一直在讲原生端的实现,今天换个视角——从Dart层看flutter_speech是怎么被使用的。

示例应用(example/lib/main.dart)是插件的"门面",也是开发者第一次接触插件时看到的东西。一个好的示例应用应该做到:功能完整、代码清晰、交互友好。flutter_speech的示例App虽然只有188行代码,但覆盖了插件的所有功能——语言选择、语音识别、实时显示、停止取消、错误处理。

我在做OpenHarmony适配的时候,示例App也做了一些调整,主要是增加了平台判断逻辑——在OpenHarmony上限制语言选择,避免用户选了不支持的语言。

💡 本文对应源码example/lib/main.dart,完整188行。

一、example/lib/main.dart 完整代码解析

1.1 文件结构概览

// 导入
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_speech/flutter_speech.dart';

// 入口
void main() { ... }

// 语言数据
const languages = [ ... ];
class Language { ... }

// 主应用
class MyApp extends StatefulWidget { ... }
class _MyAppState extends State<MyApp> {
  // 状态变量
  // 生命周期方法
  // UI构建方法
  // 交互方法
  // 回调处理方法
}

1.2 应用入口

void main() {
  debugDefaultTargetPlatformOverride = TargetPlatform.fuchsia;
  runApp(MyApp());
}

debugDefaultTargetPlatformOverride = TargetPlatform.fuchsia这行代码是Flutter-OHOS的一个约定——OpenHarmony在Flutter中被映射为Fuchsia平台。设置这个覆盖后,Flutter的Material组件会使用Fuchsia的默认样式。

📌 为什么是Fuchsia:Flutter最初设计时就预留了Fuchsia平台的支持。OpenHarmony的Flutter适配复用了这个平台标识,避免了修改Flutter框架核心代码。

1.3 语言数据定义

const languages = const [
  const Language('中文', 'zh_CN'),
  const Language('English', 'en_US'),
  const Language('Francais', 'fr_FR'),
  const Language('Pусский', 'ru_RU'),
  const Language('Italiano', 'it_IT'),
  const Language('Español', 'es_ES'),
];

class Language {
  final String name;
  final String code;
  const Language(this.name, this.code);
}

6种语言,每种语言有显示名称和locale代码。Language类非常简单——两个final字段加一个const构造函数。

语言 显示名称 Locale代码 OpenHarmony支持
中文 中文 zh_CN
英文 English en_US
法文 Francais fr_FR
俄文 Pусский ru_RU
意大利文 Italiano it_IT
西班牙文 Español es_ES

二、语言选择器 UI 实现与 PopupMenuButton

2.1 PopupMenuButton

语言选择器放在AppBar的actions中:

AppBar(
  title: Text('SpeechRecognition'),
  actions: [
    PopupMenuButton<Language>(
      onSelected: _selectLangHandler,
      itemBuilder: (BuildContext context) => _buildLanguagesWidgets,
    )
  ],
),

点击后会弹出一个下拉菜单,显示所有可选语言。

2.2 菜单项构建

List<CheckedPopupMenuItem<Language>> get _buildLanguagesWidgets => languages
    .map((l) => CheckedPopupMenuItem<Language>(
          value: l,
          checked: selectedLang == l,
          child: Text(l.name),
        ))
    .toList();

使用CheckedPopupMenuItem,当前选中的语言会显示一个勾选标记。这是一个很好的UX细节——用户一眼就能看到当前选的是哪种语言。

2.3 语言选择处理

void _selectLangHandler(Language lang) {
  if (Platform.isOhos &&
      !lang.code.startsWith('zh')) {
    _messengerKey.currentState?.showSnackBar(
      const SnackBar(content: Text('当前设备仅支持中文语音识别')),
    );
    return;
  }
  setState(() => selectedLang = lang);
}

这里有一个平台判断:如果是OpenHarmony设备,且用户选择了非中文语言,会弹出SnackBar提示"当前设备仅支持中文语音识别",并且不会切换语言。

💡 用户体验考量:与其让用户选了不支持的语言后在activate时报错,不如在选择时就拦截。这种"前置拦截"的体验更好——用户立刻就知道为什么不能选,不需要等到点击"开始识别"才发现问题。

2.4 Platform.isOhos

if (Platform.isOhos && !lang.code.startsWith('zh')) {

Platform.isOhos是Flutter-OHOS SDK提供的平台判断属性。在标准Flutter SDK中没有这个属性,所以这段代码只在Flutter-OHOS环境下才能编译。

平台判断 属性 说明
Android Platform.isAndroid 标准Flutter
iOS Platform.isIOS 标准Flutter
macOS Platform.isMacOS 标准Flutter
OpenHarmony Platform.isOhos Flutter-OHOS扩展

三、语音识别状态管理:Available、Listening、Result

3.1 状态变量

class _MyAppState extends State<MyApp> {
  late SpeechRecognition _speech;

  bool _speechRecognitionAvailable = false;  // 引擎是否可用
  bool _isListening = false;                  // 是否正在监听
  String transcription = '';                   // 识别结果文本
  Language selectedLang = languages.first;     // 当前选择的语言
}

四个状态变量,各有职责:

变量 类型 初始值 控制什么
_speechRecognitionAvailable bool false Listen按钮是否可点击
_isListening bool false 按钮状态切换、动画显示
transcription String ‘’ 显示区域的文本内容
selectedLang Language 中文 语言选择器的当前值

3.2 状态转换

初始状态:
  _speechRecognitionAvailable = false
  _isListening = false
  transcription = ''

activate成功后:
  _speechRecognitionAvailable = true  ← 引擎就绪

listen后:
  _isListening = true                 ← 开始监听

识别过程中:
  transcription = "你好"              ← 实时更新
  transcription = "你好世界"

识别完成:
  _isListening = false                ← 停止监听
  transcription = "你好世界"          ← 最终结果

错误发生:
  _speechRecognitionAvailable = false ← 引擎不可用
  _isListening = false
  → 自动重新activate

3.3 状态与UI的映射

// Listen按钮:引擎可用且未在监听时可点击
_buildButton(
  onPressed: _speechRecognitionAvailable && !_isListening
      ? () => start()
      : null,
  label: _isListening ? 'Listening...' : 'Listen (${selectedLang.code})',
),

// Cancel按钮:正在监听时可点击
_buildButton(
  onPressed: _isListening ? () => cancel() : null,
  label: 'Cancel',
),

// Stop按钮:正在监听时可点击
_buildButton(
  onPressed: _isListening ? () => stop() : null,
  label: 'Stop',
),
状态 Listen按钮 Cancel按钮 Stop按钮
未初始化 灰色 灰色 灰色
引擎就绪 可点击 灰色 灰色
正在监听 灰色(显示"Listening…") 可点击 可点击
识别完成 可点击 灰色 灰色

四、Platform.isOhos 平台判断与功能限制提示

4.1 使用场景

示例App中Platform.isOhos只在一个地方使用——语言选择处理:

void _selectLangHandler(Language lang) {
  if (Platform.isOhos && !lang.code.startsWith('zh')) {
    _messengerKey.currentState?.showSnackBar(
      const SnackBar(content: Text('当前设备仅支持中文语音识别')),
    );
    return;
  }
  setState(() => selectedLang = lang);
}

4.2 为什么不在其他地方也做平台判断

你可能会想:为什么不在start方法中也加平台判断?

因为原生端已经做了语言校验。即使Dart层没有拦截,原生端的isSupportedLocale也会返回错误。Dart层的拦截只是为了更好的用户体验——提前告诉用户,而不是等到调用失败。

Dart层拦截(UX优化):
  用户选择English → SnackBar提示 → 不切换语言

原生端拦截(兜底保护):
  用户调用activate("en_US") → ERROR_LANGUAGE_NOT_SUPPORTED → catchError

两层拦截,双重保险。

4.3 SnackBar提示

_messengerKey.currentState?.showSnackBar(
  const SnackBar(content: Text('当前设备仅支持中文语音识别')),
);

使用ScaffoldMessengerStateshowSnackBar方法,在屏幕底部显示一个临时提示。这比弹窗(AlertDialog)更轻量,不会打断用户的操作流程。

📌 注意:这里用的是_messengerKey.currentState而不是ScaffoldMessenger.of(context),因为_selectLangHandler的调用上下文可能不在Scaffold内部。通过GlobalKey可以在任何地方访问ScaffoldMessenger。

五、按钮交互逻辑:Listen、Cancel、Stop

5.1 start方法

void start() => _speech.activate(selectedLang.code).then((_) {
      return _speech.listen().then((result) {
        print('_MyAppState.start => result $result');
        setState(() {
          _isListening = result;
        });
      });
    });

start方法做了两件事:

  1. activate:用当前选择的语言激活引擎
  2. listen:开始监听

注意这是一个链式调用——先activate,成功后再listen。如果activate失败(比如语言不支持),listen不会被调用。

🤔 设计思考:每次start都重新activate,这意味着每次识别都会重新申请权限和创建引擎。对于频繁使用的场景,可以优化为只在第一次或切换语言时activate。但对于示例App来说,这种简单的实现足够了。

5.2 cancel方法

void cancel() =>
    _speech.cancel().then((_) => setState(() => _isListening = false));

调用_speech.cancel()取消识别,然后将_isListening设为false

5.3 stop方法

void stop() => _speech.stop().then((_) {
      setState(() => _isListening = false);
    });

调用_speech.stop()停止识别,然后将_isListening设为false

5.4 cancel和stop的区别(从UI角度)

操作 用户看到的效果
Cancel 按钮恢复,识别文本保持为部分结果(不会更新为最终结果)
Stop 按钮恢复,识别文本更新为最终结果(通过onRecognitionComplete回调)

5.5 按钮构建

Widget _buildButton({required String label, VoidCallback? onPressed}) => Padding(
    padding: EdgeInsets.all(12.0),
    child: ElevatedButton(
      onPressed: onPressed,
      child: Text(
        label,
        style: const TextStyle(color: Colors.white),
      ),
    ));

统一的按钮构建方法,接受labelonPressed两个参数。当onPressednull时,按钮自动变为灰色不可点击状态。

六、回调处理方法详解

6.1 activateSpeechRecognizer

void activateSpeechRecognizer() {
  print('_MyAppState.activateSpeechRecognizer... ');
  _speech = SpeechRecognition();
  _speech.setAvailabilityHandler(onSpeechAvailability);
  _speech.setRecognitionStartedHandler(onRecognitionStarted);
  _speech.setRecognitionResultHandler(onRecognitionResult);
  _speech.setRecognitionCompleteHandler(onRecognitionComplete);
  _speech.setErrorHandler(errorHandler);
  _speech.activate('zh_CN').then((res) {
    setState(() => _speechRecognitionAvailable = res);
  }).catchError((e) {
    print('_MyAppState.activateSpeechRecognizer error: $e');
    setState(() => _speechRecognitionAvailable = false);
  });
}

这是初始化方法,做了以下事情:

  1. 创建SpeechRecognition单例
  2. 设置5个回调处理器
  3. 用中文激活引擎
  4. 根据结果更新UI状态

⚠️ 回调必须在activate之前设置。因为SpeechRecognition的回调属性是late声明的,如果在回调触发时还没设置,会抛出LateInitializationError

6.2 五个回调方法

// 1. 引擎可用性变化
void onSpeechAvailability(bool result) =>
    setState(() => _speechRecognitionAvailable = result);

// 2. 识别开始
void onRecognitionStarted() {
  setState(() => _isListening = true);
}

// 3. 识别结果(实时)
void onRecognitionResult(String text) {
  print('_MyAppState.onRecognitionResult... $text');
  setState(() => transcription = text);
}

// 4. 识别完成
void onRecognitionComplete(String text) {
  print('_MyAppState.onRecognitionComplete... $text');
  setState(() => _isListening = false);
}

// 5. 错误处理
void errorHandler() => activateSpeechRecognizer();
回调 触发时机 状态更新 UI效果
onSpeechAvailability activate成功/失败 _speechRecognitionAvailable Listen按钮可用性
onRecognitionStarted 引擎开始采集音频 _isListening = true 显示"Listening…"
onRecognitionResult 收到部分/最终结果 transcription = text 文本区域实时更新
onRecognitionComplete 识别结束 _isListening = false 按钮恢复
errorHandler 发生错误 重新初始化 自动恢复

6.3 errorHandler的自动恢复策略

void errorHandler() => activateSpeechRecognizer();

错误处理的策略非常简单粗暴——重新初始化。调用activateSpeechRecognizer()会重新创建SpeechRecognition实例、设置回调、重新activate。

这种策略的优缺点:

优点 缺点
实现简单 可能导致无限重试(如果错误持续发生)
能恢复大多数临时错误 用户体验不够好(没有错误提示)
不需要区分错误类型 每次都重新申请权限(虽然已授权不会弹窗)

🤦 潜在问题:如果错误持续发生(比如设备不支持语音识别),errorHandler会不断调用activateSpeechRecognizer,形成无限循环。实际使用中应该加一个重试次数限制。

七、UI布局分析

7.1 整体布局

┌──────────────────────────┐
│  AppBar: SpeechRecognition  [≡]  ← 语言选择菜单
├──────────────────────────┤
│                          │
│  ┌────────────────────┐  │
│  │                    │  │
│  │  识别结果文本区域    │  │  ← Expanded,占据剩余空间
│  │  (灰色背景)         │  │
│  │                    │  │
│  └────────────────────┘  │
│                          │
│  ┌────────────────────┐  │
│  │  Listen (zh_CN)    │  │  ← 开始按钮
│  └────────────────────┘  │
│                          │
│  ┌────────────────────┐  │
│  │  Cancel            │  │  ← 取消按钮
│  └────────────────────┘  │
│                          │
│  ┌────────────────────┐  │
│  │  Stop              │  │  ← 停止按钮
│  └────────────────────┘  │
│                          │
└──────────────────────────┘

7.2 文本显示区域

Expanded(
    child: Container(
        padding: const EdgeInsets.all(8.0),
        color: Colors.grey.shade200,
        child: Text(transcription))),
  • Expanded让文本区域占据按钮之外的所有空间
  • 灰色背景区分文本区域和按钮区域
  • transcription变量绑定到Text组件,实时更新

7.3 按钮区域

三个按钮垂直排列,每个按钮有12px的padding:

_buildButton(
  onPressed: _speechRecognitionAvailable && !_isListening ? () => start() : null,
  label: _isListening ? 'Listening...' : 'Listen (${selectedLang.code})',
),
_buildButton(
  onPressed: _isListening ? () => cancel() : null,
  label: 'Cancel',
),
_buildButton(
  onPressed: _isListening ? () => stop() : null,
  label: 'Stop',
),

Listen按钮的label会动态变化:

  • 未监听时:Listen (zh_CN) — 显示当前语言
  • 监听中:Listening... — 提示正在监听

八、完整的用户交互流程

8.1 正常识别流程

1. App启动
   → activateSpeechRecognizer()
   → activate('zh_CN')
   → 权限弹窗(首次)→ 用户允许
   → 引擎创建成功
   → onSpeechAvailability(true)
   → Listen按钮变为可点击

2. 用户点击Listen
   → start()
   → activate(selectedLang.code)
   → listen()
   → onRecognitionStarted()
   → 按钮显示"Listening...",Cancel和Stop可点击

3. 用户说话
   → onRecognitionResult("你好")
   → 文本区域显示"你好"
   → onRecognitionResult("你好世界")
   → 文本区域更新为"你好世界"

4. 用户停止说话(VAD超时)
   → onRecognitionComplete("你好世界")
   → 按钮恢复,文本保持"你好世界"

8.2 用户手动Stop

(识别进行中)
用户点击Stop
   → stop()
   → _isListening = false → 按钮恢复
   → (稍后) onRecognitionComplete("你好世界")
   → 文本更新为最终结果

8.3 用户手动Cancel

(识别进行中)
用户点击Cancel
   → cancel()
   → _isListening = false → 按钮恢复
   → (不会收到onRecognitionComplete)
   → 文本保持为最后一次的部分结果

8.4 错误恢复

(识别过程中网络断开)
   → onError触发
   → errorHandler()
   → activateSpeechRecognizer() → 重新初始化
   → activate('zh_CN') → 重新激活
   → 恢复到就绪状态

九、示例App的改进建议

9.1 当前的不足

问题 影响 改进方向
errorHandler无限重试 可能死循环 加重试次数限制
没有loading状态 activate时UI无反馈 加CircularProgressIndicator
没有错误提示 用户不知道出了什么错 显示错误信息
每次start都activate 性能浪费 缓存引擎状态
文本区域无滚动 长文本显示不全 用SingleChildScrollView

9.2 加loading状态

// 改进示例
bool _isActivating = false;

void start() {
  setState(() => _isActivating = true);
  _speech.activate(selectedLang.code).then((_) {
    setState(() => _isActivating = false);
    return _speech.listen().then((result) {
      setState(() => _isListening = result);
    });
  }).catchError((e) {
    setState(() => _isActivating = false);
    // 显示错误
  });
}

9.3 加重试限制

int _retryCount = 0;
static const int _maxRetries = 3;

void errorHandler() {
  if (_retryCount < _maxRetries) {
    _retryCount++;
    activateSpeechRecognizer();
  } else {
    setState(() {
      _speechRecognitionAvailable = false;
    });
    // 显示"语音识别不可用"的提示
  }
}

这些改进不影响插件本身的功能,只是让示例App的用户体验更好。

总结

本文从Dart层的角度完整解析了flutter_speech的示例应用:

  1. 语言选择:PopupMenuButton + CheckedPopupMenuItem,OpenHarmony上限制只能选中文
  2. 状态管理:四个状态变量控制UI的所有状态变化
  3. Platform.isOhos:平台判断实现功能限制提示
  4. 交互逻辑:start(activate+listen)、cancel、stop三个操作
  5. 回调处理:五个回调方法分别处理可用性、开始、结果、完成、错误
  6. 错误恢复:errorHandler自动重新初始化

下一篇是本系列第1-20篇的收官之作——跨平台差异对比,我们将把Android、iOS、OpenHarmony三个平台的实现做一次全面的横向对比。

如果这篇文章对你有帮助,欢迎点赞👍、收藏⭐、关注🔔,你的支持是我持续创作的动力!


相关资源:

请添加图片描述

Logo

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

更多推荐