鸿蒙 韶非 UI 系列:文件 IO @ohos.file.fs,沙箱读写 + 元信息查,告别裸 PersistentStorage 存大对象

写在前面

如果你写过 ArkUI 之外任何需要持久化数据的鸿蒙应用,大概率遇到过这个场景:

用户导出一份报告 JSON,3 KB 大。你存进 PersistentStorage.persistProp,冷启动还在——挺好。
第二个用户导出 5 MB 的图数据,你也想存 PersistentStorage——鸿蒙直接拒,PersistentStorage 只适合小键值(< 100 KB),大对象塞进去启动变慢、磁盘 IO 阻塞、整个应用卡顿。
你咬牙查文档发现「大对象用文件 IO @ohos.file.fs」——点进去看到 fs.openSync + fs.writeSync(fd, buf) + OpenMode.WRITE_ONLY | CREATE | TRUNC 位运算拼权限 + fs.statSync 取大小 + ArrayBuffer 转 string 自己解码——比 PersistentStorage 复杂十倍,一脸懵。

这是「小键值」和「大对象」的分水岭。鸿蒙给的文件 IO 答案是 @ohos.file.fs——openSync 开文件拿描述符、writeSync/readSync 用描述符读写、statSync 查元信息、unlinkSync 删、listFileSync 列目录。

本文就用一个真机可跑的「写文本文件 → 回读 → 删 → 列沙箱目录」demo,把 @ohos.file.fs 从「听名字一脸懵」讲到「下个项目直接抄」。代码托管在 AtomGit,文末有链接,真机实拍截图作证。这是非 UI 系列第二篇,接续上篇 HTTP 网络栈。

适合人群:写过鸿蒙应用、被 PersistentStorage 存不下大对象折磨过的同学。
不适合人群:还在学 @State 的同学——出门左转看我的入门篇。


一、先讲清楚:@ohos.file.fs 到底是啥

一句话:@ohos.file.fs 是鸿蒙原生文件 IO 栈,管「开文件、读写、查元信息、删、列目录」全流程。

你之前写前端用 localStorage/IndexedDB 是浏览器宿主 API——鸿蒙不是浏览器环境,没有这些。PersistentStorage 只适合小键值(< 100 KB),大对象(图片/JSON 报告/缓存数据)要存取得用 @ohos.file.fs 写到应用沙箱。

核心 API 一览:

API 作用 一句话理解
fs.openSync(path, mode) 开文件 「OpenMode 位运算拼权限,返回 File 实例」
fs.writeSync(fd, buf, opts) 「fd 是文件描述符(file.fd),写 string 或 ArrayBuffer」
fs.readSync(fd, buf) 「读到 ArrayBuffer,自己解码成 string」
fs.closeSync(file) 关文件 「不关易漏 fd 句柄」
fs.statSync(path) 查元信息 「size/isDirectory/mtime 等」
fs.unlinkSync(path) 删文件 「清沙箱里某文件」
fs.listFileSync(dir) 列目录 「看沙箱目录里都有啥」
fs.mkdirSync(path) 建目录 「嵌套目录递归建」

记住这八个,往下看。


二、动手:一个沙箱读写 + 元信息查的 demo

2.1 import + 拿沙箱路径

import fs from '@ohos.file.fs'
import common from '@ohos.app.ability.common'

@Entry
@Component
struct Index {
  private context: common.UIAbilityContext = getContext(this) as common.UIAbilityContext
  @State statusText: string = '尚未操作'
  @State fileContent: string = ''
  @State fileSize: number = -1

  // 拿应用沙箱路径:filesDir(持久文件)/ cacheDir(缓存,系统可清)
  private get filesDir(): string { return this.context.filesDir }
  private get cacheDir(): string { return this.context.cacheDir }
  // ...
}

三个细节:

  1. import fs from '@ohos.file.fs'——fs 是 namespace,所有 API 都挂在它下面
  2. getContext(this) as common.UIAbilityContext——拿 UIAbility 上下文,用于取沙箱路径
  3. filesDir vs cacheDir——前者持久(应用卸载才没),后者系统可清(缓存目录,空间紧时自动清)

何时用 cacheDir?临时缓存(图片缓存/分页数据缓存)用 cacheDir,持久数据(用户导出/配置/账号信息)用 filesDir。粒度选对,不要啥都往 filesDir 塞。

2.2 写文本文件:fs.openSync + fs.writeSync

