如果你正在鸿蒙设备上做一个带 Web 内核的 Flutter 应用,或者准备把一个浏览器形态的产品搬进鸿蒙生态,大概很快会遇到这样一个坎:网页里的文件选择、本地读写、目录遍历这些能力,在 PC 浏览器上有 W3C File System Access API 可以调,但在鸿蒙的 Web 容器里,这套能力是缺失的。以前在 Android 上做 Flutter 时可以直接套 SAF(Storage 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 type="file">,那相当于"借书看完就还",每次处理都要重新选文件,写回磁盘更是绕了一大圈,体验和桌面应用差着一大截。File System Access API 的完整实现等于给了网页一个"长期借书证",这也是各种 Web 端编辑器、低代码平台、图片批处理工具敢做重文件操作的前提。
Flutter 生态里的file_system_access_api把这个标准能力封装成了跨平台 API,Dart 侧提供FileSystemAccess.openFile()、FileSystemFileHandle.createWritable()这样的方法,底层分别对接各平台的系统实现。Windows 上直接调 Win32 API,macOS 上用 NSOpenPanel,Android 上用 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里注册 MethodChannel:
import { 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 | 初始化权限检查与临时目录准备 |
showOpenFilePicker | acceptTypes, multiple | uri 列表 | 打开文件选择器 |
showSaveFilePicker | suggestedName, acceptTypes | uri | 打开保存对话框 |
openFileByUri | uri | handleId, name, size | 通过 URI 打开文件并注册句柄 |
createWritableChunk | handleId, chunkIndex | bool | 创建原子化写入流的一个数据块 |
writeToHandle | handleId, path, offset | writtenBytes | 从临时文件路径写入数据 |
commitWrite | handleId | bool | 提交原子化写入,完成 rename |
abortWrite | handleId | bool | 回滚未提交写入 |
closeHandle | handleId | bool | 释放文件句柄 |
这张表定了之后,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): Promise<boolean> { 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): Promise<string[]> { 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 侧大致这样:
Future<void> flush() async { final written = await _channel.invokeMethod<int>('writeToHandle', { 'handleId': _handleId, 'path': tmpPathForChunk, 'offset': _offset, }); _offset += written; }5. 穿梭在 Dart 与鸿蒙之间的桥:MethodChannel 设计细节
MethodChannel 是 Flutter 插件最基础的通信机制,但插件做深了才发现,真正难的不是"怎么建立一个 Channel",而是"怎么设计一套不会崩的 Channel 协议"。
5.1 方法命名与参数格式
桥接方法我统一用动词+名词的 camelCase,Dart 侧和 ArkTS 侧共用同一套字符串常量,避免随手写错字符串导致调用静默失败。参数一律用扁平 Map,key 用 snake_case,value 只允许基础类型(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、refCount,Dart 侧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文件进行编辑,然后 Ctrl+S 保存回磁盘。浏览器里的标准做法是走 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 的 Polyfill
JS 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()触发commit,renameSync把临时文件替换为目标文件。整个链路里,用户感知到的是"保存成功了",但实际上文件经历了完整的双阶段提交,即便是保存到一半断电,原文件也完好无损。
有个微妙点需要留意:在浏览器 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 拿到的 handle,createWritable()后第一次写入正常,第二次写入却失败。原因是我 JS 侧的写流对象没有正确持有原生侧创建的 handleId,每次都新开一个临时文件,commit 时永久替换掉了目标文件,第二次写入就找不着了。修复方式就是让 createWritable 返回的写流在对象内部维护一个状态标志:已经 close 或 abort 后,后续 write 调用必须抛InvalidStateError,而不是静默失败。这个改完,编辑器的多轮保存行为才真正稳定下来。
适配过程中我还踩过不少小坑,比如fs.openSync的模式位用错导致写不了、BusinessError的 code 没映射导致 UI 只能展示"未知错误"、Web 组件的缓存策略导致注入的 Polyfill 没更新等等。但上面五个问题代表了鸿蒙文件访问适配中最大的一类共性:权限生命周期、文件占用、跨桥大数据、对象状态机。把这几类问题想清楚,鸿蒙上的文件系统访问引擎基本就立住了。
如果让我重新做一遍这个适配,第一件事不会是去写原生代码,而是花一个下午把权限状态机画清楚:从 picker 授权到 URI 授权,到写句柄打开,到什么情况下权限会失效,什么情况下 handle 会作废。这张图一旦清楚了,后续所有实现都只是体力活。最后一个实用小技巧:调试期可以在鸿蒙侧把每次 MethodChannel 调用都打一行日志,格式是方法名 + 参数 + 耗时,实测对定位"哪个环节反序列化失败""哪次调用超时"极其有效。等整体稳定后再把这层日志关掉,性能又是一次小提升。