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

前一篇讲了 perfect_volume_control 的鸿蒙化适配。这一篇换个角度:库已经在 AtomGit 上了,怎么在一个真实页面里把它用好

我选的场景是播放器的音量面板。这个场景能暴露这类库的全部难点——它不是"调一个 API 设个音量"就完事,真正的麻烦在于双向同步:用户拖滑块要改系统音量,用户按音量键滑块也要跟着动。这两条链路一旦处理不干净,滑块就会自己抖。

完整示例工程:文末附依赖配置。

在这里插入图片描述


一、接入只有三步

依赖用 git + TAG 方式引入:

dependencies:
  perfect_volume_control:
    git:
      url: https://atomgit.com/oh-flutter/perfect_volume_control.git
      # ref: 根据下方表格选择不同框架适配的TAG版本
      ref: 1.0.6-ohos-1.0.0-beta.1
flutter pub get

这个库对外只有三样东西:

API用途
PerfectVolumeControl.getVolume()读取当前媒体音量,返回 0.0 - 1.0
PerfectVolumeControl.setVolume(double)设置媒体音量,入参 0.0 - 1.0
PerfectVolumeControl.stream监听音量变化

stream 是这里的重点。没有它,滑块就只是个单向控制器——用户按音量键时界面不会动,看起来像坏了。

二、滑块抖动的根因

先把最容易踩的坑说清楚,因为它决定了整个页面的写法。

直觉写法是这样:

// 反面示例:会抖
_slider.onChanged = (double value) {
  PerfectVolumeControl.setVolume(value);   // 写系统音量
};

PerfectVolumeControl.stream.listen((double value) {
  setState(() => _volume = value);         // 读回来更新滑块
});

拖动时会发生什么:

  1. 手指移到 0.33,调用 setVolume(0.33)
  2. 系统按整数档位存成 5/15 = 0.3333
  3. stream 回调把 0.3333 推回来
  4. setState 把滑块值改成 0.3333,手指位置和滑块位置开始打架
  5. 手指继续动,回到第 1 步

结果是滑块在拖动过程中持续跳动,手感很差。

关键在于:写入的精度和读回的精度不一致。系统音量是离散档位,你写 0.33 它存 0.3333,这个差值就是抖动的来源。

处理办法是加一个拖动标志位,拖动期间只认手势、不认回调:

bool _dragging = false;

void _onSystemVolumeChanged(double value) {
  if (!mounted) {
    return;
  }
  setState(() {
    // 拖动过程中不覆盖滑块,避免与手势打架
    if (!_dragging) {
      _volume = value;
      _status = '系统音量已变更为 ${_percent(value)}%';
    }
  });
}

void _onSliderChanged(double value) {
  setState(() => _volume = value);
  PerfectVolumeControl.setVolume(value);
}

void _onSliderChangeEnd(double value) {
  setState(() => _dragging = false);
  _syncFromSystem();   // 松手后回读,让滑块落到真实档位
}

对应到 Slider 的三个回调:

Slider(
  value: volume.clamp(0.0, 1.0),
  onChanged: onChanged,
  onChangeStart: (_) => setState(() => _dragging = true),
  onChangeEnd: onChangeEnd,
)

松手后要主动回读一次。因为手指停在 0.33 的位置,而系统实际存的是 0.3333,不回读的话滑块会显示一个系统里并不存在的值。

三、进页面先读一次

滑块初始位置必须来自系统,不能写死默认值,否则进页面瞬间会显示错的值再跳一下:


void initState() {
  super.initState();
  _subscription = PerfectVolumeControl.stream.listen(_onSystemVolumeChanged);
  _loadInitialVolume();
}

Future<void> _loadInitialVolume() async {
  final double value = await PerfectVolumeControl.getVolume();
  if (!mounted) {
    return;
  }
  setState(() {
    _volume = value;
    _status = '已读取系统音量';
  });
}

if (!mounted) return; 不是可选的。getVolume() 是异步的,页面可能在它返回之前就被销毁了,这时 setState 会抛异常。

四、别忘了取消订阅


void dispose() {
  // 必须取消订阅:回调在异步时机到达,页面销毁后再 setState 会抛异常
  _subscription.cancel();
  super.dispose();
}

音量回调来自原生侧,到达时机不受页面控制。播放器这类页面又经常被反复进出(切歌、返回列表),漏掉 cancel() 的话,每次进入都会多一个订阅者,最后就是一次音量变化触发 N 次重建。

五、实测结果

在 HarmonyOS 7.0.0(26.0.0) 的 API 26 设备上跑了完整验证。

进入页面:读到系统当前音量 47%,滑块位置与之对应。

在这里插入图片描述