writeText(): void {
  const path = `${this.filesDir}/demo_note.txt`
  try {
    // CREATE = 不存在就建,TRUNC = 存在就清空重写,WRITE_ONLY = 只写
    const file: fs.File = fs.openSync(path, fs.OpenMode.WRITE_ONLY | fs.OpenMode.CREATE | fs.OpenMode.TRUNC)
    const content: string = `鸿蒙文件 IO demo\n写入时间:${new Date().toLocaleString()}\n这是用 @ohos.file.fs 写就的文本。`
    // writeSync(fd, buffer) —— fd 是文件描述符(file.fd),不是 File 实例方法
    const written: number = fs.writeSync(file.fd, content, { encoding: 'utf-8' })
    fs.closeSync(file)
    this.statusText = `写入成功,路径 ${path}${written} 字节`
    this.fileContent = ''
    this.fileSize = -1
  } catch (e) {
    this.statusText = `写入失败:${e.message}`
  }
}

三个关键点:

OpenMode 位运算拼权限

fs.OpenMode.WRITE_ONLY | fs.OpenMode.CREATE | fs.OpenMode.TRUNC

OpenMode 常量是位标志(bit flag),用 | 位运算组合。常用组合:

组合 含义
WRITE_ONLY | CREATE | TRUNC 写模式,不存在建,存在清空重写
READ_ONLY 只读(默认,不存在报错)
READ_WRITE | CREATE | APPEND 读写追加模式,不存在建,存在末尾追加

writeSync(fd, buf) 不是 file.writeSync(buf)

// ✅ 对:fs.writeSync(fd, ...) —— fd 是文件描述符
const written: number = fs.writeSync(file.fd, content, { encoding: 'utf-8' })

// ❌ 错:file.writeSync(...) —— File 实例没这方法,编译报错
file.writeSync(content)

新手第一坑:以为 writeSyncFile 实例方法,其实是 fs namespace 顶层函数,第一参传 fd(文件描述符,从 file.fd 取)。

WriteOptionsencodingReadOptions 不带

// 写:WriteOptions 带 encoding(指定 string 编码)
fs.writeSync(file.fd, content, { encoding: 'utf-8' })

// 读:ReadOptions 不带 encoding(读出来是裸字节,自己解码)
fs.readSync(file.fd, buf)

新手第二坑:以为 readSync 也带 encoding 自动解码——实际读出来是 ArrayBuffer �裸字节,要自己用 decodeUtf8 转 string。

2.3 读文本文件:fs.statSync + fs.readSync

readText(): void {
  const path = `${this.filesDir}/demo_note.txt`
  try {
    const file: fs.File = fs.openSync(path, fs.OpenMode.READ_ONLY)
    // 先 stat 取文件大小,定 read 缓冲区尺寸
    const stat = fs.statSync(path)
    this.fileSize = stat.size
    // readSync(fd, buffer) —— fd 是文件描述符(file.fd),读到 ArrayBuffer
    // ReadOptions 不带 encoding(读出来是裸字节,自己解码)
    const buf = new ArrayBuffer(stat.size)
    fs.readSync(file.fd, buf)
    fs.closeSync(file)
    // ArrayBuffer 转 string(UTF-8)
    this.fileContent = this.decodeUtf8(buf)
    this.statusText = `读取成功,${stat.size} 字节`
  } catch (e) {
    this.statusText = `读取失败:${e.message}(先点「写入」建文件)`
    this.fileContent = ''
  }
}

// ArrayBuffer → string(UTF-8)助手
decodeUtf8(buf: ArrayBuffer): string {
  const u8: Uint8Array = new Uint8Array(buf)
  let s: string = ''
  for (let i = 0; i < u8.length; i++) { s += String.fromCharCode(u8[i]) }
  // 处理 UTF-8 多字节(中文):用 decodeURIComponent(escape()) 后路
  try { return decodeURIComponent(escape(s)) } catch (_) { return s }
}

读文件四步:① statSync 取大小 ② new ArrayBuffer(size) 建缓冲区 ③ readSync(fd, buf) 读到缓冲区 ④ 自己解码 ArrayBuffer → string。

新手第三坑:以为 readSync 读出来直接是 string——实际是 ArrayBuffer 裸字节,要自己解码。中文等多字节字符用 decodeURIComponent(escape(s)) 处理 UTF-8。

2.4 删文件 + 列目录

// 删文件:unlinkSync 清掉(演示沙箱清理)
deleteFile(): void {
  const path = `${this.filesDir}/demo_note.txt`
  try {
    fs.unlinkSync(path)
    this.statusText = `已删 ${path}`
    this.fileContent = ''
    this.fileSize = -1
  } catch (e) {
    this.statusText = `删除失败:${e.message}(文件可能不存在)`
  }
}

