欢迎加入 CPF-Flutter 鸿蒙社区:https://atomgit.com/CPF-Flutter

perfect_volume_control 做鸿蒙适配,理由很实际:它只有三个 Dart 方法和一个回调,依赖里除了 Flutter SDK 什么都没有,代码量小到能一眼看完。这种库最适合用来把一整条适配流程走通——从查重、读上游、定 API,到真机跑起来。

先说结论:适配本身不难,难的是鸿蒙侧的音频音量接口在最近几个 API 版本里动过。官方文档里推荐的替代方案,对 Flutter 插件来说根本用不了。这一点值得单独拿出来讲。

适配后的仓库:https://atomgit.com/oh-flutter/perfect_volume_control

在这里插入图片描述


一、先看清上游给了什么契约

鸿蒙化的第一件事不是写 ArkTS,而是把上游的平台通道契约读明白。Dart 层的 API 一个字都不能改,原生侧只能照着契约实现。

lib/perfect_volume_control.dart 里的全部内容:

class PerfectVolumeControl {
  static const MethodChannel _channel =
      const MethodChannel('perfect_volume_control');

  static StreamController<double> _streamController =
      StreamController.broadcast();

  static const String _volumeChangeListenerName = "volumeChangeListener";

  static Future<dynamic> _methodCallHandler(call) async {
    if (call.method == _volumeChangeListenerName) {
      double volume = call.arguments;
      _streamController.add(volume);
    }
  }

  static Stream<double> get stream {
    _channel.setMethodCallHandler(_methodCallHandler);
    return _streamController.stream;
  }

  static set hideUI(bool hide) {
    _channel.invokeMethod('hideUI', {"hide": hide});
  }

  static Future<double> get volume => getVolume();

  static Future<double> getVolume() async {
    return await _channel.invokeMethod('getVolume');
  }

  static Future<void> setVolume(double volume) async {
    assert(volume >= 0 && volume <= 1);
    return await _channel.invokeMethod('setVolume', {"volume": volume});
  }
}

提取出来的契约有四条:

Dart 侧通道方向参数
getVolume()Dart → 原生无,返回 double
setVolume(double)Dart → 原生{"volume": double},取值 0.0–1.0
hideUI = boolDart → 原生{"hide": bool}
stream原生 → Dart回调方法名 volumeChangeListener,参数为归一化音量

注意最后一条。这个库没有用 EventChannel,而是走 MethodChannel 的反向调用——原生侧主动 invokeMethod 回来。这是适配时最容易做错的地方:看到别的音量插件用 EventChannel,顺手也写成 EventChannel,Dart 侧就永远收不到回调。

再看 Android 实现,它把语义钉死了:用的是 STREAM_MUSIC(媒体流,不是铃声也不是通话),读取时用 当前档位 / 最大档位 做归一化,监听听的是 android.media.VOLUME_CHANGED_ACTION 广播。

int max = audioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC);
int current = audioManager.getStreamVolume(AudioManager.STREAM_MUSIC);
result.success((double) current / (double) max);

所以鸿蒙侧要做的事情很清楚:找到媒体流的音量读写接口,找到一个系统级音量变化事件,然后按上面的契约把值送回去。

二、动手前先查重

这一步不能省。我用的适配清单(待适配列表)数据抓取于 2026-09-05,而适配是持续在发生的,清单有一周左右的滞后。

这次查重里就踩到了实例:清单 Top 500 里 volume_listener(第 485 位)和 is_lock_screen(第 449 位)都标注着"需要适配",但实际到 AtomGit 上一查——

✅ 存在  oh-flutter/volume_listener    ohos/=✓
✅ 存在  oh-flutter/is_lock_screen     ohos/=✓

两个都已经适配完了。要是照着清单直接开工,白做。

查重有个技术细节:不能拿仓库页面的 HTTP 状态码判断仓库是否存在。AtomGit 前端是 SPA 路由,任何不存在的仓库地址也会返回 200。必须走 API:

