适合谁看

  • 第一次在鸿蒙 Flutter 项目里接语音能力的人

  • module.json5 声明和运行期权限申请的关系还不清楚的人

  • 想把鸿蒙权限逻辑收进原生插件的人

  • 想理解权限被拒后错误怎么传到 Flutter 页面的人

问题背景

麦克风权限最常见的误区有三个:

  • 误区一:声明了就等于已经授权module.json5 里写了 ohos.permission.MICROPHONE 只是"声明资格",不等于"拿到授权"

  • 误区二:在 Flutter 侧弹一个权限框就够了 — Flutter 没有鸿蒙权限的 API,权限申请必须在 ArkTS 层完成

  • 误区三:权限用途文案不重要 — 鸿蒙系统要求敏感权限必须配 reason 字段,否则系统可能直接拒绝弹窗

这三个理解放到鸿蒙 Flutter 项目里都不够准确。鸿蒙的权限模型比 Android 更严格:声明 + 运行期申请 + 用途文案三者缺一不可。

项目中的真实场景

食界探味的语音识别服务于 AI 探味助手,用户"按住说话"时需要麦克风权限。整个权限处理分布在四个文件里:

app/ohos/entry/src/main/module.json5                                    ← 工程层声明
app/ohos/entry/src/main/resources/base/element/string.json              ← 英文用途文案
app/ohos/entry/src/main/resources/zh_CN/element/string.json             ← 中文用途文案
app/ohos/entry/src/main/ets/plugins/SpeechRecognitionPlugin.ets         ← 运行期申请

这说明权限不是某一个文件的事,而是工程配置、资源文案和插件逻辑三者共同完成

权限链路全景

用户按下"按住说话"
      │
      ▼
Flutter: SpeechRecognitionChannel.startListening()
      │
      ▼ (MethodChannel)
鸿蒙插件: handleStartListening()
      │
      ├──▶ requestMicrophonePermission()    ← 运行期申请
      │         │
      │         ├── 检查 module.json5 是否声明  ← 工程层声明
      │         ├── 检查 reason 文案是否存在    ← 资源层文案
      │         └── requestPermissionsFromUser ← 弹窗问用户
      │                    │
      │                    ├── 授权 ──▶ 继续创建引擎
      │                    └── 拒绝 ──▶ result.error('PERMISSION_DENIED')
      │                                    │
      ▼                                    ▼
Flutter 收到 Future<String>         Flutter 收到异常
      │                                    │
      ▼                                    ▼
coordinator.submitQuery(text)        coordinator 显示"语音识别出错,请手动输入"

核心实现

一、工程层声明权限

module.json5requestPermissions 数组里声明:

{
  "name": "ohos.permission.MICROPHONE",
  "reason": "$string:mic_reason",
  "usedScene": {
    "abilities": ["EntryAbility"],
    "when": "inuse"
  }
}

每个字段的含义:

字段

含义

name

ohos.permission.MICROPHONE

鸿蒙麦克风权限标识

reason

$string:mic_reason

权限用途说明,指向资源文件中的字符串

usedScene.abilities

["EntryAbility"]

哪个 Ability 会使用这个权限

usedScene.when

inuse

权限生效时机:仅使用期间(不是始终)

这一层负责告诉鸿蒙系统:应用有使用麦克风的合法需求,而且只在特定页面、使用期间才需要

为什么 when 要用 inuse 而不是 always

  • inuse — 应用在前台使用时才需要麦克风,用户更容易接受

  • always — 包括后台也需要,鸿蒙审核更严格,普通语音输入不需要

二、资源层配置权限用途文案

鸿蒙要求敏感权限必须配 reason,这个 reason 指向的是资源文件里的字符串。食界探味在两个语言目录里都配了:

英文(resources/base/element/string.json):

{
  "name": "mic_reason",
  "value": "Used for speech recognition to convert your voice into text"
}

中文(resources/zh_CN/element/string.json):

