Flutter/OpenHarmony 三方库适配实战:sound_stream 音频流
音频流插件的适配难点不在“把麦克风打开”,而在于如何让同一条 DartMethodChannel在 OHOS 上继续传递 PCM 数据、回传状态、处理权限,并在页面退出
时释放原生资源。sound_stream原本面向 Android 和 iOS,Dart 层使用RecorderStream和PlayerStream暴露采集、播放、状态和扬声器切换能力。
本次适配版本为0.4.2-ohos.1,目标是保持这些接口不变,只补齐 OHOS HAR
实现。
一、项目信息与适配目标
| 项目 | 内容 |
|---|---|
| 仓库名称 | flutter_sound_stream_zh |
| 原始包名 | sound_stream |
| 适配版本 | 0.4.2-ohos.1 |
| 方法通道 | vn.casperpas.sound_stream:methods |
| 音频格式 | PCM S16LE、单声道 |
| 本地状态 | Dart 分析、示例测试、debug Hap 构建已通过;虚拟机运行待测 |
适配链路如下:
RecorderStream / PlayerStream | v MethodChannel("vn.casperpas.sound_stream:methods") | v SoundStreamPlugin (ArkTS) | v AudioCapturer + AudioRenderer | v PCM Uint8List / status events二、先保住 Dart API,不让业务代码分叉
原库的 Dart 层已经把麦克风数据封装成Stream<Uint8List>,播放端接收同样的
字节流。如果为 OHOS 单独设计一套类名或方法,业务页面就必须写平台判断,音频
数据也会在不同平台产生不同格式。因此本次没有修改RecorderStream和PlayerStream的公开接口。
例如初始化和采集仍然沿用原调用:
finalrecorder=RecorderStream();finalplayer=PlayerStream();awaitFuture.wait([recorder.initialize(sampleRate:16000),player.initialize(sampleRate:16000),]);recorder.audioStream.listen((Uint8Listchunk){player.writeChunk(chunk);});awaitrecorder.start();awaitplayer.start();方法通道覆盖的名称也保持不变:
| Dart 方法 | OHOS 原生动作 |
|---|---|
initializeRecorder | 申请麦克风权限并创建AudioCapturer |
startRecording/stopRecording | 启动或停止采集循环 |
initializePlayer | 创建AudioRenderer |
startPlayer/stopPlayer | 启动或停止渲染 |
writeChunk | 校验 PCM 字节并写入渲染器 |
usePhoneSpeaker | 通过AudioRoutingManager切换通信设备 |
三、让 Flutter OHOS 工具链找到插件
只创建ohos/目录并不够,插件根pubspec.yaml必须声明 OHOS 平台入口:
version:0.4.2-ohos.1flutter:plugin:platforms:android:package:vn.casperpas.sound_streampluginClass:SoundStreamPluginios:pluginClass:SoundStreamPluginohos:pluginClass:SoundStreamPluginOHOS 模块按普通 HAR 配置:
{ "module": { "name": "sound_stream", "type": "har", "deviceTypes": ["default", "tablet"] } }ohos/index.ets只负责导出 ArkTS 插件类,避免示例工程直接依赖内部文件:
importSoundStreamPluginfrom'./src/main/ets/components/plugin/SoundStreamPlugin';exportdefaultSoundStreamPlugin;这三层声明分别回答三个问题:Dart 如何注册平台实现、Hvigor 如何编译 HAR、
示例应用如何加载入口。
四、权限:不能在插件加载时就索取麦克风
插件被 Engine 加载时可能还没有进入录音页面。如果此时立即调用权限接口,
应用会在用户没有发起音频操作时弹窗,也不利于生命周期管理。OHOS 实现将权限
请求放在initializeRecorder中:
privateasynchasMicrophonePermission():Promise<boolean>{constcontext=this.uiAbilityContext;if(context===null){returnfalse;}constpermissions:Permissions[]=[MICROPHONE_PERMISSION];constrequest=awaitabilityAccessCtrl.createAtManager().requestPermissionsFromUser(context,permissions);returnrequest.authResults.length>0&&request.authResults.every((value:number)=>value===0);}插件实现AbilityAware,在onAttachedToAbility中保存当前 UIAbility
Context,在onDetachedFromAbility中清空引用。权限和音频设备都依赖当前
Ability,不能只保存 ApplicationContext。
示例应用的entry/src/main/module.json5同时声明权限和用途:
{"name":"ohos.permission.MICROPHONE","reason":"$string:microphone_permission_reason","usedScene":{"abilities":["EntryAbility"],"when":"inuse"}}权限失败通过result.error('permission_denied', ...)返回,Dart 层不会得到
一个虚假的true。
五、采集循环:把 AudioCapturer 变成 Dart 数据流
初始化时统一使用单声道 PCM S16LE:
conststreamInfo:audio.AudioStreamInfo={samplingRate:sampleRate,channels:audio.AudioChannel.CHANNEL_1,sampleFormat:audio.AudioSampleFormat.SAMPLE_FORMAT_S16LE,encodingType:audio.AudioEncodingType.ENCODING_TYPE_RAW,};constcapturerInfo:audio.AudioCapturerInfo={source:audio.SourceType.SOURCE_TYPE_MIC,capturerFlags:0,};this.recorder=awaitaudio.createAudioCapturer({streamInfo,capturerInfo});启动录音后,ArkTS 维持一个异步读取循环。读取到字节就转换为Uint8Array
并通过dataPeriod事件发回 Dart:
while(this.recorderLoopRunning&&recorder===this.recorder&&recorder.state===audio.AudioState.STATE_RUNNING){constbuffer=awaitrecorder.read(RECORD_BUFFER_SIZE,true);if(buffer.byteLength>0){this.sendEvent('dataPeriod',newUint8Array(buffer));}}这里的三个条件缺一不可:停止标志让循环退出,引用比较避免释放旧对象后继续
读取,状态检查避免已经停止的采集器被反复唤醒。Dart 端只需监听原有流:
_audioStream=_recorder.audioStream.listen((data){_micChunks.add(data);});六、播放端:保留原始 PCM,不引入文件缓存
播放器初始化同样使用 PCM S16LE 单声道,然后接收 Dart 传来的字节:
constrendererInfo:audio.AudioRendererInfo={usage:audio.StreamUsage.STREAM_USAGE_MUSIC,rendererFlags:0,};this.renderer=awaitaudio.createAudioRenderer({streamInfo,rendererInfo});writeChunk会先检查参数类型,再切出当前Uint8List对应的缓冲区,避免把
一个已经被 Dart 回收的视图直接交给原生层:
constvalue:ESObject=call.argument('data');letbytes:Uint8Array;if(valueinstanceofUint8Array){bytes=valueasUint8Array;}elseif(valueinstanceofArrayBuffer){bytes=newUint8Array(valueasArrayBuffer);}else{result.error('invalid_argument','data must be a byte array',null);return;}constbuffer=bytes.buffer.slice(bytes.byteOffset,bytes.byteOffset+bytes.byteLength,)asArrayBuffer;awaitrenderer.write(buffer);播放在startPlayer时启动,在stopPlayer或插件销毁时停止。渲染器和
采集器都在releaseRecorder、releaseRenderer中执行停止和释放,释放失败
只记录为 teardown 过程中的尽力而为,不让页面退出被异常阻塞。
七、扬声器切换与状态回传
usePhoneSpeaker不直接修改 Dart 变量,而是调用 OHOS 通信设备路由:
constrouting=audio.getAudioManager().getRoutingManager();awaitrouting.setCommunicationDevice(audio.CommunicationDeviceType.SPEAKER,value,);状态事件继续使用原库约定的字符串:
privatesendEvent(name:string,data:Object):void{constpayload:Record<string,Object>={'name':name,'data':data,};this.channel.invokeMethod('platformEvent',payload);}Dart 端因此可以继续把recorderStatus和playerStatus映射为SoundStreamStatus.Playing、Stopped等枚举,不需要增加 OHOS 专用状态。
八、示例工程与权限体验
示例应用保留原有的三个主要动作:
- 设置按钮初始化录音器和播放器;
- 麦克风按钮开始或停止采集;
- 播放按钮开始或停止渲染,并支持扬声器切换。
example/test/widget_test.dart改成验证真实界面标题和设置入口,避免模板
计数器测试与当前音频界面不一致。example/test/golden_test.dart使用 Flutter
golden 渲染同一界面,生成本地可复现的界面图:
这张图是本地 Flutter 渲染证据,不是 OHOS 真机截图。虚拟机运行后,应补充麦克风
权限弹窗、录音状态变化、播放状态和扬声器切换的设备截图。
九、验证过程与结果
本次执行的完整命令如下:
flutter pub get flutter analyzecdexample flutter pub get flutter analyze fluttertestflutter build hap--debug--no-codesign当前环境结果:
| 检查项 | 结果 |
|---|---|
根包flutter analyze | 通过,No issues found |
示例flutter analyze | 通过,No issues found |
示例flutter test | 通过,界面测试和 golden 测试均通过 |
flutter build hap --debug --no-codesign | 通过 |
| 生成 Hap | example/build/ohos/hap/entry-default-unsigned.hap |
| 虚拟机/真机运行 | 待测试,当前环境无 OHOS 设备 |
Hvigor 成功只证明 HAR、entry 模块、Dart 产物和权限配置可以组合成 Hap,不
等于麦克风权限、音频设备和路由策略已经在设备上验证。这个边界必须保留。
十、虚拟机测试清单
拿到虚拟机后建议按顺序验证:
- 安装
entry-default-unsigned.hap,确认应用能够启动。 - 点击设置按钮,观察麦克风权限弹窗和初始化结果。
- 点击麦克风按钮,确认状态事件变为
Playing,日志中出现非空 PCM 数据。 - 点击播放按钮,确认
AudioRenderer启动并能听到声音。 - 切换扬声器按钮,记录设备是否支持通信设备路由切换。
- 返回页面或退出应用,确认没有继续读取麦克风、没有重复回调。
- 截取权限、录音、播放、扬声器切换和退出后的界面,作为后续回归证据。
如果出现permission_denied,先检查module.json5和权限 reason;如果状态
事件没有变化,检查SoundStreamPlugin是否注册、onAttachedToAbility是否
拿到 UIAbility Context、方法名是否完全一致;如果有状态但没有声音,再检查
采样率、单声道配置和writeChunk的字节类型。
十一、适配总结
这次适配的价值在于把“能编译的 OHOS 文件”推进成一条可追踪的音频链路:
- Dart API 和方法通道保持原样,业务调用不需要增加 OHOS 分支。
- 权限在初始化录音时申请,并通过明确的错误码返回失败。
AudioCapturer、AudioRenderer和路由接口承担平台能力,数据不落盘。- Ability 生命周期负责 Context,插件销毁时负责释放采集器和渲染器。
- Dart 分析、测试、golden 渲染和 Hap 构建构成当前的自动化验收。
- 虚拟机运行、真实权限、声音和路由表现单独记录,不与构建成功混为一谈。
后续升级 Flutter OH 或 HarmonyOS SDK 时,应重点回归AudioCapturer.read、AudioRenderer.write、setCommunicationDevice的签名与权限策略,并保留新的
设备截图。
欢迎加入 CPF-Flutter 鸿蒙社区,一起交流 Flutter 三方库适配、工程实践和
虚拟机/真机验证经验:
https://atomgit.com/CPF-Flutter