首页 / 资讯中心 / 文章详情

Flutter在OpenHarmony上的录音按钮状态机与事件通道实践

Flutter在OpenHarmony上的录音按钮状态机与事件通道实践 ★ FEATURED ARTICLE
去年底接了个需求在一台 OpenHarmony 开发板上做一款带录音功能的音乐播放器。用户的典型场景是播放一首歌、跟唱一段录下来再回放对比。项目本身不复杂难就难在团队没人写过鸿蒙原生时间又压得紧最终技术选型落在了 Flutter 上——理由很直接Dart 异步模型适合处理音频流Flutter 的自绘 UI 能保证录音按钮在不同分辨率设备上长一个样。这篇文章不聊整个播放器只把录音控制按钮和录音状态按钮这两个模块从设计到落地的过程完整拆开包含状态机、事件通道、权限适配和各种坑。1. 为什么这个项目选 Flutter 而不是原生 ArkTS1.1 需求背后的真实场景与技术选型逻辑先说需求背景。项目是三端共用的一套跨平台音乐学习系统跑在 OpenHarmony 设备上的核心功能是播放伴奏 录制人声 回放对比。录制过程中用户随时可能切歌、暂停、删除录音片段所以录音控制按钮就是整个交互的核心入口。它承担的不只是开始/停止这么简单而是整个录音流程的状态显示、操作反馈和异常提醒。技术选型时其实没有太多犹豫。第一原生 ArkTS 开发对团队来说学习成本高但 Flutter 团队已经有成熟组件库第二这个应用未来要考虑安卓和 iOS 的多端复用用 Flutter 一套 UI 可以横向铺开第三Flutter 在音频渲染上有独立生态包虽然 OpenHarmony 上原生插件不能直接复用但 Flutter 的插件机制本身是开放的可以通过自写原生适配层接上鸿蒙的音频 API。这里有个很多人容易忽略的点Flutter 应用跑在 OpenHarmony 上并不是简单地把安卓的插件搬过来用。安卓插件走的是 JNI Surface 纹理鸿蒙端走的是 NAPI OHOS 原生组件两者底层 API 完全不同。所以项目一开始就定了基调Flutter 负责 UI 层和业务逻辑OpenHarmony 侧只提供录音、播放、权限等系统能力通过 channel 做能力透传。这个边界清晰后录音按钮才能独立演进不会被底层 API 绑死。1.2 Flutter 在 OpenHarmony 上的运行底座与互操作方式Flutter 工程要跑在 OpenHarmony 设备上需要一套适配鸿蒙的引擎。早期社区的做法是手动移植 Flutter engine 到鸿蒙可维护性差现在主流做法是直接用 OpenHarmony 社区维护的 Flutter 适配分支。它的原理很直观Flutter engine 被编译为 so 文件鸿蒙应用通过一个封装好的入口把 Flutter 作为内核加载起来Dart 层的渲染依然走 Flutter 自绘管线不依赖 ArkUI 控件树。这就带来了一个明显好处录音按钮的视觉形态完全由 Flutter 掌控圆角、涟漪、动画在不同设备上表现一致。坏处是当按钮触发的动作需要调用原生能力时必须走消息通道。我在项目里用到了两套通道MethodChannel用于一次性的调用比如开始录音停止录音检查权限。EventChannel用于持续的数据流比如当前分贝值录音时长回调。这两者的分工是从按钮需求反推出来的。按钮点击本质上是发出命令和接收结果MethodChannel 足够状态按钮要持续刷新时长和音量EventChannel 更顺。后面的模块就按这个思路拆。1.3 录音与播放并存的模块划分音乐播放器里同时存在音频播放和录音两条链路如果放在同一个模块里处理状态会互相污染。我的做法是把它们拆成两个独立服务各管各的状态机PlayerService 负责音频播放、暂停、跳转输出播放进度RecorderService 负责录音生命周期输出录音状态、时长、分贝。录音控制按钮只消费 RecorderService 的状态不关心 PlayerService录音状态按钮只展示 RecorderService 推过来的数据。播放器页面在 UI 层把两个服务组装在一起。这样拆完之后标题里的两个按钮其实就是 RecorderService 的一头一尾控制按钮是命令入口状态按钮是反馈出口。这个架构对后续扩展特别友好。比如二期要加哼唱识别只需要在 RecorderService 后面再接一个分析器按钮逻辑一行都不用改。这也是我在做代码评审时给团队的明确建议按钮永远不要直接持有录音器对象只持有状态。2. 录音控制按钮把状态机画清楚再动手2.1 按钮的四种状态idle、recording、paused、completed做按钮之前我先把录音流程的状态机完整画了一遍。这个项目里录音控制按钮一共定义四个状态状态含义按钮外观可执行的下一步操作idle空闲未开始录音灰色圆形 麦克风图标开始录音recording录音中红色圆形 停止方形图标暂停 / 停止paused暂停中黄色圆形 开始图标继续 / 停止completed录音完成已生成文件绿色圆形 播放/保存图标重新录音 / 使用录音这个表看着简单但它是整个按钮功能的地基。编码之前先定义好状态流转合法性从 idle 只能到 recordingrecording 可以到 paused 或 completedpaused 只能到 recording 或 completed。禁止跨状态跳跃比如从 recording 直接跳到 idle 是不允许的除非用户主动取消。我把这套规则直接写进单元测试每个状态变化都跑一遍后面改 UI 时就不会出现按钮显示异常的问题。实际开发中很多按钮卡死的 bug 都来自状态机允许了不合法迁移。比如录音过程中应用被切到后台资源被回收代码试图把状态从 recording 改成 idle但底层的录音器实例已经销毁UI 层就出现了按钮显示空闲、录音文件却还在写入的诡异状态。2.2 点击控制链路从手指按下到原生录音启动按钮本身只是 GestureDetector 包一层真正的逻辑在 onTap 回调里。完整链路是检查当前状态是否允许该操作防呆检查麦克风权限idle 状态下首次点击必须做调用 RecorderService 的 start / pause / resume / stop等待原生层回调结果成功后更新状态触发 UI 刷新。Dart 层核心代码大致是这样class RecorderService { final MethodChannel _channel const MethodChannel(app.recorder); Futurevoid start() async { assert(_status RecordStatus.idle); final ok await _channel.invokeMethod(start); if (ok true) { _status RecordStatus.recording; } else { throw RecorderException(start failed); } } }这里有个关键设计按钮点击后要立刻显示正在启动的过渡态而不是等原生层回调回来才变色。因为原生录音器从实例化到开始写入文件之间有一定耗时在 OpenHarmony 上少则几十毫秒多则几百毫秒如果 UI 傻等回调用户会觉得按钮失效了。我的做法是在发出 invokeMethod 的同时就切换到 recording 状态如果在超时时间内收到失败回调再回滚到 idle 并弹出提示。这属于乐观更新体验好很多。不过乐观更新要有兜底。我设了 2 秒超时超过 2 秒没有回调自动回滚状态并提示检查麦克风。这个值是在真机上反复试出来的OpenHarmony 上冷启动录音器有时会慢一些2 秒是一个比较安全的上限。2.3 状态切换时的 UI 反馈与防抖处理录音按钮的视觉反馈不能只是颜色变化用户需要明确感知我按到了。我做了三层反馈点击涟漪用 InkWell 自带 splashColor按下瞬间就有触感反馈图标切换录音中显示停止方块暂停时显示三角开始图标完成后显示勾切换时加一个 150ms 的 AnimatedSwitcher 过渡避免闪变环形动画录音中按钮外围有一条不断绕圈的进度环提示正在录音这个环是 CustomPainter 画的圆弧动画时间 1 秒循环播放。防抖是必须处理的。用户连续快速点击开始/停止会导致 channel 调用并发原生层收到两个 start 就会报错。方法很简单在 service 层加一个操作锁bool _operationLock false; Futurevoid start() async { if (_operationLock) return; _operationLock true; try { await _channel.invokeMethod(start); } finally { _operationLock false; } }UI 层的双击是防不住的真正要防的是 service 层的重入。只要 service 层保证一个时刻只执行一个命令按钮的点击频率再高也没关系。3. 录音状态按钮反馈链路与实时 UI 的实现3.1 状态按钮的职责边界只显示、不控制标题里的录音状态按钮和录音控制按钮容易被人当成一回事实际上语义完全不同。控制按钮是命令入口状态按钮是反馈出口。在我这个项目里状态按钮是一排常驻在页面顶部的指示器展示当前录音所处的状态、已录时长、实时音量还会在出现异常时显示警告色。设计时要克制一点状态按钮绝对不允许被用户点击触发操作。它唯一的功能是读状态单向绑定。如果让状态按钮也能点击就会形成两个入口写同一个状态的局面后面维护状态机就是噩梦。这一点是我一开始就定死的规矩所有 UI 组件访问 service 必须走只读接口service 层所有的写方法都放在独立 repository 里。实现上我用 ValueNotifier 管理状态数据final ValueNotifierRecorderUiState recorderStateNotifier ValueNotifier(RecorderUiState.idle());状态按钮直接监听这个 notifier控制按钮也监听同一份数据但触发命令时走 service 方法。这样两个按钮永远显示一致不需要手动同步。这也是我反复给团队强调的点任何手动同步两个组件状态的操作最终都会在某个极端时序下出 bug所有状态变化必须走单一数据源。3.2 计时器与时长显示用 Stopwatch 替代 Timer录音状态下最常见的一个 UI 是00:12:34这样的时长显示。新手很容易写成Timer.periodic(Duration(seconds: 1), (_) { setState(() _duration 1); });这个写法在普通页面上问题不大但在录音场景会累积误差。因为 Timer 回调本身可能被系统异步任务延迟每次延迟几毫秒跑几分钟就差了半秒跑半小时能差出好几秒。而且录音中途还有暂停/恢复逻辑暂停期间 timer 不该走恢复后又得把暂停时段去掉写起来很容易乱。我的方案是service 层用 Stopwatch 记录真实流逝时间UI 层用 Timer 每 200ms 拉一次 Stopwatch 的时间并格式化。暂停时不停止计时器而是调用 Stopwatch.stop()恢复时 stopwatch.start()。这样显示的时间永远贴合真实录音时长。class RecorderService { final Stopwatch _watch Stopwatch(); void pause() { _watch.stop(); _status RecordStatus.paused; } void resume() { _watch.start(); _status RecordStatus.recording; } }状态按钮的时长文本用 200ms 周期的 Timer 驱动格式化更新。因为 Stopwatch.elapsed 精度足够UI 也感知不到 200ms 的刷新延迟同时也不用每帧都去格式化。暂停时 UI 把时长保持住颜色变成暂停黄数字后面跟两个闪烁的小圆点表示计时暂停中。闪烁效果用 AnimationController 做 opacity 循环即可。3.3 音量波形的小型可视化实现状态按钮上的音量波形是另一个重点。原生录音层每隔 100ms 回调一个分贝值通过 EventChannel 推到 Dart 层。我维护了一个环形缓冲区保存最近 30 帧的分贝数据然后传给 CustomPainter 画柱状图。分贝值换算要看懂。原生层拿到的原始数据是音频流振幅通常是 -60dB 到 0dB 的负数UI 层要展示出一个 0 到 1 的归一化结果不能直接把负值拿来画。换算公式double normalized (rawDb 60.0) / 60.0; normalized normalized.clamp(0.0, 1.0);这里加一个指数平滑更好避免波形跳变太突兀_smoothVolume _smoothVolume * 0.7 normalized * 0.3;柱子高度的计算我做了对数映射因为人耳对音量大小的感知接近对数关系用线性值画出来会觉得大部分时间都贴地。实际画的时候每个柱子高度按 pow(value, 1.5) 再乘基准高度视觉效果更接近真实听感。CustomPainter 的性能要点是 shouldRepaint 的判断。如果返回 true 会导致每一帧都重新布局整个视图数据量大时很容易掉帧。我在 shouldRepaint 里只比较当前音量值是否变化override bool shouldRepaint(covariant VolumePainter oldDelegate) { return oldDelegate.volumeList ! volumeList; }如果列表没有变化直接返回 false让引擎复用上一帧缓存。我在真机上测试把刷新频率从每帧改成 100ms 后整体帧率从 40fps 提升到 58fps 左右效果非常明显。3.4 状态按钮与控制按钮的双向联动策略前面强调了单一数据源但两个按钮之间依然需要联动。场景是控制按钮切到 recording状态按钮的时长马上开始走控制按钮切到 paused状态按钮计时停住并把颜色变成黄色。这套联动完全通过 ValueNotifier 自动完成不需要在 UI 层写任何通知对方的代码。另一个联动是录音完成的瞬间停止录音后原生层返回录音文件路径和时长RecorderService 把 completed 状态和文件信息写进 notifier状态按钮显示绿色勾和文件时长控制按钮则切换成一个重新录制的图标。这里有一个值得记录的细节completed 状态不能直接复用 recording 的按钮图标因为用户对再次点击会录一条新的这个心智很不明确。我专门把 completed 态的控制按钮做成一个小的重新录制按钮放在状态按钮旁边明确提示保留当前录音再录一版。4. 接住 OpenHarmony 平台权限、原生录音与生命周期4.1 OpenHarmony 下 Flutter 工程环境搭建要点OpenHarmony 上跑 Flutter 工程环境搭建和安卓不太一样。首先需要准备好 OpenHarmony SDK 和 DevEco Studio其次 Flutter SDK 要用 OpenHarmony 社区维护的适配版本不能用官方纯净版。差异集中在两点编译目标从 android 变成 ohos插件通道从 JNI 变成 NAPI。项目里我在工程根部配置了 ohos 目录Flutter 侧的东西基本不动原生侧需要把鸿蒙的 EntryAbility 作为入口。首次跑通时最容易踩的坑是 so 文件架构不匹配开发板一般是 arm64-v8a但模拟器可能是 x86_64如果编译时没指定 target 就会在启动阶段直接崩。命令行构建时固定指定 target 为对应架构。另一个高频问题Dart 侧 import package:flutter/services.dart 没有报错但运行时 invokeMethod 一直返回 MissingPluginException。这种情况几乎都是原生插件没有注册到入口。解决办法是在鸿蒙原生工程的初始化阶段手动注册 MethodChannel 和 EventChannel 的 handler。我习惯把通道注册逻辑单独放一个文件避免和页面生命周期代码耦合。4.2 麦克风权限声明与原生录音能力对接Flutter 层调用原生录音能力前首先要解决权限问题。OpenHarmony 的权限声明写在 module.json5 里{ module: { requestPermissions: [ { name: ohos.permission.MICROPHONE, reason: 用于录制用户跟唱音频, usedScene: { abilities: [EntryAbility], when: inuse } } ] } }reason 字段是申请弹窗里展示的文案必须写得清楚。只声明权限还不够运行时还要动态请求授权。在 OpenHarmony 上麦克风属于 user_grant 权限必须经过用户同意才能使用。原生侧用 abilityAccessCtrl 申请。我把这块逻辑封装成了 PermissionServiceFlutter 侧统一调 requestPermission 方法返回值是 granted / denied / limited 三态。这里有一个容易漏的点模拟器上权限弹窗常常不弹或者点了没反应调试时容易被误导为权限 bug。我的经验是先在原生侧写一个简单的按钮触发权限申请确认弹窗正常后再接 Flutter 通道分层排查会省很多时间。4.3 事件通道把分贝和时长持续推给 UI前文提到原生层回调分贝数据具体实现是 OpenHarmony 的 ohos.multimedia.audio AudioCapturer。每当原生侧采集到一块音频 buffer我就会把振幅转换成 dB 值并通过 EventChannel 推给 Dartimport { audio } from kit.AudioKit; let capturer: audio.AudioCapturer | null null; async function startCapture() { const options: audio.AudioCapturerOptions { streamUsage: audio.StreamUsage.STREAM_USAGE_VOICE_COMMUNICATION, sourceType: audio.SourceType.SOURCE_TYPE_MIC, capturerFlags: 0, audioCodecFormat: audio.AudioCodecFormat.AUDIO_CODEC_PCM_16BIT, sampleRate: 44100, channelCount: 2, }; capturer await audio.createAudioCapturer(options); await capturer.start(); // 从 buffer 计算分贝后通过 eventSink 推送 }音频 buffer 是 Uint8Array要算分贝得先转成 Int16。我写了一个 helper取 200ms 内所有采样点的绝对峰值再映射到 dB。这块代码很小但有一个原则不要在原生事件通道里做大量计算否则会阻塞 UI 线程我只算一个峰值其他的留给 Dart 侧处理。EventChannel 在 Dart 侧这样接收EventChannel(app.recorder/volume) .receiveBroadcastStream() .listen((data) { final dB (data as num).toDouble(); volumeNotifier.value (dB 60) / 60; });4.4 生命周期与后台录音的合规处理录音功能最敏感的环节是生命周期。OpenHarmony 设备熄屏、应用退后台时如果还在录音系统会限制麦克风使用甚至直接回收音频资源。我们的产品设计是应用回到前台才允许继续录音进后台时自动暂停录音并保留已录部分。实现上我在 EntryAbility 的 onBackground 回调里调用一次 channel 方法暂停录音在 onForeground 时把按钮状态恢复为 paused让用户自己决定是否继续。这个逻辑在真机上验证过如果后台不暂停再回前台时 AudioCapturer 的资源已经被系统回收恢复录音会报错直到重新创建实例。还有一个实际体验问题录音过程中来电话、被系统打断原生层会抛 AudioInterrupt 事件。这个事件不会自动推到 Dart必须在原生 EventChannel 里单独设一个监听把 interrupt 状态发到 Flutter页面顶部状态按钮立刻由红变灰并弹一个小提示录音已被打断。5. 实战排坑实录按钮状态、权限弹窗与性能5.1 状态卡住一个经典的异步竞态问题项目刚跑通时出现过一个特别闹心的 bug点击停止按钮录音停止后按钮状态却停留在 recording必须再点一次才能恢复。排查后发现不是 Flutter 的问题而是原生层回调了两次停止结果一次成功一次失败失败的回调把状态回滚了。Dart 侧我用了一个简单的防抖只在当前状态和期望状态一致时才允许更新。停止按钮点击后状态先乐观更新为 completed原生返回的最终结果如果还是 completed 就直接忽略。这类问题本质是状态更新被重复触发根治方案是给状态机加幂等性任何状态变更操作都允许重复执行但只有第一次真正生效。调试这类问题我习惯开一个状态日志面板页面右下角悬浮显示当前 RecordStatus 和最近一次操作的时间戳、来源、耗时。上线后面板隐藏但保留在 debug 构建里维护成本很低价值极高。5.2 权限弹窗与按钮触发的顺序问题权限弹窗和按钮点击的竞争也是高频问题。第一次点击录音按钮时原生层弹出权限请求用户还没点允许就把按钮又点了这时原生层还在等待授权回调第二个 start 进来就乱了。我的解法是把权限检查从按钮回调里抽出来放在 service 层。service 内部用一个状态位标记 permissionsInFlight当权限请求已发出但未返回时任何进入的 start 调用都直接忽略这样 UI 不会重复弹窗。等授权结果回来后如果用户拒绝service 把状态置为 idle控制按钮变灰并在提示里说明原因。这个流程我把边界测试写得很细授权中、授权成功、授权拒绝、不再提示四个分支分别跑用例。5.3 波形绘制的帧率掉坑音量波形一开始用的是 AnimationController 每帧重绘在低端 OpenHarmony 设备上 Profile 一看CPU 占用 30%。优化方案分两步第一步先把波形刷新频率降到 100ms第二步 CustomPainter 只画可见区域的柱子超过屏幕宽度的旧数据直接丢弃。优化后 CPU 占用降到 8% 左右而且视觉上完全无感。需要注意一个细节波形柱的间距不要用小数值比如 2.3px否则部分设备上会出现柱子抖动。统一用整数间距录制的音频数据本身就有随机性视觉上已经足够活跃。5.4 录音文件的编码与回放兼容性录音完成后要回放播放器用的是播放整个音频文件。此时录音文件的编码格式必须和播放器支持的格式一致。AudioCapturer 默认输出 PCM我封装时在停止录音后把 PCM 转成 WAV播放器就能直接播放。如果产品场景需要更小的文件体积就转成 AAC但需要引入额外包。经验是音频编码格式的坑不要去踩第一次就用 WAV 保底等需求确实需要压缩再换。转格式时要小心字节顺序。OpenHarmony 的音频 buffer 是小端序但部分设备上的 PCM 数据可能是大端序如果不处理转出来的 WAV 回放就会是噪声。我在原生侧加了一个判断把 Uint8Array 统一按 Little-Endian 处理这个问题一次就解决了。这套录音控制按钮和状态按钮做完我最大的体会是按钮本身不难难的是把状态机和数据源理顺。控制按钮管发令状态按钮管反馈两者共享同一份状态各自职责完全分离后面的所有功能扩展都建立在清晰边界之上。如果你也在做 Flutter × OpenHarmony 的音频类应用建议先把状态机画清楚再让原生能力保持最小透传层最后用 EventChannel 把数据推给 UI会少走很多弯路。最后再分享一个小技巧录音按钮的图标切换一定要加过渡动画别让颜色直接跳变——用户在专注跟唱时对闪变的敏感度远高于你的想象。
阅读完成 · 觉得有帮助?
咨询建站