Flutter 三方库 flutter_ringtone_player 的鸿蒙化适配指南:零依赖资产解析与 AVPlayer 播放链
一、插件简介与适配结论
应用里要播一条自定义提示音、循环响一段告警音——flutter_ringtone_player 就是干这个的。它的 API 面很薄:一个 play()、三个系统音快捷方法、一个 stop()。参数是"按平台给选项"的风格:android: 接 Android 枚举、ios: 接 iOS 枚举、fromAsset / fromFile 接自定义音源。
把它带到鸿蒙上之前,先回答三个问题,答案决定了整个方案:
- 自定义音源(fromAsset / fromFile)能用吗? 能,完整适配。鸿蒙的
AVPlayer公开可用,音量、循环都支持——这也是这个库在鸿蒙上的全部实际价值; - 系统铃声枚举呢? 如实降级。查证本机 SDK 的 d.ts 后确认,当前版本的
systemSoundManager已不再向三方应用开放 RingtonePlayer 能力(只剩相机音效)。这条路径会返回SYSTEM_SOUND_UNAVAILABLE错误码,而上游 Dart 侧本来就会静默处理播放异常,所以业务不会崩溃、只是不出声; - 业务代码要改吗? 不用。这次适配做到了 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 SDK | 3.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 stop | AVPlayer release |
volume / looping | 0.0–1.0 / 循环 | 生效 | setVolume / loop 属性 |
asAlarm | Android"静音也响" | 音频流选择 | 忽略(无对应公开语义) |
两个关键契约细节:
第一,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 BusinessError → arkts-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
更多推荐


所有评论(0)