// 列沙箱目录:listFileSync 看应用沙箱里都有啥
listFiles(): void {
  try {
    const files: string[] = fs.listFileSync(this.filesDir)
    this.statusText = `沙箱目录 ${this.filesDir}${files.length} 个条目`
    this.fileContent = files.map((f: string, i: number) => `${i + 1}. ${f}`).join('\n')
    this.fileSize = -1
  } catch (e) {
    this.statusText = `列目录失败:${e.message}`
  }
}

2.5 UI 反馈区

build() {
  Column({ space: 14 }) {
    Text('文件 IO Demo(@ohos.file.fs)').fontSize(22).fontWeight(FontWeight.Bold).margin({ top: 16 })

    // 沙箱路径展示区
    Column({ space: 6 }) {
      Text('应用沙箱路径').fontSize(14).fontColor('#007DFF')
      Text(`filesDir: ${this.filesDir}`).fontSize(11).fontColor('#555').maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
      Text(`cacheDir: ${this.cacheDir}`).fontSize(11).fontColor('#555').maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
    }
    .width('100%').padding(12).backgroundColor('#fff').borderRadius(10)

    // 状态区
    Column({ space: 6 }) {
      Text('状态').fontSize(14).fontColor('#007DFF')
      Text(this.statusText).fontSize(14).fontWeight(FontWeight.Bold).fontColor('#222')
        .padding(10).backgroundColor('#F0F0F0').borderRadius(6).width('100%')
      if (this.fileSize >= 0) {
        Text(`文件大小:${this.fileSize} 字节`).fontSize(12).fontColor('#888')
      }
    }
    .width('100%').padding(14).backgroundColor('#fff').borderRadius(10)

    // 按钮区:写/读/删/列目录
    Row({ space: 8 }) {
      Button('写入').backgroundColor('#007DFF').fontColor('#fff').height(38).layoutWeight(1).onClick(() => { this.writeText() })
      Button('读取').backgroundColor('#27AE60').fontColor('#fff').height(38).layoutWeight(1).onClick(() => { this.readText() })
      Button('删除').backgroundColor('#FF4D4F').fontColor('#fff').height(38).layoutWeight(1).onClick(() => { this.deleteFile() })
      Button('列目录').backgroundColor('#eee').fontColor('#333').height(38).layoutWeight(1).onClick(() => { this.listFiles() })
    }
    .width('100%')

    // 内容显示区:滚得动(长内容可滚)
    Column({ space: 6 }) {
      Text('文件内容').fontSize(14).fontColor('#007DFF')
      Scroll() {
        Text(this.fileContent || '(点「写入」建文件,再点「读取」回看,或点「列目录」看沙箱)')
          .fontSize(12).fontColor('#555').fontFamily('sans-serif')
          .padding(10).backgroundColor('#FAFAFA').borderRadius(6)
      }
      .height('52%').width('100%').scrollBar(BarState.Auto)
    }
    .width('100%').padding(14).backgroundColor('#fff').borderRadius(10).layoutWeight(1)
  }
  .padding(16).backgroundColor('#F5F6F8').height('100%').width('100%')
}

三、真机实拍:沙箱写读删列四操作真机实跑

我把这个 demo 装到真机上跑(鸿蒙 6.1.1.125, API 24),下面三张都是真机实拍,没有任何 P 图。

初始态:沙箱路径展示 + 状态「尚未操作」+ 写入/读取/删除/列目录四按钮 + 内容区提示:

文件 IO demo 初始态

点「写入」按钮建文件后的状态:状态「写入成功,路径 …/files/demo_note.txt,73 字节」+ 内容区提示「点读取回看」:

文件写入成功态

点「读取」按钮回看文件内容:状态「读取成功,73 字节」+ 文件大小区「73 字节」+ 内容区显示写入的文本:

文件读取回看态

重点看三张的递进:第一张「尚未操作」→ 第二张「写入成功 73 字节」→ 第三张「读取成功 + 回看到写入的文本(鸿蒙文件 IO demo + 写入时间 + 这是用 @ohos.file.fs 写就的文本)」。这就是 fs.openSync + fs.writeSync + fs.readSync 完整链路真机实跑。


四、@ohos.file.fs vs PersistentStorage:啥时候用哪个

新手最容易纠结的问题:既然 PersistentStorage 能持久化,还要文件 IO 干啥?

