最近一直在写“鸿蒙工具学习”这个系列前面十几篇分别把设备能力、任务调度、数据管理等过了一遍。到了第十五篇我想认真聊一聊一个让很多开发者又爱又恨的话题——沙箱文件访问。说它“又爱又恨”是因为沙箱机制确实解决了数据隔离和用户隐私的大问题但代价就是你再也不能像以前做安卓开发那样随手拿一个绝对路径到处读写了。很多刚转过来的朋友第一反应是拿 Linux 或 Android 的思维去写文件操作结果刚跑起来就给你抛一个No such file or directory或者干脆Permission denied查半天不知道问题在哪。这篇文章就把沙箱文件访问的底层设计、目录结构、合法访问路径和常见坑一次讲透适合正在学鸿蒙应用开发、尤其是从传统移动开发转过来的读者拿来当一份可查阅的实操笔记。我在写的时候会尽量把“为什么这么设计”也讲清楚因为只有理解了设计动机你才知道哪些野路子不能碰哪些正规路子最省事。1. 沙箱是什么先从“你连自己的文件都管不住”这件事说起很多人第一次接触鸿蒙的文件能力时都会产生一个困惑我明明在代码里创建了一个文件为什么重启应用之后就找不到了或者我明明把数据写进了一个看起来挺正常的路径为什么换个设备就崩了这些问题本质上都指向同一个东西——应用沙箱。1.1 旧时代的文件访问方式为什么被否掉在早期移动系统里应用是可以直接读写公共存储区域的。好处是方便任何文件随手就能访问但坏处也很明显一个应用能把另一个应用的数据翻个底朝天恶意应用还能偷取用户照片、通讯录导出文件等隐私数据。沙箱模型就是为了终结这种“裸奔”状态。每个应用被关进一个只属于自己的“小房间”房间里有卧室、有储物柜、有临时工作台但房间之间是密封的你想拿别人的东西必须通过管理员授权或者主人亲自递给你。鸿蒙在这个方向延续了现代操作系统的通用做法先隔离再授权。应用默认只能在自己的沙箱目录里自由读写对外部文件的一切操作都要走系统提供的授权通道。1.2 沙箱模型的三个核心约定隔离、映射、授权我对沙箱的理解可以概括成三条约定隔离每个应用有自己独立的目录空间应用 A 不能看到应用 B 的数据。这里的“看不到”不是文件系统权限上的不可访问而是目录层级的物理隔离——应用 A 在逻辑上就不存在指向应用 B 目录的路径。映射应用看到的路径不是物理真实路径而是系统经过重定向后的逻辑路径。简单说应用认为自己写到了/data/storage/el2/base/...物理上可能早就被系统映射到另一个带包名的深层目录里去了。这种映射的好处是未来系统升级调整物理布局时应用完全无感知不会因为路径变化而崩溃。授权想访问沙箱之外的东西必须通过三类手段之一——用户主动选择文件选择器、系统受限权限如相册读取、或者系统服务代理访问。不存在“声明一个权限就随便读”的入口。理解了这三条你就明白为什么网上有些文章说“直接把绝对路径写死到配置文件里跑不通”了。沙箱世界里的路径是虚拟的物理路径是厂商实现细节不应该也不允许成为应用代码里的常驻字符串。2. 应用沙箱目录全景一层一层剥开路径映射这一节我们把“房间”的户型图摸清楚。鸿蒙的应用沙箱目录不是一团乱麻它是有清晰层级的。2.1 Stage模型下的标准沙箱子目录当前鸿蒙主推 Stage 模型应用配置好context之后可以拿到一套标准子目录。我用表格整理一下最常用的几个获取方式逻辑路径示例用途context.filesDir/data/storage/el2/base/haps/entry/files应用私有的常规文件context.cacheDir/data/storage/el2/base/haps/entry/cache临时缓存系统可清理context.tempDir/data/storage/el2/base/haps/entry/temp临时文件目录context.databaseDir/data/storage/el2/base/haps/entry/database数据库文件目录context.preferencesDir/data/storage/el2/base/haps/entry/preferences偏好配置文件目录context.distributedFilesDir/data/storage/el2/base/haps/entry/distributedfiles分布式文件同步目录这里要提醒一句这些目录名看着像物理路径实际上是逻辑路径。你在 IDE 里或设备的 shell 下能按这个路径找到文件是因为调试工具帮你把映射关系处理好了但这不代表你可以在真机代码里硬编码这个字符串去猜测其他应用的路径。2.2 加密分区EL1到EL4别把数据放错篮子沙箱目录路径里有个el2这个el是Encryption Level的意思。鸿蒙把用户数据区按加密等级分成了几种类型等级特点典型使用场景EL1设备级加密设备首次解锁前即可访问开机向导、系统级静态资源EL2用户级加密用户解锁后可访问绝大多数应用数据EL3更高等级加密访问条件更严格敏感信息但较 EL4 宽松EL4最强加密依赖用户认证状态生物认证、密钥材料普通应用默认跑在 EL2这也是为什么大多数路径里都有el2字样。有些开发者想把登录凭据存进沙箱根目录图省事直接放 EL1 区域这在安全等级上是有问题的因为设备第一次开机没解锁时就能被读取。反过来如果业务数据默认放 EL3/EL4用户没完成解锁认证应用就可能读不到数据影响启动流程。结论绝大多数场景老老实实用context.filesDir就好别自己来回横跳。2.3 从逻辑路径到物理路径一张表讲清映射关系为了让你更直观地理解“重定向”我拿日常调试时观察到的映射关系举例不同系统版本物理路径可能有差异重点看逻辑关系应用可见逻辑路径实际物理路径示例/data/storage/el2/base/.../data/app/el2/100/base/bundleName/.../data/storage/el1/base/.../data/app/el1/100/base/bundleName/.../data/storage/el2/base/haps/entry/files/.../data/app/el2/100/base/bundleName/haps/entry/files/...也就是说你代码里看到的/data/storage/el2/base物理上位于/data/app/el2/100/base/bundleName。中间的100是用户 IDbundleName是应用包名。系统把这两段隐藏了应用永远看不到其他人的真实物理目录。这条映射链是沙箱的核心实现也是很多排查工作的起点。3. 沙箱内文件读写的正确姿势路径从哪来文件怎么写搞清楚了目录层级接下来就是实际动手。沙箱内读写文件没有太多花活但有几个细节值得记牢。3.1 Context 是你唯一的路径入口对 Stage 模型来说获取路径的规范做法是拿Context的方法不要自己去拼字符串。因为逻辑路径前缀可能会随系统版本调整但Context给的永远是当前版本正确的位置。常见的获取方式有两种。在UIAbility内部可以直接拿import { UIAbility } from kit.AbilityKit; export default class EntryAbility extends UIAbility { onCreate() { let filesDir this.context.filesDir; let cacheDir this.context.cacheDir; console.info(filesDir: ${filesDir}, cacheDir: ${cacheDir}); } }在普通页面组件里则需要这样拿import { common } from kit.AbilityKit; let context getContext(this) as common.UIAbilityContext; let filesDir context.filesDir;这里有一个非常容易踩的坑getContext(this)拿到的必须是一个UIAbilityContext如果你在非 UI 组件里调用上下文类型可能不对后续调用filesDir时就会报错。我建议在页面初始化时把 context 保存到一个公共模块里后续业务代码统一从模块取避免每个文件都去getContext一遍。3.2 读写文件的一整套 ArkTS 代码样本沙箱内读写文件最直接的方式是使用fileIo模块。我一般这样操作import { fileIo } from kit.CoreFileKit; // 写入 let filePath filesDir /demo.txt; let file fileIo.openSync(filePath, fileIo.OpenMode.READ_WRITE | fileIo.OpenMode.CREATE); fileIo.writeSync(file.fd, hello sandbox); fileIo.closeSync(file); // 读取 let file2 fileIo.openSync(filePath, fileIo.OpenMode.READ_ONLY); let buf new ArrayBuffer(1024); let readLen fileIo.readSync(file2.fd, buf); fileIo.closeSync(file2); let content String.fromCharCode(...new Uint8Array(buf.slice(0, readLen))); console.info(content: ${content});注意fileIo.openSync返回的是一个包含fd的File对象后续所有操作都是基于fd文件描述符来做的。写完一定要closeSync否则文件句柄泄漏短期内看不出问题长时间运行就会出现“打开文件失败”的怪毛病。如果想要按行读取文本fileIo也提供了createRandomAccessFileSync这类接口适合处理结构化数据。不过我个人的经验是小文件直接读进ArrayBuffer最干脆大文件才考虑流式读取别一上来就整复杂 API。3.3 文件描述符与流操作的注意点文件描述符fd是一个非常重要的概念。你打开一个文件系统返回一个整数 fd之后读写关闭都靠它。跨函数传递 fd 没问题但要注意不要在关闭 fd 之后还继续读写会直接抛异常。多个协程同时操作同一个 fd要做好串行化处理否则写入内容会互相覆盖。如果把 fd 传给他人使用要明确谁负责关闭避免双重关闭或无人关闭。流式操作方面fileIo提供了read、write的同步和异步版本。异步版本适合大文件避免阻塞 UI 线程。同步版本代码更简单适合小文件。我通常在工具类里封装一个readAllText(path)方法内部用同步方式读完外部业务逻辑再决定在哪里调用。4. 走出沙箱用户授权、受限权限与系统服务三条路沙箱内的数据永远只属于应用自己。但现实需求往往是我要选择一张图片发给朋友、我要读取一个下载目录里的文档、我要把导出文件分享到聊天软件。这些都需要走出沙箱而去往外部世界的路只有三条。4.1 最省事的路FilePicker 让用户自己选第一种方案是文件选择器Picker。它相当于系统充当一个“递话人”应用说“我想让用户选个文件”系统弹出选择界面用户选完后系统把文件 URI 交给应用。它的关键在于权限是用户当场授予的应用不需要申请任何媒体权限。这是最符合隐私保护原则的做法也是我们做普通应用时的首选。代码大致长这样import { picker } from kit.CoreFileKit; async function pickDocument() { let documentPicker new picker.DocumentViewPicker(); let uris await documentPicker.select(); if (uris uris.length 0) { // uris[0] 就是用户选中的文件 URI console.info(selected: ${uris[0]}); } }除了DocumentViewPicker还有PhotoViewPicker和AudioViewPicker分别对应相册、音频这类需要系统媒体库支撑的选择场景。它们返回的 URI 是file://media/...或file://docs/...形式别拿它去跟普通沙箱路径字符串拼接用完之后要通过fileIo或媒体接口按 URI 打开。4.2 媒体库受限权限不弹文件选择器的替代方案如果业务逻辑是“自动读取最近 30 天的新照片”总不能每次都让用户手动选一遍此时可以申请受限权限例如ohos.permission.READ_IMAGEVIDEO。这里要特别说明这类权限属于“受限权限”不是普通权限不是你在module.json5里写一行就能直接用的。使用受限权限的前提是应用确实有强烈的合理用途而且需要通过某种审核或系统弹窗确认。即便申请成功系统通常也会在用户侧有明确提示。所以这条路更适合照片备份、相册整理等功能明确的应用。声明权限只是第一步运行时还要向用户发起授权请求import { abilityAccessCtrl, Permissions } from kit.AbilityKit; let atManager abilityAccessCtrl.createAtManager(); let permission: Permissions ohos.permission.READ_IMAGEVIDEO; let result await atManager.requestPermissionsFromUser(context, [permission]); // 检查 result 里的授权状态授权通过后再通过媒体库模块查询图片资源。这个流程比 Picker 重但如果应用本身就是做图库备份的这个方案才符合产品预期。4.3 系统服务中介为什么有些类型文件还必须走它还有一类文件既不适合用户手动选也不适合给应用开放媒体权限。比如短信附件、系统设置备份文件、其他应用的私有数据等。这种情况下系统提供了各种系统能力SASystem Ability作为中介。应用把自己的需求发给某个系统服务系统服务以高权限身份读取目标文件再通过回调把结果返回给应用。应用始终接触不到底层真实路径有效避免了权限蔓延。实际上很多你以为是“直接读文件”的操作底层都是 SA 在代理。比如读取联系人数据库、查询通话记录应用拿到的都是处理后的数据对象而不是原始文件路径。这也是沙箱架构里最容易被误解的一点文件访问不等于路径访问很多业务对象通过接口就可以拿到压根不用关心它在哪个目录。5. URI 临时授权与跨应用分享文件还可以“借给别人”用沙箱世界里每个应用都有一扇门锁。如果你想把自己的文件递给另一个应用怎么操作答案是 URI 临时授权。5.1 拿到一个文件 URI 后权限的边界在哪当你通过 Picker 拿到一个 URI并不意味着这个 URI 永远有效。它是和你的应用进程绑定在一起的临时授权。锁定期限、过期策略由系统管理。所以不要试图把 URI 字符串存进数据库留到下个月再用完全可能失效。这个机制和 Android 的content://URI 临时授权很像但鸿蒙在实现上有自己的一套。应用拿到 URI 后可以在沙箱内通过fileIo.openSync(uri, ...)直接打开。注意这里的openSync入参也可以是 URI 而不是绝对路径这点和 Linuxopen不太一样算是鸿蒙给开发者的一个便利。5.2 通过 Want 传递 URI 时的 flag 设置如果想把文件传给另一个应用需要使用 Want 启动对方并携带 URI同时设置授权 flag。import { Want, WantFlags } from kit.AbilityKit; let want: Want { bundleName: com.example.target, abilityName: EntryAbility, uri: file://docs/storage/Users/currentUser/Downloads/report.pdf, flags: WantFlags.FLAG_AUTH_READ_URI_PERMISSION };FLAG_AUTH_READ_URI_PERMISSION表示只给对方读权限。如果是写文件要改用FLAG_AUTH_WRITE_URI_PERMISSION。千万不要图省事把读写两个 flag 都加上除非业务确实需要对方回写否则这是给自己留安全后门。5.3 授权是一次性的吗失效场景复盘我见过一个非常典型的 bug应用 A 通过 X 组件把文件 URI 发给应用 BB 在后台处理了一段时间后再访问 URI结果报权限错误。排查下来发现授权是有生命周期的和临时令牌、过期时间这套概念类似。另外如果用户在系统设置里撤销了某个应用的某项权限已下发 URI 也可能随之失效。所以稳健的做法是收到 URI 后尽快处理不要把 URI 序列化到持久化存储中留到下次启动再用。如果确实需要跨多次启动处理建议把文件复制到自己的沙箱目录里用本地副本继续工作。6. 沙箱写入与升级兼容那些会翻车的细节和我的排查经验这一节算补充实战经验都是我在维护自己的小工具时积累出来的教训。6.1 千万别把物理路径写死在配置里有次我图省事把调试时看到的物理路径/data/app/el2/100/base/...当成常量配进了配置文件结果系统一升级物理路径前缀变了整个功能直接瘫痪。从那次之后我的代码里从不会出现任何以/data/app/开头的字符串。所有需要路径的地方一律通过Context获取。6.2 升级后目录变化与数据迁移系统版本升级后沙箱目录的逻辑结构大概率会保持稳定但不可完全依赖。应用自身升级时上下文目录结构一般不会变化但如果你之前把数据写在了/cache目录里系统在存储紧张时可能直接清理导致用户数据丢失。所以缓存目录只放可再次下载或重建的内容真正重要的用户资料必须放在files或database目录。6.3 一个典型的“文件找不到”排查流程结合我的经验遇到“文件找不到”类问题我一般按下面几步排查确认路径是否来自Context获取而不是硬编码字符串。确认目录是否存在必要时先fileIo.mkdirSync递归创建父目录。确认文件名大小写和扩展名是否正确很多小问题出在粗心。确认是否触碰了外部文件权限如果是外部文件检查 URI 格式和授权状态。最后再考虑底层映射因素查看日志里有没有重定向相关提示。这套流程虽然朴素但十次里有九次能在前三步找到问题。另外还有个小技巧调试沙箱问题时可以在代码里多打几个日志把filesDir、cacheDir的实际值打出来看一眼。很多开发者会惊讶地发现自己以为的文件路径和系统给的真实路径根本不是一码事这一步能帮你快速校正方向。最后说一点个人体会。沙箱文件访问看似是限制实际上是把“文件乱飞”的问题系统化了。你只要遵守规则绝大多数场景都能找到很优雅的解法。怕就怕在拿老经验硬套新体系路径写死、权限乱申请、URI 长期保存这些做法迟早要还债。建议开发者在动手写文件代码之前先花十分钟理清自己要访问的数据属于沙箱内、用户授权、还是系统服务管辖选对路子后面能省几小时的排查时间。
阅读完成 · 觉得有帮助?