一、插件简介与适配结论

应用里要播一条自定义提示音、循环响一段告警音——flutter_ringtone_player 就是干这个的。它的 API 面很薄:一个 play()、三个系统音快捷方法、一个 stop()。参数是"按平台给选项"的风格:android: 接 Android 枚举、ios: 接 iOS 枚举、fromAsset / fromFile 接自定义音源。

把它带到鸿蒙上之前,先回答三个问题,答案决定了整个方案:

  1. 自定义音源(fromAsset / fromFile)能用吗? 能,完整适配。鸿蒙的 AVPlayer 公开可用,音量、循环都支持——这也是这个库在鸿蒙上的全部实际价值;
  2. 系统铃声枚举呢? 如实降级。查证本机 SDK 的 d.ts 后确认,当前版本的 systemSoundManager 已不再向三方应用开放 RingtonePlayer 能力(只剩相机音效)。这条路径会返回 SYSTEM_SOUND_UNAVAILABLE 错误码,而上游 Dart 侧本来就会静默处理播放异常,所以业务不会崩溃、只是不出声;
  3. 业务代码要改吗? 不用。这次适配做到了 Dart 层零改动、零新依赖——lib/ 目录与上游逐字节一致(第三节解释为什么能做到)。

1.1 适配版怎么引入、怎么用

pubspec.yaml 中以 git 依赖引入适配分支:

dependencies:
  flutter_ringtone_player:
    git:
      url: https://atomgit.com/oh-flutter/flutter_ringtone_player.git
      ref: feat/ohos-adaptation

调用方式与上游完全一致,Android / iOS 行为不受任何影响:

final player = FlutterRingtonePlayer();

// 播放打包在 assets 里的提示音
player.play(fromAsset: 'sounds/alert.mp3', volume: 0.8, looping: true);

// 停止
player.stop();

唯一要记住的平台差异:playAlarm() / playNotification() / playRingtone() 这三个走系统铃声枚举的方法,在鸿蒙上是静默无操作(安全返回、不出声、不崩溃)。面向鸿蒙的业务请统一走 fromAsset / fromFile

本文的验证环境:

版本
上游基线tag v4.0.0+4(master 的 5.0.0-dev.1 是预发布版,未采用)
适配分支AtomGit oh-flutter 组织的 feat/ohos-adaptation
Flutter SDK3.41.10-ohos-1.0.1(Dart 3.11.5)
工具链DevEco CLI 1.3.0
验证设备HUAWEI PSN-AL00 真机,OpenHarmony 7.0.0.105(API 26)

二、从源码仓库开始:保住上游历史

标准流程:

git clone https://github.com/inway/flutter_ringtone_player.git
cd flutter_ringtone_player
git checkout v4.0.0+4
git checkout -b feat/ohos-adaptation
flutter create --platforms ohos .

适配后的目录职责:

flutter_ringtone_player/
├── lib/                      # Dart 层:API 与平台接口(零改动、零新依赖)
├── ohos/                     # 本次适配核心:ArkTS 平台实现
│   └── src/main/ets/components/plugin/FlutterRingtonePlayerPlugin.ets
├── example/ohos/             # OHOS 示例宿主
├── pubspec.yaml              # 注册 ohos 平台的 pluginClass
└── README.OpenHarmony*.md    # 双语鸿蒙使用说明(交付件)

pubspec.yaml 的平台声明只需在 flutter.plugin.platforms 下补一个 ohos 条目:

flutter:
  plugin:
    platforms:
      ohos:
        pluginClass: FlutterRingtonePlayerPlugin

这个插件值得说的是"没有发生的事":首版方案曾经在 Dart 侧打过补丁(详见第五节坑 1),最终完全撤销——最终提交里 lib/ 目录与上游逐字节一致。适配产出里最珍贵的有时是"没有引入的东西"。

两个命令的输出就是这次适配变更范围的直接证据:

$ git diff --stat v4.0.0+4..HEAD -- lib/
(无任何输出——lib/ 与上游逐字节一致)

