资讯动态

鸿蒙文件访问全攻略:沙箱机制、Picker授权与fileIo实操

发布时间:2026/10/9 8:40:04 来源:尧图企业网站定制
做鸿蒙开发一年多最让我觉得不能按安卓惯性思维去写的就是文件访问。HarmonyOS 把文件访问拆成两条独立路线应用文件访问和用户文件访问。前者在应用沙箱内用 fileIo/fs 接口畅通无阻后者要经过 Picker 授权拿回来的是 URI 而不是能直接拼接的路径。这篇文章是我中级课程里“访问和操作文件”章节的完整笔记内容包括沙箱目录结构、fileIo/fs 模块读写、DocumentViewPicker 和 PhotoViewPicker 实操以及几个我实际踩过的权限、URI 失效、大文件读写坑。所有步骤以 API 12 的 kit.CoreFileKit 为主老项目里从 ohos.file.fs 迁移过来的差异我也会顺带点一下。1. 先搞清楚鸿蒙的文件模型为什么访问要分成两条路1.1 应用沙箱与可访问目录HarmonyOS 的应用默认运行在沙箱里。每个应用安装后系统会分配一块只属于自己的存储区域应用在沙箱内读写文件不需要申请任何权限但也只能在这块区域内活动。这个设计和 Android 早期的随意访问完全不同更接近 iOS 的沙箱思路。从数据安全角度看它的好处很直观应用拿不到别人的数据别的应用也无权扫描你的私有目录即使设备中了招单个应用的破坏范围也有限。在 Stage 模型下通过 UIAbilityContext 可以直接拿到几个关键目录context.filesDir应用私有文件目录适合放数据库、用户生成内容、导出文件等需要持久保存的数据。context.cacheDir缓存目录适合放临时下载、缩略图、日志等可重新生成的数据系统在存储空间紧张时可能清理这里。context.tempDir临时目录生命周期更短适合放一次性的中间文件。context.distributedFilesDir分布式文件目录用于跨设备流转和协同场景。获取方式很简单import { common } from kit.AbilityKit; const context getContext(this) as common.UIAbilityContext; const filesDir context.filesDir; const cacheDir context.cacheDir;有一点值得留意filesDir 返回的是真实路径比如 /data/storage/el2/base/haps/entry/files而不是带 file:// 协议的字符串。日常开发中写路径、拼路径优先基于这些 context 返回的目录去拼不要自己硬编码存储根路径。不同版本系统的路径结构虽然肉眼看起来相似但底层细节随时可能调整自己拼串总有一天会翻车。1.2 路径、URI 与沙箱路径的表现形式文件操作中我们会遇到三种表达形式真实路径、沙箱 URI、用户文件 URI。它们对应不同的访问方式弄混了就会出现“文件找不到”或者“没有权限”的低级错误。真实路径就是上面 filesDir、cacheDir 拼接出来的字符串用于直接调用 fileIo/fs 的 open、read、write 等方法。这种路径在应用进程内部可以随意使用但不能直接跨应用传递。应用沙箱 URI 是带 file:// 的统一资源标识符例如 file:///data/storage/el2/base/haps/entry/files/notes.txt它常被用来做剪贴板、跨模块传递或者在需要以 URI 打开文件的场景中使用。用户文件 URI 则完全不一样比如 file://media/Photo/1 或 file://docs/storage/Users/currentUser/Downloads/a.pdf这种 URI 必须拿到用户授权后才能通过 fileIo/fs 打开而且不能把它当成普通路径去拼接子路径。有些新手会把用户文件 URI 的前缀替换成 /data/storage 去拼一个“看起来合理的真实路径”这个做法在鸿蒙体系里是无效的。用户文件不在应用沙箱内应用对那块区域本来就没有路径级访问能力唯一的合法入口就是系统授权的 URI。理解这一层后面第三章的代码就不会觉得绕了。1.3 设计本质隔离、授权与按需访问把文件访问拆成应用文件和用户文件本质是在做边界控制。应用文件是你自己的不需要打扰用户用户文件是用户资产系统必须确认每一次访问都有用户的知情和意图。HarmonyOS 没有给应用提供“扫描整个公共存储”的通用权限取而代之的是让系统 UI 来承担选择和授权动作也就是 Picker 机制。Picker 的核心特征是不需要提前申请权限用户每次主动选择文件时系统临时授权这一次访问应用拿到 URI 按需使用。这种方式很克制用户控制感强。如果应用确实需要长期访问媒体库资源比如做一个自动备份工具那才需要申请 READ_IMAGEVIDEO / WRITE_IMAGEVIDEO 权限并配合 photoAccessHelper 使用。后面的实战部分我会把这两种路径怎么选、怎么写都讲清楚。2. 应用文件访问与操作fileIo/fs 模块的完整实操2.1 fs.open 与基础读写应用文件访问的核心模块是 fileIo在 API 12 中推荐这样导入import { fileIo as fs } from kit.CoreFileKit;如果你的项目还在 API 9 或 API 10可以写成 import fileIo from ohos.file.fs然后把后面的调用从 fs.openSync 改成 fileIo.openSync 即可方法名和参数基本保持一致。下面的示例以新 Kit 为准。先看一个最基础的写读场景在 filesDir 下创建 notes.txt写入一段文字再读回控制台。const notesPath context.filesDir /notes.txt; // 打开文件不存在则创建按可读可写方式打开 const file fs.openSync(notesPath, fs.OpenMode.READ_WRITE | fs.OpenMode.CREATE); // 写入字符串内部会按当前字符集编码 fs.writeSync(file.fd, 鸿蒙文件访问笔记); // 关闭文件释放文件描述符 fs.closeSync(file); // 先看文件有多长再按这个长度读取 const stat fs.statSync(notesPath); const buff new ArrayBuffer(stat.size); const readFile fs.openSync(notesPath, fs.OpenMode.READ_ONLY); fs.readSync(readFile.fd, buff); fs.closeSync(readFile); const text String.fromCharCode(...new Uint8Array(buff)); console.info(read text:, text);这串代码里有几个关键点要展开说。OpenMode 是一个组合枚举常见取值包括 READ_ONLY、WRITE_ONLY、READ_WRITE、CREATE、TRUNC、APPEND、NONBLOCK。READ_WRITE 表意清楚CREATE 表示文件不存在时自动创建TRUNC 表示打开时清空原内容APPEND 表示写入位置追加到文件末尾。注意如果只用了 READ_ONLY而文件不存在系统只会抛出找不到文件的错误不会自动创建。写文件时通常需要 READ_WRITE、CREATE、TRUNC 几个组合具体看需求。读取时为什么要先 stat.size因为 readSync 需要把数据塞进一个 ArrayBuffer这个缓冲区的大小得由我们自己控制。如果缓冲区太小一次就读不完如果缓冲区远大于文件大小读完后很难判断有效内容到哪里结束。用 stat.size 初始化缓冲区在小文件场景下是最省心的做法。大文件的流式读取我会在第四章单独讲。另外代码里使用的 writeSync、readSync 是同步版本方便理解和调试。真实项目中我更推荐异步版本避免大文件读写卡住 UI 线程。异步接口在 API 12 中是 Promise 风格用法如下async function writeAndReadAsync() { try { const file await fs.open(notesPath, fs.OpenMode.READ_WRITE | fs.OpenMode.CREATE); try { await fs.write(file.fd, 异步写入内容); } finally { await fs.close(file); } } catch (err) { console.error(write failed: ${JSON.stringify(err)}); } }用 try/finally 是防止遗漏 close 的关键。尤其业务逻辑中间抛出异常时如果关闭动作不放在 finally 里文件描述符就会泄漏。鸿蒙对进程可用的文件描述符数量有限制泄漏多了后续所有打开文件的操作都会陆续报错而且是那种比较难定位的错误。2.2 文件的一次性读写接口如果只是简单保存和读取一段文本其实不必手动 open、read、write 这么繁琐。fileIo 提供了 readText 和 writeText 两个高层接口直接用路径操作内部自己处理打开和关闭。// 写入文本覆盖原内容 fs.writeTextSync(notesPath, hello harmonyos); // 读取文本 const text fs.readTextSync(notesPath); console.info(text:, text);readText 和 writeText 在 API 10 之后一直很稳定适合配置文件、日志片段、小型数据文件的读写。它们的优势是代码量小、可读性强缺点是所有内容一次性读进内存不适合大文件。如果一份文件可能达到几十 MB比如日志压缩包、视频片段最好不要走 readText那会导致内存峰值极高。还有一个常见诉求是复制和移动文件。fileIo 提供了 copyFile 和 moveFile 接口核心代码写着很短const sourcePath context.filesDir /notes.txt; const targetPath context.filesDir /backup/notes_backup.txt; // 先保证目标目录存在 fs.mkdirSync(context.filesDir /backup); // 复制文件 fs.copyFileSync(sourcePath, targetPath); // 重命名 / 移动文件 fs.renameSync(targetPath, context.filesDir /backup/notes_final.txt);我在实际操作中发现copyFile 的第二个参数允许传路径也允许传 File 对象当你从用户文件 URI 打开了一个文件对象又想把它的内容保存到应用沙箱时可以直接把 File 对象传给 copyFile 的目标参数这比手动循环读写要稳定得多。移动操作使用 renameSync 时要注意源文件和目标路径必须在同一个挂载点上跨挂载点会失败。如果确实需要跨目录移动而系统报错就退回 copyFile 加 unlink 的组合。2.3 目录遍历、状态查询与删除应用沙箱内的目录管理也不复杂常见方法包括 mkdir、listFile、stat、unlink、rmdir。我最常用的是 listFile 配合 stat 做目录扫描。下面的函数可以统计某个目录下所有文件的字节数function calcDirSize(dir: string): number { let total 0; const names fs.listFileSync(dir); for (const name of names) { const fullPath dir / name; const s fs.statSync(fullPath); if (s.isDirectory()) { total calcDirSize(fullPath); } else { total s.size; } } return total; } const cacheSize calcDirSize(context.cacheDir); console.info(cache size: ${cacheSize});这里有几个编码细节容易忽略。listFileSync 返回的是目录下直接子项的“文件名”不是完整路径。想得到完整路径需要自己把父目录拼上去。statSync 返回的 Stat 对象中有 isDirectory() 方法判断是否是目录也有 size、mtime 等属性。做清理类功能时判断完目录后递归处理避免遗漏。删除文件用 unlinkSync删除空目录用 rmdirSync。如果目录里还有文件直接 rmdir 会失败必须先递归清空里面的文件。为了省事也可以先对目录内所有文件执行 unlink再执行 rmdir。这个操作有一定危险性删缓存目录还好如果删错了业务目录可能直接损失用户数据。我一般会在代码里加一个环境判断只有确认目录路径以 cacheDir 开头时才允许递归删除。这不是什么高深技巧但它能有效防误操作。应用文件的操作到此已经覆盖了日常开发八成以上的场景打开、读写、一次性读写、复制、移动、遍历、删除。剩余两成属于进阶场景比如文件流式读写、文件描述符和文件对象的混用、分布式文件同步这些通常在中级偏高级阶段才会大规模遇到。3. 用户文件访问与操作Picker 授权与媒体库接入3.1 用户文件的三条访问路径用户文件是用户在系统里实实在在看得见、会管理的文件比如照片、视频、PDF 文档、下载的压缩包。应用不能直接对公共存储目录做全局扫描更不能用file://media/Photo/1这种 URI 去猜路径。系统给开发者留了三条合法路径第一条是各类 Picker包括 PhotoViewPicker、DocumentViewPicker、AudioViewPicker、VideoViewPicker。Picker 由系统拉起选择界面用户选完文件后应用得到一个或多个 URI。这个过程不需要申请权限每选一次授权一次。大多数仅需用户主动选择文件的场景比如上传头像、导入文档、选择音频做铃声都应该优先走 Picker。第二条是媒体库访问也就是 photoAccessHelper 配合 READ_IMAGEVIDEO / WRITE_IMAGEVIDEO 权限。这条路径适合需要批量读取图片视频或者常驻后台同步的场景比如相册备份应用。它要求用户在系统设置里授权应用拿到的同样是 URI 而不是物理路径。第三条是安全控件。HarmonyOS 提供了 SaveButton 这类安全控件允许应用在用户明确点击保存按钮后不申请权限就把文件写入公共下载或相册目录。这条路径更适合做导出和保存功能比如把生成的报表保存到 Downloads。它的细节不少这篇笔记先不展开重点讲前两条因为它们能覆盖“选择文件并读取”和“读取相册图片”两个最高频需求。3.2 DocumentViewPicker 选择并读取任意用户文档文档类用户文件的首选接口是 DocumentViewPicker。在 API 12 中它属于 CoreFileKit 的 picker 模块导入和调用方式如下import { picker } from kit.CoreFileKit; async function pickDocument() { const documentPicker new picker.DocumentViewPicker(context); const documentUris await documentPicker.select({ maxSelectNumber: 1 }); if (documentUris.length 0) { console.info(picked uri:, documentUris[0]); } }select 方法的入参是 DocumentSelectOptions支持 maxSelectNumber 限制多选数量。返回结果的类型取决于当前系统版本新版多以 documentUris 数组返回老版本可能返回的是 result 对象这点在升级兼容时要特别注意。我在项目里见过多个同事因为这一项差异在系统适配测试中返工建议代码里先判断返回结构再取数组。拿到 URI 后接下来的读取方式和应用沙箱文件有一个关键区别不能直接把这个 URI 当作路径去拼文件名也不要尝试访问 URI 的上级目录。合法做法是直接让 fileIo 打开这个 URIasync function readPickedDocument(uri: string) { const file await fs.open(uri, fs.OpenMode.READ_ONLY); try { const stat await fs.stat(file.fd); const buff new ArrayBuffer(stat.size); await fs.read(file.fd, buff); // 这里拿到的 buff 可以继续解析、复制或转存 console.info(file size: ${stat.size}); } finally { await fs.close(file); } }fs.open 允许传入 file:// 开头的 URI。这是理解用户文件访问的核心授权后的数据入口是 URI而 URI 可以被 fileIo 直接打开但你不能对它做路径拼接类的操作。如果想把用户选择的文件持久化留存正确的思路是从这个 URI 读取内容后写入应用沙箱文件而不是长期持有这个 URI。Picker 授权本质上是“临时通行证”应用重启或系统清理后URI 可能依然存在但对应的访问权限或资源状态并不保证一直有效。3.3 PhotoViewPicker 读取照片并转存到应用沙箱图片选择是另一个高频场景。PhotoViewPicker 的选择方式更贴近媒体库会过滤出图片和视频资源。下面的代码选择最多九张图片import { picker } from kit.CoreFileKit; async function pickImages() { const photoPicker new picker.PhotoViewPicker(); const result await photoPicker.select({ maxSelectNumber: 9, MIMEType: picker.PhotoViewMIMETypes.IMAGE_TYPE }); // result.photoUris 是 URI 数组 const uris result.photoUris; for (const uri of uris) { await copyUriToSandbox(uri, context.filesDir /picked/ Date.now() .jpg); } }注意 PhotoViewPicker 的构造普通文档 Picker 通常需要传 context而 PhotoViewPicker 在 API 10 之后可以直接 new不过传 context 也没有问题。MIMEType 可以缩小选择范围IMAGE_TYPE、VIDEO_TYPE、IMAGE_VIDEO_TYPE 是三个常用值。把照片 URI 转存到应用沙箱我推荐封装一个通用函数。这里不放代码其实对不起读者直接给出我一直在用的版本async function copyUriToSandbox(uri: string, targetPath: string) { const src await fs.open(uri, fs.OpenMode.READ_ONLY); const dst await fs.open(targetPath, fs.OpenMode.READ_WRITE | fs.OpenMode.CREATE); try { const stat await fs.stat(src.fd); const buffer new ArrayBuffer(4096); let readLen 0; while (readLen stat.size) { const len await fs.read(src.fd, buffer); if (len 0) { break; } await fs.write(dst.fd, buffer.slice(0, len)); readLen len; } } finally { await fs.close(src); await fs.close(dst); } }这块代码看起来基础实际生产时反而出问题最多。常见的是只读不写照片选完却没有保存落地还有的是一次性把几 MB 照片塞进一个 ArrayBuffer导致内存飙升。流式拷贝虽然多写几行但能应对任意大小的资源文件适合作为团队公共函数沉淀。buffer 设置为 4096 字节是通用值实测小文件没问题大文件也可以接受如果想提高速度可以加大到 64KB但会牺牲一定内存可以根据设备性能调整。3.4 PhotoAccessHelper 与媒体库权限申请如果需求不是“用户每次主动选一张照片”而是要拿到媒体库里一批资源做批量处理那就要走 photoAccessHelper。老项目里导入方式是 ohos.file.photoAccessHelperAPI 12 之后很多人会迁移到新 Kit。示例代码如下import { photoAccessHelper } from ohos.file.photoAccessHelper; async function readAllImages() { const helper new photoAccessHelper.PhotoAccessHelper(context); const fetchResult await helper.getAssets({ selections: { mediaType: photoAccessHelper.PhotoType.IMAGE } }); const assets await fetchResult.getAllObjects(); console.info(image count: ${assets.length}); }这段逻辑能不能跑通取决于一个前置条件应用必须申请 READ_IMAGEVIDEO 权限并且用户在系统弹窗中允许。权限申请代码一般在进入页面时触发import { abilityAccessCtrl } from kit.AbilityKit; const atManager abilityAccessCtrl.createAtManager(); const permissions: ArrayPermissions [ohos.permission.READ_IMAGEVIDEO]; const result await atManager.requestPermissionsFromUser(context, permissions);看到这里你应该能分清场景了。如果只是用户发朋友圈选图一款社交应用如果上来就申请“读取所有图片”权限反而会让用户警惕。几乎所有的单次选择场景都该用 PhotoViewPicker只有高频、批量、后台读取的场景才值得用权限。权限也不是申请一次就一劳永逸用户随时可能在系统设置里关闭某类权限因此每次读取前都应该检查授权状态或者做好被拒绝后的降级方案。4. 我在真实项目里踩过的坑错误码、失效 URI 和大文件4.1 常见错误码与排查速查表文件操作不同于普通业务逻辑报错时如果只看错误信息经常被一串错误码搞晕。下面是我实际项目中最常遇到的几类错误和对应处理办法整理成一个速查表方便直接对照排查。现象或错误码常见原因排查思路201 Permission denied在没有授权的情况下尝试访问用户文件或媒体库权限被拒绝确认是否走了 Picker申请权限场景确认用户在弹窗中点了允许401 Parameter check failed参数格式错误如路径不是合法字符串、OpenMode 组合不合法、URI 前缀异常检查是否把用户文件 URI 当普通路径处理检查 fs.open 的第二个参数13900005 / ERR_FS_NOENT文件或目录不存在确认拼接路径是否正确确认目标文件是否已被其他逻辑删除ERR_FS_BADF文件描述符无效是否重复 close或者 close 之后继续读写ERR_FS_IO底层 I/O 读写失败检查目标目录是否还有空间检查源文件是否被系统回收13900013 / 资源不可用用户文件 URI 对应的授权或资源已经失效重新拉起 Picker让用户再次选择或改为把文件提前转存沙箱我见过的最典型案例是把 Picker 返回的 URI 存进数据库隔天再用。结果应用一升级、系统一清理URI 指向的资源已经不存在了用户点进去就报错。这类问题在开发阶段很难发现因为当时 URI 刚生成一读一个准。只能说无论是图片还是文档选定后第一时间转存到应用沙箱别把 URI 当长期引用。4.2 用户文件 URI能用一阵子别用一辈子关于用户文件 URI 的时效性我再展开说几句。Picker 返回的 URI 一般有效期到应用进程结束或授权上下文被系统回收不同系统版本和不同 URI 形态可能略有差异。当你把它持久化到本地数据库后下次冷启动应用再用成功率并不稳定。因此正确且唯一的长期保存姿势就是把内容拷进应用沙箱。如果你需要保存的是图片但没有立即转存的条件可以先临时保存在应用沙箱的一个“待处理目录”里等业务逻辑跑完再清理。这比在数据库里存 URI 可靠得多。而且应用沙箱文件是你可以随意管理、统计、删除的不受授权边界的限制。还要提醒一点当对同一个 URI 并发发起多个 fs.open 时如果对端是媒体库资源某些系统版本会偶尔出现连接失败。我的处理方式是在获取用户文件后立刻串行转存不并发。转存成功再关闭 UI 层的加载状态给用户的体验也更加平滑。逻辑上多等几百毫秒换来的稳定性是值得的。4.3 大文件写入与缓存治理应用文件里也常遇到大文件问题。大日志文件、下载包、数据库备份动辄几十甚至几百 MB直接 readText 或者一次性 new ArrayBuffer 读入内存内存压力会非常大。正确做法是循环读取配合固定大小的缓冲区。前面 copyUriToSandbox 里用的就是这种模式应用沙箱内读取大文件同样适用。另一个容易忽略的问题是 fsync。write 不一定立刻落盘数据先到系统缓存之后由系统决定什么时候真正写入存储。对于关键数据比如用户手动触发保存我建议写完文件后调用 fs.fsyncSync 或 fs.fsync强制把数据刷到磁盘。我之前做过一个导出功能用户导出完一份数据应用直接退出结果文件大小是 0就是因为没刷盘。加上 fsync 之后这个现象再没出现过。缓存治理也要有节制。cacheDir 是系统可以清的地方但系统只在存储真正紧张时才清应用不能指望系统及时清理。养成定期清缓存、控制缓存上限的习惯比等到“手机存储不足”再处理更好。我在项目里做了一个简单的缓存上限判断每当缓存目录超过 200MB 时清理最早创建的临时文件只保留最近三天内使用的数据。这个策略不复杂但对用户体感改善很大。最后再分享一个小细节打开文件后无论同步还是异步都尽量在同一个函数作用域内闭合资源不要到处传 fd 和 File 对象。文件描述符是非常有限的资源鸿蒙多并发场景下尤其如此。把打开、读写、关闭收进一个职责单一的函数既能提高代码可读性也能避免资源泄漏带来的随机崩溃。这也是我自己从几次线上问题里换来的教训。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