curl -s "https://atomgit.com/api/v5/repos/oh-flutter/库名/contents"
# 返回 JSON 数组 → 仓库存在
# 返回 error_code → 不存在

还有一点,CPF-Flutter 组织下的仓库命名是 fluttertpc_库名 而不是 flutter_库名,两个都要试。

perfect_volume_control 的查重结果是干净的:CPF-Flutter / oh-flutter / hxa-flutter 三个组织、7 种命名变体都不存在,AtomGit 和 Gitee 全站搜索 0 条,上游仓库本身也没有 ohos 目录或鸿蒙分支。

三、鸿蒙侧的 API 选型:三个真实的坑

这一步花了最多时间,因为官方文档给的路径对 Flutter 插件走不通。

坑一:AudioVolumeGroupManager 已经没有 setVolume

按文档最直觉的写法是拿到 AudioVolumeGroupManager,然后 getVolume / setVolume。但在 API 26 的 @ohos.multimedia.audio.d.ts 里逐条核对后会发现:

L4488  getVolume(volumeType, callback)     [deprecated 20 → AudioVolumeManager#getVolumeByStream]
L4505  getVolume(volumeType): Promise      [deprecated 20 → AudioVolumeManager#getVolumeByStream]
L4525  getVolumeSync(volumeType): number   [deprecated 20 → AudioVolumeManager#getVolumeByStream]
L4581  getMaxVolume(...)                   [deprecated 20 → AudioVolumeManager#getMaxVolumeByStream]

读取接口全部自 API 20 标记废弃,而且整个接口里找不到 setVolume。写入这一半直接空缺。

坑二:官方推荐的替代方案,插件用不了

AudioManager.setVolume 呢?它的注解是:

L1934  setVolume(volumeType, volume, callback)  [deprecated 9 → ohos.multimedia.avVolumePanel.AVVolumePanel]

自 API 9 就废弃了,官方建议改用 AVVolumePanel。去看这个替代品是什么:

@Component
export declare struct AVVolumePanel {
    @Prop volumeLevel?: number;
    @Prop volumeParameter?: AVVolumePanelParameter;
}

是个 ArkUI 的 @Component 组件——界面上放一个音量条让用户拖。对纯 UI 应用没问题,但 Flutter 插件需要的是程序化设置系统音量,没有界面上下文,这个组件用不上。

所以结论是:AudioManager.setVolume 虽然挂着 deprecated 标记,但它是当前 SDK 里唯一可用的程序化写入接口。标注废弃不等于不能调用。

坑三:监听事件也换了名字

L4334  on('volumeChange', callback: Callback<VolumeEvent>)       [deprecated 20 → #event:streamVolumeChange]
L4442  on('streamVolumeChange', streamUsage, callback: Callback<StreamVolumeEvent>)

旧事件 volumeChange 自 API 20 废弃。新事件 streamVolumeChange 需要显式传一个 StreamUsage 来指定订阅哪条流——正好,我们要的就是媒体流。

新接口还有个好处:getVolumeByStream / getMaxVolumeByStream 都是同步返回的,不用等 Promise。

最终方案

读取和监听走 API 20 的新接口,写入用那个"废弃但仍可用"的 AudioManager.setVolume

private getNormalizedVolume(): number {
  const max: number = this.getMaxLevel();
  if (max <= 0) {
    throw new Error('Invalid max volume value');
  }
  return this.getCurrentLevel() / max;
}

private getMaxLevel(): number {
  const volumeManager = this.requireVolumeManager();
  try {
    const max: number = volumeManager.getMaxVolumeByStream(MEDIA_STREAM_USAGE);
    if (typeof max === 'number' && max > 0) {
      return max;
    }
  } catch (_) {
    // 低版本系统不支持流式接口,走下面的回退路径。
  }
  return volumeManager.getVolumeGroupManagerSync(audio.DEFAULT_VOLUME_GROUP_ID)
    .getMaxVolumeSync(MEDIA_VOLUME_TYPE);
}

这里有个当初没预料到的问题:flutter create --platforms ohos 生成的工程默认 compatibleSdkVersion5.1.0(18)。如果只用 API 20 的新接口,这个声明就是假的——在 API 18/19 的设备上会直接运行时报错。

所以每个读写路径都做成了新接口优先、旧接口回退的结构。上面 getMaxLevel 里的 try/catch 就是这个用途。这样既保留了现代实现,又不破坏工程声明的兼容范围。

四、生命周期:两个容易漏的地方

引擎分离时必须注销系统订阅

onDetachedFromEngine(binding: FlutterPluginBinding): void {
  // 必须先注销系统订阅,否则引擎反复挂载会累积系统级监听器。
  this.stopListening();
  this.channel?.setMethodCallHandler(null);
  this.channel = null;
  this.audioManager = null;
  this.volumeManager = null;
}

音量变化监听是挂在系统音频服务上的。不注销的话,热重载几次就会积下多个监听器,每个都往 Dart 侧发一遍回调。

用代次号丢弃陈旧回调

有个更隐蔽的情况:停止订阅、重新订阅、或者引擎销毁之前,可能已经有事件排在了队列里。等它执行时,订阅关系早就变了。

处理办法是给每次订阅打一个递增的代次号,回调里先比对:

private generation: number = 0;

private isStale(generation: number): boolean {
  return !this.listening || generation !== this.generation;
}
const generation = ++this.generation;
const streamCallback = (event: audio.StreamVolumeEvent): void => {
  if (!this.isStale(generation) && event.streamUsage === MEDIA_STREAM_USAGE) {
    this.notifyVolumeChanged();
  }
};
volumeManager.on('streamVolumeChange', MEDIA_STREAM_USAGE, streamCallback);

stopListening() 里会 this.generation++,排队中的旧回调代次对不上,直接被丢掉,不会打到已经分离的引擎上。

一处刻意的"不直接用事件值"

回调里我没有直接使用事件对象带的 event.volume

private notifyVolumeChanged(): void {
  try {
    this.channel?.invokeMethod(VOLUME_CHANGE_LISTENER_NAME, this.getNormalizedVolume());
  } catch (_) {
    // 读取失败时跳过本次事件,不中断订阅。
  }
}

原因很直接:StreamVolumeEvent.volume 的官方注释只写了"Volume.",没说是档位还是百分比。既然单位不明,就干脆重读一次当前档位再归一化——getVolumeByStream 是同步的,多一次调用换来确定性,划算。

五、pubspec 只加一个条目

pubspec.yaml 的改动就这么多,Dart 层完全没有触碰:

  plugin:
    platforms:
      android:
        package: top.huic.perfect_volume_control.perfect_volume_control
        pluginClass: PerfectVolumeControlPlugin
      ios:
        pluginClass: PerfectVolumeControlPlugin
      ohos:
        pluginClass: PerfectVolumeControlPlugin

这里顺带确认过一件事:pluginClass 是必须的,package 在 OHOS 侧可选。Flutter 工具生成的注册器用的是插件名而不是包名:

import PerfectVolumeControlPlugin from 'perfect_volume_control';

所以 ohos/index.ets 只要保证 default export 的类名和 pluginClass 一致就行。

六、踩坑记录

ABI 不匹配,装不上

第一次构建完,flutter build hap --debug 一切正常,但安装直接失败:

error: failed to install bundle. code:9568347
error: install parse native so failed.
In the module named entry, the Abi type supported by the device does not
match the Abi type configured in the C++ project.

原因是 flutter build haptarget-platform 默认值是 ohos-arm64,而我用的模拟器是 x64 架构。加参数指定就行:

flutter build hap --debug --target-platform ohos-x64

flutter run 会自动按设备架构选择,所以这个问题只在手动 build hap 时出现。

模拟器锁屏导致启动失败

Error Code:10106102
Error Message: The device screen is locked during the application launch,
unlock screen failed.
Error cause: The current mode is developer mode, and the screen cannot be
unlocked automatically

模拟器空闲一段时间会锁屏,开发者模式下又不会自动解锁,应用就起不来。先唤醒再启动:

hdc shell power-shell wakeup

音量是离散档位

适配完成后第一次测 setVolume(0.3),回读得到的是 0.3333 而不是 0.3。看着像 bug,其实是正常的:

round(0.3 × 15) = 5      系统共 15 档
5 / 15 = 0.3333

系统音量不是连续的,只能落在整数档位上。Android 侧行为一致,所以这不属于适配缺陷,但文档里应该写清楚,不然使用者会以为是精度问题。

七、真机验证

验证在 HarmonyOS 7.0.0(26.0.0) 的 API 26 模拟器上完成,逐项对照上游的四个能力。

读取音量:点击示例里的 getVolume 按钮,界面显示 0.4667,对应 15 档系统中的第 7 档。

在这里插入图片描述

设置音量:点击「设为 0.7」后,日志确认写入成功,监听回调同步收到新值。

[PVC] setVolume(0.7) done
[PVC] stream volume=0.7333333333333333

0.7333 就是 11/15,同样是被档位量化后的结果。

在这里插入图片描述

外部音量变化:这一项最关键——它验证的是系统事件订阅是否真的生效,而不是只能收到自己调用产生的回声。

先用应用把音量设到 0.7,再通过模拟器的硬件音量键减两次:

[PVC] setVolume(0.7) done
[PVC] stream volume=0.7333333333333333    ← 应用内设置
[PVC] stream volume=0.6666666666666666    ← 外部音量减,10/15
[PVC] stream volume=0.6                   ← 再减一次,9/15

每次按键精确减少 1/15,说明 streamVolumeChange 订阅的是系统级事件,并且归一化计算正确。

在这里插入图片描述

hideUI:接口调用正常,状态正确回传。但见下面的已知限制。

在这里插入图片描述

八、已知限制

hideUI 在鸿蒙上不生效,这一点必须说清楚,不能让使用者误以为设了就有用。

Android 那边靠 FLAG_SHOW_UI / FLAG_REMOVE_SOUND_AND_VIBRATE 控制"设置音量时要不要弹系统音量条"。鸿蒙没有对应的程序化开关,官方建议的 AVVolumePanel 又是 ArkUI 组件(前面说过)。所以适配里 hideUI 只做了一件事:把状态存下来,保证 Dart API 契约完整。

setVolume 使用已废弃接口这件事也需要在文档里注明原因,否则代码审查时会被当成疏忽。

这两条都写进了仓库的 README.OpenHarmony_CN.md,接口说明表格里 hideUI 那一行的「OpenHarmony平台支持」列如实填了「否」。

小结

这个库的逻辑量很小,但把几件事串起来了:平台通道契约的读法、鸿蒙音频接口的版本变迁、新旧 API 的双路径兼容、TypeScript 事件订阅的生命周期治理,以及真机验证该验到什么程度。

如果只按官方文档顺着写,会在 AVVolumePanel 那个坑上卡住——文档告诉你 setVolume 该换成它,但它是个界面组件。这类"文档路径对当前场景不成立"的情况,只能靠把 .d.ts 从头翻一遍才能发现。

适配后的仓库和完整文档在这里:

https://atomgit.com/oh-flutter/perfect_volume_control

dependencies:
  perfect_volume_control:
    git:
      url: https://atomgit.com/oh-flutter/perfect_volume_control.git
      ref: 1.0.6-ohos-1.0.0-beta.1

欢迎加入 CPF-Flutter 鸿蒙社区:https://atomgit.com/CPF-Flutter

Flutter 三方库鸿蒙适配清单:https://atomgit.com/oh-flutter/flutter-ohos-adaptation-checklist

Logo

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

更多推荐