开发 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 应该显示什么 |
|---|---|---|---|---|
| false | false | false | 空闲 | 开始按钮 |
| true | false | false | 录制中 | 暂停按钮 + 波形 |
| true | true | false | 已暂停 | 继续按钮 |
| false | false | true | 出错 | 错误提示 + 重置 |
| true | false | true | 录制中但出错 | ? |
第五行这种状态在真实场景里完全可能出现:录音过程中权限被回收、磁盘写入失败、底层音频会话被系统打断,都会让“正在录音”和“已出错”同时成立。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'); Stream<double> get audioLevel => _levelChannel .receiveBroadcastStream() .map((event) => (event as num).toDouble()); Future<void> start() async { await _commandChannel.invokeMethod('start', { 'sampleRate': 44100, 'channels': 1, 'sampleFormat': 'S16LE', 'outputPath': _buildOutputPath(), }); } Future<void> 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): Promise<void> { 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 点开始进入 recording,recording 点暂停进入 paused,paused 点继续回到 recording,recording 或 paused 点停止进入 done,done 返回 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 List<Object?> get props => [status, duration, amplitude]; } class RecorderCubit extends Cubit<RecorderState> { RecorderCubit(this._repository) : super(const RecorderState()); final RecordingRepository _repository; Timer? _ticker; Future<void> 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; } } Future<void> _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)); } } Future<void> _pause() async { await _repository.pause(); _ticker?.cancel(); emit(state.copyWith(status: RecorderStatus.paused)); } Future<void> _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)); } Future<void> stop() async { await _repository.stop(); _ticker?.cancel(); emit(state.copyWith(status: RecorderStatus.done)); } @override Future<void> 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): Promise<boolean> { 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 List<double> 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 = List<double>.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 定位是语音备忘,我选了 WAV,44100Hz、16bit 单声道下每分钟约 5MB,可以接受。
封装 WAV 文件头这段逻辑不复杂,但容易写错:RIFF 块大小要算上文件总长度减 8,fmt 块的数据块大小固定 16,data 块大小要等录音结束才能回填。顺序是先写文件头,录音过程中在 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 |
| 波形重绘频率 | 30fps | 30fps |
| 录音时 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 后来越用越顺手的一个小功能,实现成本只有几十行代码。