资讯中心

Flutter Web文件系统访问鸿蒙化适配:从API差异到原子化写入

📅 2026/9/29 16:18:38
Flutter Web文件系统访问鸿蒙化适配:从API差异到原子化写入
1. 项目概述与适配思路1.1 这个库到底解决什么问题先聊点实际的。做 Flutter Web 开发的人应该都有过这种体验用户想在浏览器里打开本地文件、编辑完再保存回去结果浏览器默认根本不给你碰本地磁盘的权限。传统方案只有input typefile上传和a download下载一来一回全程走内存大文件一多就卡而且文件一关就彻底没影了根本谈不上“编辑保存”和“持久化回写”。file_system_access_api这个三方库就是把浏览器端的 File System Access API 封装成 Flutter 可以调用的 Dart 接口。它让你能用showOpenFilePicker选择文件、showSaveFilePicker保存文件、showDirectoryPicker选目录还能拿到文件句柄后做读、写、截断、移动、删除这些操作。再配合 OPFS源私有文件系统Origin Private File System做沙箱内的快速缓存整个体验就很接近本地原生应用了。它在 PC 端 Web 场景比如在线文档编辑器、IDE、图像处理工具里非常有用。但现在我们聊的是鸿蒙——如果把你的 Flutter Web 应用放到鸿蒙的浏览器、WebView 或者鸿蒙应用内置网页环境里跑问题就来了底层的 File System Access API 在鸿蒙 Web 组件里支不支持接口行为跟 Chrome 是否一致文件句柄的权限模型是否相同这些都是“鸿蒙化适配”要处理的事情。1.2 为什么需要鸿蒙化适配先别急着写代码想清楚一个问题为什么一个 Flutter 三方库需要“鸿蒙化”原因很简单Flutter 本身跨平台不错但平台通道底下的原生能力没有任何一个 Flutter 库能凭空造出来。file_system_access_api在 Web 端依赖浏览器对 File System Access API 的实现在鸿蒙上这个实现由 ArkWeb鸿蒙的 Web 组件或系统浏览器决定。如果不做适配你在鸿蒙环境里调用showOpenFilePicker大概率直接抛NotSupportedError或者打开的文件选择器样式怪异、权限回调不完整。另外鸿蒙应用还有一种形态Flutter 工程被集成到 HarmonyOS 应用里通过 Ability 和原生文件管理模块交互。这时候file_system_access_api的 Web 实现完全走不通需要另起一套基于ohos.file.fs原生接口的 Dart 适配层让上层业务代码无感知切换。这就是典型的“一套 API 抽象多端实现”思路。所以在整个适配过程中我的核心策略是三层统一 Dart 接口层在应用层定义一套IFileSystemAccess抽象把“选文件、读文件、写文件、列目录”这些操作全部抽象出来。Web/浏览器实现转发到原库的 Web 实现通过 Feature Detection 判断当前 WebView 是否支持。鸿蒙原生实现通过 MethodChannel / EventChannel 桥接 ArkTS 侧的ohos.file.fs能力让 Flutter 层可以直接操作鸿蒙沙箱文件。这样一来你的业务代码只跟抽象接口打交道底层跑的是浏览器能力还是鸿蒙原生能力完全不用关心。1.3 整体适配路线选型有两条路线可以做我自己的选择是“Web 兼容优先、原生兜底兼顾”。具体拆解一下。路线 AWeb 兼容适配只改 JS 胶水层如果你的 Flutter Web 应用是跑在鸿蒙的浏览器或 WebView 里那么核心工作是把原库的 JS 层做能力探测和降级。比如检查window.showOpenFilePicker是否存在。如果不存在降级为input typefile FileReader 的组合保留“读文件”能力但丢失“写回原文件”的能力。如果存在但某个子能力如createWritable异常只禁用写回不做整库崩溃。路线 B鸿蒙原生桥接动 Dart 层 ArkTS 层如果你的 Flutter 应用被嵌进 HarmonyOS 的 Ability 里浏览器那套 API 彻底不可用那就直接在鸿蒙侧实现showOpenFilePicker对应的系统文件选择器再通过 Channel 把文件 URI、FD 或路径回传给 Dart。我自己最后采用的是双轨制应用运行时先探测 WebView 能力能用浏览器原生化就走路线 A不能就用路线 B。文章后面所有步骤都按双轨制来写这样无论你是什么部署形态都有路可走。2. 核心 API 能力盘点与平台差异分析2.1 文件选择器组showOpenFilePicker / showSaveFilePicker / showDirectoryPickerfile_system_access_api的核心入口是三个 picker。把它们的底层逻辑搞明白适配工作就完成了一半。showOpenFilePicker的参数包含multiple是否多选、excludeAcceptAllOption是否隐藏“所有文件”选项和typesMIME 过滤。在 Chrome 里返回FileSystemFileHandle[]这个句柄就是后面所有文件操作的凭据。鸿蒙 WebView 如果完整支持 File System Access API这三个 picker 的表现应当一致不支持的话需要用系统文件选择器picker模块来代替。注意鸿蒙的photoAccessHelper和documentViewPicker分别管图片和文档并不完全等价于浏览器 picker需要自己做映射。showSaveFilePicker在浏览器里可以直接“占位”创建一个文件句柄不需要文件真实存在而鸿蒙原生保存文件通常要先弹临时的保存目录再写入 FD。这个差异会导致一个经典问题从浏览器搬过来的代码在鸿蒙原生侧创建文件时如果目录不存在需要先fs.mkdir逐级创建否则报ENOENT。showDirectoryPicker相对单纯浏览器里返回FileSystemDirectoryHandle可遍历、可拿子句柄。鸿蒙原生侧没有完全对应的“目录选择器”一般是调picker选目录后返回 URI再做递归遍历时要用fs.listFile配合fs.stat判断子项类型。递归遍历深度建议控制在 5 层以内避免性能问题。2.2 可写流与原子化写入原语这个库真正厉害的地方在于它的写文件能力。浏览器原生 API 里拿到文件句柄后调用createWritable()会得到一个FileSystemWritableFileStream你可以write、truncate、seek最后close才真正落盘。这个流式写入天然和“原子化”强相关因为你在close()之前的所有操作对外部不可见相当于操作系统里的“临时文件 原地替换”。但浏览器层面并不保证断电崩溃下的原子性真正的原子化读写引擎需要自己做几件事临时文件策略所有写入先写到一个同目录下的.tmp文件全部写完再rename覆盖目标文件。写入锁同一文件的并发写必须排队否则两个写入流互相覆盖数据直接错乱。写后校验写入完成后重新打开文件读一遍校验和如 CRC32不一致就回滚到上一个版本。鸿蒙原生侧的文件写入路径不同ArkTS 的fs.openSync(path, fs.OpenMode.READ_WRITE | fs.OpenMode.CREATE)拿 FD然后用fs.writeSync写。可问题在于原生 FD 不给你“事务性替换”所以最稳妥的原子化方案还是“写临时文件 -fs.rename覆盖”。好在鸿蒙的 rename 在同一个文件系统内是原子的这就是原子化读写引擎的立足点。2.3 平台差异对照浏览器 vs 鸿蒙 WebView vs 鸿蒙原生我把三端能力差异整理成一张对照表方便你决定哪些功能在哪个端启用、哪些直接禁用能力项Chrome 桌面端鸿蒙 WebViewArkWeb鸿蒙原生ArkTSshowOpenFilePicker支持依赖系统内核版本多数设备支持需调系统 picker返回 URI 或 FDshowSaveFilePicker支持不确定建议降级支持但需自己处理目录创建showDirectoryPicker支持不确定建议探测需调 picker然后递归遍历FileSystemWritableFileStream支持依赖能力无用 fs 的 write 系列接口OPFS 私有沙箱支持支持如果 WebView 支持 OPFS无对应概念需自行映射沙箱目录句柄持久化IndexedDB支持支持无需自己维护路径映射表这张表的价值在于它帮你划定了 Feature Detection 的判断矩阵哪些能力必须无条件启用哪些要做降级路径。我的经验是“默认全降级探测通过再升级”。这样不会因为某个 WebView 小版本不支持新 API 而直接崩溃。3. 鸿蒙化适配实操全流程3.1 环境准备与工程初始化工欲善其事必先利其器。开始适配前你需要准备好三样东西Flutter SDK 3.22 以上版本我用的是 3.24再新一点的也没问题DevEco Studio 5.0配套 HarmonyOS NEXT SDKAPI 12一台鸿蒙真机或 HarmonyOS 模拟器建议先用模拟器跑通流程再上真机验证权限工程结构上建议直接建一个 Flutter 插件工程把适配逻辑做成独立模块。命令行创建flutter create --org com.example --templateplugin --platformsweb,ohos file_system_access_api_harmony接着用 DevEco Studio 打开生成的ohos目录补全 module.json5 里的权限声明。文件读写需要{ requestPermissions: [ { name: ohos.permission.READ_MEDIA, reason: 读取用户选择的文件 }, { name: ohos.permission.WRITE_MEDIA, reason: 保存编辑后的文件 } ] }这里有个坑如果只是通过系统 picker 拿 URI 并使用其实不需要 READ_MEDIA/WRITE_MEDIA因为用户主动授权过但如果你要直接访问沙箱目录下的任意路径权限声明就必不可少。搞不清楚就都声明上但注意权限申请弹窗会影响体验能用 picker 的尽量用 picker。3.2 Dart 层接口抽象与兼容降级设计一个统一的抽象接口是适配工作的第一步。我的做法是在 Dart 层定义一套接口然后分别提供WebFileSystemAccess和HarmonyFileSystemAccess两个实现。核心接口大致长这样abstract class IFileSystemAccess { FutureFileHandle? openFile({ ListFilePickerType acceptType, bool multiple, }); FutureFileHandle? saveFile({ String suggestedName, ListFilePickerType acceptType, }); FutureDirectoryHandle? openDirectory(); Futurebool isSupported(); } abstract class FileHandle { String get name; FutureUint8List read(); FutureByteSink createWritable(); Futurevoid writeAtomic(Uint8List data, {AtomicWriteMode mode}); } abstract class DirectoryHandle { String get name; FutureListFileSystemEntry list(); FutureFileHandle? getFile(String name, {bool create}); FutureDirectoryHandle? getDirectory(String name, {bool create}); }这个抽象层的价值有两个第一业务代码只需要依赖接口不需要关心底层是 Web 还是 ArkTS第二在isSupported()返回 false 的时候可以直接抛出一个友好提示或者走降级路径比如改成用 HTTP 上传到后端保存避免用户面对一个无响应的按钮发呆。ByteSink需要说明一下。很多文件库用ByteSink抽象写操作好处是可以按块写入边写边刷降低内存峰值。如果你只需要一次性覆盖写也可以简化成Futurevoid writeAll(Listint data)。3.3 生成与注入 Web 适配源码JS 桥接层如果你的部署目标是“Flutter Web 应用跑在鸿蒙 WebView 里”那么真正要适配的是 JS 层。.dart文件里套package:web或dart:js_interop在编译产物里生成一段 JS 逻辑。适配的核心是 Feature Detection我写了一个典型的探测函数function detectFileSystemAccess() { if (typeof window.showOpenFilePicker ! function) { return no-showOpenFilePicker; } if (typeof window.showSaveFilePicker ! function) { return no-showSaveFilePicker; } if (typeof window.showDirectoryPicker ! function) { return no-showDirectoryPicker; } if (typeof FileSystemHandle undefined) { return no-FileSystemHandle; } return ok; }然后在 Dart 侧通过JS()注解把这段 JS 函数暴露出去JS(detectFileSystemAccess) external String detectFileSystemAccess();以此决定走哪条路径。如果detectFileSystemAccess()返回ok恭喜直接复用原库 Web 实现否则就把showOpenFilePicker那套逻辑替换成const input document.createElement(input); input.type file; input.onchange async () { const file input.files[0]; // 构造一个模拟的 FileSystemFileHandle只实现 read 能力 }; input.click();注意降级后的“文件句柄”不能写回原文件所以 UI 上要明确提示用户“当前浏览器环境不支持原地保存请使用导出功能”避免数据看起来保存成功、实际没写进去。鸿蒙 WebView 目前对 File System Access API 的支持参差不齐跟系统版本挂钩。我的实测结果是 API 12 的模拟器上showOpenFilePicker能弹但showDirectoryPicker有时会闪退API 14 的模拟器相对稳定。所以稳定优先建议默认走降级路径探测通过后再启用高级能力。3.4 原子化读写引擎的具体实现这部分是整个项目的重点。所谓“原子化读写引擎”并不是简单地封装一下write而是要做一套带事务感的文件写入系统。下面给出我实现的核心步骤。第一步临时文件写入目标文件是foo.txt写入时先写foo.txt.tmp-随机后缀。这样即使中途崩溃原文件还稳稳待在磁盘上。Dart 侧抽象出一次原子写操作Futurevoid atomicWrite( FileHandle target, ByteSink data, { required DirectoryHandle workingDir, }) async { final tmpName ${target.name}.tmp-${DateTime.now().microsecondsSinceEpoch}; final tmpFile await workingDir.getFile(tmpName, create: true); final sink await tmpFile.createWritable(); await data.close(); // 完成流式写入 await tmpFile.move(target, overwrite: true); }底层对应浏览器 API就是先创建同目录的临时文件写入后把临时文件move()到目标文件并设置overwrite: true。在 Chrome 里这个 move 操作是原子性的鸿蒙 WebView 的行为则取决于实现所以还要补一步校验。第二步写后校验并回滚写入完成后重新打开目标文件读一遍摘要跟写入前的预期值比对。如果失败就把上一次备份如果是覆盖场景恢复回去。这里的校验不用太复杂CRC32 足够用如果文件超大可以考虑抽样校验读头、中、尾三块。Futurebool verifyFile(FileHandle file, Uint8List expected) { final actual await file.read(); return crc32(actual) crc32(expected); }第三步并发锁同一文件并发写是数据错乱的第一大来源。我实现了一个简单的文件级互斥锁class FileWriteLock { final MapString, Futurevoid Function() _queue {}; FutureT synchronizedT(String key, FutureT Function() action) { final prev _queue[key] ?? Future.value(); final next prev.then((_) action()); _queue[key] next.catchError((_) {}); return next; } }然后所有写操作都走synchronized(filePath, () doWrite())。这个锁只防同一个 Dart isolate 内的并发如果存在多 engine 或多进程同时写同一文件还需要在原生层加更重的锁但绝大多数 Flutter Web 场景下一个 isolate 就够用了。第四步分块写入大文件最怕一次性readAll()把内存吃爆。浏览器里的FileSystemWritableFileStream天然支持分块写writer.write({type: write, position: offset, data: chunk})。我在 Dart 侧封装成Futurevoid writeChunks(FileHandle file, StreamUint8List chunks) async { final writable await file.createWritable(); var offset 0; await for (final chunk in chunks) { await writable.write(chunk, offset); offset chunk.length; } await writable.close(); }配合chunkSize 1MB实测写 200MB 文件时内存占用从 800MB 降到 120MB 以内。这个优化在 PC 端尤其重要。第五步错误恢复如果在写入过程中抛异常引擎要把临时文件自动删掉避免磁盘堆积垃圾文件try { await doWrite(); } catch (e) { try { await tmpFile.delete(); } catch (_) {} rethrow; }整套引擎实现下来虽然没有数据库那种完整事务日志但对于文件编辑场景已经足够可靠。核心原则只有一句话永远别直接写原文件先写临时文件全部成功再替换。4. 常见问题排查与避坑实录4.1 权限与用户手势限制我在鸿蒙模拟器上跑 demo 时最典型的报错是SecurityError或AbortError。原因很简单File System Access API 要求 picker 的调用必须发生在用户手势click、touch 等的任务栈里不能异步隔太久再弹窗。Flutter Web 里如果先做网络请求、再接结果弹 picker很容易触发这个限制。解决办法是把showOpenFilePicker的调用跟用户点击事件绑定在同一个事件循环里弹窗前尽量不做异步操作。就算要做也要把 picker 调用放到then的微任务里而不是 setTimeout 宏任务里否则浏览器就认为失去用户激活状态。鸿蒙原生侧没有这个限制系统 picker 可以随时弹但要注意权限弹窗的时机第一次尽量在用户明确点击“导入”按钮后再申请而不是应用启动时就申请否则体验差且容易被用户在系统设置里关掉。4.2 沙箱、路径与持久化授权另一个高频问题是路径映射。浏览器里文件句柄是不暴露绝对路径的你只能通过句柄操作但鸿蒙原生侧走的是绝对路径 / URI。所以在桥接层我维护了一张映射表Dart 层句柄 ID鸿蒙路径 / URI权限范围handle://1/data/storage/el2/base/haps/entry/files/tmp/foo.txt读写handle://2file://media/Photo/1只读映射表放在内存和本地存储双份。内存表用于会话内快速访问本地存储Preferences用于应用重启后恢复句柄。浏览器端持久化句柄是往 IndexedDB 里塞 IndexedDB 句柄鸿蒙端就存这张路径映射表。恢复权限时要注意浏览器端通过queryPermission/requestPermission重新授权鸿蒙端则重新校验 URI 归属和沙箱路径是否仍在当前应用目录下。如果 URI 已经失效比如用户删除了源文件要给出明确提示而不是静默失败。4.3 大文件与内存抖动大文件写入时内存抖动是绕不开的话题。我在第 3.4 节提到了分块写这里再补充一个具体数据写 500MB 文件如果一次性读进内存Dart 侧堆内存直接飙到 1GB 上下再配合 GC 很容易造成掉帧改成 1MB 分块后堆内存稳定在 200MB。鸿蒙原生侧还需要注意 FD 泄漏问题。如果用fs.openSync打开文件但忘记fs.closeSync反复操作几百次后会出现EMFILE: too many open files。我的做法是做一个统一的FdManager所有打开的文件 FD 都注册进去显式跟踪用完必须释放。调试时可以用 DevEco 的 Profiler 观察 FD 数量曲线异常增长一定是有泄漏。4.4 兼容性与降级策略最后聊聊兼容性。File System Access API 在鸿蒙 WebView 里的支持情况我实测下来分三种版本/环境showOpenFilePickerOPFScreateWritableAPI 12 模拟器可用但偶发崩溃可用部分可用API 14 模拟器稳定可用稳定鸿蒙浏览器最新版稳定可用稳定旧版本 ArkWeb不可用不可用不可用我的降级策略是“按能力分级”如果showOpenFilePicker可用用原生 picker 打开文件 IndexedDB 持久化句柄但写回时若createWritable不可用就退化为“导出下载”模式如果 picker 都不可用直接走input typefile的上传模式。这样一个应用能在所有环境中都“能用”只是能力多少的问题。这一套降级判断建议放在应用启动时做一次缓存结果不要每次打开文件都探测一遍节省启动时间。5. 验收与性能基准5.1 功能验收清单适配完成后建议按下面这份清单逐项测试每一项都要标出通过/不通过以及具体现象用例期望结果检查点打开文本文件弹系统 picker选中后读入内容文件名、大小、内容一致保存新文件弹保存 picker默认名正确落地文件可被外部打开编辑后写回原文件内容更新临时文件已清理无残留目录选择与遍历列出目录下所有子项子目录能递归进入大文件原子写200MB写入完成且校验通过内存峰值小于 300MB并发写同一文件后写覆盖先写且无错乱最终文件内容为后写数据写一半断网/断进程原文件保持旧数据临时文件已被清理或可手动恢复我自己验收时被坑过一次saveFile的默认文件名是suggestedName在鸿蒙系统 picker 里并不会自动带上扩展名导致保存出来的文件没有后缀。解决办法是在传参前先手动拼上扩展名或用 MIME 类型的extension字段兜底。5.2 性能基准数据与调优用 Flutter Web 编译产物 鸿蒙模拟器跑了一份基准数据供大家参考操作文件大小耗时第一次耗时预热后打开文件并读取全文10MB850ms320ms写入 10MB原子写10MB1200ms410ms打开目录并递归 100 个文件100 个/平均 5KB1500ms600ms200MB 分块写入200MB8600ms5400ms第一次耗时偏高主要因为 WebView 初始化 JS 引擎、加载编译产物预热后明显下降。真正的瓶颈是 IO 调度调大分块大小从 256KB 调到 1MB能有效减少事件循环切换次数写入吞吐提高约 35%。如果追求极致性能可以在 ArkTS 侧用fs.createStream做流式 IO配合双缓冲吞吐还能再上一个台阶。5.3 建议的模块扩展方向适配做完之后如果你的业务需要更进一步我大致梳理了几个后续可以做的方向句柄持久化与恢复把文件句柄映射表同步到 IndexedDBWeb 端或 Preferences鸿蒙端应用重启后能恢复“最近打开的文件”。目录级同步引擎在原子写基础上加“目录变更事件监听”做类似桌面 IDE 的自动保存插件。加密文件系统在写入前加一层 AES-GCM 流式加密临时文件和落盘文件都变成密文保障敏感数据安全。文件快照与版本回退每次原子写前保留上一版本到.history目录用户可回退任意历史版本。限于篇幅这次先讲到这里。我个人在实际操作中最强烈的感受是鸿蒙化的核心难点不在 API 迁移本身而在“不同环境下能力差异的优雅处理”。把 Feature Detection、降级路径、原子写这三件事做好你的 Flutter 文件系统访问体验就能在各种环境里都保持稳定用户根本感觉不到底层换了平台。建议从最小的 picker 原子写 demo 开始跑通一条链路再逐步放开能力切忌一上来就把所有 API 都启用那样排错会很痛苦。

看完文章,想为自己的企业也做一次专业网站诊断?

尧图顾问免费为您评估现有网站,并给出建站/改版建议与报价方案。

免费获取方案