鸿蒙 韶非 UI 系列:文件 IO @ohos.file.fs,沙箱读写 + 元信息查,告别裸 PersistentStorage 存大对象
鸿蒙 韶非 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 }
// ...
}
三个细节:
import fs from '@ohos.file.fs'——fs是 namespace,所有 API 都挂在它下面getContext(this) as common.UIAbilityContext——拿 UIAbility 上下文,用于取沙箱路径filesDirvscacheDir——前者持久(应用卸载才没),后者系统可清(缓存目录,空间紧时自动清)
何时用
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)
新手第一坑:以为 writeSync 是 File 实例方法,其实是 fs namespace 顶层函数,第一参传 fd(文件描述符,从 file.fd 取)。
③ WriteOptions 带 encoding,ReadOptions 不带
// 写: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 图。
初始态:沙箱路径展示 + 状态「尚未操作」+ 写入/读取/删除/列目录四按钮 + 内容区提示:

点「写入」按钮建文件后的状态:状态「写入成功,路径 …/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) —— 同上 |
readSync 带 encoding |
编译报错「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)decodeUtf8ArrayBuffer → string 助手(UTF-8 多字节处理)OpenMode位运算权限组合示范- 可直接用 DevEco Studio 打开运行
八、下一步该学什么?
跑通这个 demo 之后,你的鸿蒙文件 IO 就入门了。这是非 UI 系列第二篇,后续按这个顺序往下:
- 能力调用
@ohos.ability:调起相机/相册/定位等系统能力,应用集成系统服务 - 后台任务
@ohos.backgroundTask:长时后台跑、保活、调度,真机部署必学 - 数据持久化
@ohos.data.relationalStore:鸿蒙 SQLite 封装,结构化数据存取 - WebSocket
@ohos.net.webSocket:长连接、推送、实时通讯,聊天应用必学 - 媒体扫描
@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,随便用,别告我
更多推荐



所有评论(0)