{
  "name": "mic_reason",
  "value": "用于语音识别,将您的语音转换为文字"
}

这个文案会出现在鸿蒙系统弹出的权限请求对话框里。如果没配这个字符串,$string:mic_reason 解析不出来,鸿蒙系统可能直接拒绝弹窗——用户根本看不到"允许麦克风"的选项。

三、运行期真正申请权限

SpeechRecognitionPlugin.ets 里,真正弹窗申请权限的是 requestMicrophonePermission()

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

逐行解析:

  1. abilityAccessCtrl.createAtManager() — 创建鸿蒙权限管理器

  2. permissions: Permissions[] — 要申请的权限列表,这里是麦克风

  3. getContext(this) — 获取当前插件的上下文,鸿蒙的权限 API 必须传入 Ability 上下文

  4. requestPermissionsFromUser(context, permissions) — 弹出系统权限对话框,等用户选择

  5. grantResult.authResults.every(...) — 检查每个权限是否都被授权(every 是因为可能一次申请多个权限)

工程层声明是"资格预审",运行期申请才是"真正弹窗"。

四、权限被拒后如何中断

handleStartListening 里,权限申请是整个流程的第一步:

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) {
    // ...
  }
}

关键点:权限不通过时 return 掉,引擎根本不会被创建。这避免了在无权限状态下浪费系统资源。

五、权限逻辑留在鸿蒙原生层

食界探味没有把权限申请放在 Flutter 页面里,而是放在鸿蒙语音识别插件内部。这么做的好处是:

  • 调用方不需要先写权限前置逻辑 — Flutter 侧 startListening() 一行代码搞定,不需要先 requestPermission()startListening()

  • 语音识别入口天然自带权限保护 — 任何地方调 startListening 都会经过权限检查,不存在"忘加权限"的情况

  • 后续换页面调用时不会遗漏 — 如果有第二个页面也需要语音输入,直接调 channel 就行,权限逻辑不会重复

关键代码位置

  • app/ohos/entry/src/main/module.json5ohos.permission.MICROPHONE 声明

  • app/ohos/entry/src/main/resources/base/element/string.json — 英文权限用途文案

  • app/ohos/entry/src/main/resources/zh_CN/element/string.json — 中文权限用途文案

  • app/ohos/entry/src/main/ets/plugins/SpeechRecognitionPlugin.ets — 运行期权限申请逻辑

  • app/lib/core/platform/speech_recognition_channel.dart — Flutter 侧 Channel 封装

  • app/lib/core/ai/ai_explore_coordinator.dart — Flutter 侧权限拒绝后的错误处理

鸿蒙侧实现

鸿蒙侧的权限处理建议固定成一条顺序:

1. module.json5 声明权限 + reason 文案
2. string.json 里写中英文权限用途说明
3. 插件 handleStartListening 里调 requestPermissionsFromUser
4. 拒绝时 result.error('PERMISSION_DENIED'),直接 return
5. 通过后才创建引擎、注册监听器、开始识别

这套顺序的核心思路是:权限是识别链路的守门人,不是可选步骤

鸿蒙权限 vs Android 权限

维度

鸿蒙

Android

工程层声明

module.json5requestPermissions

AndroidManifest.xml<uses-permission>

运行期申请

atManager.requestPermissionsFromUser()

ActivityCompat.requestPermissions()

用途文案

必须配 reason 字段,否则可能不弹窗

可选,不影响弹窗

生效时机

when: inuse / always

maxSdkVersion 等控制

权限管理器

abilityAccessCtrl.createAtManager()

ActivityCompat / ContextCompat

可以看到鸿蒙的权限模型整体比 Android 更严格,尤其是 reason 文案是硬性要求。

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 ?? '';
  }
}

Flutter 侧完全不需要感知鸿蒙权限的存在。如果权限被拒,鸿蒙插件会返回 error('PERMISSION_DENIED', ...),Flutter 侧的 invokeMethod 会抛出 PlatformException,由协调器统一捕获:

