环境搭建指引: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();

三个细节决定了适配的形状:

  1. feed() 是"投喂"而不是"播放":数据进队列就返回,什么时候真的出声由原生侧决定;
  2. 回调是事件驱动、不是定时器驱动:库文档里只承诺两件事会触发回调——低水位事件(排队帧数低于阈值)和排空事件(排队帧数正好为 0),并且"每次 feed() 之后各自最多触发一次";
  3. 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;   // 明确告诉系统"这段数据有效"
};

三个实现选择值得说明:

  1. 不需要自建线程:填充发生在系统回调里,队列就是一个普通数组 + 读偏移,也没有安卓那种跨线程加锁问题;
  2. feed() 必须拷贝:MethodChannel 解出来的 Uint8Array 可能是共享缓冲区上的视图,直接入队会在下一帧被改写,所以 new Uint8Array(buffer.length) + set();
  3. 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 秒再释放:

每次回调投喂量setFeedThresholdpulled 字节silent 字节欠载比例feed 次数
20 个周期(≈50ms,上游示例的取值)库默认 8000 帧2031616106769452.6%217
20 个周期(≈50ms)4410 帧(≈100ms)2056192103443450.3%219
60 个周期(≈150ms)库默认 8000 帧19824641965349.9%129
60 个周期(≈150ms)4410 帧204800024495012.0%133

两条结论,第二条和直觉相反:

  1. 投喂量是主因:20 个周期无论如何都有约一半时间是静音;提到 60 个周期直接掉到 10% 上下;
  2. 把阈值调大几乎没用: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 SDK3.44.9+ohos-0.0.1-canary1(Dart 3.12.2)
DevEco Studio26.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 就 feedrelease() 后直接 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 releasesetup + 投喂 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 帧)525ms1.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)
适配 TAG3.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 SDK3.44.9+ohos-0.0.1-canary1
Dart3.12.2
DevEco Studio26.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

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