Flutter 三方库 sound_mode 的鸿蒙化适配指南:免权限读取与受限写入的契约对齐
一、插件简介与适配目标
sound_mode 是一个"读写系统铃声模式"(normal / silent / vibrate)的 Flutter 插件。它的四个通道方法里藏着两种完全不同的权限世界:getRingerMode 读取模式,Android 上免权限;setNormalMode / setSilentMode / setVibrateMode 修改模式,Android 上需要勿扰访问(Do Not Disturb Access)授权,配套 getPermissionStatus 查询授权状态、openToDoNotDisturbSettings 跳转授权页。
适配鸿蒙前先把目标定准:
- 读取模式完整可用:鸿蒙的音频音量组管理器对三方应用开放读取,免权限,这是这个库在鸿蒙上价值最大的能力;
- 设置模式如实受限:鸿蒙上切换铃声模式依赖
ohos.permission.ACCESS_NOTIFICATION_POLICY,普通三方应用拿不到。适配版把它映射到上游同款INVALID_PERMISSIONS错误码——与 Android 未授权时的行为一致,不假成功; - 权限配套如实降级:授权状态恒返回
false(三方确实拿不到),授权页跳转静默返回(没有面向三方的授权页可跳),都不抛异常。
基线信息:上游 sound_mode 3.1.1(master @ 74e0e0b,上游已宣布停止维护,适配版后续在社区 fork 上演进),适配成果在 AtomGit oh-flutter 组织的 feat/ohos-adaptation 分支,验证环境为 Flutter 3.41.10-ohos-1.0.1、DevEco CLI 1.3.0、HarmonyOS 7.0.0(API 26)模拟器。
二、从源码仓库开始:保住上游历史
标准流程:clone 上游、切适配分支、补全 OHOS 目录:
git clone https://github.com/TryingOutSomething/sound_mode.git
cd sound_mode
git checkout -b feat/ohos-adaptation
flutter create --platforms ohos .
适配后的目录职责:
sound_mode/
├── lib/ # Dart 层:API 与平台接口(零改动)
├── ohos/ # 本次适配核心:ArkTS 平台实现
│ └── src/main/ets/components/plugin/SoundModePlugin.ets
├── example/ohos/ # OHOS 示例宿主
├── pubspec.yaml # 注册 ohos 平台的 pluginClass(channel 名也要对上)
└── README.OpenHarmony*.md # 双语鸿蒙使用说明(交付件)
这个插件有个容易被忽略的注册细节:通道名是契约的一部分。上游 Dart 侧写的是 Constants.METHOD_CHANNEL_NAME,值为一串不太常规的 method.channel.audio——ArkTS 侧必须用一模一样的字符串,不能"顺手规范化"成更常见的命名。通道名不匹配的表现是运行时 MissingPluginException,而注册文件看起来一切正常,排查成本远高于照抄。

