Flutter for OpenHarmony 实战:三方库 flutter_pcm_sound 的鸿蒙化适配指南
环境搭建指引:https://atomgit.com/CPF-Flutter/flutter_samples/blob/master/ohos/getting-started/flutter-oh-env-setup.md
flutter_pcm_sound 干的事情很纯粹:把应用自己算出来的实时 PCM(16bit 小端)喂给扬声器,不是播放音频文件,而是给"边算边出声"的场景用的(合成器、音效引擎、TTS 前置处理等)。它的 3.3.3 版支持 Android、iOS、macOS,没有 OpenHarmony。
这个库的适配难点不在"接口多",而在播放模型正好相反:安卓是"我方起线程、阻塞写入设备",鸿蒙是"系统按缓冲区节奏来拉数据"。同一条 Dart 通道底下,这两种模型对"队列空了怎么办"的答案完全不同——本篇会把这件事讲透,并且用设备侧的静音字节统计把它量化出来。
适配对象:上游 flutter_pcm_sound 3.3.3(MIT,基线取 master fa4c977);适配产物 TAG 3.3.3-ohos-1.0.0-beta.1。
一、这个库要解决什么
1.1 上游 API
// 建流(iOS 分类、安卓用途属性都在这里给)
await FlutterPcmSound.setup(sampleRate: 44100, channelCount: 1);
// 设回调阈值:排队帧数低于它时通知你"该喂了"
await FlutterPcmSound.setFeedThreshold(4410);
// 装回调;参数是"还排着多少帧"
FlutterPcmSound.setFeedCallback((int remainingFrames) {
if (remainingFrames == 0) { /* 队列空了 */ }
FlutterPcmSound.feed(PcmArrayInt16.fromList(frames));
});
// 也可以手动起步(只在还没开始喂的时候有用)
FlutterPcmSound.start();
// 释放
await FlutterPcmSound.release();
三个细节决定了适配的形状:
feed()是"投喂"而不是"播放":数据进队列就返回,什么时候真的出声由原生侧决定;- 回调是事件驱动、不是定时器驱动:库文档里只承诺两件事会触发回调——低水位事件(排队帧数低于阈值)和排空事件(排队帧数正好为 0),并且"每次
feed()之后各自最多触发一次"; PcmArrayInt16用Endian.host写字节:在 ARM 上就是小端,鸿蒙侧按 16bit 小端解析即可。
1.2 契约
Dart 层只用了一条方法通道和一条反向回调,没有事件通道:
class FlutterPcmSound {
static const MethodChannel _channel = const MethodChannel('flutter_pcm_sound/methods');
static Future<void> setLogLevel(LogLevel level) // {log_level: 0..3}
static Future<void> setup({...}) // {sample_rate, num_channels, ios_*, android_*}
static Future<void> feed(PcmArrayInt16 buffer) // {buffer: Uint8List}
static Future<void> setFeedThreshold(int t) // {feed_threshold}
static Future<void> release() // 无参
static Future<dynamic> _methodCallHandler(MethodCall call) {
case 'OnFeedSamples': // 原生 -> Dart
int remainingFrames = call.arguments["remaining_frames"];
_needsStart = remainingFrames == 0;
if (onFeedSamplesCallback != null) onFeedSamplesCallback!(remainingFrames);
}
}
setFeedCallback 里那句 _channel.setMethodCallHandler(_methodCallHandler) 是关键:反向回调的注册发生在 Dart 侧,原生只要在合适的时机 invokeMethod('OnFeedSamples', ...) 就行。
pubspec.yaml 的平台声明里也没有 ohos:
flutter:
plugin:
platforms:
android: { package: com.lib.flutter_pcm_sound, pluginClass: FlutterPcmSoundPlugin }
ios: { pluginClass: FlutterPcmSoundPlugin }
macos: { pluginClass: FlutterPcmSoundPlugin }
整个 Dart 层没有任何平台门(没有 Platform.is*、没有 defaultTargetPlatform 的 switch、没有 UnsupportedError),所以鸿蒙侧只要把 flutter_pcm_sound/methods 接住,Dart 一行都不用改——这正是可以适配的那类库。
二、选库:四道筛 + 在线查重
2.1 四筛
| 筛子 | 检查 | 结果 |
|---|---|---|
| ① pub.dev 平台列表 | 是否已含 ohos | [android, ios, macos],不含 → 需要适配 |
② 上游根目录是否有 <lib>_ohos 兄弟目录 / pub.dev 是否有 <lib>_ohos 包 | 官方是否已分离出鸿蒙实现 | 都没有 → 需要自己写 |
| ③ Dart 入口是否有平台门 | 有门就等于"鸿蒙被否决",改了 Dart 就违约 | 无平台门、无 UnsupportedError → 可适配 |
| ④ 依赖健康度 | 插件依赖里有没有"鸿蒙无实现"的包 | dependencies: flutter 一项,无第三方插件依赖 → 干净 |
第 ④ 条这次格外省事:这个库的卖点之一就是"零依赖"(除了 Flutter 本身和三个平台的系统音频 API),所以不存在"依赖的依赖没有鸿蒙实现"这种连带问题。
2.2 在线查重
离线快照会滞后(实测滞后一周以上,flutter_file_dialog、record、audioplayers 等都出现过"离线说干净、实际已被适配"的误判),所以每次都要在线过一遍四个组织的仓库:
node .agents/tools/live-dedup.mjs flutter_pcm_sound
结果里出现了一个假警报:
verdict : CHECK-MANUALLY
pub.dev : v3.3.3 platforms=[android,ios,macos] sdk=>=2.15.1 <4.0.0
atomgit : hxa-flutter/flutter_pcm_sound -> status=403
note : hxa-flutter/flutter_pcm_sound 状态未知 status=403
hxa-flutter 返回 403,脚本没法判断。这里不能靠猜,要做一次标定:拿一个确定存在、一个确定不存在的仓库名去撞同一个接口。
hxa-flutter/flutter_pcm_player -> 200 [android,example,ios,lib,ohos,test,...] # 存在
hxa-flutter/zzz_definitely_not_here_… -> 404 # 不存在
两者都不是 403,说明 403 既不是"存在"也不是"不存在"的通用码,单点探测不可信。最终定论用分页拉全量组织仓库列表:
node .agents/tools/org-grep.mjs hxa-flutter pcm sound audio
hxa-flutter: 共 123 个仓库
关键词 "pcm" -> 1 个
HXA·鸿蒙系统Flutter开源库社区 / flutter_pcm_player pushed=2026-08-06
关键词 "sound" -> 1 个
HXA·鸿蒙系统Flutter开源库社区 / native_camera_sound
关键词 "audio" -> 2 个
another_audio_recorder / audio_streamer
123 个仓库里没有 flutter_pcm_sound;flutter_pcm_player 是另一个库(同样做 PCM 播放,但不是同一个包),不构成重复适配。另外三个组织(oh-flutter / CPF-Flutter / oh-tpc)对 flutter_pcm_sound 都返回 404。结论:干净,可以适配。
顺手记一条经验:查重脚本给出
CHECK-MANUALLY时,别用"再撞一次接口"来消解,要用不同形态的接口(这里是从"单仓库探测"换成"组织仓库列表分页")去交叉验证,否则很容易把限流/异常码当成事实。
2.3 基线:发布版比 master 旧
克隆下来先对基线,这一步差点被"版本号一样"骗过去:
git clone https://gh-proxy.com/https://github.com/chipweinberger/flutter_pcm_sound.git
# HEAD = fa4c977 master pubspec version: 3.3.3
pubspec.yaml 里写的也是 3.3.3,看起来和 pub.dev 上的发布版一致。但逐文件对比(按 LF 归一化后比内容哈希)发现 6 个文件不同:
node .agents/tools/tree-diff.mjs _probe/cand08/flutter_pcm_sound _probe/fps_work
相同: 12 内容不同: 6 仅 A 有: 0 仅 B 有: 3
内容不同:
README.md
android/build.gradle
android/src/main/java/com/lib/flutter_pcm_sound/FlutterPcmSoundPlugin.java
lib/flutter_pcm_sound.dart <-- Dart 层就不一样
macos/Classes/FlutterPcmSoundPlugin.h
macos/Classes/FlutterPcmSoundPlugin.m
lib/flutter_pcm_sound.dart 的差值是"多出来"的:master 新增了 AndroidAudioUsage / AndroidAudioContentType / AndroidLegacyStreamType 三个枚举,setup() 也多送了三个参数。这就说明发布版 3.3.3 落后于 master,如果照着发布版写鸿蒙实现,会漏掉这三个参数。
基线取 master fa4c977,并且把这三个参数一并接住(见 4.4)。上游 tag 只有 1.0.0 / 1.0.1 两个远古版本,不可用作基线。
三、六步适配流程
第一步:把上游同步到 AtomGit
在 oh-flutter 组织下建仓(描述用中文,注意 AtomGit 的 POST /orgs/{org}/repos 才是建到组织下):
node .agents/tools/atomgit.mjs create oh-flutter flutter_pcm_sound "flutter_pcm_sound 的 OpenHarmony 适配(实时 PCM 播放)"
第二步:本地克隆(并用发布版核对基线)
git clone https://gh-proxy.com/https://github.com/chipweinberger/flutter_pcm_sound.git _probe/fps_work
工作区按序号放好,本篇对应 _probe/fps_work/。
第三步:建分支并补出鸿蒙目录
cd _probe/fps_work
git checkout -b feat/ohos_flutter_pcm_sound_3.3.3
flutter create -t plugin --platforms ohos .
这一步会生成 ohos/(插件 HAR)和 example/ohos/(示例工程),同时顺手塞进来一堆模板垃圾(见第六节)。pubspec.yaml 里补一行:
ohos:
pluginClass: FlutterPcmSoundPlugin
第四步:写鸿蒙实现
只新增一个文件:ohos/src/main/ets/components/plugin/FlutterPcmSoundPlugin.ets。
第五步:补全额外文件
README.OpenHarmony.md / README.OpenHarmony_CN.md / CHANGELOG.OpenHarmony.md,根 README.md 加一节 OpenHarmony;example/lib/main.dart 改造成"自检台"(见第七节);.gitignore 放开 example/ohos/(上游的 .gitignore 是 example/* 白名单式忽略,不放行的话示例工程进不了库)。
第六步:推送并打 TAG
git push atomgit feat/ohos_flutter_pcm_sound_3.3.3
git push atomgit HEAD:main
git tag -a 3.3.3-ohos-1.0.0-beta.1 -m "flutter_pcm_sound 3.3.3 OpenHarmony 适配 1.0.0-beta.1"
git push atomgit 3.3.3-ohos-1.0.0-beta.1
提交前必须清空 example/ohos/build-profile.json5 里的 signingConfigs——devecocli signature generate 会把证书路径和(明文)密码写进去,带上库等于泄漏。

四、代码写在哪个文件
ohos/
├── index.ets # export { default } from './src/main/ets/components/plugin/FlutterPcmSoundPlugin'
├── oh-package.json5 # name: flutter_pcm_sound, version: 3.3.3
├── build-profile.json5 / hvigorfile.ts # HAR 模板原文
└── src/main/
├── module.json5 # { name: flutter_pcm_sound, type: har }
└── ets/components/plugin/
└── FlutterPcmSoundPlugin.ets # 本篇唯一新增的实现文件
4.1 播放模型:安卓是"推",鸿蒙是"拉"
先把上游安卓的做法看清楚,因为它定义了"正确行为":
// setup(): 起一条线程
playbackThread = new Thread(this::playbackThreadLoop, "PCMPlaybackThread");
playbackThread.setPriority(Thread.MAX_PRIORITY);
playbackThread.start();
// 线程主体:队列取一块、阻塞写一块
while (!mShouldCleanup) {
ByteBuffer data = mSamples.take(); // 队列空 -> 挂起
mAudioTrack.write(data, data.remaining(), AudioTrack.WRITE_BLOCKING);
// 然后算 remainingFrames 并决定要不要回调 Dart
}
队列空的时候线程就阻塞,一个字节都不写——所以安卓实现里根本不存在"欠载补静音"这个概念。
鸿蒙没有"阻塞写"这条路可走(ArkTS 应用侧是单线程模型,起不了安卓那种阻塞写线程),官方的实时播放范式是 writeData 事件:系统按缓冲区节奏来要数据,回调的入参就是要你填满的 ArrayBuffer。
private readonly onWriteData = (data: ArrayBuffer): audio.AudioDataCallbackResult => {
const view: Uint8Array = new Uint8Array(data);
const total: number = view.length;
let filled: number = 0;
while (filled < total && this.queuedBytes > 0) {
const head: Uint8Array = this.queue[0];
const available: number = head.length - this.headOffset;
const want: number = Math.min(available, total - filled);
view.set(head.subarray(this.headOffset, this.headOffset + want), filled);
this.headOffset += want;
filled += want;
this.queuedBytes -= want;
if (this.headOffset === head.length) {
this.queue.shift();
this.headOffset = 0;
}
}
if (filled < total) {
view.fill(0, filled); // 队列空了:补静音
this.silentBytes += total - filled;
if (this.firstFeedSeen) this.underruns += 1;
}
this.pulledBytes += total;
this.notifyIfNeeded();
return audio.AudioDataCallbackResult.VALID; // 明确告诉系统"这段数据有效"
};
三个实现选择值得说明:
- 不需要自建线程:填充发生在系统回调里,队列就是一个普通数组 + 读偏移,也没有安卓那种跨线程加锁问题;
feed()必须拷贝:MethodChannel解出来的Uint8Array可能是共享缓冲区上的视图,直接入队会在下一帧被改写,所以new Uint8Array(buffer.length)+set();silentBytes/underruns是"安卓不会有、鸿蒙必须有"的指标:既然拉模式下必然要交静音,就把它记下来,release()时一起打出来:
release: pulled_bytes=1982464 silent_bytes=196534 underruns=23 feeds=129 queued_bytes=0
4.2 事件语义:按安卓的实现对齐,而不是按文档
Dart 文档说低水位事件发生在"排队帧数低于阈值"时,但安卓代码写的是:
boolean isLowBufferEvent = (remainingFrames <= feedThreshold) && (mLastLowBufferFeed != totalFeeds);
boolean isZeroCrossingEvent = (remainingFrames == 0) && (mLastZeroFeed != totalFeeds);
是 <=,并且按 feed 次数去重。鸿蒙侧逐条照抄:
private notifyIfNeeded(): void {
const remaining: number = this.remainingFrames();
const totalFeeds: number = this.totalFeeds;
const isLowBufferEvent: boolean =
remaining <= this.feedThreshold && this.lastLowBufferFeed !== totalFeeds;
const isZeroEvent: boolean = remaining === 0 && this.lastZeroFeed !== totalFeeds;
if (!isLowBufferEvent && !isZeroEvent) return;
if (isLowBufferEvent) this.lastLowBufferFeed = totalFeeds;
if (isZeroEvent) this.lastZeroFeed = totalFeeds;
const channel = this.channel;
if (channel === null) return;
setTimeout((): void => {
const args: Map<string, number> = new Map();
args.set('remaining_frames', remaining);
channel.invokeMethod('OnFeedSamples', args);
}, 0);
}
三个要点:
remaining现算:队列字节数 / (2 × 声道数),是"此刻真实的排队帧数",不是投喂量。Dart 侧就是靠它判断_needsStart;- 用
totalFeeds去重:得到的就是"每次feed()之后,低水位事件与排空事件各自最多一次"这条语义; - 回调用
setTimeout(..., 0)挪出系统写路径(对应安卓的mainThreadHandler.post):既避免在音频回调里做跨语言调用,也避免 Dart 立刻回喂时和写路径重入。
还有一个容易漏的边界:totalFeeds 与两个 last*Feed 初始值都是 0,所以建流之后即使一直没人投喂、remaining 恒为 0,也不会凭空触发回调——这正是期望行为(没有 feed() 就没有事件)。setup() 时把队列和这几个计数一起重置,语义最干净。
4.3 采样率:鸿蒙只认 15 个档位
AudioSamplingRate 是枚举,只有:
8000 11025 12000 16000 22050 24000 32000 44100 48000 64000 88200 96000 176400 192000 384000
而 Dart 侧的 sampleRate 是任意整数(安卓直接丢给 AudioTrack)。鸿蒙实现取最近的一档,并且只在真的发生回退时打日志:
const rate: number = nearestRate(sampleRate);
...
this.logInfo(`renderer started: requested_rate=${sampleRate} actual_rate=${rate} ...`);
if (rate !== sampleRate) {
this.logInfo(`sample rate ${sampleRate} is not supported by OHOS, ` +
`closest rate ${rate} is used (pitch will shift accordingly)`);
}
实测 setup(sampleRate: 40000):
renderer started: requested_rate=40000 actual_rate=44100 channels=2 usage=1 content_type=music buffer_size=16384
sample rate 40000 is not supported by OHOS, closest rate 44100 is used (pitch will shift accordingly)
这是行为差异而不是缺陷:喂 40000Hz 的数据、按 44100Hz 播放,音高会高约 10%。需要精确采样率就从上表里挑一个。
同样值得注意的是声道数:安卓只区分"2 声道 → 立体声,其它 → 单声道",鸿蒙实现照抄这条(CHANNEL_2 / CHANNEL_1),保持与上游一致而不是"更聪明"。
4.4 安卓语义参数怎么落到鸿蒙
master 的 setup() 会多送三个参数,名字都是安卓味的。但它们描述的是"这段音频用来干什么",而鸿蒙的 StreamUsage 恰好是同一维度,于是按语义映射:
const USAGE_TABLE: Map<string, audio.StreamUsage> = buildUsageTable();
// unknown -> STREAM_USAGE_UNKNOWN media -> STREAM_USAGE_MUSIC
// voiceCommunication(Signalling) -> STREAM_USAGE_VOICE_COMMUNICATION
// alarm -> STREAM_USAGE_ALARM notification(Event) -> STREAM_USAGE_NOTIFICATION
// notificationRingtone -> STREAM_USAGE_NOTIFICATION_RINGTONE
// assistanceAccessibility -> STREAM_USAGE_ACCESSIBILITY
// assistanceNavigationGuidance -> STREAM_USAGE_NAVIGATION
// assistanceSonification -> STREAM_USAGE_NOTIFICATION
// game -> STREAM_USAGE_GAME assistant -> STREAM_USAGE_VOICE_ASSISTANT
android_audio_content_type 在鸿蒙没有对应字段(内容类型由 usage 承载),只记进日志;android_legacy_stream_type 是安卓 API 23 以下的兼容项,直接忽略。两者都不影响 Dart 侧调用。
4.5 错误与释放
} else if (call.method === 'feed') {
if (!this.didSetup) {
this.logError('feed rejected: must call setup first'); // 便于设备侧取证
result.error('Setup', 'must call setup first', null); // 与安卓同码同文案
return;
}
release() 做成幂等:没有流时直接返回 true;有流时先 off('writeData') 再 stop() / release(),并把本次统计打出来,最后清空队列。onDetachedFromEngine 也会释放渲染器,但那时不再向引擎回调。
五、一个只有设备侧统计才能发现的问题:拉模式欠载
接口全对、事件计数全对、release 也干净,但声音是断续的——这种事在单元测试和"日志没有报错"里是看不出来的,只能靠设备侧的字节账目。
release() 打印的三个数就是账目:pulled_bytes(系统总共拉走多少字节)、silent_bytes(其中有多少是补的静音)、underruns(补了几次)。示例页做了两个开关——每次回调投喂量(20 / 60 个周期)与阈值(库默认 8000 帧 / 4410 帧)——就是为了把这四格量出来。每格点一次"播放音阶"跑 20 秒再释放:
| 每次回调投喂量 | setFeedThreshold | pulled 字节 | silent 字节 | 欠载比例 | feed 次数 |
|---|---|---|---|---|---|
| 20 个周期(≈50ms,上游示例的取值) | 库默认 8000 帧 | 2031616 | 1067694 | 52.6% | 217 |
| 20 个周期(≈50ms) | 4410 帧(≈100ms) | 2056192 | 1034434 | 50.3% | 219 |
| 60 个周期(≈150ms) | 库默认 8000 帧 | 1982464 | 196534 | 9.9% | 129 |
| 60 个周期(≈150ms) | 4410 帧 | 2048000 | 244950 | 12.0% | 133 |
两条结论,第二条和直觉相反:
- 投喂量是主因:20 个周期无论如何都有约一半时间是静音;提到 60 个周期直接掉到 10% 上下;
- 把阈值调大几乎没用:20 周期那两格是 52.6% vs 50.3%,60 周期那两格是 9.9% vs 12.0%。因为 Dart 从收到回调到把数据喂回去要一次完整往返(实测约 100ms),而 20 个周期只有约 50ms 音频——阈值再早触发,也补不上"喂进去的比抽走的少"这个缺口。
为什么安卓不会这样:安卓的 AudioTrack 自己带了足够大的内部缓冲,Dart 往返这一百来毫秒被它兜住了;鸿蒙是把缓冲区交给你管(getBufferSize() 实测单声道 8192 字节 ≈ 93ms、双声道 16384 字节),一次只喂 50ms 就必然见底。
适配建议:一次投喂量至少覆盖"一次往返耗时对应的帧数"(44100Hz 下约 4410 帧 / 100ms),阈值只负责"别等到见底才想起要喂"。示例把上游的 periods: 20 提到 periods: 60,并在界面留了开关方便复现两种量级。
六、编译与构建踩坑
6.1 getAudioTime() 是 Promise,同步版本叫 getAudioTimeSync()
第一版构建直接失败:
ERROR: 10505001 ArkTS Compiler Error
Error Message: Type 'Promise<number>' is not assignable to type 'number'.
At File: ohos/src/main/ets/components/plugin/FlutterPcmSoundPlugin.ets:298:5
ERROR: 10505001 ArkTS Compiler Error
Error Message: The left-hand side of an arithmetic operation must be of type 'any', 'number',
'bigint' or an enum type. At .../FlutterPcmSoundPlugin.ets:321:33
AudioRenderer 上 getAudioTime() / getAudioTime(callback) / getAudioTimeSync() 三个重载并存,只有 getAudioTimeSync() 直接返回 number。换成同步版本即可。
顺带得到一个必须写下来的结论:getAudioTimeSync() 在这台模拟器上不能当播放时长用——同一个渲染器运行 50 秒,setup 时读到 6920336553264,release 时读到的还是同一个值(audio_time_ms=0),而不同渲染器 setup 时读到的是当时的系统时间(6971118467255、7010634268078)。所以实现里只把它打进日志、不参与任何判断,本节的时间数据也全部改用"Dart 侧投喂完成 → 排空事件到达"的墙钟间隔。
6.2 模板垃圾这次进得更深
flutter create -t plugin --platforms ohos . 在一个"老式非联邦"插件上跑,生成的模板和上游结构冲突,必须删干净:
analysis_options.yaml # 引用 flutter_lints,但没声明这个 dev 依赖
android/build.gradle.kts # 上游是 build.gradle
android/settings.gradle.kts
android/src/main/kotlin/ # 上游是 Java 实现,留着会双注册
ios/Classes/FlutterPcmSoundPlugin.swift # 上游是 .h/.m
macos/Classes/FlutterPcmSoundPlugin.swift
lib/flutter_pcm_sound_method_channel.dart # 联邦模板产物,本库不用
lib/flutter_pcm_sound_platform_interface.dart # 同上,还引用未声明的 plugin_platform_interface
test/ example/test/ example/integration_test/ # 模板用例,与上游 API 不符
example/analysis_options.yaml
判断标准很简单:这些文件不是"上游没有但应该补上",而是"模板以为你在写一个新插件"。上游只认 lib/flutter_pcm_sound.dart + 各平台原生目录,多出来的联邦分层文件会把包结构搞乱。清完之后 flutter analyze 干净:
Analyzing fps_work...
No issues found!
6.3 示例层自己踩的 _needsStart 状态机坑
自检跑到第 3 项(“未 setup 就 feed”)之后,再点"播放音阶"一个 feed 都没有:
release: pulled_bytes=1998848 silent_bytes=1998848 underruns=0 feeds=0
pulled_bytes == silent_bytes 说明渲染器在跑、系统一直在拉,但队列里一个字节都没进过。原因在 Dart 侧:_needsStart 被"投喂了数据但被原生拒绝"(自检 3 那条路径,feed() 里 _needsStart = false 之后抛异常)留成了 false,于是 start() 直接返回 false、不再触发回调;而队列又是空的,排空事件因为 totalFeeds 还是 0 也不会触发——两头都等对方先动。
修法就是别把 start() 当唯一入口:
final bool started = FlutterPcmSound.start();
if (!started) {
await _feedFrames(_scale.generate(periods: _periodsPerFeed));
}
这条不是插件的 bug(_needsStart 的语义就是这样,安卓同样如此),但很值得写进示例:只要你的代码里存在"投喂失败"的路径,就要准备好自己踢第一脚。
6.4 其它
uinput点击用的是物理像素,且结果卡片会随文案换行改变高度,按钮坐标每次都要重新截图量;- 模拟器会自己掉线(
hdc list targets返回[Empty]),devecocli emulator start "Pura X View"重启约 1 分钟上线; - 验证必须
--debug构建:Log.i在 release 下被框架默认级别(WARN)吞掉,hilog 里什么都看不到。
七、真机(模拟器)验证
7.1 验证环境
示例页被改造成"自检台":五个按钮各跑一个场景,界面给出 PASS/FAIL,原生侧统计进 hilog;uinput 点击驱动,截图取证。
| 项 | 值 |
|---|---|
| Flutter for OpenHarmony SDK | 3.44.9+ohos-0.0.1-canary1(Dart 3.12.2) |
| DevEco Studio | 26.0.0.621(OpenHarmony SDK API 26) |
| 设备 | Pura X View 模拟器,HarmonyOS 7.0.0(26.0.0) Beta2,ohos-x64 |
| 产物 | example/build/ohos/hap/entry-default-signed.hap |
| 取日志 | hdc shell hilog -x | Select-String "FlutterPcmSoundPlugin" |
7.2 五项自检
| 自检 | 操作 | 设备侧结果 |
|---|---|---|
| 1 单发 2 秒 | setup(44100, 1) + 阈值 4410 + 一次投喂 88200 帧 | first feed: bytes=176400 frames=88200;21:12:05.458 投喂 → 21:12:06.971 低水位事件(remaining_frames=2184)→ 21:12:06.976 排空事件(remaining_frames=0);PASS,耗时 1543ms,低水位/排空各 1 次,剩余帧序列单调不增 |
| 2 阈值语义 | 阈值 2205 + 一次投喂 22050 帧 | remaining_frames=1570 feed=1 low=true zero=false → remaining_frames=0 feed=1 low=false zero=true:每次 feed 恰好各触发一次;PASS |
| 3 未 setup 就 feed | release() 后直接 feed() | 原生 E … feed rejected: must call setup first;Dart 捕获 PlatformException.code=Setup;PASS |
| 4 非标采样率 | setup(sampleRate: 40000, channelCount: 2) | requested_rate=40000 actual_rate=44100 channels=2 buffer_size=16384 + 回退提示;随后正常出声、排空事件到达;PASS |
| 5 release | setup + 投喂 0.5s → 排空 → release() | release: pulled_bytes=65536 silent_bytes=21436 underruns=1 feeds=1:44100(数据)+ 21436(静音)= 65536,账目自洽;释放后 hilog 再无拉取记录;PASS |
第 1 项的原生日志(一次投喂到排空的完整链路):
renderer started: requested_rate=44100 actual_rate=44100 channels=1 usage=1 content_type=music buffer_size=8192
feed threshold = 4410 frames
first feed: bytes=176400 frames=88200 threshold=4410
feed #1 bytes=176400 queued_bytes=176400 remaining_frames=88200
notify Dart: remaining_frames=2184 feed=1 low=true zero=false
notify Dart: remaining_frames=0 feed=1 low=false zero=true



7.3 时间口径:模拟器不能当声卡
"2 秒音频多久排空"这件事在同一台模拟器上抖得很厉害:
| 投喂量 | 墙钟排空时间 | 与理论时长之比 |
|---|---|---|
| 2.0s(88200 帧) | 1518ms / 1543ms / 1981ms(三次) | 0.76× / 0.77× / 0.99× |
| 0.5s(22050 帧) | 525ms | 1.05× |
比值落在 0.76×–1.05× 之间,说明模拟器的消费节奏并不等价于真机声卡(模拟器没有真实音频设备,也没有可靠的音频时钟,getAudioTimeSync() 不推进正是同一个原因)。因此本篇只把模拟器结果用于验证"事件语义、字节账目、错误码、释放行为",不用于任何延迟/音画同步结论——那些必须上真机。


八、已知限制
- 采样率会被就近回退到 15 个受支持档位之一(见 4.3),需要精确采样率请直接用这些档位;
- 拉模式下会补静音:
silent_bytes/underruns是可观测指标(见第五节),把投喂量提上去可以显著压低,但鸿蒙侧没有"阻塞写"这条能彻底消除欠载的路; iosAudioCategory/iosAllowBackgroundAudio是 iOS 专属,鸿蒙侧忽略(不报错);androidLegacyStreamType是安卓 API 23 以下的兼容项,鸿蒙侧忽略;- 后台播放未实现:没有申请长时任务/后台任务权限,应用切后台后能否继续出声取决于系统策略;本库 Dart API 也没有这个开关;
- 没有暴露音画同步接口:Dart API 里没有
getAudioTime之类的方法,鸿蒙实现内部的getAudioTimeSync()只进日志,且在模拟器上不可靠; - 示例移除了
integration_test:它在场时鸿蒙构建会因packages/integration_test/ohos路径报AdaptorError 00303231,上游那两个集成测试因此未随适配保留(功能验证改由自检台承担)。
九、常见问题
Q1:为什么基线取 master 而不是 pub.dev 上的 3.3.3?
因为两者不等价:master 的 lib/flutter_pcm_sound.dart 多了三个枚举和三个 setup() 参数(第二节的逐文件对比可证)。照发布版写会漏接口。上游 tag 只有 1.0.0/1.0.1,更不能当基线。
Q2:为什么鸿蒙实现要自己起 writeData,不用 renderer.write() 推送?
write() 是"我方主动推",在 ArkTS 单线程模型里要自己组织循环和节奏;writeData 是"系统按缓冲区节奏拉",回调里把缓冲区填满即可,是官方推荐的实时播放范式。代价是队列必须自己管、空了要补静音——这笔账在第五节量化过了。
Q3:低水位事件到底用 < 还是 <=?
按安卓实现用 <=(remainingFrames <= feedThreshold)。Dart 文档写的是"below",但文档和实现不一致时,跨平台行为一致优先。
Q4:回调会不会在很短时间内被疯狂调用?
不会。判定按 feed 次数去重:每次 feed() 之后,低水位事件与排空事件各自最多一次。所以"回调频率"实际上由你每次投喂的量决定——喂得多、回调少而间隔长;喂得少、回调密但容易欠载。
Q5:remaining_frames 是什么单位、怎么来的?
“当前队列里还排着多少帧”,由队列字节数 /(2 × 声道数) 现算(16bit 定点)。它不是投喂量,也不是播放位置。
Q6:feed() 必须在 setup() 之后吗?
是。顺序错了会拿到 PlatformException('Setup', 'must call setup first'),与安卓同码同文案;鸿蒙侧还会多打一行 feed rejected: must call setup first 方便取证。
Q7:为什么点了播放没声音?
先看两点:_needsStart 是否被一次失败的 feed() 留成了 false(此时 start() 不会触发回调,要自己喂第一口,见 6.3);以及投喂量是否低于一次往返的耗用(20 周期在鸿蒙上有一半时间是静音,见第五节)。
Q8:切后台还能继续播吗?
本实现没有申请后台任务权限,官方 Android 实现同样没有;能否续播取决于系统策略,Dart API 里也没有对应开关。
Q9:需要宿主额外加依赖吗?
不需要。Dart 侧只用 MethodChannel,鸿蒙侧只用 @kit.AudioKit,不像 path_provider 那类需要显式引入 *_ohos 包。
Q10:为什么示例要把每次投喂从 20 个周期改成 60 个周期?
因为上游示例的 20 个周期是配着安卓的 AudioTrack 内部缓冲设计的,搬到鸿蒙的拉模式下会有约一半时间在补静音。60 个周期实测把欠载压到 10% 上下,示例里保留开关以便对照。
十、本篇用到的库
| 项 | 值 |
|---|---|
| 适配仓库 | https://atomgit.com/oh-flutter/flutter_pcm_sound |
| 上游仓库 | https://github.com/chipweinberger/flutter_pcm_sound |
| 上游版本 | 3.3.3(MIT,基线取 master fa4c977) |
| 适配 TAG | 3.3.3-ohos-1.0.0-beta.1 |
| 适配分支 | feat/ohos_flutter_pcm_sound_3.3.3 |
| 平台目录 | ohos/(插件 HAR)、example/ohos/(示例工程) |
| 通道 | 方法通道 flutter_pcm_sound/methods、反向回调 OnFeedSamples |
| 鸿蒙侧依赖 | @kit.AudioKit(audio.createAudioRenderer / writeData) |
依赖写法(写死 TAG,不跟分支):
dependencies:
flutter_pcm_sound:
git:
url: https://atomgit.com/oh-flutter/flutter_pcm_sound.git
ref: 3.3.3-ohos-1.0.0-beta.1
验证环境
| 项 | 值 |
|---|---|
| Flutter for OpenHarmony SDK | 3.44.9+ohos-0.0.1-canary1 |
| Dart | 3.12.2 |
| DevEco Studio | 26.0.0.621(OpenHarmony SDK API 26) |
| 设备 | Pura X View 模拟器,HarmonyOS 7.0.0(26.0.0) Beta2,ohos-x64 |
| 构建产物 | example/build/ohos/hap/entry-default-signed.hap |
复现命令
# 1. 构建(PUB_CACHE 必须与工程同盘;模拟器是 ohos-x64;Log.i 只在 debug 下可见)
$env:PUB_CACHE = "E:\pub-cache"
cd _probe/fps_work/example/ohos
devecocli signature generate # 首次需要,提交前记得清空 signingConfigs
cd ..
flutter pub get
flutter build hap --debug --target-platform ohos-x64
# 2. 安装并启动
hdc install -r build/ohos/hap/entry-default-signed.hap
hdc shell aa start -a EntryAbility -b com.lib.flutter_pcm_sound_example
# 3. 依次点击:自检1 单发2秒 → 自检2 阈值语义 → 自检3 未setup就feed
# → 自检4 非标采样率 → 自检5 release
# (坐标是物理像素;卡片高度会随文案变化,点前先截图确认)
hdc shell snapshot_display -f /data/local/tmp/fps.jpeg
hdc file recv /data/local/tmp/fps.jpeg .
# 4. 欠载四格:顶部两个开关切"投喂量/阈值",点"播放音阶"跑 20 秒
# → 点"停止" → 点"自检3"(它会先 release,原生打出本次统计)
# 5. 取原生日志
hdc shell hilog -x | Select-String "FlutterPcmSoundPlugin"
欢迎加入 CPF-Flutter 鸿蒙社区:https://atomgit.com/CPF-Flutter
更多推荐

所有评论(0)