$ git diff --stat v4.0.0+4..HEAD | tail -2
 .../plugin/FlutterRingtonePlayerPlugin.ets  | 207 +++++++++++++++++++++
 pubspec.yaml                                |   2 +
 44 files changed, 946 insertions(+), 2 deletions(-)

三、Dart 接口与平台通道契约分析

通道名 flutter_ringtone_player,契约表:

通道方法Dart 侧行为Android 实现鸿蒙方案
play(uri 路径)传文件路径或资产名临时文件 + MediaPlayer缓存文件 + AVPlayer(fdSrc)
play(系统音枚举)只下发枚举值,args 里没有 uri播系统默认铃声uri → 返回 SYSTEM_SOUND_UNAVAILABLE
stop停止播放MediaPlayer stopAVPlayer release
volume / looping0.0–1.0 / 循环生效setVolume / loop 属性
asAlarmAndroid"静音也响"音频流选择忽略(无对应公开语义)

两个关键契约细节:

第一,Dart 侧的"透传分支"。 上游把资产名转成 uri 的逻辑是按平台分派的,看一眼真实源码就明白了:

static Future<String> _generateAssetUri(String asset) async {
  if (Platform.isAndroid) {
    // Android:rootBundle 读资产 → 写临时文件 → 返回文件路径
    ...
  } else if (Platform.isIOS) {
    return asset;
  } else {
    return asset;   // ← 鸿蒙命中这里:资产名原样透传给平台层
  }
}

鸿蒙天然命中 else 分支——只要原生层能同时理解"绝对路径"和"资产名"两种 uri,Dart 层就一行不用改。适配版用"以 / 开头 = 本地文件,否则 = 资产名"来区分(上游 Android 也是资产名直传,语义一致)。

第二,上游的容错模型决定了错误码怎么设计。 上游没有要求业务方 catch 播放异常——如果平台层随手抛错,没做防护的业务会当场崩溃。好在它的实际实现对 play 做了静默处理(这本是它应对"iOS 没有系统铃声"的方式)。鸿蒙的系统音不可用错误走的正是这条既有容错通道:返回错误码 → 上游静默吞掉 → 业务无感。这给适配者的提醒是:设计错误路径之前,先通读上游的异常处理逻辑,搞清楚你的错误码最终会被谁消费——否则"如实报错"可能变成"在线崩溃"。

四、OHOS 原生实现:逐段解读 ArkTS 插件

4.1 引擎绑定:拿到资产与应用上下文

onAttachedToEngine(binding: FlutterPluginBinding): void {
  this.channel = new MethodChannel(binding.getBinaryMessenger(), CHANNEL_NAME);
  this.channel.setMethodCallHandler(this);
  this.flutterAssets = binding.getFlutterAssets();
  this.appContext = binding.getApplicationContext() as common.ApplicationContext;
}

onDetachedFromEngine(binding: FlutterPluginBinding): void {
  // ...清 handler、清引用...
  this.releasePlayer();   // 引擎分离时释放播放器,防止后台残留发声
}

三个引用各司其职:channel 通信、flutterAssets 把资产名翻译成包内 rawfile 路径、appContext 提供缓存目录与资源管理器。onDetachedFromEngine 里释放播放器是音频插件的生命周期底线——引擎都没了播放器还在响,是音频类插件最恶性的 bug。

4.2 资产解析:从 Dart 资产名到可播放文件

进入 play 后先做分发判断,这段逻辑决定了所有路径的走向:

private handlePlay(call: MethodCall, result: MethodResult): void {
  const uri: string | null = (call.argument('uri') as string) ?? null;
  if (uri === null || uri.length === 0) {
    result.error('SYSTEM_SOUND_UNAVAILABLE',
      'Built-in system sounds are not available to third-party apps on OpenHarmony. ' +
      'Use fromAsset or fromFile instead.', null);
    return;   // 系统音枚举路径:如实降级
  }
  // ...volume 钳位 [0,1]...
  if (uri.startsWith('/')) {
    this.startPlayback(uri, looping, volume, result);      // fromFile:绝对路径
    return;
  }
  this.resolveAssetToCache(uri).then((path) => {           // fromAsset:资产名
    this.startPlayback(path, looping, volume, result);
  }).catch((error: BusinessError) => {
    result.error('ASSET_LOAD_FAILED', `Failed to load asset '${uri}': ${error.message}`, null);
  });
}