三、Dart 接口与平台通道契约分析
契约表(通道名以 Constants.METHOD_CHANNEL_NAME 为准,值为 method.channel.audio):
| 通道方法 | Dart 侧期望返回 | Android 行为 | 鸿蒙方案 |
|---|---|---|---|
getRingerMode | 模式字符串(normal/silent/vibrate/unknown) | AudioManager 免权限读取 | getVolumeGroupManagerSync().getRingerModeSync() 免权限 |
setNormalMode / setSilentMode / setVibrateMode | 设置后的模式字符串 | 需勿扰授权,未授权抛异常 | setRingerMode(系统权限),失败映射 INVALID_PERMISSIONS |
getPermissionStatus | 授权布尔值 | 查询勿扰访问授权状态 | 恒返回 false(如实降级) |
openToDoNotDisturbSettings | void(跳转授权页) | 跳系统授权页 | 静默返回 + 日志标记(无页可跳) |
三个决定实现的契约细节:
第一,返回值是"模式字符串"不是枚举对象。 Dart 侧拿字符串去匹配 RingerModeStatus 枚举名,匹配不上就得到 unknown。ArkTS 侧返回的字符串必须与 Dart 枚举名逐字一致(normal / silent / vibrate,全小写)——多一个空格、大写一个字母,静默降级成 unknown,不报错,很难查。
第二,设置失败的错误形态。 上游 Dart 侧对设置方法的文档写明:未授权时抛 PlatformException。Android 未授权时抛的错误码正是 INVALID_PERMISSIONS。鸿蒙系统拒绝(错误码 201,权限拒绝)时要翻译成同一个错误码,让上游文档和业务侧既有的 catch 逻辑原样生效。
第三,MethodResult 恰好完成一次。 上游 Dart 用 await 等待结果——原生侧对同一个调用调了两次 result(先 error 后 success)会直接崩溃或行为未定义。这条约束决定了设置路径必须有"已结算"守卫(见 4.3)。
四、OHOS 原生实现:逐段解读 ArkTS 插件
4.1 读取路径:一次链式调用,免权限
private handleGetRingerMode(result: MethodResult): void {
try {
const audioManager = audio.getAudioManager();
const groupManager = audioManager.getVolumeManager()
.getVolumeGroupManagerSync(audio.DEFAULT_VOLUME_GROUP_ID);
result.success(this.toModeString(groupManager.getRingerModeSync()));
} catch (error) {
const err = error as BusinessError;
result.error('SERVICE_UNAVAILABLE', `Failed to read ringer mode: ${err.message}`, null);
}
}
这是鸿蒙音频 API 的正确打开方式:getAudioManager() → getVolumeManager() → getVolumeGroupManagerSync(DEFAULT_VOLUME_GROUP_ID) → getRingerModeSync()。三层套娃缺一层都编译不过(API 按"管理器域"分层组织,不像 Android 一个 AudioManager 全包)。同步版本(Sync 后缀)在通道方法里用起来最省心,避免再嵌一层 Promise。toModeString 把 AudioRingMode 枚举翻成契约字符串,逐字对齐 Dart 枚举名。
4.2 设置路径:弃用 API + 系统权限的现实
try {
// Deprecated API(当前唯一公开的 setRingerMode 入口),
// 且受 ACCESS_NOTIFICATION_POLICY 门控——三方应用拿不到。
const audioManager = audio.getAudioManager();
audioManager.setRingerMode(mode).then(() => {
// ...settled 守卫...
result.success(this.toModeString(mode));
}).catch((error: BusinessError) => {
// ...settled 守卫...
const err = error as BusinessError;
if (err.code === 201) {
result.error('INVALID_PERMISSIONS',
'Changing the ringer mode requires ohos.permission.ACCESS_NOTIFICATION_POLICY, ' +
'which is not grantable to ordinary third-party apps on OpenHarmony.', null);
} else {
result.error('SERVICE_UNAVAILABLE', `Failed to set ringer mode: ${err.message}`, null);
}
});
} catch (error) { /* ...同步异常也走 finishWithError... */ }
设计要点:错误码 201(权限拒绝)翻译成上游契约的 INVALID_PERMISSIONS,消息里写清楚根因与不可授权的事实;其他错误走 SERVICE_UNAVAILABLE。实测发现模拟器等管控宽松的设备上设置真实成功(设备确实进入静音且读回一致),严格管控的设备上则命中 201 → INVALID_PERMISSIONS——两种路径都是合法行为,README 如实说明。
4.3 恰好一次守卫:给"可能不回复的 Promise"上保险
setRingerMode 是弃用 API,实测中在部分音频 HAL 上 promise 永不 settle(既不 resolve 也不 reject)。如果放任不管,Dart 侧的 await 永远挂起,业务界面卡死。解法是竞速一个超时:
const timeoutMs: number = 3000;
let settled: boolean = false;
const finishWithError = (code: string, message: string): void => {
if (settled) { return; } // 已结算:丢弃迟到的结果
settled = true;
result.error(code, message, null);
};
const timerId: number = setTimeout(() => {
finishWithError('SERVICE_UNAVAILABLE', `setRingerMode did not respond within ${timeoutMs} ms.`);
}, timeoutMs);
settled 标志贯穿成功、失败、超时三条路径,保证 result 恰好完成一次;Promise 迟到回复时被守卫丢弃。这个模式是"对不可信的异步 API 做契约兜底"的通用模板,后来被沿用到铃声播放插件的播放流程里。
4.4 权限配套:诚实是唯一正确的实现
case 'getPermissionStatus':
// ACCESS_NOTIFICATION_POLICY 不对普通三方应用开放,授权状态恒为 false。
result.success(false);
break;
case 'openToDoNotDisturbSettings':
// 鸿蒙没有面向三方的勿扰授权页;静默返回并留日志标记。
hilog.info(0x0000, LOG_TAG,
'openToDoNotDisturbSettings: not applicable to third-party apps on OpenHarmony.');
result.success(null);
break;
不假成功、不抛异常、留日志说明——三个动作让上游"查权限 → 跳授权页 → 再设置"的标准流程在鸿蒙上自然走进"未授权"分支,业务代码零平台分支。