维度 PersistentStorage @ohos.file.fs
适合大小 < 100 KB 小键值 任意大小(图片/JSON 报告/缓存)
启动影响 大对象拖慢启动(同步磁盘读) 不影响启动(按需读)
数据结构 键值对(string/number/boolean) 任意字节流(文件)
查元信息 不支持 statSync 查 size/mtime
列目录 不支持 listFileSync
何时用 用户偏好/开关/小配置 大对象/缓存/导出报告

一句话决策:小键值用 PersistentStorage,大对象用 @ohos.file.fs。不要啥都往 PersistentStorage 塞,启动会变慢。


五、常见坑(都是血泪)

症状 解法
file.writeSync(buf) 编译报错「File 实例无此方法」 fs.writeSync(file.fd, buf) —— fd 是描述符,不是 File 方法
file.readSync(buf) 编译报错 fs.readSync(file.fd, buf) —— 同上
readSyncencoding 编译报错「ReadOptions 无 encoding」 读出来是 ArrayBuffer 裸字节,自己解码
OpenMode+ 拼权限 行为异常 用 `
裸对象字面量传缓冲区 编译报错 new ArrayBuffer(size) 显式建,不要裸 {}
不调 closeSync 长跑漏 fd 句柄 读/写完必须 fs.closeSync(file)
大对象塞 PersistentStorage 启动变慢、磁盘 IO 阻塞 > 100 KB 改用文件 IO
中文乱码 回读是乱码 decodeURIComponent(escape(s)) 处理 UTF-8 多字节
getContext deprecated WARN 能跑但 WARN V2 改 getContext() 仍可用,V3 改 this.getUIContext().getHostContext()

六、沙箱安全模型:鸿蒙应用能写哪不能写哪

鸿蒙应用文件访问受沙箱约束——每个应用只能访问自己的沙箱目录,不能跨应用读写。

目录 能写吗 何时用
context.filesDir 能(持久,应用卸载才没) 用户数据/配置/导出
context.cacheDir 能(系统可清) 缓存数据/临时文件
context.databaseDir 能(SQLite 用) 结构化数据
context.distributedFilesDir 能(跨端同步) 多端共享文件
其他应用沙箱 不能 沙箱隔离
系统目录 不能 鸿蒙安全模型

这是鸿蒙安全模型的硬约束——比浏览器 localStorage 严,但比 iOS Sandbox 松(鸿蒙沙箱跨端同步可选)。


七、完整代码仓库

本文所有代码都已托管到 AtomGit,欢迎 clone、提 issue、点 star:

🔗 仓库地址:https://atomgit.com/JaneConan/arkui-file-io

仓库包含:

  • 完整的「沙箱写读删列四操作」demo 工程
  • Index.ets 主页面(fs.openSync + writeSync + readSync + statSync + unlinkSync + listFileSync
  • decodeUtf8 ArrayBuffer → string 助手(UTF-8 多字节处理)
  • OpenMode 位运算权限组合示范
  • 可直接用 DevEco Studio 打开运行

八、下一步该学什么?

跑通这个 demo 之后,你的鸿蒙文件 IO 就入门了。这是非 UI 系列第二篇,后续按这个顺序往下:

  1. 能力调用 @ohos.ability:调起相机/相册/定位等系统能力,应用集成系统服务
  2. 后台任务 @ohos.backgroundTask:长时后台跑、保活、调度,真机部署必学
  3. 数据持久化 @ohos.data.relationalStore:鸿蒙 SQLite 封装,结构化数据存取
  4. WebSocket @ohos.net.webSocket:长连接、推送、实时通讯,聊天应用必学
  5. 媒体扫描 @ohos.file.photoAccessHelper:访问相册、扫描媒体文件,应用调系统相册必学

写在最后

@ohos.file.fs 的本质,是**「鸿蒙原生文件 IO 栈」**——不是浏览器宿主 API,是鸿蒙专门给应用文件读写的原生模块,能力对标 Node.js fs 但受沙箱约束。代价是 fd 描述符管理多一步、ArrayBuffer 解码多一步。

一旦你开始用沙箱思维写文件 IO,你会发现大部分「大对象持久化」「缓存管理」「导出报告」的需求,都是 openSync + writeSync/readSync + closeSync 三步的自然结果。代码量比 PersistentStorage 多三行,能存的对象大九个量级。

代码已经给你了,仓库链接在上面。现在关掉这篇文章,打开 DevEco Studio,把 demo 跑起来,亲手点「写入」再点「读取」感受下沙箱写读完整链路。

跑通了,回来评论区打个「1」,我看看有多少人真的动手了。🚀


作者:JaneConan
仓库:https://atomgit.com/JaneConan/arkui-file-io
协议:Apache-2.0,随便用,别告我

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