资产解析是"零依赖"方案的核心——不经过任何 path_provider,直接在原生层完成:

private resolveAssetToCache(asset: string): Promise<string> {
  return new Promise<string>((resolve, reject) => {
    try {
      const rawfilePath: string = this.flutterAssets.getAssetFilePathByName(asset);
      const bytes: Uint8Array = this.appContext.resourceManager.getRawFileContentSync(rawfilePath);
      const extension: string = asset.lastIndexOf('.') > 0
        ? asset.substring(asset.lastIndexOf('.')) : '.mp3';
      const tempName: string = `ringtone_${Date.now()}_${this.tempCounter++}${extension}`;
      const targetPath: string = `${this.appContext.cacheDir}/${tempName}`;
      const file: fs.File = fs.openSync(targetPath,
        fs.OpenMode.READ_WRITE | fs.OpenMode.CREATE | fs.OpenMode.TRUNC);
      try {
        const buffer: ArrayBuffer = bytes.buffer.slice(
          bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer;
        fs.writeSync(file.fd, buffer);
      } finally {
        fs.closeSync(file);
      }
      resolve(targetPath);
    } catch (error) {
      reject(error as BusinessError);
    }
  });
}

链路是"资产名 → getAssetFilePathByName 翻译成模块 rawfile 路径 → getRawFileContentSync 读字节 → 写缓存文件"。三个细节值得展开:时间戳 + 自增计数器命名,避免连续播放同名资产时互相覆盖;扩展名从资产名解析,解析不出兜底 .mp3字节到 ArrayBuffer 时按 byteOffset 精确切窗口——这是跨语言传字节的通用暗坑:方法通道解码出的 Uint8Array 不保证 buffer 全长都是有效数据,直接 bytes.buffer 可能带出尾部脏数据,必须用 byteOffset + byteLength 切出精确窗口。

4.3 播放链:stateChange 事件驱动的 AVPlayer

这是本次适配技术浓度最高的一段,也是踩坑最多的地方(见第五节坑 2):

private startPlayback(path: string, looping: boolean, volume: number, result: MethodResult): void {
  this.releasePlayer();
  let settled: boolean = false;
  const finishWithError = (message: string): void => {
    if (settled) { return; }
    settled = true;
    this.releasePlayer();
    result.error('PLAY_FAILED', message, null);
  };

  media.createAVPlayer().then((player: media.AVPlayer) => {
    this.player = player;
    player.loop = looping;
    player.on('error', (error: BusinessError) => {
      finishWithError(`AVPlayer error ${error.code}: ${error.message}`);
    });
    player.on('stateChange', (state: string) => {
      if (state === 'initialized') {
        player.prepare().catch((error: BusinessError) => {
          finishWithError(`Prepare failed: ${error.message} (${error.code})`);
        });
      } else if (state === 'prepared') {
        player.setVolume(volume);
        player.play().then(() => {
          if (!settled) { settled = true; result.success(null); }
        }).catch((error: BusinessError) => {
          finishWithError(`Play failed: ${error.message} (${error.code})`);
        });
      }
    });

    // 本地文件必须走文件描述符:url 属性不接受裸路径。
    // 赋值 fdSrc 后播放器从 idle 迁移到 initialized 是异步的——交给 stateChange。
    const file: fs.File = fs.openSync(path, fs.OpenMode.READ_ONLY);
    try {
      const stat: fs.Stat = fs.statSync(file.fd);
      player.fdSrc = { fd: file.fd, offset: 0, length: stat.size };
    } catch (error) {
      fs.closeSync(file);
      throw new Error(`Failed to open '${path}': ${(error as BusinessError).message}`);
    }
  }).catch((error: BusinessError) => {
    finishWithError(`Failed to start playback: ${error.message} (${error.code})`);
  });
}

正确姿势可以总结成三句话:fdSrc 播本地url 属性不接受本地裸路径,要 openSync + statSync 拿文件描述符和长度);状态迁移靠事件fdSrc 赋值后 idle → initialized 是异步的,必须监听 stateChange,在 initialized 态 prepare、prepared 态 play——赋值后立刻 prepare 会报状态错误 5400102);恰好一次守卫settled 标志贯穿 error 事件、状态回调、Promise 三条路径,迟到的结果一律丢弃)。stop 直接 release() 播放器实例,这是 AVPlayer 的终态语义,比 stop + 复位更干净。

