
如果你正在鸿蒙设备上做一个带 Web 内核的 Flutter 应用或者准备把一个浏览器形态的产品搬进鸿蒙生态大概很快会遇到这样一个坎网页里的文件选择、本地读写、目录遍历这些能力在 PC 浏览器上有 W3C File System Access API 可以调但在鸿蒙的 Web 容器里这套能力是缺失的。以前在 Android 上做 Flutter 时可以直接套 SAFStorage Access Framework那套适配到了 iOS 就换成 security-scoped URL。可鸿蒙一来这套旧经验基本作废你得重新设计文件句柄怎么映射、权限怎么授权、写操作怎么保证原子性。这篇文章就记录我怎么把一个 Flutter 三方库file_system_access_api完整鸿蒙化的全过程包括权限模型映射、DocumentViewPicker 适配、MethodChannel 桥接、原子化写入引擎以及让 Web 页面里的 JS 也能调起鸿蒙原生文件能力。适合正在做鸿蒙 Flutter 插件适配的人、想在鸿蒙 WebView 里做本地文件读写的人以及对鸿蒙安全模型和文件系统关系好奇的人。1. 为什么鸿蒙上的 Web 文件访问需要一次重新发明先说清楚file_system_access_api这个东西到底解决了什么。浏览器原生的 File System Access API 让网页应用可以像本地桌面软件一样弹出真正的文件选择器拿到一个可持久的文件句柄FileSystemFileHandle然后反复读写这个文件、创建目录、遍历文件夹甚至把句柄存在 IndexedDB 里下次打开页面还能继续用。以前网页处理文件的方式是input typefile那相当于借书看完就还每次处理都要重新选文件写回磁盘更是绕了一大圈体验和桌面应用差着一大截。File System Access API 的完整实现等于给了网页一个长期借书证这也是各种 Web 端编辑器、低代码平台、图片批处理工具敢做重文件操作的前提。Flutter 生态里的file_system_access_api把这个标准能力封装成了跨平台 APIDart 侧提供FileSystemAccess.openFile()、FileSystemFileHandle.createWritable()这样的方法底层分别对接各平台的系统实现。Windows 上直接调 Win32 APImacOS 上用 NSOpenPanelAndroid 上用 SAF。这个库原本不支持鸿蒙要让它跑在 HarmonyOS 上就得把整条链路重新实现一遍。1.1 鸿蒙的沙箱是另一套游戏规则鸿蒙应用默认跑在沙箱里App 能访问的区域只有自己的私有目录和有限的公共目录。想要读取用户选中的媒体文件或文档必须走两条线一条是声明 requestPermissions 权限比如ohos.permission.READ_MEDIA另一条是通过 Picker 组件让用户主动授权返回文件 URI。看起来和 Android 很像但细节完全不同。Android SAF 的 URI 是content://鸿蒙 Picker 返回的多是file://或特定 provider 格式授权生命周期、恢复方式、写回策略都不一样。更关键的是鸿蒙 Web 组件web_webview加载的页面默认没有宿主应用的沙箱访问能力页面就是页面跟浏览器一样不能随便碰应用文件。这意味着如果页面里的 JS 想直接读写文件必须通过 JS bridge 打到原生侧再走 Picker URI 授权。这套组合拳打下来你会发现在鸿蒙上实现 File System Access API不是把一个现成插件复制粘贴而是要在鸿蒙体系里重新发明一个能对接到 Web 层的文件服务层。1.2 为什么 Android/iOS 的适配代码不能直接抄很多团队做鸿蒙适配时会想把 Android 的 MethodChannel 逻辑翻译成 ArkTS 不就行了我一开始也这么想然后被现实教育了。Android 的 SAF 有takePersistableUriPermission()把权限持久化这件事系统帮你做了一半iOS 的 security-scoped bookmark 也是这样系统会记住用户授权App 重启后还能恢复访问。鸿蒙的 URI 授权更接近会话级授权按需重新激活用户通过 Picker 选中文件后本次 App 进程内可以访问但进程重启后 URI 权限可能不会自动保留。你需要自己管理权限恢复流程比如冷启动时重新拉起授权校验甚至引导用户重新选择文件。线程模型也差很多。ArkTS 侧虽然有 async/await但文件操作的底层回调、TaskPool 的线程隔离、UI 线程的跳转约束都和 Java/Kotlin 不一样。原库的 Android 实现在主线程和 Binder 线程之间来回切换翻译到鸿蒙不能照搬否则很快会遇到Inner Error或者回调丢失。所以正确的姿势是把原插件的 API 层保留把平台适配层整个重写。2. 鸿蒙化适配的前置工作环境、分支与插件骨架动手之前先明确工具链。鸿蒙上的 Flutter 不是官方 flutter.dev 那个分支直接能跑的需要 OpenHarmony 社区维护的flutter_flutter比如3.22.1-ohos这样的版本。这个分支会同步上游 Flutter 代码同时把鸿蒙的 embedder、engine 适配、插件加载机制都接进来。建议直接用这个分支不要用个人维护的魔改分支否则后期升级、接入三方库都会很难受。2.1 工程骨架怎么搭file_system_access_api是个插件鸿蒙插件的工程结构和 Android/iOS 插件类似但有自己的目录约定。我用官方模板生成之后目录长这样file_system_access_api/ ├── lib/ │ ├── file_system_access_api.dart │ └── src/ │ ├── file_system_access.dart │ ├── file_handle.dart │ └── platform_interface.dart ├── ohos/ │ ├── build-profile.json5 │ ├── hvigorfile.ts │ ├── oh-package.json5 │ └── src/main/ │ ├── ets/ │ │ ├── plugin/ │ │ │ ├── FileSystemAccessPlugin.ets │ │ │ └── FileSystemAccessModel.ets │ │ └── entryability/ │ └── resources/ ├── example/ │ └── ohos/ └── pubspec.yamlpubspec.yaml里需要声明 ohos 平台插件入口flutter: plugin: platforms: ohos: package: dev.fsa.ohos pluginClass: FileSystemAccessPlugin这里有个容易忽略的细节package不要求是你的应用包名但必须是 ohos 侧的 oh-package 名称在oh-package.json5里要对应。插件注册的时候鸿蒙侧会自动扫描这个pluginClass。原生侧实现一个继承自Plugin的类在onAttach里注册 MethodChannelimport { Plugin } from ohos/hvigor-plugin; import { MethodChannel } from ohos/flutter_ohos; export class FileSystemAccessPlugin extends Plugin { onAttach(engine: any) { const channel new MethodChannel(engine, dev.fsa/file_system_access); channel.setMethodCallHandler(this.handleCall.bind(this)); } }2.2 先把桥接协议画清楚再写代码我最建议的启动姿势不是直接写 ArkTS而是先把原插件 Dart 侧的方法清单整理出来弄清楚每个方法的参数、返回值、错误类型。因为鸿蒙适配的 80% 工作是在处理边界情况方法少列一个后面就是硬伤。下面是我整理的桥接方法表这张表后来成了鸿蒙侧和 Dart 侧对合同的唯一依据MethodChannel 方法入参返回值说明initialize无bool初始化权限检查与临时目录准备showOpenFilePickeracceptTypes, multipleuri 列表打开文件选择器showSaveFilePickersuggestedName, acceptTypesuri打开保存对话框openFileByUriurihandleId, name, size通过 URI 打开文件并注册句柄createWritableChunkhandleId, chunkIndexbool创建原子化写入流的一个数据块writeToHandlehandleId, path, offsetwrittenBytes从临时文件路径写入数据commitWritehandleIdbool提交原子化写入完成 renameabortWritehandleIdbool回滚未提交写入closeHandlehandleIdbool释放文件句柄这张表定了之后Dart 侧和 ArkTS 侧可以并行开发我大概花了半天时间把 Dart 层用 Mock 实现跑通剩下一整天全砸在鸿蒙侧实现上。3. 权限模型对比从 Android 动态权限到鸿蒙安全模型的映射鸿蒙权限体系的底层逻辑和 Android 有相似之处但也有本质差异。Android 上你声明权限、运行时请求、用户授权后就完事了鸿蒙额外引入了一整套abilityAccessCtrl和属性权限的概念并且把用户可见的授权弹窗和系统内部的能力判定拆成两件事。3.1 system_grant 和 user_grant鸿蒙把权限分成两类。system_grant是安装时直接授予的比如网络权限、获取设备信息的权限这类权限只要在module.json5声明即可不需要运行时弹窗。user_grant是需要用户主动允许的敏感权限典型就是媒体文件读取、位置信息、麦克风等。文件访问这个场景里ohos.permission.READ_MEDIA、ohos.permission.WRITE_MEDIA都是user_grant必须走运行时请求流程。这个区别意味着你在 manifest 里写了 READ_MEDIA 还不够还得在应用逻辑里通过abilityAccessCtrl.requestPermissionsFromUser()真正发起授权请求否则权限状态永远是 denied连 Picker 都能开但一读文件就报权限不足。3.2 module.json5 里声明的正确写法很多新手栽在编译不过或者弹窗不出现都是因为module.json5的requestPermissions配置不完整。鸿蒙要求user_grant权限必须带reason和usedScene否则打包工具直接报错。参考配置如下{ requestPermissions: [ { name: ohos.permission.READ_MEDIA, reason: $string:reason_read_media, usedScene: { abilities: [EntryAbility], when: inuse } } ] }reason是给用户在系统设置里看的一句话说明必须定义在 string 资源里具体值类似用于读取您选择的文件并将其保存到本地。when填inuse表示前台使用期间申请。3.3 运行时请求授权的完整链路运行时请求权限这一步我封装成了一个独立模块联动 Dart 层的initialize方法。核心逻辑大致是import { abilityAccessCtrl, common } from kit.AbilityKit; import { BusinessError } from kit.BasicServicesKit; async function ensureMediaPermission(context: common.Context): Promiseboolean { const atManager abilityAccessCtrl.createAtManager(); try { const result await atManager.requestPermissionsFromUser(context, [ ohos.permission.READ_MEDIA, ohos.permission.WRITE_MEDIA, ]); return result.authResults.every(item item 0); } catch (e) { const err e as BusinessError; console.error(request permission failed: ${err.code}); return false; } }这里值得注意的一点是requestPermissionsFromUser返回的authResults数组顺序和你传入的权限数组一致用every()检查全部通过再继续。华为的授权弹窗一次只能弹一组所以把读写两个权限一起传是没问题的但如果把很多权限一次性塞进去用户拒绝一个后面的流程直接断掉。尽量只申请本次操作真正需要的权限。3.4 拿 READ_MEDIA 不等于能直接写文件这是我在权限测试里踩的最深的一个坑。趁着有 READ_MEDIA 权限直接拿 Picker 返回的 URI 去fileIo.openSync(uri, OpenMode.READ_WRITE)结果能读不能写报出类似Permission denied的错。查了半天才明白媒体权限解决的是能不能看到媒体库里的文件不代表你有权修改这个文件本身。真正决定能不能改写文件内容的是 URI 授权机制即 picker 选中文件时系统是否对该 URI 授予了写能力。解决路径是在打开文件句柄前先检查并激活 URI 权限import { uriPermission } from kit.AbilityKit; function takeUriPermission(uri: string): void { const helper uriPermission.getUriPermissionManager(); try { helper.grantUriPermission(uri, { accessMode: uriPermission.UriPermissionMode.READ_WRITE, }); } catch (e) { // 某些 URI 本身不可授权此时需要换用临时文件方案 } }所以权限适配的正确顺序是先申请 user_grant 权限再用 Picker 拿到 URI再对 URI 做写授权最后才打开文件。漏任何一环都会出现能选文件但改不了内容的诡异现象。4. 核心 API 的鸿蒙实现文件选择器、读写句柄与原子化写入权限模型捋顺之后真正的实现重点就变成了三个文件选择器、文件句柄、写入引擎。这一节我会把实现思路和关键代码片段都放出来。4.1 用 DocumentViewPicker 实现打开与保存文件选择器直接用鸿蒙系统自带的 Picker 最省事兼容性最好。DocumentViewPicker负责文档PhotoViewPicker负责图片和视频。以打开文件为例import { picker } from kit.CoreFileKit; async function pickFiles(context: common.Context): Promisestring[] { const documentPicker new picker.DocumentViewPicker(context); const pickResult await documentPicker.select({ maxSelectNumber: 1, fileSuffixFilters: [.txt, .md, .json, .png], }); return pickResult; }保存文件同理用documentPicker.save()入参传newFileNames和fileSuffixFilters返回的 URI 就是你之后要写入的目标路径。实际用下来选择器在 HarmonyOS 的设备上表现比较稳定包括文件管理原子服务里弹出的那套界面就是系统自带的用户认知成本低。4.2 从 URI 到可写的文件句柄拿到 URI 后我用fileIo.openSync把它转成一个真实文件描述符然后注册到自己的句柄表里import { fileIo as fs } from kit.CoreFileKit; function openHandle(uri: string, mode: number): number { const file fs.openSync(uri, mode); const handleId globalHandleCounter; handleRegistry.set(handleId, { fd: file.fd, path: file.path, uri, }); return handleId; }句柄表是必须的。你不能每次操作都把 URI 拿来 open 一次一个是性能差另一个是文件在被外部改动或删除后 URI 的重放打开会失败。保留一个 fd 和路径的双重缓存读写、commit、close 都走 handleId既统一又高效。句柄 ID 是自增数字天然适合跨 MethodChannel 传递。4.3 原子化写入引擎的核心思路这是整个适配里最有价值的部分也最值得展开。标题里说的原子化读写引擎本质要解决一个非常朴素的问题用户在网页里点保存如果写到一半断电、崩溃、或者被系统中断文件坏了怎么办最朴素的方案是直接打开原文件写入但如果写入块很大写到一半进程被杀原文件就残缺了。所以我的写入引擎采用临时文件 双阶段提交模型流程如下在目标文件同目录下创建一个临时文件name.tmp.random所有写入流都写到这个临时文件写入完成调用fs.fsyncSync(fd)确保数据落盘关闭临时文件句柄调用fs.renameSync(tmpPath, targetPath)完成原子替换如果中途任何一步失败或调用方主动 abort直接删除临时文件目标文件不受影响。对应 ArkTS 侧的提交逻辑function commitWrite(handleId: number): boolean { const rec handleRegistry.get(handleId); if (!rec || !rec.tmpPath) return false; try { fs.fsyncSync(rec.tmpFd); } catch (e) { return false; } fs.closeSync(rec.tmpFd); fs.renameSync(rec.tmpPath, rec.targetPath); handleRegistry.delete(handleId); return true; }这里有个细节必须注意临时文件一定要放在目标文件同一个目录下不能放 cache 目录。因为renameSync在同一个文件系统内是原子操作跨越挂载点/存储分区时无法保证一次调用的原子性甚至在极端情况下会直接失败。这也是我后面踩坑的一个主要来源第七节会专门说。4.4 批量合并与进度回调页面里的编辑器可能一秒钟产生几十次小写入如果每次写入都走一次 MethodChannel 一次 openSync开销会大得离谱。我在 Dart 侧实现了一个ChunkedWriter把连续的小写入攒进内存缓冲区达到阈值比如 256KB或用户主动 flushes 时再一次性发给原生侧由原生侧写进临时文件。这样一次 commit 就可能覆盖几十个小写入整体效率高很多。进度回调我用 EventChannel 或者说直接用一个长期存在的 MethodChannel invokeMethod 每批回报一次也行测试下来后者更稳。Dart 侧大致这样Futurevoid flush() async { final written await _channel.invokeMethodint(writeToHandle, { handleId: _handleId, path: tmpPathForChunk, offset: _offset, }); _offset written; }5. 穿梭在 Dart 与鸿蒙之间的桥MethodChannel 设计细节MethodChannel 是 Flutter 插件最基础的通信机制但插件做深了才发现真正难的不是怎么建立一个 Channel而是怎么设计一套不会崩的 Channel 协议。5.1 方法命名与参数格式桥接方法我统一用动词名词的 camelCaseDart 侧和 ArkTS 侧共用同一套字符串常量避免随手写错字符串导致调用静默失败。参数一律用扁平 Mapkey 用 snake_casevalue 只允许基础类型int、double、bool、String、List。这样做的原因是 MethodChannel 序列化层对嵌套复杂对象的支持在不同平台上表现不一致尤其是在鸿蒙 Flutter 分支上最稳的还是扁平的 JSON-able 结构。一个参数定义的教训传时间戳时不要用 Dart 的DateTime对象直接塞进 Map鸿蒙侧收到的是 unknown解析成本高。我直接传 UTC 毫秒数int两边都是整数零歧义。5.2 大文件千万别跨桥搬二进制很多人第一次写文件插件会把整个字节数组塞进invokeMethod比如读取一个 200MB 的文件直接ByteArray全量传。这个做法在 PC 上可能还跑得动在手机上一试必崩表现为 UI 卡死、Channel 调用超时、内存直接撑爆。我的做法是路径共享代替数据搬运Dart 侧先把要写的内容写入应用私有缓存里的临时文件然后把临时文件路径告诉鸿蒙侧鸿蒙侧拿到路径直接 open 读取再把数据写入最终的目标临时文件或原文件。读取大文件也一样鸿蒙侧把数据落盘到指定缓存路径Dart 侧再通过文件流读取避免一次跨桥传输超大数据。如果确实需要直接传字节就把数据切成 1MB 左右的分片一次传一片并统计进度回传。但实测下来路径共享的效率和稳定性都完胜分片传输尤其是在处理图片、PDF 这类文件时。5.3 句柄生命周期的管理这一块是我在中后期补上的。刚开始写的时候每次调用都开 fd用完后随手一放不管。测试连续打开关闭几十个文件后fd 数暴涨再打开文件直接报Too many open files。后来规范了句柄注册表每个 handleId 都绑定了fd、tmpPath、targetPath、refCountDart 侧closeHandle一定要调用原生侧在 App 进入后台或 Web 容器销毁时也要统一回收所有残留句柄。还有一个细节MethodChannel 调用本身也可能失败比如 App 被切后台、ArkTS 侧抛异常Dart 侧的try/catch只能捕获 Dart 异常捕获不到平台层异常。所以我在 Dart 侧封装了一层统一异常转换把鸿蒙BusinessError的 code 映射成 Dart 侧的FileSystemAccessException和PermissionDeniedException这样上层 UI 能直接根据异常类型提示用户重选或去设置页开启权限。6. 与 Web 浏览器的融合让网页 JS 也能调起鸿蒙文件系统做 Flutter 插件鸿蒙化只是第一步真正让人头疼的是标题里的后半句让 Web 浏览器里的页面也能用上这套文件能力。鸿蒙 Web 容器里跑的网页原本和普通浏览器页面一样受限于沙箱完全碰不到宿主文件。要把 file_system_access_api 的能力开放给网页 JS需要一套完整的 JS bridge 方案。6.1 场景内嵌浏览器里的在线文档编辑器我的实际场景是鸿蒙 App 内嵌了一个 Web 页面这个页面是一个在线 Markdown 编辑器用户希望直接打开本地的一个.md文件进行编辑然后 CtrlS 保存回磁盘。浏览器里的标准做法是走 File System Access API但在鸿蒙 WebView 中这个 API 不存在或者是空壳。所以需要把宿主的能力通过 JS bridge 暴露给页面。6.2 三层调用链路怎么设计方案有两个方向。一个是完全在鸿蒙原生侧处理使用web_webview的javaScriptProxy注册一个全局对象比如window.harmonyFileAccess网页 JS 直接调这个对象的方法再通过 WebView 回调拿到结果。另一个是让 WebView 的 JS 通过 Flutter WebView 插件抛给 Flutter Dart 层再由 Dart 走 MethodChannel 转发给原生。第一种更快、链路更短我最终选的也是这个。注册 JS proxy 的代码大概长这样import { web_webview } from kit.WebKit; let controller: web_webview.WebviewController new web_webview.WebviewController(); controller.registerJavaScriptProxy({ objectName: harmonyFileAccess, object: new FileAccessJsBridge(this.context), methodList: [openFile, saveFile, createWritable, writeChunk, commit, abort], asyncMethodList: [openFile, saveFile, createWritable, writeChunk, commit, abort], });FileAccessJsBridge内部其实只是薄薄一层把 JS 传来的handleId、chunk、path等参数转手交给之前实现的原生核心模块所以文件访问的核心逻辑全部复用不会出现 JS 桥里塞满复杂逻辑的局面。6.3 给页面注入 File System Access API 的 PolyfillJS proxy 有了但直接让业务页面去调window.harmonyFileAccess并不友好。更优雅的做法是在页面初始化时注入一个 File System Access API 的 Polyfill把showOpenFilePicker、showSaveFilePicker、FileSystemHandle这些标准接口补全。页面代码完全不用感知鸿蒙以为自己在跑一个标准浏览器。注入脚本如下(() { if (window.showOpenFilePicker) return; window.showOpenFilePicker async (options {}) { const uri await window.harmonyFileAccess.openFile(options); return [{ kind: file, name: uri.split(/).pop(), getFile: async () ({ uri }), createWritable: async () { let handleId await window.harmonyFileAccess.createWritable(uri); return { write: async (data) { await window.harmonyFileAccess.writeChunk(handleId, data); }, close: async () { await window.harmonyFileAccess.commit(handleId); }, abort: async () { await window.harmonyFileAccess.abort(handleId); } }; } }]; }; })();这里的write(data)接收的data可以是Blob、ArrayBuffer或字符串。因为 JS bridge 不能直接传输二进制 Buffer我内部先把它转成 base64 字符串再到原生侧解回字节实测 10MB 以内是够用的更大的文件建议先写进indexedDB攒成分片再逐块传速度同样可接受。6.4 页面里真正的 保存 会发生什么用户点保存后页面 JS 调 Polyfill 的createWritable()返回一个写流对象编辑器把 Markdown 全文通过write()送入ChunkedWriter 在 JS 侧攒满一个阈值后一次性传给原生原生侧把数据追加到临时文件用户继续编辑可能再攒几批最后close()触发commitrenameSync把临时文件替换为目标文件。整个链路里用户感知到的是保存成功了但实际上文件经历了完整的双阶段提交即便是保存到一半断电原文件也完好无损。有个微妙点需要留意在浏览器 API 里createWritable()之后可以多次 write 最终 close但在我们这套桥接实现里commit是把临时文件 rename 成目标文件所以一旦 commit原来的 handle 就失效了。因此 Polyfill 里的createWritable()每次调用都会在原生侧重新创建一个新临时文件确保已打开的句柄和正在写入的临时文件是一一对应的而不是复用同一个。7. 踩坑记录我在鸿蒙化过程中遇到的最棘手的 5 个问题适配做到后期基本都是在解决各种环境差异和实现细节问题。挑五个最典型的记录一下每一个都有真实的解决路径希望你能少走一次弯路。7.1 READ_MEDIA 权限声明了却不弹窗现象module.json5明明写了ohos.permission.READ_MEDIA运行时requestPermissionsFromUser就是不弹窗直接返回授权失败。排查后发现问题出在usedScene.abilities忘记配置或者配置成了错误的能力名。鸿蒙对 user_grant 权限除了要求reason还要求明确声明哪些 Ability 在使用该权限如果没对上系统会静默拒绝弹窗。解决方法是把权限声明里的abilities和你实际注册的 EntryAbility 名称逐一核对同时确认reason指向的字符串资源存在且不是空串。7.2 Picker 返回的 URI 在重启后失效打开文件后把 URI 存到了本地第二天 App 冷启动后拿着这个 URI 去 open直接报权限不足。鸿蒙的 URI 授权不像 Android 的持久化 URI 授权那样天然跨启动需要你在 App 启动时重新执行一次 URI 授权激活流程。我的做法是把最近用过的 URI 列表持久化冷启动时遍历并调用grantUriPermission恢复授权如果授权失败则主动丢弃该 URI 并通知 UI 层上次打开的文件已无法访问请重新选择。好在这个情况不常发生因为大多数用户是选中就编辑而不是选完放几天但作为文件系统引擎状态必须可控。7.3 rename 在跨目录或文件句柄未关闭时失败原子化写入中临时文件已经写满renameSync却偶尔抛异常。第一次遇到时百思不解后来看了错误码原因是目标文件仍被之前打开的原句柄占用。我最初的设计里如果用户在createWritable()之前先通过getFile()拿过一次读句柄那个读句柄没有关闭rename 就会失败。解决方法是 commit 之前先强制关闭所有指向目标文件的注册句柄再执行 rename。另外临时文件和目标文件必须同目录已经说过了跨存储卡、跨分区 rename 是没有原子性保证的这属于物理限制代码再绕也绕不过去。7.4 大文件读取 OOM给一个视频文件做读取-回写测试时Dart 侧直接 OOM 崩溃。根本原因是 MethodChannel 一次传了太大的二进制块。后来把大文件读取改成分片读取原生侧每次只读 4MB 的块写入临时文件再用路径共享交给 Dart 侧读取。UI 线程完全不被大数据卡住内存占用维持在极低水平。如果你也打算适配大文件处理记住一条原则MethodChannel 是信令通道不是数据传输通道超过几 MB 的数据就应该考虑用文件路径或者持久化队列来传递。7.5 JS 桥返回的 FileSystemFileHandle 是一次性的页面里通过 Polyfill 拿到的 handlecreateWritable()后第一次写入正常第二次写入却失败。原因是我 JS 侧的写流对象没有正确持有原生侧创建的 handleId每次都新开一个临时文件commit 时永久替换掉了目标文件第二次写入就找不着了。修复方式就是让 createWritable 返回的写流在对象内部维护一个状态标志已经 close 或 abort 后后续 write 调用必须抛InvalidStateError而不是静默失败。这个改完编辑器的多轮保存行为才真正稳定下来。适配过程中我还踩过不少小坑比如fs.openSync的模式位用错导致写不了、BusinessError的 code 没映射导致 UI 只能展示未知错误、Web 组件的缓存策略导致注入的 Polyfill 没更新等等。但上面五个问题代表了鸿蒙文件访问适配中最大的一类共性权限生命周期、文件占用、跨桥大数据、对象状态机。把这几类问题想清楚鸿蒙上的文件系统访问引擎基本就立住了。如果让我重新做一遍这个适配第一件事不会是去写原生代码而是花一个下午把权限状态机画清楚从 picker 授权到 URI 授权到写句柄打开到什么情况下权限会失效什么情况下 handle 会作废。这张图一旦清楚了后续所有实现都只是体力活。最后一个实用小技巧调试期可以在鸿蒙侧把每次 MethodChannel 调用都打一行日志格式是方法名 参数 耗时实测对定位哪个环节反序列化失败哪次调用超时极其有效。等整体稳定后再把这层日志关掉性能又是一次小提升。