1. 项目概述与整体方案
1.1 为什么盯上了 Flutter × OpenHarmony 这个组合
先说个背景。我一直在做跨平台音乐播放器类应用,过去主要跑 Android 和 iOS 两套。OpenHarmony 生态近两年势头很猛,尤其面向带屏幕的物联网设备、智能座舱、教育平板这类场景,音频类应用几乎是刚需。于是我把一个原本基于 Flutter 的播放器 Demo 迁移到了 OpenHarmony 上跑,其中一个比较有代表性的模块,就是录音功能里的两个按钮:录音控制按钮和录音状态按钮。
这俩按钮看着不起眼,但真正做起来牵扯的东西不少。控制按钮管开始、停止、暂停,状态按钮管空闲、录音中、暂停中、已完成,背后还要驱动音频采集、文件写入、波形刷新一系列逻辑。标题里把这两个按钮单独拎出来说,其实是因为它们在 UI 层和业务层的耦合特别典型,能说明白这一块,整个 Flutter × OpenHarmony 的跨端开发套路也就通了七七八八。
回到技术选型本身。为什么不用纯 OpenHarmony 的 ArkUI 去写?因为我的项目是移动端和鸿蒙设备端共用一套代码库,Flutter 做 UI 层能省掉两套界面维护成本。为什么 Flutter 能跑在 OpenHarmony 上?因为 OpenHarmony 的底层是 Linux 内核加自研的用户态框架,Flutter 引擎通过社区适配层把自己“翻译”成 OpenHarmony 能调用的原生能力,最核心的就是让 Flutter 的纹理渲染接入了 OpenHarmony 的图形栈。这套东西目前在 GitHub 上有对应的开源适配仓库,社区版本已经能支撑实际业务落地了。
1.2 这个项目具体要解决什么问题
一句话概括:在 Flutter 渲染的界面上,点“开始录音”按钮,调用 OpenHarmony 原生层的音频采集能力,录完点“停止”,音频文件落盘,同时状态按钮全程正确反映录音生命周期。
拆开来看有四个子问题:
- UI 层按钮的状态管理,包括点击后的禁用、文案切换、颜色变化,这属于 Flutter 侧 StatefulWidget 的常规活。
- Flutter 和 OpenHarmony 原生层的数据通道,这里用 MethodChannel 或平台通道的 OpenHarmony 变体来桥接。
- OpenHarmony 原生层的音频采集代理封装,需要调用系统音频采集 API,同时处理权限申请。
- 录音过程中的异常处理,比如权限被拒、存储不可用、录音时长超限。
这四个子问题里,最容易翻车的是第二条。Flutter 官方标准通道在 OpenHarmony 上的实现细节和 Android 不完全一样,有一个叫 FlutterOpenHarmony 的适配层封装了通道和引擎,如果你直接照搬 Android 那边的写法,大概率会踩到注册不上的坑。
后面几节我会把这个项目的完整实操过程铺开讲,按“思路拆解 → 核心细节 → 具体实现 → 踩坑排查”的顺序走,最后再补一点我个人迭代下来觉得值得记住的经验。如果你正打算在 OpenHarmony 上做 Flutter 应用,或者单纯想了解跨端适配怎么做音频这种偏底层能力的功能,这篇可以直接当参考手册用。
2. 按钮模块设计思路与状态模型
2.1 两个按钮各自负责什么
先定边界。录音控制按钮管的是“用户动作”的入口:按下去要干什么,是开始还是停止,是不是允许打断当前状态。录音状态按钮只管“当前系统状态”的呈现:录音有没有在进行,是不是在暂停中,录音结束后是否处于待处理状态。
很多新手容易把这两个按钮混成一坨,点击逻辑、状态渲染、数据请求全部塞在一个 Widget 里,后面需求一扩就崩。这个项目从一开始就做了严格的职责拆分:
控制按钮是一个可交互的按钮组件,监听用户手势后分发命令事件,不持有业务数据。状态按钮是一个纯展示组件,接收上游状态枚举,根据枚举切换视觉表现,不响应点击。
这样拆分的好处直接体现在测试和改需求上。比如后面产品说“录音状态下控制按钮要变成暂停图标”,你只需要改控制按钮的展示分支,状态按钮完全不用动。又比如要加一个“录音超 15 分钟自动停止”的规则,状态按钮那边只需要多监听一个超时状态值就行。
2.2 录音状态机的定义与流转
录音按钮要正确工作,必须先定义一套清晰的状态机。我在项目里用的是五态模型:
| 状态 | 含义 | 控制按钮表现 | 状态按钮表现 |
|---|---|---|---|
| idle | 空闲待机 | 可点击,显示“开始录音” | 灰色圆点,文字“未录音” |
| recording | 录音中 | 可点击,显示“停止录音” | 红色圆点闪烁,文字“录音中 mm:ss” |
| paused | 暂停中 | 可点击,显示“继续录音” | 黄色圆点,文字“已暂停” |
| stopping | 停止中(过渡态) | 禁用,显示“处理中” | 橙色圆点,文字“正在保存” |
| finished | 已完成 | 可点击,显示“开始录音” | 绿色圆点,文字“已保存” |
这个模型核心是加了 stopping 这个过渡态。实践中如果不加它,用户快速连点“停止”会导致原生层重复回调,轻则文件写坏,重则崩溃。有了过渡态,控制按钮在停止指令发出后立即变为禁用状态,等原生层确认文件落盘再切到 finished,这就是一种自然的防抖。
状态流转上,我规定只有 idle 能进 recording,recording 能进 paused,paused 能回 recording,recording 和 paused 都能进 stopping,stopping 完成后只能回 idle 或 finished。所有状态变更统一收口到一个 ChangeNotifier 里分发,不在 Widget 内部零散 setState。
2.3 用 ChangeNotifier 还是 Bloc
状态管理我选的是 ChangeNotifier 加一个轻量封装。理由很简单:项目里真正需要全局共享的状态就是录音状态机这一个,用不上 Bloc 那一整套的 Event/State 拆分。你如果项目大、团队多人协作,Bloc 更稳,但这里用 ChangeNotifier 能把代码量减少一半。
具体结构是:
class RecorderController extends ChangeNotifier { RecorderStatus _status = RecorderStatus.idle; RecorderStatus get status => _status; void update(RecorderStatus newStatus) { if (_status == newStatus) return; _status = newStatus; notifyListeners(); } }控制按钮和状态按钮都通过ListenableBuilder监听同一个 RecorderController 实例。控制按钮在回调里判断当前状态来决定发出什么命令,状态按钮在 builder 里根据状态值切换图标、文案、颜色。
这套写法看起来简单,但有个好习惯值得养成:notifyListeners之前一定要判断状态是否真的变了,不然会出现连续点击开始按钮触发两次录音启动的 bug,我在开发最初的版本里就踩过,后面细讲。
3. 核心细节拆解:按钮的交互与视觉实现
3.1 控制按钮的交互状态与手势处理
控制按钮的 UI 我用了一个自定义的圆角矩形按钮,内部包含图标和文字两部分。不直接用系统自带按钮,是因为需要同时控制点击态、加载态、禁用态三种视觉反馈,自定义一个 StatelessWidget 反而更干净。
核心代码骨架如下:
class RecordControlButton extends StatelessWidget { final RecorderStatus status; final VoidCallback onPressed; final bool enabled; const RecordControlButton({ super.key, required this.status, required this.onPressed, this.enabled = true, }); @override Widget build(BuildContext context) { final (icon, label) = switch (status) { RecorderStatus.idle => (Icons.mic_none, '开始录音'), RecorderStatus.recording => (Icons.stop_circle_outlined, '停止录音'), RecorderStatus.paused => (Icons.play_circle_outline, '继续录音'), RecorderStatus.stopping => (Icons.hourglass_top, '处理中'), RecorderStatus.finished => (Icons.mic_none, '开始录音'), }; return Opacity( opacity: enabled ? 1.0 : 0.5, child: FilledButton.icon( onPressed: enabled ? onPressed : null, icon: Icon(icon), label: Text(label), style: FilledButton.styleFrom( minimumSize: const Size(160, 52), backgroundColor: status == RecorderStatus.recording ? Colors.redAccent : Colors.blueAccent, ), ), ); } }这里用了 Dart 3 的 switch 表达式做映射,代码更紧凑。实际开发中我还在按钮外围包了一层GestureDetector,用来处理“侧滑取消”的交互——用户按住按钮后划出按钮区域,本次点击作废,这个对录音这种不可逆操作很实用,能防误触。
3.2 状态按钮的实时刷新与动画
状态按钮这一侧要解决的是“怎么把录音秒数实时刷起来”。如果单纯 setState 每秒刷新整个 Widget 树,渲染开销虽说不至于卡,但会有多余重建。我采用的方法是:监听录音进度流,只刷新状态按钮内部。
录音进度用 Stream 来推:
class RecordStatusButton extends StatefulWidget { final RecorderController controller; final Stream<Duration> tickStream; ... } class _RecordStatusButtonState extends State<RecordStatusButton> { Duration _elapsed = Duration.zero; StreamSubscription<Duration>? _sub; @override void initState() { super.initState(); _sub = widget.tickStream.listen((duration) { setState(() => _elapsed = duration); }); } @override void dispose() { _sub?.cancel(); super.dispose(); } }动画部分,录音中那个红色圆点用了 Flutter 内置的AnimationController做的透明度呼吸闪烁,时长 0.8 秒,循环播放。收到 tick 更新时不需要重启动画,动画和文字刷新是两套逻辑,互不干扰。这点比较关键。
还有一个容易忽略的细节:状态按钮在 finished 状态时要显示录音总时长,而不是最后那个 tick 的累计值。所以我在 controller 里额外存了一个totalDuration字段,finished 时优先读它,避免 ui 层相互覆盖。
3.3 布局中的层级分离策略
两个按钮在页面不同位置,控制按钮在底部操作栏,状态按钮在左上角信息区。我把它们分成两个独立的 Widget 挂在页面上,通过同一个 controller 联动。
布局结构大致是:
Scaffold( body: Stack( children: [ // 主内容区:播放列表 / 波形显示 const PlayerBody(), // 左上角状态按钮 Positioned( top: 24, left: 16, child: RecordStatusButton(controller: _controller, tickStream: _tickStream), ), ], ), bottomNavigationBar: SafeArea( child: RecordControlButton( status: _controller.status, onPressed: _handleControlPressed, enabled: _controller.status != RecorderStatus.stopping, ), ), )这种分离的好处是布局调整时不会牵扯业务逻辑。比如后续想加一个“长按状态按钮弹出录音详情”,只需要在状态按钮内部包一层 GestureDetector,不需要动控制按钮的任何代码。
4. 实操过程:从通道搭建到录音落盘全流程
4.1 Flutter 与 OpenHarmony 原生层的通道初始化
Flutter 跑在 OpenHarmony 上,通道机制与 Android 端基本同构,但注册方式不同。OpenHarmony 的 Flutter 适配层暴露了PlatformChannel的注册接口,你需要在 OpenHarmony 原生工程的某个入口文件里显式注册。
具体做三步:
第一步,在 OpenHarmony 的 EntryAbility 或类似能力入口中创建一个自定义的 PlatformChannel:
// RecorderChannel.ets import { PlatformChannel } from '@ohos/flutter_ohos'; export class RecorderChannel extends PlatformChannel { constructor() { super('com.example.recorder/channel'); } override handleMethodCall(call: MethodCall): Promise<any> { switch (call.method) { case 'startRecord': return this.startRecord(call.arguments as RecordParams); case 'stopRecord': return this.stopRecord(); case 'pauseRecord': return this.pauseRecord(); default: return Promise.reject(new Error(`Unknown method: ${call.method}`)); } } // ... 具体实现 }第二步,注册通道:
// 在初始化 Flutter 引擎时 flutterEngine.registerPlatformChannel(new RecorderChannel());第三步,Flutter 侧调用:
class RecorderApi { static const MethodChannel _channel = MethodChannel('com.example.recorder/channel'); static Future<void> startRecord() async { await _channel.invokeMethod('startRecord', { 'outputPath': await getOutputPath(), 'sampleRate': 44100, }); } static Future<void> stopRecord() async { await _channel.invokeMethod('stopRecord'); } }这里有个坑我必须提一下:OpenHarmony 适配版 Flutter 的 MethodChannel 支持的是Promise异步回传,而 Android 原生是Result回调,两边写法别搞混。如果你把 Android 的result.success()那套直接搬到 OpenHarmony,会发现接口根本不匹配。
4.2 OpenHarmony 原生层录音代理实现
原生层要做的事,核心是调用 OpenHarmony 的音频采集能力。当前 OpenHarmony 提供的 API 是@ohos.multimedia.audio中的AudioCapturer。
一个最小可用的录音实现长这样:
import audio from '@ohos.multimedia.audio'; let capturer: audio.AudioCapturer | null = null; let destPath: string = ''; async function startRecord(params: RecordParams) { // 先申请权限 const granted = await abilityAccessCtrl.createAtManager() .requestPermissionsFromUser(['ohos.permission.MICROPHONE']); if (!granted) { throw new Error('Microphone permission denied'); } const streamInfo: audio.AudioStreamInfo = { samplingRate: params.sampleRate ?? 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, }, }; capturer = await audio.createAudioCapturer(capturerOptions); await capturer.start(); // 这里把 PCM 数据写入文件 await writePcmToFile(capturer, destPath); } async function stopRecord() { if (capturer) { await capturer.stop(); await capturer.release(); capturer = null; } }PCM 数据写入文件部分我单独封装了一个AudioFileWriter类,它维护FileOutputStream,在startRecord时打开,在stopRecord时 flush 并 close,中间持续写入回调里的字节数组。
4.3 录音文件格式选择与封装
原生层录出来的原始数据是 PCM,直接拿给播放器用不了。对音乐播放器应用来说,最后落盘格式必须封装成 AAC 或 WAV 这种有文件头的格式。
我在这个项目里做了两个方案:
- 调试用 WAV:文件头简单,前端可以裸解析,方便验证通道通不通。
- 正式用 AAC:体积小,播放器通用。
WAV 封装很简单,就是一个 44 字节的 RIFF 头加 PCM 数据。我在 OpenHarmony 这边写了一个工具函数,在 stop 时根据样本数推算文件大小填进头部。AAC 封装则直接调 OpenHarmony 的编码器 API,把 PCM 喂给系统编码器,拿到编码后的数据再写文件。
这里的经验是:前期调试一定先用 WAV,等业务链路全通了再切 AAC,否则你很难判断是通道的问题还是编码器参数配错了的问题。
4.4 权限申请与生命周期绑定
录音权限申请不能等到原生层才做,应该在 Flutter 页面进入时就启动预申请。原因很简单:原生层的requestPermissionsFromUser是异步弹窗,如果用户正在点击“开始录音”的瞬间弹窗遮住界面,点击事件会被吞掉,状态机就卡住了。
我的做法是在录音页面的initState里发起权限预申请:
@override void initState() { super.initState(); RecorderApi.preRequestPermission().then((granted) { if (!granted) { // 更新状态按钮为“无权限”态 _controller.update(RecorderStatus.noPermission); } }); }同时,页面dispose时要兜底停止录音,防止用户带着录音直接退出页面,资源不释放。这个在移动端尤其重要——录音不停止,后台会一直占用麦克风,其他应用录音会失败,还会导致系统报错。
4.5 录制时长的精确计算
状态按钮上显示的mm:ss来自 Stream 里推的 duration。这里有个细节:duration 不能简单用“当前时间减开始时间”来算,因为录音中间有暂定、恢复的过程。
我的做法是累计增量:
Duration _accumulated = Duration.zero; DateTime? _lastStartTime; void onResume() { _lastStartTime = DateTime.now(); } void onPause() { if (_lastStartTime != null) { _accumulated += DateTime.now().difference(_lastStartTime!); _lastStartTime = null; } } Duration get currentDuration { if (_lastStartTime == null) return _accumulated; return _accumulated + DateTime.now().difference(_lastStartTime!); }这套算法既能在暂停时冻结时间,又能在恢复后继续累积。状态按钮的每秒刷新来自一个 Timer.periodic,每秒读取一次 currentDuration 并推给 UI,而不是依赖原生层回调,这样即使原生线程繁忙,UI 上的秒表也不会跳秒。
5. 问题排查与避坑经验
5.1 点击开始按钮后没有任何反应
这个现象多半是通道没注册成功。我之前在模拟器上调试,一直调invokeMethod,但原生层的handleMethodCall始终没收到消息。排查下来发现是 OpenHarmony 适配版 Flutter 的通道注册必须在引擎加载完成后立刻注册,不能放在某个 Activity 的 onCreate 里等一会儿再注册。
正确做法是引擎创建后同步注册:
flutterEngine.registerPlatformChannel(new RecorderChannel());另外,通道名称两端要完全一致,Flutter 侧用'com.example.recorder/channel',原生侧也必须是同样字符串,漏一个斜杠都白搭。
5.2 录音文件写出来是空的
文件存在但大小为零,通常是 stream 的读取循环提前退出了。原因在于AudioCapturer.read返回的可读字节数不确定,如果直接用固定 buffer 去读,可能还没读到数据就判断buffer.length == 0退出了。
稳定写法是循环读,直到 stop 标志位被置位:
while (!stopFlag) { const buffer = await capturer.read(bufferSize); if (buffer && buffer.byteLength > 0) { await writer.write(buffer); } }注意 bufferSize 要取capturer.getBufferSize()返回值,别自己拍脑袋定 1024,不同采样率下缓冲区大小差异很大,定小了会导致 read 一直返回空。
5.3 状态按钮显示与实际录音状态不一致
最常见的原因是 Flutter 侧状态机和原生层回调不同步。比如原生层因为系统来电自动停止了录音,但 Flutter 侧只靠用户点击按钮来更新状态,于是 UI 卡在 recording,实际已经没在录了。
我的解法是让原生层在录音异常终止时主动向 Flutter 推送状态:
this.sendEvent('onRecordInterrupted', { reason: 'phoneCall' });Flutter 侧提前在通道上注册EventChannel监听:
_eventChannel.receiveBroadcastStream().listen((event) { if (event == 'onRecordInterrupted') { _controller.update(RecorderStatus.finished); } });这套“原生主动推、Flutter 被动收”的机制,能兜住来电中断、低电量强制回收、音频焦点抢占等场景。
5.4 Android 迁移代码的兼容性
从 Android 迁这个音乐播放器项目过来时,代码不能无脑搬。我遇到的主要有下面几个点:
AudioManager相关 API 在 OpenHarmony 没有对应概念,需要用 OpenHarmony 的AudioStreamManager替代音频焦点判断。SharedPreferences换成 OpenHarmony 的Preferences,接口风格类似但异步返回结构不同。MediaRecorder直接废弃,改用AudioCapturer自己封装录音流程。- 权限字符串发生变化,
RECORD_AUDIO不存在,必须申请ohos.permission.MICROPHONE。
建议在项目初期就把这些差异列成一张兼容表,后续每个功能开发前先查表,能省掉一大半 debug 时间。
6. 对后续扩展的一些想法
录音控制按钮和状态按钮只是整个播放器里的一个小切片,但把这个模块打磨顺了,后面扩展音频相关的功能会顺畅很多。我自己规划里已经列了三件事:
第一,波形显示。录音时把 PCM 数据的振幅实时转成波形图,画在状态按钮下方。波形数据源可以直接复用现在的 tick Stream,把振幅峰值一并推给 UI,用 CustomPainter 绘制,不用额外开通道。
第二,多轨混音。音乐播放器经常需要录一段人声再叠背景音乐,这就涉及录音和播放同时进行。OpenHarmony 的音频通路要支持同时采集和播放,目前看需要配置低延迟模式,后续值得单独测一轮。
第三,录音文件的导入导出。和系统文件管理模块打通,把录音文件暴露到系统相册或文件管理器中,这块需要接 OpenHarmony 的文件访问框架,和按钮逻辑本身无关,但直接影响用户体验。
最后再分享一点个人习惯:每次改完状态机或按钮逻辑,我会手动把录音流程完整点一遍——空闲进录音、录音暂停、暂停恢复、录音停止、停止后再进录音,同时观察状态按钮的颜色、文案、动画是否在每一步都正确切换。这套手自回归虽然费时间,但对音频这种涉及原生资源和 UI 状态强耦合的功能来说,比任何单元测试都靠谱。
本文是纯技术记录,没有平台倾向,只是给同样在 Flutter × OpenHarmony 上折腾的朋友提供一个可参照的样本。