按音量键:按一次音量减,滑块跟着左移,状态栏同步更新。

再按一次,继续跟随:

操作界面显示滑块横坐标
进入页面47%(7/15)570
按音量减40%(6/15)528
再按一次33%(5/15)486

滑块位置确实在动,说明 stream 回调把原生事件带回了 Dart 侧。这一条是这类库的核心价值,也是适配时最容易做错的地方——如果原生侧用错事件(比如订阅了废弃的 volumeChange 而不是 streamVolumeChange),界面就是死的。

拖滑块:从 40% 的位置向右拖,系统音量随之写入:

拖动前:40%   滑块 x=528
拖动后:87%   滑块 x=822
状态:已同步为 87%

在这里插入图片描述

注意 87% 这个值:拖到大概 0.87 的位置,系统存的是 13/15 = 0.8667。这是离散档位导致的,不是误差,也不影响使用。

六、构建时踩到的跨盘符问题

这一条跟业务代码无关,但会让构建直接失败,值得单独记一笔。

我最初把示例工程放在 E: 盘,而 Flutter 的 pub 缓存在默认的 C:\Users\<用户名>\AppData\Local\Pub\Cacheflutter pub get 一切正常,但 flutter build hap 报错:

> hvigor ERROR: AdaptorError 00303231 Configuration Error
Error Message: The srcPath is not a relative path:
C:/Users/34272/AppData/Local/Pub/Cache/git/perfect_volume_control-45b3776.../ohos
* Try the following:
  > Make sure the srcPath in the hvigorconfig.ts file of the project is a relative path.

原因是 Flutter 的鸿蒙构建插件 flutter-hvigor-plugin 负责把插件的 ohos 目录注册成 hvigor 的子模块,它算相对路径的实现是:

export function relativePath(basePath: string, filePath: string): string {
  let p = path.relative(basePath, filePath)
  if (!path.isAbsolute(p) && !p.startsWith('.')) {
    p = './' + p
  }
  return p.replaceAll('\\', '/')
}

path.relative() 在 Windows 上遇到跨盘符(项目在 E:、插件在 C:)时,返回的不是相对路径而是绝对路径。上面那个 if 只处理"既不是绝对路径、也不以 . 开头"的情况,于是绝对路径被原样返回,hvigor 拒绝。

绕开办法是让 pub 缓存和项目在同一个盘符。把缓存指到 E: 盘再重新解析:

# PowerShell
$env:PUB_CACHE = "E:\pub-cache"
flutter pub get
flutter build hap --debug

这样插件会被解析到 E:\pub-cache\git\...path.relative() 能算出合法相对路径,构建通过。构建过程本身没有其他改动。

这个问题影响所有「项目在非系统盘、pub 缓存保持默认」的 Windows 环境,跟具体是哪个插件无关。如果项目本来就在 C: 盘,或者用的是路径依赖(插件目录与工程同盘),则不会触发。

七、顺手把测试补上

插件走平台通道,想在 flutter test 里跑页面就得先把通道接上,否则 getVolume() 会因为没有原生侧而失败。用 setMockMethodCallHandler 造一个假的原生实现即可:

const MethodChannel channel = MethodChannel('perfect_volume_control');

setUp(() {
  log = <MethodCall>[];
  TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
      .setMockMethodCallHandler(channel, (MethodCall call) async {
    log.add(call);
    if (call.method == 'getVolume') {
      return 7 / 15;   // 假装系统媒体音量是 7 档
    }
    return null;
  });
});

这样就能断言"进页面显示 47%"和"点静音写入 0"这类行为:

testWidgets('进入页面后读取系统音量并显示在滑块右侧', (WidgetTester tester) async {
  await tester.pumpWidget(const VolumePanelApp());
  await tester.pumpAndSettle();

  expect(find.text('47%'), findsOneWidget);
  expect(log.any((MethodCall c) => c.method == 'getVolume'), isTrue);
});

flutter analyze 无告警、flutter test 两个用例通过之后,再上真机验证,两端的问题是分开的,定位起来省事。

小结

用这个库的关键不在调 API,而在管住两条链路的关系

  • 拖动期间以手势为准,用 _dragging 挡住回调,松手后再回读对齐真实档位;
  • 进页面主动读一次初始值,避免显示错值再跳;
  • 页面销毁一定 cancel(),否则回调会打到已销毁的 State 上。

加上构建环节那个跨盘符的坑,这个库算是把「简单 API + 真实场景复杂度」的对比展示得比较清楚了。

依赖配置再贴一次:

dependencies:
  perfect_volume_control:
    git:
      url: https://atomgit.com/oh-flutter/perfect_volume_control.git
      # ref: 根据下方表格选择不同框架适配的TAG版本
      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开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