Future<void> startVoiceInput() async {
  if (!mounted) return;
  state = state.copyWith(
    status: AiSessionStatus.listening,
    errorMessage: null,
  );

  try {
    final text = await SpeechRecognitionChannel.startListening();
    // ... 正常流程
  } catch (e) {
    // 权限被拒、引擎创建失败、识别超时等异常都在这里统一兜底
    AppLogger.error('[AI助手] 语音识别出错: $e');
    if (!mounted) return;
    state = state.copyWith(
      status: AiSessionStatus.error,
      errorMessage: '语音识别出错,请手动输入',
    );
  }
}

不管鸿蒙侧返回的是 PERMISSION_DENIED 还是 ASR_ERROR,Flutter 协调器都统一处理为"语音识别出错,请手动输入",然后降级到文字输入。用户不需要知道具体是哪个环节出了问题,只需要知道"语音不行了,可以打字"。

这就是跨端权限设计的价值:鸿蒙侧管权限细节,Flutter 侧管用户体验

常见坑

  • module.json5 里声明了权限,但没有运行期申请 — 鸿蒙系统会直接拒绝麦克风访问,不弹窗,用户完全无感

  • 运行期申请写了,但 module.json5 没声明requestPermissionsFromUser 会直接报错,因为系统不知道你有这个权限资格

  • 配了 reason 指向 $string:mic_reason,但 string.json 里没有这个 key — 鸿蒙解析不到文案,系统可能拒绝弹窗

  • 只配了英文文案,没配 zh_CN 文案 — 中文用户的权限弹窗可能显示 key 名而不是说明文字

  • 运行期申请写在 Flutter 页面层 — 导致多个入口重复申请,而且 Flutter 没有鸿蒙权限 API,根本写不了

  • 拒绝权限后仍然继续创建识别引擎 — 引擎在无权限状态下创建会失败或行为异常,浪费时间又增加错误处理复杂度

  • 权限被拒时没清理 pendingResultpendingResult 保留了悬挂的 MethodResult,后续可能被重复调用

可复用模板

鸿蒙 module.json5 权限声明

"requestPermissions": [
  {
    "name": "ohos.permission.MICROPHONE",
    "reason": "$string:mic_reason",
    "usedScene": {
      "abilities": ["EntryAbility"],
      "when": "inuse"
    }
  }
]

鸿蒙 string.json 权限文案

// resources/base/element/string.json (英文/默认)
{
  "name": "mic_reason",
  "value": "Used for speech recognition to convert your voice into text"
}

// resources/zh_CN/element/string.json (中文)
{
  "name": "mic_reason",
  "value": "用于语音识别,将您的语音转换为文字"
}

鸿蒙运行期权限申请(ArkTS)

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

鸿蒙插件中权限拒绝后的中断模板

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;  // ← 直接中断,不创建引擎
  }

  // 权限通过后才继续...
  await this.createEngine();
  this.setupListener();
  this.startListening();
}

Flutter 侧权限异常的统一兜底

try {
  final text = await SpeechRecognitionChannel.startListening();
  // 正常流程...
} catch (e) {
  // 权限被拒、引擎失败等所有异常统一兜底
  state = state.copyWith(
    status: AiSessionStatus.error,
    errorMessage: '语音识别出错,请手动输入',
  );
}

本篇总结

  • 鸿蒙麦克风权限至少有三层module.json5 声明、string.json 用途文案、ArkTS 运行期申请,缺任何一层都可能导致权限流程断裂

  • 鸿蒙的权限模型比 Android 更严格:reason 文案是硬性要求,不配可能直接不弹窗

  • 权限逻辑应该收进鸿蒙原生插件内部(handleStartListening 的第一步),让 Flutter 侧完全不感知权限细节

  • 权限被拒后的错误通过 PlatformException 传到 Flutter,由协调器统一降级为"请手动输入"

  • when: "inuse""always" 更适合语音输入场景,用户更容易接受

Logo

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

更多推荐