真机(HUAWEI PSN-AL00 / OpenHarmony 7.0.0.105)上实际输出的 hilog:

RingtonePlayerPlugin: Playing /data/storage/el2/base/cache/ringtone_1789362458809_0.mp3 (looping=false, volume=1)

五、踩坑实录:三个真实问题与解法

坑 1:path_provider 的鸿蒙实现不在主包里,首版 Dart 补丁全部撤销。首版方案仿照 Android 分支:在 Dart 侧用 getTemporaryDirectory() 落临时文件再传路径——运行时直接 MissingPluginException(getTemporaryDirectory)。注意 path_provider 本身是有鸿蒙实现的,但它是联邦插件的结构:pub.dev 主包只背书 Android/iOS 等平台,鸿蒙实现单独放在生态仓 flutter_packages 里,要以 git 依赖 + path 的方式显式引入。上游依赖树里只有主包(path_provider: ^2.1.1),没有鸿蒙实现包,方法通道在鸿蒙上自然无人应答,MissingPluginException 就是这么来的。解法有两条:路线 A,显式引入生态实现包(见下方 FAQ Q1 的配置);路线 B,撤掉全部 Dart 补丁,改走 4.2 的原生资产解析——上游 else 分支恰好透传资产名,零 Dart 改动、零新依赖。本文选了 B:为一个临时目录引入一份 git 依赖(还要处理与上游 ^2.1.1 的版本协调),不如原生侧用 embedding 自带能力直接解析。教训:官方插件的鸿蒙实现在生态仓里、不在 pub 主包里——评估依赖树上每个插件时,先确认它的 ohos 实现包是否存在、要不要显式引入;能用原生等价实现替代的,就优先替代而不是加依赖。

坑 2:AVPlayer 本地播放入门三连坑。① player.url = '/data/...' 裸路径 → prepare 失败(url 不接受本地绝对路径)→ 改 fdSrc;② fdSrc 赋值后立刻 prepare → 5400102 The current state is idle → 状态迁移是异步的,必须等 stateChange;③ throw error as BusinessErrorarkts-limited-throw 编译错 → 改抛 new Error(...)(ArkTS 对 throw 的类型有静态限制)。三连坑的教训浓缩成一句:AVPlayer 是状态机不是遥控器,按状态机的方式写代码

坑 3:模拟器残留进程导致反复启动失败emulator start 报"launched but did not appear in hdc list targets"随后 stopped。根因是上一次会话的 Emulator 进程残留占住了实例。解法:PowerShell 清理残留进程后重启成功。教训:模拟器启动失败先查残留进程,再怀疑镜像。

在这里插入图片描述

六、验证:编译通过不等于功能通过

三层验证:

第一层:Dart 检查与单测。 flutter analyze 0 问题;flutter test 5/5 通过(上游测试原样通过,证明 Dart 层零改动的承诺兑现)。

第二层:HAP 构建。 构建通过,安装启动无 MissingPluginException

第三层:设备行为验证(HUAWEI PSN-AL00,OpenHarmony 7.0.0.105)。 直接用上游自带的 example 应用验证:构建安装后,点击「Play from asset (iphone.mp3)」按钮触发 fromAsset 播放,hilog 里两条关键日志:

FlutterEngineCxnRegistry --> Adding plugin: FlutterRingtonePlayerPlugin
RingtonePlayerPlugin: Playing /data/storage/el2/base/cache/ringtone_1789362458809_0.mp3 (looping=false, volume=1)

