ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

Flutter+HarmonyOS录音功能开发:状态机设计与踩坑指南

Flutter+HarmonyOS录音功能开发:状态机设计与踩坑指南 开发 EchoMusic回声音乐时我第一个写完的功能就是录音控制区因为它是整个 App 的敲门砖。可就是这个看起来只有“开始、暂停、停止”三个按钮的区域让我返工了整整三轮第一轮在真机上双击直接崩第二轮在 HarmonyOS 6.0 上录出了 40 分钟的空文件第三轮切后台回来波形和计时器全部错乱。这篇文章把最后落地的方案完整拆开状态机定义、Flutter 与 HarmonyOS 6.0 的通道分工、关键代码以及我排查最久的三个坑。如果你正在用 Flutter 做音频类应用尤其目标平台包含 HarmonyOS这份记录可以直接当作参考。1. 初版录音区翻车实录三个按钮引发的连锁事故1.1 一次“双击”测试带来的崩溃第一版实现非常朴素一个 GestureDetector 包住录音按钮onTap里判断_isRecording这个 bool然后调start()或stop()录完把文件路径存起来。真机测试时有个习惯性动作——双击按钮。第一次点击之后录音还没完全启动第二次点击又进来了于是start()被连续调了两次在 HarmonyOS 6.0 上直接创建了两个 AudioCapturer 实例。结果不是“第二次被忽略”而是底层音频会话冲突随后抛了一个我没见过的平台异常应用当场闪退。这类问题的诡异之处在于它不是必然复现的取决于两次点击的间隔是否小于录音引擎初始化耗时。用户那边表现为“偶尔点一下就没反应多点几下就退出”非常难定位。1.2 问题的根源状态被拆散在 UI 层后来我复盘发现真正的问题不是“没有防抖”而是整个录音生命周期被拆成了一堆散落的 bool。_isRecording、_isPaused、_hasError、_hasPermission每个字段单独看都挺清楚合在一起就是一场灾难。比如“暂停后再次点击”和“录制中再次点击”虽然 UI 上都是同一个按钮但底层要做的事情完全不同靠if (_isRecording true _isPaused false)这种组合判断去分支迟早有漏网之鱼。可以画一张表看这些组合有多离谱_isRecording_isPaused_hasError实际含义UI 应该显示什么falsefalsefalse空闲开始按钮truefalsefalse录制中暂停按钮 波形truetruefalse已暂停继续按钮falsefalsetrue出错错误提示 重置truefalsetrue录制中但出错第五行这种状态在真实场景里完全可能出现录音过程中权限被回收、磁盘写入失败、底层音频会话被系统打断都会让“正在录音”和“已出错”同时成立。UI 层用布尔组合根本表达不了这种重叠情况唯一的出路就是引入显式的状态机让同一时刻只有一个状态是权威的。2. 架构分工Flutter 画界面HarmonyOS 管声音2.1 为什么录音这条路不能全交给 Dart很多 Flutter 新手会问录音不是有record这种现成包吗为什么还要跟原生层打交道问题在于Flutter 官方生态里的录音插件大多绑定 Android 的 MediaRecorder 和 iOS 的 AVAudioRecorder而 HarmonyOS 6.0 提供的是另一套基于 AudioCapturer 的音频采集 API。社区插件对 HarmonyOS 的适配进度参差不齐与其等某个插件支持不如直接通过 MethodChannel 调原生能力把接口设计成自己可控的形状。这个选择的另一个理由是录音涉及音频会话管理、权限弹窗、后台任务这些平台强相关能力Dart 层不可能也不应该去模拟。Dart 擅长的是 UI 渲染、状态管理和业务编排原生层擅长的是跟系统音频框架打交道。把两边擅长的事分开后面调试和适配都会轻松很多。2.2 通道设计一条指令通道一条音频流通道我在 Flutter 和 HarmonyOS 6.0 之间设计了两条通道。第一条是 MethodChannel名字叫echo_music/recording负责所有“一次性指令”开始录音、暂停、继续、停止、取消第二条是 EventChannel名字叫echo_music/audio_level负责持续回传实时音量振幅用来驱动波形绘制。这样分离的原因很直接指令是请求-响应模型适合 MethodChannel振幅数据是持续的流如果也用 MethodChannel 反复 invoke会产生大量双向通信开销EventChannel 单向推送更合适。class RecordingRepository { static const _commandChannel MethodChannel(echo_music/recording); static const _levelChannel EventChannel(echo_music/audio_level); Streamdouble get audioLevel _levelChannel .receiveBroadcastStream() .map((event) (event as num).toDouble()); Futurevoid start() async { await _commandChannel.invokeMethod(start, { sampleRate: 44100, channels: 1, sampleFormat: S16LE, outputPath: _buildOutputPath(), }); } Futurevoid stop() async { await _commandChannel.invokeMethod(stop); } }原生侧用 ArkTS 实现 AudioCapturer 的采集逻辑。需要注意的是不同 HarmonyOS API 版本在字段名上可能有细微差异我贴的是实际跑通的版本。import { audio } from kit.AudioKit; import { fileIo } from kit.CoreFileKit; export class AudioRecorder { private capturer?: audio.AudioCapturer; private file?: fileIo.File; private recording false; async start(outputPath: string): Promisevoid { const streamInfo: audio.AudioStreamInfo { samplingRate: audio.AudioSamplingRate.SAMPLE_RATE_44100, channels: audio.AudioChannel.CHANNEL_1, sampleFormat: audio.AudioSampleFormat.SAMPLE_FORMAT_S16LE, encodingType: audio.AudioEncodingType.ENCODING_TYPE_RAW, }; const capturerOptions: audio.AudioCapturerOptions { streamInfo: streamInfo, capturerInfo: { source: audio.SourceType.SOURCE_TYPE_MIC, capturerFlags: 0, }, }; this.capturer await audio.createAudioCapturer(capturerOptions); await this.capturer.start(); this.recording true; // 之后在循环里 read 数据并写入文件 } }这里有个关键的取舍AudioCapturer 吐出来的是裸 PCM 数据不是现成的 MP3 或 M4A。如果直接把它当音频文件存播放器根本认不出来。我第一版就把 PCM 流直接写了.aac后缀的文件结果录了 40 分钟文件 0 字节或者打开全是噪音。后面会专门讲这个坑。2.3 环境清单Flutter HarmonyOS 6.0 的版本搭配给出一份可以复现的环境组合。Flutter 侧我用的 3.27 分支配合 OpenHarmony SIG 维护的 Flutter 引擎构建产物HarmonyOS 侧用 DevEco Studio 5.x 以上版本API Level 18 起可以完整覆盖 AudioCapturer、权限管理和后台任务能力。第一次把 Flutter 工程接到 DevEco 时命令行会刷一条 using a Flutter SDK that may not be fully supported 的警告这是社区分支与官方版本差异导致的确认编译产物正常后可以忽略。flutter --version # Flutter 3.27.4 # DevEco Studio 5.0.3依赖方面Dart 侧只需要 flutter_bloc 和 equatable权限和录音都走了自建通道不需要额外引入可能不兼容 HarmonyOS 的插件包。这个依赖面从结果看是值得的少了插件层的黑盒出问题能直接定位到原生代码。3. 状态机重写把“录制中”拆成一张可枚举的表3.1 五个状态与合法迁移状态机的第一版抽象我把录音区域定义成五个状态空闲idle、录制中recording、已暂停paused、录制完成done、出错error。任何时刻 UI 只认这一个状态按钮的文案、颜色、可用性全部由状态推导不再允许出现“bool 组合判断”。合法迁移画出来是这样一条链idle 点开始进入 recordingrecording 点暂停进入 pausedpaused 点继续回到 recordingrecording 或 paused 点停止进入 donedone 返回 idle任意状态遇到权限被拒、写入失败、底层异常都进入 error再由用户确认后回到 idle。非法迁移直接忽略比如在 idle 状态下连点两次开始第二次的 toggle 事件查表发现“idle 到 recording”已经被执行过一次就不再处理。3.2 Cubit 落地代码我用 flutter_bloc 的 Cubit 实现了这个状态机因为录音控制区的状态变化是事件驱动的而且状态之间有清晰的转换关系Cubit 比手写 ChangeNotifier 更紧凑也比 Bloc 的 Event/Sink 机制轻量。enum RecorderStatus { idle, recording, paused, done, error } class RecorderState extends Equatable { const RecorderState({ this.status RecorderStatus.idle, this.duration Duration.zero, this.amplitude 0.0, }); final RecorderStatus status; final Duration duration; final double amplitude; RecorderState copyWith({ RecorderStatus? status, Duration? duration, double? amplitude, }) { return RecorderState( status: status ?? this.status, duration: duration ?? this.duration, amplitude: amplitude ?? this.amplitude, ); } override ListObject? get props [status, duration, amplitude]; } class RecorderCubit extends CubitRecorderState { RecorderCubit(this._repository) : super(const RecorderState()); final RecordingRepository _repository; Timer? _ticker; Futurevoid toggle() async { switch (state.status) { case RecorderStatus.idle: case RecorderStatus.done: await _start(); case RecorderStatus.recording: await _pause(); case RecorderStatus.paused: await _resume(); case RecorderStatus.error: emit(const RecorderState()); default: break; } } Futurevoid _start() async { try { await _repository.start(); _ticker?.cancel(); _ticker Timer.periodic(const Duration(seconds: 1), (_) { emit(state.copyWith(duration: state.duration const Duration(seconds: 1))); }); emit(state.copyWith(status: RecorderStatus.recording)); } catch (_) { emit(state.copyWith(status: RecorderStatus.error)); } } Futurevoid _pause() async { await _repository.pause(); _ticker?.cancel(); emit(state.copyWith(status: RecorderStatus.paused)); } Futurevoid _resume() async { await _repository.resume(); _ticker Timer.periodic(const Duration(seconds: 1), (_) { emit(state.copyWith(duration: state.duration const Duration(seconds: 1))); }); emit(state.copyWith(status: RecorderStatus.recording)); } Futurevoid stop() async { await _repository.stop(); _ticker?.cancel(); emit(state.copyWith(status: RecorderStatus.done)); } override Futurevoid close() async { _ticker?.cancel(); await super.close(); } }这里有个细节计时器一开始放在_start()里创建但暂停、继续时需要反复取消和重建所以我把 ticker 提升成了成员变量并且在 Cubit close 时确保取消防止页面销毁后 Timer 还在跑导致内存泄漏。这个细节当初也坑了一次后面会说。3.3 三个容易被忽略的状态入口第一处是 error 状态下的按钮点击。用户看到错误之后第一反应往往是再点一次按钮“试试”。所以 toggle 里对 error 的处理不是进入录音而是先重置为空闲。第二处是 done 状态。录完音之后用户可能直接退出页面也可能想重录所以 done 的按钮文案是“重新录制”点击后回到 idle 并清理旧文件。第三处是权限被拒。这本该是一个独立的分支我在 state 里没有单列“权限未授予”状态而是复用 error由外部根据错误码区分提示文案状态机保持精简。4. 录音权限链路从“弹一次窗”到“写进清单”4.1 声明与注册module.json5 里的两个字段HarmonyOS 的麦克风权限和 Android 一样分成静态声明和动态申请两步。静态声明在module.json5的requestPermissions数组里注册注意敏感权限必须带上reason和usedScene否则运行时弹窗会因为缺少使用场景说明而校验失败。{ module: { name: entry, requestPermissions: [ { name: ohos.permission.MICROPHONE, reason: $string:mic_reason, usedScene: { abilities: [EntryAbility], when: inuse } }, { name: ohos.permission.KEEP_BACKGROUND_RUNNING } ], backgroundModes: [audioRecording] } }KEEP_BACKGROUND_RUNNING和backgroundModes是给后台继续录音准备的如果只做前台录音可以不加但用户锁屏或者切到别的 App 时录音会被系统打断体验很差。这个权限不弹窗属于特殊权限但声明的时机要早因为系统在审核后台模式时是查清单的。4.2 运行时申请原生弹窗 Flutter 回调动态申请必须用当前 UI 的 context所以 Flutter 侧先通过 MethodChannel 把申请请求发给原生层原生层调用requestPermissionsFromUser再把结果回传。注意不要在主线程外调用这个 API否则回调不生效。import { abilityAccessCtrl, common } from kit.AbilityKit; export class PermissionHelper { static async requestMicrophone(context: common.UIAbilityContext): Promiseboolean { const manager abilityAccessCtrl.createAtManager(); try { const result await manager.requestPermissionsFromUser( context, [ohos.permission.MICROPHONE] ); if (result.authResults.length 0) { return false; } // authResults 中 0 表示授权-1 表示拒绝 return result.authResults[0] 0; } catch (error) { return false; } } }回到 Flutter 层start()里做的第一件事不是创建音频会话而是先检查权限状态。如果之前被拒绝过直接弹出自定义的引导对话框链接到系统设置页而不是再次触发系统弹窗——系统弹窗第二次被拒后不会再出现用户会在沉默中困惑。4.3 权限被拒后的静默降级权限被拒后最差的处理方式是让界面卡在“点开始没反应”。我的做法是进入 error 状态然后根据错误码区分三种提示“从未授权请点击允许”“已被拒绝请到设置打开麦克风权限”“系统当前不可用比如正在通话中”。后面两种只展示引导不触发重新申请。另外录制中权限被系统回收的情况也要兜住AudioCapturer 会抛异常原生层要把这个异常跨通道抛回 Dart让状态机跳转到 error否则 UI 还停留在录音中实际上已经没声音了。5. 波形可视化的 60fps 账本5.1 CustomPainter 画波形的基本盘EchoMusic 的录音控制区中央是一块波形显示区实时把音量振幅画成一条上下起伏的折线。这个效果用 CustomPainter 实现并不复杂核心就两步拿振幅数据画线。class WaveformPainter extends CustomPainter { WaveformPainter({required this.points, required this.color}); final Listdouble points; final Color color; override void paint(Canvas canvas, Size size) { if (points.length 2) return; final paint Paint() ..color color ..strokeWidth 2 ..strokeCap StrokeCap.round ..style PaintingStyle.stroke; final midY size.height / 2; final step size.width / (points.length - 1); final path Path(); for (var i 0; i points.length; i) { final x i * step; final y midY - points[i] * midY; if (i 0) { path.moveTo(x, y); } else { path.lineTo(x, y); } } canvas.drawPath(path, paint); } override bool shouldRepaint(WaveformPainter oldDelegate) { return oldDelegate.points ! points; } }振幅数据的来源是 EventChannel。原生层在录音采集循环里每读到一个 buffer 就计算一次 RMS 值转成振幅百分比推给 Flutter。buffer 大小我设成 2048 字节44100Hz、16bit 单声道下大约对应 23 毫秒的数据换算下来原生层每 20 到 30 毫秒推一次数据点频率已经不低。5.2 节流波形不需要 60fps如果你直接把收到的每个振幅数据都塞进 CustomPainter 并触发重绘画面确实很跟手但代价是 UI 线程要满负荷跑绘图逻辑。实测在 HarmonyOS 6.0 的中端设备上这种方式会让主线程占用飙升列表滑动、按钮点击都会出现肉眼可见的掉帧。我的处理是原生 30Hz 推数据Flutter 侧 30Hz 重绘中间用时间戳节流。30fps 对波形来说完全够用因为人眼对高频抖动的感知在音频波形场景并不敏感而 CPU 占用可以降一半以上。具体做法是在收到事件的回调里判断距离上次重绘是否超过 33 毫秒满足才把数据加入展示数组并触发 setState。DateTime _lastRepaint DateTime.fromMillisecondsSinceEpoch(0); static const _repaintInterval Duration(milliseconds: 33); void _onAmplitude(double value) { final now DateTime.now(); if (now.difference(_lastRepaint) _repaintInterval) return; _lastRepaint now; final points Listdouble.from(_points)..add(value); if (points.length 120) points.removeRange(0, points.length - 120); _points points; setState(() {}); }波形数据保留最近 120 个点在 30fps 下正好是 4 秒的波形长度既能看到最近几秒的声音变化又不至于让绘制负担持续增长。5.3 RepaintBoundary 与 Impeller 的配合波形区域是整个录音控制区里唯一每帧都在变化的元素如果它的重绘波及到父级布局那代价会被放大。我在 CustomPaint 外面包了一层 RepaintBoundary把重绘隔离在波形区域内部。Flutter 3.27 之后 Impeller 渲染引擎默认接管绘制像波形这样简单的 Path 折线在 Impeller 下开销很低但有一点要注意不要在波形上叠加模糊、阴影这类会触发离屏渲染的效果实测 Impeller 在离屏渲染上的开销比 Skia 更敏感一条带 MaskFilter 的线能把帧耗拉高好几倍。还有个小技巧shouldRepaint里比较的是 points 引用而不是内容。因为每次重绘我都List.from创建了新数组引用必然不等所以比较可以简写成oldDelegate.points ! points。如果你的实现是复用同一个数组实例那 must 逐个元素比较否则重绘会被吞掉。6. HarmonyOS 6.0 适配踩坑三个查了最久的问题6.1 坑一录出来的文件是空的或全是噪音这是我在 HarmonyOS 6.0 上踩的最大的坑。第一版实现我以为 AudioCapturer 给出来的数据可以直接按.aac后缀存盘结果录了几分钟文件要么 0 字节要么播放出来是尖锐噪音。原因拆开看有两层。第一层是编码格式AudioCapturer 默认吐出的是裸 PCM不能用文件扩展名伪装成 AAC播放器拿到 PCM 当 AAC 解自然全是噪音。第二层更隐蔽如果 start 之后立刻读 buffer此时底层音频会话还没稳定read 返回的数据长度可能是 0如果我用返回值判断“没有数据就继续读”那文件里就会有一段空洞。正确做法是二选一要么以 WAV 封装 PCM在文件头写入 RIFF 信息播放器按 PCM 解码就能正常出声要么用 AVCodec 的编码器把 PCM 转成 AAC/M4A体积小但实现复杂度高。EchoMusic 定位是语音备忘我选了 WAV44100Hz、16bit 单声道下每分钟约 5MB可以接受。封装 WAV 文件头这段逻辑不复杂但容易写错RIFF 块大小要算上文件总长度减 8fmt 块的数据块大小固定 16data 块大小要等录音结束才能回填。顺序是先写文件头录音过程中在 data 区追加 PCM 数据结束前回到文件头偏移位置重写一次 data 大小。6.2 坑二退后台之后录音中断文件被截断用户录到一半锁屏或者切微信回了个消息回来发现录音停了而且文件长度正好停在切后台那一刻。这个行为的直接原因是 HarmonyOS 对后台任务的限制普通应用切后台后音频采集这类资源会被系统挂起。解决方式分两步。第一步是前面提过的在 module.json5 里声明ohos.permission.KEEP_BACKGROUND_RUNNING权限和backgroundModes: [audioRecording]。第二步是在原生层把采集循环放进长时任务声明了后台模式之后系统才会允许应用在后台维持音频会话。这个配置加完之后锁屏实测录音能持续跑完不会再被截断。但要注意后台模式不是“一劳永逸”的挡箭牌。系统在资源紧张时依然可能回收后台音频资源所以捕获到 AudioCapturer 中断异常时必须把状态机切到 error并在恢复前台后引导用户检查录音是否完整。我在这里额外加了一个策略每 5 秒把已写入的 PCM 数据段长度记录到日志一旦异常退出下次启动能根据日志判断用户损失了多少录音。6.3 坑三返回键退出页面后录音机还在响状态机做到位之后界面层的逻辑看起来很完整但真实用户并不会遵守你的状态流。录制过程中按系统返回键Flutter 默认会销毁页面Cubit close 了可原生层的 AudioCapturer 如果没有同步 stop 和 release它会继续采集麦克风指示灯一直亮着后台还残留一个录音进程。修复方案是三件事用 PopScope 拦截返回手势或返回键如果当前状态是 recording 或 paused弹确认对话框“录音尚未保存确定退出吗”确认退出时先调原生 stop 并 release再让页面真正 pop在页面 dispose 里补一道保险再调一次_repository.stop()应对其他非返回路径的页面销毁。PopScope( canPop: false, onPopInvokedWithResult: (didPop, result) async { if (didPop) return; final shouldExit await _confirmExit(context); if (shouldExit) { await _cubit.stop(); if (context.mounted) { Navigator.of(context).pop(); } } }, child: _buildRecorderPanel(), )这道保险很关键因为不止返回键会销毁页面系统内存不足触发重建、异常路由跳转、甚至是开发调试时的热重载都可能让 Dart 对象被回收而原生资源没释放。把资源释放写进原生层的生命周期感知里更稳妥我最终在原生侧也监听 onDestroy确保 Flutter 失联时录音资源也能被回收。7. 实测数据、兜底策略与最后一点建议7.1 关键指标实测完成上述改造后我在两台设备上做了对比测试一台是 HarmonyOS 6.0 的中端机一台是旧款 HarmonyOS 5.0 设备。数据如下指标HarmonyOS 6.0 中端机HarmonyOS 5.0 旧设备按钮点击到录音状态生效约 180ms约 260ms波形重绘频率30fps30fps录音时 CPU 占用增量约 8%约 14%WAV 文件体积5.2MB/分钟5.2MB/分钟后台持续录音 10 分钟正常正常快速连点 20 次按钮无崩溃无崩溃CPU 差异主要来自旧设备对 AudioCapturer 的 PCM 搬运效率偏低波形绘制开销两者基本一致。对比下来30fps 重绘加 120 点滑动窗口的方案在中端机上的表现非常稳定页面其他部分依旧保持 60fps。7.2 崩溃兜底录到一半怎么挽救录音最怕的不是崩溃而是崩溃后用户一无所获。我的兜底方案是写临时文件机制录音先写到recordings/pending/目录下文件名带时间戳录制完成且用户确认保存后才移动到正式的录音目录。应用下次启动时扫描 pending 目录发现残留文件就恢复元数据在列表页提示“有一条未保存的录音是否恢复”。恢复时补上实际时长但不上传云端只有用户主动保存才进入正式库。这套机制不复杂但把“录音丢失”这件事从绝望变成了可恢复用户口碑差异很大。7.3 给后来者的三条实操建议第一录音功能必须在真机上测模拟器没有麦克风而且模拟器的音频框架和真机差异很大很多权限问题在模拟器里根本不会暴露。第二调试时给原生层加足够的日志尤其是 AudioCapturer 的 start、read 返回长度、异常信息这三类日志能让你在出问题时快速定位到底是权限、编码还是设备问题。第三不要迷信某个录音插件在 Android 上能用就以为 HarmonyOS 也能用HarmonyOS 6.0 的音频栈是独立实现的越早自己掌握通道层后面适配越省心。最后再分享一个小技巧波形数据除了画折线还可以存一份低采样率的摘要录音结束后画成静态缩略图放在录音列表里。用户扫一眼缩略图就能判断这段录音哪里是空白、哪里是内容这是 EchoMusic 后来越用越顺手的一个小功能实现成本只有几十行代码。
返回列表