Flutter 三方库 document_file_save_plus 的鸿蒙化适配指南:用系统 Picker 替代静默写入
一、插件简介与适配目标
document_file_save_plus 是 Flutter 生态里的文件保存插件:把字节内容保存成用户可见的文件。它的 Android 实现走 MediaStore 静默写入公共 Downloads 目录;iOS 实现拉起分享面板让用户选去向。两个平台的交互差异很大,但 Dart 契约是统一的:saveFile(data, fileName, mimeType) / saveMultipleFiles(...),完成即返回,取消静默。
这个插件适配鸿蒙的核心矛盾在安全模型:鸿蒙不允许三方应用静默写入公共目录。Android 的实现思路整体不可用,必须换成系统 Picker 方案。适配目标:
- 保存能力走系统
DocumentViewPicker的 save 模式——用户确认位置后写入授权 URI,这是鸿蒙官方推荐的文件导出路径,零权限声明; - 取消语义与 iOS 对齐(静默返回,不抛异常);
- 附带接口(电量、平台版本)保持可用,
getBatteryPercentage顺带补齐 iOS 都没有的实现。
基线信息:上游 document_file_save_plus 2.0.1(master @ c40a4d1,SDK 约束兼容当前工具链),适配成果在 AtomGit oh-flutter 组织的 feat/ohos-adaptation 分支,验证环境为 Flutter 3.41.10-ohos-1.0.1、DevEco CLI 1.3.0、HarmonyOS 7.0.0(API 26)模拟器。
二、从源码仓库开始:保住上游历史
与系列前几篇相同的标准流程:clone 上游保留完整历史,从基线切出适配分支,适配改动收敛为单提交:
git clone https://github.com/advoques/document_file_save_plus.git
cd document_file_save_plus
git checkout -b feat/ohos-adaptation
flutter create --platforms ohos .
适配后的目录职责:
document_file_save_plus/
├── lib/ # Dart 层:API 与平台接口(零改动)
├── ohos/ # 本次适配核心:ArkTS 平台实现
│ └── src/main/ets/components/plugin/DocumentFileSavePlusPlugin.ets
├── example/ohos/ # OHOS 示例宿主
├── pubspec.yaml # 注册 ohos 平台的 pluginClass
└── README.OpenHarmony*.md # 双语鸿蒙使用说明(交付件)
这次适配的一个标志性事实:Dart 侧一行未改。上游 Dart API 的参数结构(三个平行列表)、异常模型、void 返回在鸿蒙上全部成立,所有平台差异都收敛在 ArkTS 层——契约对齐做得好,Dart 层就可以是"只读"的。

