一、插件简介与适配目标

sound_mode 是一个"读写系统铃声模式"(normal / silent / vibrate)的 Flutter 插件。它的四个通道方法里藏着两种完全不同的权限世界:getRingerMode 读取模式,Android 上免权限;setNormalMode / setSilentMode / setVibrateMode 修改模式,Android 上需要勿扰访问(Do Not Disturb Access)授权,配套 getPermissionStatus 查询授权状态、openToDoNotDisturbSettings 跳转授权页。

适配鸿蒙前先把目标定准:

  1. 读取模式完整可用:鸿蒙的音频音量组管理器对三方应用开放读取,免权限,这是这个库在鸿蒙上价值最大的能力;
  2. 设置模式如实受限:鸿蒙上切换铃声模式依赖 ohos.permission.ACCESS_NOTIFICATION_POLICY,普通三方应用拿不到。适配版把它映射到上游同款 INVALID_PERMISSIONS 错误码——与 Android 未授权时的行为一致,不假成功;
  3. 权限配套如实降级:授权状态恒返回 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(如实降级)
openToDoNotDisturbSettingsvoid(跳转授权页)跳系统授权页静默返回 + 日志标记(无页可跳)

三个决定实现的契约细节:

第一,返回值是"模式字符串"不是枚举对象。 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。toModeStringAudioRingMode 枚举翻成契约字符串,逐字对齐 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

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