适合谁看
第一次在鸿蒙 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.json5的requestPermissions数组里声明:
{ "name": "ohos.permission.MICROPHONE", "reason": "$string:mic_reason", "usedScene": { "abilities": ["EntryAbility"], "when": "inuse" } }每个字段的含义:
字段 | 值 | 含义 |
|---|---|---|
|
| 鸿蒙麦克风权限标识 |
|
| 权限用途说明,指向资源文件中的字符串 |
|
| 哪个 Ability 会使用这个权限 |
|
| 权限生效时机:仅使用期间(不是始终) |
这一层负责告诉鸿蒙系统:应用有使用麦克风的合法需求,而且只在特定页面、使用期间才需要。
为什么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; } }逐行解析:
abilityAccessCtrl.createAtManager()— 创建鸿蒙权限管理器permissions: Permissions[]— 要申请的权限列表,这里是麦克风getContext(this)— 获取当前插件的上下文,鸿蒙的权限 API 必须传入 Ability 上下文requestPermissionsFromUser(context, permissions)— 弹出系统权限对话框,等用户选择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.json5—ohos.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 |
|---|---|---|
工程层声明 |
|
|
运行期申请 |
|
|
用途文案 | 必须配 | 可选,不影响弹窗 |
生效时机 |
|
|
权限管理器 |
|
|
可以看到鸿蒙的权限模型整体比 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,根本写不了
拒绝权限后仍然继续创建识别引擎— 引擎在无权限状态下创建会失败或行为异常,浪费时间又增加错误处理复杂度
权限被拒时没清理
pendingResult—pendingResult保留了悬挂的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"更适合语音输入场景,用户更容易接受