三、Dart 接口与平台通道契约分析
通道名为 document_file_save_plus,契约表如下:
| 通道方法 | Android/iOS 行为 | 参数契约 | 鸿蒙方案 |
|---|---|---|---|
saveMultipleFiles | Android:MediaStore 静默写公共目录;iOS:分享面板 | dataList(字节数组列表)+ fileNameList + mimeTypeList,三列表等长 | DocumentViewPicker.save + 按序写授权 URI |
getBatteryPercentage | Android:BatteryManager;iOS 不支持 | 无 | batteryInfo.batterySOC |
getPlatformVersion | 平台版本字符串 | 无 | HarmonyOS {displayVersion} |
| (隐含)重复调用 | 无保护 | — | SAVE_IN_PROGRESS 错误码 |
三个影响实现的契约细节:
第一,dataList 是字节数组列表。 方法通道把 Dart Uint8List 解码为 ArkTS Uint8Array,而文件写入 API 要的是 ArrayBuffer——两者不通用,需要一个精确的转换(见 4.3)。
第二,取消的语义。 iOS 分享面板取消是静默返回,Dart 侧没有为取消设计任何错误处理。鸿蒙的 DocumentViewPicker.save 在用户取消时是 promise reject——如果直接把 reject 转成 result.error,一次正常用户取消会变成业务异常。必须在 reject 分支返回 result.success(null)。
第三,多文件的部分成功。 系统 Picker 理论上允许用户在确认界面增删文件名,导致授权的 URI 数量与请求的文件数不一致。契约上最合理的处理:按序写入实际授权的 URI,数量不符时打告警日志(而不是整个失败——用户已确认的部分应该被尊重)。
四、OHOS 原生实现:逐段解读 ArkTS 插件
4.1 骨架:这个插件必须绑定 Ability
与 system_theme 不同,系统 Picker 必须由 UIAbilityContext 拉起,所以要实现 AbilityAware:
export default class DocumentFileSavePlusPlugin
implements FlutterPlugin, MethodCallHandler, AbilityAware {
private channel: MethodChannel | null = null;
private context: common.UIAbilityContext | null = null;
private saveActive: boolean = false;
onAttachedToAbility(binding: AbilityPluginBinding): void {
this.context = binding.getAbility().context; // Picker 必需
}
onDetachedFromAbility(): void {
this.context = null; // 引用随生命周期释放
}
}
两个防御设计:context === null 时返回 NO_ABILITY 错误码(Picker 无法在无 Ability 时工作);saveActive 标志位防并发——上次 Picker 还在屏幕上时再次请求保存,返回 SAVE_IN_PROGRESS,避免两个系统选择器叠加。
4.2 保存主流程:参数校验 → Picker → 写入
private handleSaveMultipleFiles(call: MethodCall, result: MethodResult): void {
// ...saveActive / context / 参数校验(等长、非空)...
this.saveActive = true;
const options = new picker.DocumentSaveOptions();
options.newFileNames = fileNames; // 预填文件名,用户可改
const documentPicker = new picker.DocumentViewPicker(this.context);
documentPicker.save(options).then(async (saveResult: Array<string>) => {
try {
if (saveResult.length !== dataList.length) {
hilog.warn(0x0000, LOG_TAG,
'Picker granted %{public}d URIs for %{public}d requested files.',
saveResult.length, dataList.length);
}
const written = Math.min(saveResult.length, dataList.length);
for (let i = 0; i < written; i++) {
this.writeBytesToUri(saveResult[i], dataList[i]);
hilog.info(0x0000, LOG_TAG, 'Saved %{public}d bytes to %{public}s',
dataList[i].length, saveResult[i]);
}
result.success(null);
} catch (error) {
const err = error as BusinessError;
result.error('SAVE_FAILED', `Failed to write file: ${err.message}`, null);
} finally {
this.saveActive = false;
}
}).catch((_error: BusinessError) => {
// 用户取消时 save 的 promise 被 reject——按契约静默返回,与 iOS 一致
this.saveActive = false;
result.success(null);
});
}
这一段的三个设计点:参数校验前置(三列表等长、每项非空,不满足返回 BAD_ARGUMENTS,不进 Picker 流程);写入循环带逐文件日志(字节数 + 目标 URI,这不仅是可观测性,还成了验证手段,见第五节坑 1);finally 复位 saveActive(成功、失败、异常三条路径都要把并发标志放回去,否则一次异常后保存功能永久锁死)。
4.3 字节写入:Uint8Array 到 ArrayBuffer 的精确转换
private writeBytesToUri(uri: string, bytes: Uint8Array): void {
const buffer: ArrayBuffer = bytes.buffer.slice(
bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer;
const file = fs.openSync(uri, fs.OpenMode.READ_WRITE);
try {
fs.writeSync(file.fd, buffer);
} finally {
fs.closeSync(file);
}
}
这里是方法通道字节传输最容易踩的暗坑:Dart Uint8List 经通道解码为 ArkTS Uint8Array 后,bytes.buffer 可能比实际数据大(存在 byteOffset),直接把 bytes.buffer 传给 writeSync 会把多余字节写进文件。必须用 slice(byteOffset, byteOffset + byteLength) 截出精确的数据窗口。实测 43 字节文件逐字节吻合,证明转换无损。
另一个细节:fs.openSync 与 fs.writeSync 之间用 try/finally 保证 closeSync——文件描述符泄漏在文件类插件里是慢性毒药。

五、踩坑实录:三个真实问题与解法
坑 1:写进公共目录的文件无法用 hdc 验证。保存成功后想 hdc shell ls 检查 Download 目录,结果 Permission denied——公共存储启用了 FBE 加密,shell 用户无权读取;尝试拉起文件管理器做 UI 验证,模拟器镜像上 aa start 直接被拒。解法:在插件写入循环里加 hilog(字节数 + URI,见 4.2),构建重跑后拿到字节级证据(Saved 43 bytes to .../Download/htmlfile 1.html,文件名的 " 1" 后缀还顺带证明了系统重名自增与首次持久化)。教训:Picker 类插件的写入验证,日志证据远比 UI 导航可靠;公共存储对 hdc 不可读是常态。
坑 2:模拟器熄屏锁定阻塞安装。构建期间模拟器熄屏,devecocli run 报 “developer mode, screen cannot be unlocked automatically”。解法:hdc shell uinput -K -d 2 -u 2(Home 键)+ 上滑解锁。教训:长时间构建前熄屏省资源没问题,但运行验证前要确认亮屏解锁。
坑 3:Git Bash 的路径转换破坏 hdc 命令。hdc shell ls /storage/... 被转成 C:/Program Files/Git/storage/...。解法:export MSYS_NO_PATHCONV=1。教训:Git Bash 下所有带绝对路径的 hdc 命令都要带这个环境变量。

六、验证:编译通过不等于功能通过
三层验证:
第一层:Dart 检查与单测。 flutter analyze 仅 2 条上游既有的 deprecation info;flutter test 3/3 通过。
第二层:HAP 构建。 devecocli build --build-mode debug 通过,安装启动无 MissingPluginException。
第三层:设备行为验证(HarmonyOS 7.0.0 模拟器)。 覆盖三条路径:
- 成功路径:43 字节 HTML + 9 字节 TXT 双文件保存,日志字节数与源数据逐字节吻合,目标目录出现
htmlfile 1.html/textfile 1.txt(重名自增 = 写入真实持久化); - 取消路径:Picker 中点返回,调用静默完成,无
SAVE_FAILED、无崩溃、无残留; - 降级路径:
getBatteryPercentage/getPlatformVersion返回正常。
其中"取消路径"值得单独强调——它是契约对齐的直接验证对象:如果 4.2 的 reject 分支写成了 result.error,这里就会复现一次"用户正常操作触发业务异常"的事故。
七、FAQ:给正在做适配的你
Q1:为什么不用 MediaStore 那样的静默写入?
鸿蒙安全模型禁止三方应用静默写公共目录,系统 Picker + 用户确认是官方推荐路径,也是唯一合规路径。这不是适配偷懒,是平台规则。
Q2:DocumentViewPicker.save 需要什么权限?
不需要任何权限声明。用户在 Picker 里的确认动作本身就是授权(临时的 URI 写权限),这正是它合规的优势。
Q3:用户取消保存,promise 是 reject 的,为什么返回 success?
上游契约(iOS 分享面板取消)是静默返回。把 reject 转成 error 会让一次正常取消在业务侧抛异常。平台层负责把"用户取消"翻译成契约里的对应行为。
Q4:多文件保存时用户在 Picker 里删了文件怎么办?
按序写入实际授权的 URI(数量取交集),数量不符打告警日志。用户确认过的部分应该被尊重,而不是整体失败。
Q5:发现适配问题如何提 issue?
到适配仓库提:https://atomgit.com/oh-flutter/document_file_save_plus/issues 。附设备型号、API 版本、Flutter/DevEco 版本、DocumentFileSavePlus 标签日志与复现步骤。
Q6:我能修,怎么提 PR?
Fork 后基于 feat/ohos-adaptation 修改,本地过 flutter analyze / flutter test / 构建三关,涉及设备行为的实测(保存 + 取消两条路径都要)后发 PR。
八、总结
这次适配最有价值的经验是在平台安全模型冲突处做"方案迁移"而不是"行为模拟":Android 的静默写入在鸿蒙不可用,适配版没有试图绕过限制,而是把实现方案整体换成系统 Picker,同时把交互差异如实写进 README——业务侧的调用代码不变,用户侧多一次确认动作,且这次确认恰好是合规优势。
三个可带走的技术点:Uint8Array 到 ArrayBuffer 必须按 byteOffset 精确切窗口,否则多写脏字节;Picker 的 promise reject 是"用户取消"不是错误,平台层负责语义翻译;finally 复位并发标志,避免一次异常永久锁死功能。另外,"写入循环里打字节日志"这个为验证而生的设计,最终留成了插件的正式可观测性代码——好的调试手段值得转正。
欢迎加入 Flutter 鸿蒙化社区(CPF-Flutter 组织):https://atomgit.com/CPF-Flutter
本文适配成果仓库:https://atomgit.com/oh-flutter/document_file_save_plus
更多推荐


所有评论(0)