第一条证明插件在鸿蒙工程里注册成功;第二条证明完整播放链走通——资产被解析成缓存文件、AVPlayer 进入 playing 态、参数与调用一致。系统音枚举路径同样做了触发验证(点击 playAlarm):错误码经平台通道原路返回、被上游静默吞掉(这条分支插件不打日志,hilog 里不会有 SYSTEM_SOUND_UNAVAILABLE 字样),应用不崩溃、界面正常,随后再点「Play from asset」仍能正常播放——降级路径安全。

最后补一条最有说服力的验证:真机上点击按钮后,实际听到了示例的提示音——日志证据(播放器进入 playing、无错误)+ 真机听感同时确认,fromAsset 播放链完整可用。

七、FAQ:给正在做适配的你

Q1:为什么不引入鸿蒙版 path_provider,和上游 Android 保持同构?
鸿蒙版是存在的,在生态仓 flutter_packages 里,引入方式:

dependencies:
  path_provider:
    git:
      url: https://gitcode.com/openharmony-tpc/flutter_packages.git
      path: packages/path_provider/path_provider
      ref: provider-v2.1.5-ohos-1.0.0

本文没走这条路是权衡而非无奈:为一个"播放前落临时文件"引入一份 git 依赖(外加与上游 path_provider ^2.1.1 的版本协调成本),不如原生侧用 embedding 自带的 FlutterAssets 与系统资源管理器直接解析——零外部依赖,行为完全可控。如果你更看重与 Android 分支代码同构,路线 A 同样可行,按上面的配置引入即可。

Q2:系统铃声真的完全没辙吗?
当前 SDK 代际的 systemSoundManager 已移除面向三方的 RingtonePlayer 能力(调研方法:直接读本机 SDK 的 d.ts,发现 RingtoneType 定义已不存在、仅剩相机音效)。结论要以 d.ts 为准,不要凭旧文档或记忆断言 API 存在。

Q3:播放器实例为什么用完就 release,不复用?
AVPlayer 的状态机复用要处理 stopped → reset → setIdle 的迁移与 fd 生命周期,复杂度高;提示音场景每次播放都重新 create + release,干净且足够快。

Q4:fdSrc 播放的文件什么时候可以删?
缓存文件在播放器 release 后即可回收;插件用时间戳命名避免覆盖,交给系统缓存管理自然清理。业务侧无需干预。

Q5:发现适配问题如何提 issue?
到适配仓库提:https://atomgit.com/oh-flutter/flutter_ringtone_player/issues 。附设备型号、API 版本、Flutter/DevEco 版本、RingtonePlayerPlugin 标签日志、音频文件编码格式。

Q6:我能修,怎么提 PR?
Fork 后基于 feat/ohos-adaptation 修改,本地过 flutter analyze / flutter test / 构建三关,播放与停止两条路径实测后发 PR。

八、总结

这次适配的叙事线是"减法":目标是让 Dart 层零改动,为此原生层多做了资产解析;为避免引入依赖,方案从 path_provider 路线换成 embedding 自带能力的组合;为尊重上游容错模型,系统音降级走了既有异常通道。最终 lib/ 目录与上游逐字节一致,ArkTS 实现约 200 行。

三个可带走的经验:一是"官方插件已鸿蒙化"指生态里有 ohos 包而非 pub 主包自带,适配时优先原生等价实现而不是加 Dart 依赖;二是 AVPlayer 的正确用法是 stateChange 事件驱动 + fdSrc 本地播放 + 恰好一次守卫,这个模板可直接复用到一切本地音频/视频适配;三是 SDK 能力调查必须 grep 本机 SDK 的 d.ts——本次系统铃声的"不可用"结论,靠的是读定义文件而不是搜旧博客。

欢迎加入 Flutter 鸿蒙化社区(CPF-Flutter 组织):https://atomgit.com/CPF-Flutter
本文适配成果仓库:https://atomgit.com/oh-flutter/flutter_ringtone_player

Logo

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

更多推荐