五、踩坑实录:自动化验证反咬一口的故事
坑 1:UI 自动化点击不可靠,差点误诊成 API 悬挂。验证设置路径时,用 devecocli ui click 反复点击示例的 “Set Silent mode” 按钮,界面毫无反应——一度诊断为 setRingerMode Promise 悬挂,并为此引入了 4.3 的超时保护。事后用代码级触发(临时在 example 的 initState 里直连调用)一次拿到结论:API 真实成功(SMTEST SET OK: RingerModeStatus.silent,UI 读回一致),之前的"无响应"是自动化点击根本没触发按钮 handler。教训有三层:① Flutter 页面上的按钮验证优先用代码级触发(initState/定时器注入),UI 自动化点击仅作辅助;② 诊断"调用无响应"时先怀疑自动化链路,再怀疑 API;③ 因误诊引入的超时守卫复核后符合"恰好一次"约束,保留成了正式代码——但要知道它是防御性设计,不是 API 缺陷的证据。
坑 2:Dart print 的日志通道不叫 Flutter。示例里的 print() 在 hilog 中以 XComFlutterOHOS_Native: flutter settings log message: ... 前缀出现,按框架名 grep 会漏掉。验证 Dart 侧行为时 grep 业务自带的标记串(本例是 settings log message / SMTEST)。
六、验证:编译通过不等于功能通过
三层验证:
第一层:Dart 检查。 flutter analyze 0 问题;上游无测试(flutter test 报 no tests found 属上游状态,非适配问题)。
第二层:HAP 构建。 构建通过,安装启动无 MissingPluginException,插件注册正常。
第三层:设备行为验证(HarmonyOS 7.0.0 模拟器)。 四条路径全走:
- 读取:默认状态返回
normal;系统面板切静音后读回silent,与系统状态栏一致; - 设置:代码级触发设置静音,设备真实进入静音模式,返回值与读回一致(
SET OK: RingerModeStatus.silent); - 权限查询:
getPermissionStatus返回false,无异常; - 授权页跳转:静默返回,日志出现"not applicable"标记,无异常。
七、FAQ:给正在做适配的你
Q1:模拟器上能设置成功,真机也一定行吗?
不一定。模拟器对权限 enforcement 宽松,真机可能严格管控——届时命中错误码 201,映射为 INVALID_PERMISSIONS。这正是设计目标:两种结果都在契约内,不会崩溃。真机回归时重点确认错误码而非崩溃。
Q2:为什么不用音频焦点/响铃振动等其他 API 绕权限?
铃声模式是系统级用户意图(用户在设置里定的状态),任何"绕权限模拟"都会造成插件状态与系统真实状态不一致。如实受限 + 错误码对齐是唯一不产生幻觉的方案。
Q3:弃用的 setRingerMode 还能用多久?
弃用 API 随时可能移除。适配版已把调用收敛在单一方法内并加了超时守卫,未来 API 移除时只需替换该处实现,错误码契约保持不变。
Q4:返回值匹配不上枚举,得到 unknown?
核对 ArkTS 侧 toModeString 的输出与 Dart 枚举名是否逐字一致(全小写、无空格)。字符串契约的失配是静默的,只能靠对比两侧源码发现。
Q5:发现适配问题如何提 issue?
到适配仓库提:https://atomgit.com/oh-flutter/sound_mode/issues 。附设备型号、API 版本、Flutter/DevEco 版本、SoundModePlugin 标签日志与复现步骤。
Q6:我能修,怎么提 PR?
Fork 后基于 feat/ohos-adaptation 修改,本地过 flutter analyze / 构建两关(上游无测试),读取与设置两条路径都要实测后发 PR。注意上游已停更,PR 落点是社区 fork。
八、总结
这次适配把"权限边界"变成了契约的一部分:能做的(读取)做到免权限开箱即用;不能做的(设置)把系统拒绝翻译成上游本就存在的 INVALID_PERMISSIONS 错误码,让业务侧用处理 Android 未授权的同一套逻辑自然降级;说不清的(权限状态)如实返回 false 并留日志。业务代码在整个过程中零平台分支。
两个可带走的通用经验:一是"恰好一次"守卫是给不可信异步 API 做契约兜底的通用模板(settled 标志 + 超时竞速 + 迟到结果丢弃);二是 UI 自动化点击验证 Flutter 按钮不可靠,代码级触发(initState 注入直连调用)才是首选验证手段——本次最大的误诊就来自对自动化链路的过度信任。
欢迎加入 Flutter 鸿蒙化社区(CPF-Flutter 组织):https://atomgit.com/CPF-Flutter
本文适配成果仓库:https://atomgit.com/oh-flutter/sound_mode
更多推荐



所有评论(0)