摘要

fs 文件系统模块用于应用沙盒目录下文件 / 文件夹的创建、读写、删除、遍历,常用于保存相机拍摄照片、缓存文本、本地日志、离线文档等场景。API Version23 重构文件句柄生命周期、同步 / 异步 IO、权限隔离、大文件分片读写逻辑,修复低版本文件句柄泄漏、大文件读取卡顿、文件夹遍历报错、文件删除残留、沙盒路径访问失效等兼容缺陷。旧项目升级 API23 后常出现:多次读写文件占用无法删除、读取超大文本页面卡死、创建文件夹失败、退出应用文件句柄未释放内存上涨等问题。本文基于 DevEco Studio 适配 OpenHarmony API23 及以上版本,封装通用文件工具类,实现文件读写、文件夹创建、文件删除、遍历目录、图片缓存存储五大核心能力,搭配日志保存、相机图片缓存两大实战页面提供完整可运行代码,输出文件句柄关闭、沙盒路径使用、大文件分片规范,汇总版本升级兼容故障解决方案,为鸿蒙本地文件管理开发提供标准化实操模板。

关键词

OpenHarmony;ArkUI;API Version23;fs;文件系统;沙盒目录;文件读写;目录遍历

一、引言

1.1 文件系统开发背景

应用所有私有文件只能存储在系统分配的独立沙盒目录,无法随意访问设备公共存储。相机拍摄图片、本地日志、离线缓存、自定义文档都依赖 @ohos.file.fs 完成操作。文件操作分为同步、异步两套 API,同步 IO 简单但主线程大量读写会阻塞 UI,异步 IO 适合大文件、批量操作。 OpenHarmony API Version23 针对 fs 底层 IO 引擎完成全面升级,核心变更点:

  1. 强制文件 fd 句柄使用后必须 closeSync 释放,长期不关闭会锁定文件;
  2. 统一沙盒路径上下文获取规则,废弃硬编码固定路径写法;
  3. 优化大文件 Buffer 分片读取,避免一次性加载全部内容占用内存;
  4. 区分文件 / 文件夹操作接口,新增 existsSync 统一判断资源是否存在;
  5. 修复多线程并发读写同一文件内容错乱问题,增加文件读写互斥逻辑;
  6. 限制跨沙盒、系统目录访问,仅允许操作自身 context 沙盒路径。

大量 API9~11 旧项目升级后,文件删不掉、重复写入内容错乱、读取大文件页面卡死,根源是未及时关闭文件 fd、主线程大量同步 IO,因此掌握 API23 标准 fs 封装规范是本地缓存、文件存储类功能必备技能。

1.2 开发环境与测试场景

开发工具:DevEco Studio 5.0 及以上 适配系统:OpenHarmony API Version23、HarmonyOS NEXT 导入模块:@ohos.file.fs、@ohos.app.ability.common 无需额外动态权限:沙盒目录应用自带读写权限 测试场景:写入本地日志文本、相机图片保存、创建缓存文件夹、遍历图片目录、删除过期缓存文件

二、API23+ fs 文件系统核心 API 与版本变更说明

2.1 常用沙盒基础路径(通过上下文获取)

ets

const ctx = getContext(this)
ctx.filesDir    // 持久化文件目录(常用,图片、文档)
ctx.cacheDir    // 缓存目录,系统可自动清理
ctx.tempDir     // 临时目录,应用重启清空

2.2 核心同步基础 API(轻量小文件使用)

  1. fs.existsSync (path):判断文件 / 文件夹是否存在
  2. fs.mkdirSync (path):创建文件夹
  3. fs.openSync (path, mode):打开文件获取 fd 句柄
  4. fs.writeSync (fd, buffer / 字符串):写入内容
  5. fs.readSync (fd, buffer):读取内容
  6. fs.closeSync (fd):释放文件句柄(必执行)
  7. fs.unlinkSync (path):删除文件
  8. fs.rmdirSync (path):删除空文件夹
  9. fs.readdirSync (path):遍历目录下所有文件名称

2.3 文件打开模式常量

ets

fs.OpenMode.CREATE        // 文件不存在则创建
fs.OpenMode.WRITE_ONLY    // 只写
fs.OpenMode.READ_ONLY     // 只读
fs.OpenMode.READ_WRITE    // 读写
fs.OpenMode.APPEND        // 追加写入,不覆盖原有内容

2.4 API23 废弃与强制约束

  1. 废弃不关闭 fd 的文件读写逻辑,长期运行会出现文件占用报错;
  2. 禁止硬编码绝对路径,全部通过 UIAbility 上下文获取沙盒根目录;
  3. 主线程禁止同步一次性读取 1MB 以上大文件,必须分片异步读取;
  4. rmdirSync 仅能删除空文件夹,删除带文件目录需先遍历删除内部文件;
  5. 不支持直接访问外部存储、相册原始路径,媒体文件统一用 picker uri。

三、API23 标准基础示例代码

3.1 写入文本文件(追加日志)

ets

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

function writeLog(ctx: common.UIAbilityContext, text: string) {
  const logPath = ctx.filesDir + "/log.txt"
  const fd = fs.openSync(logPath, fs.OpenMode.CREATE | fs.OpenMode.APPEND | fs.OpenMode.READ_WRITE)
  const content = text + "\n"
  fs.writeSync(fd, content)
  fs.closeSync(fd)
}

3.2 读取全部文本文件

ets

function readTextFile(ctx: common.UIAbilityContext, filePath: string): string {
  if (!fs.existsSync(filePath)) return ""
  const fd = fs.openSync(filePath, fs.OpenMode.READ_ONLY)
  const stat = fs.statSync(filePath)
  const buf = new ArrayBuffer(stat.size)
  fs.readSync(fd, buf)
  fs.closeSync(fd)
  return String.fromCharCode(...new Uint8Array(buf))
}

3.3 遍历文件夹所有文件名称

ets

function listDir(ctx: common.UIAbilityContext, dirPath: string): string[] {
  if (!fs.existsSync(dirPath)) return []
  return fs.readdirSync(dirPath)
}

四、两大业务完整实战案例(全兼容 API23)

4.1 实战一:全局文件工具类封装(utils/file_util.ets)

ets

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

class FileUtil {
  private static instance: FileUtil
  private ctx: common.UIAbilityContext | null = null

  static getInstance(): FileUtil {
    if (!FileUtil.instance) {
      FileUtil.instance = new FileUtil()
    }
    return FileUtil.instance
  }

  setContext(context: common.UIAbilityContext) {
    this.ctx = context
  }

  // 获取files持久化根目录
  getFilesRoot(): string {
    return this.ctx?.filesDir ?? ""
  }

  // 获取cache缓存目录
  getCacheRoot(): string {
    return this.ctx?.cacheDir ?? ""
  }

  // 判断文件/文件夹是否存在
  exists(path: string): boolean {
    return fs.existsSync(path)
  }

  // 创建文件夹
  mkdir(dirPath: string): boolean {
    try {
      if (!this.exists(dirPath)) {
        fs.mkdirSync(dirPath)
      }
      return true
    } catch (err) {
      promptAction.showToast({ message: "文件夹创建失败" })
      return false
    }
  }

  // 写入文本,覆盖原有内容
  writeText(path: string, content: string): boolean {
    try {
      const fd = fs.openSync(path, fs.OpenMode.CREATE | fs.OpenMode.WRITE_ONLY)
      fs.writeSync(fd, content)
      fs.closeSync(fd)
      return true
    } catch (err) {
      promptAction.showToast({ message: "写入文件失败" })
      return false
    }
  }

  // 文本追加写入(日志专用)
  appendText(path: string, content: string): boolean {
    try {
      const fd = fs.openSync(path, fs.OpenMode.CREATE | fs.OpenMode.APPEND | fs.OpenMode.READ_WRITE)
      fs.writeSync(fd, content + "\n")
      fs.closeSync(fd)
      return true
    } catch (err) {
      return false
    }
  }

  // 读取完整文本
  readText(path: string): string {
    if (!this.exists(path)) return ""
    try {
      const fd = fs.openSync(path, fs.OpenMode.READ_ONLY)
      const stat = fs.statSync(path)
      const buf = new ArrayBuffer(stat.size)
      fs.readSync(fd, buf)
      fs.closeSync(fd)
      return String.fromCharCode(...new Uint8Array(buf))
    } catch (err) {
      return ""
    }
  }

  // 删除单个文件
  deleteFile(path: string): boolean {
    if (!this.exists(path)) return true
    try {
      fs.unlinkSync(path)
      return true
    } catch (err) {
      promptAction.showToast({ message: "文件删除失败" })
      return false
    }
  }

  // 遍历目录,返回所有文件名数组
  listDirectory(dirPath: string): string[] {
    if (!this.exists(dirPath)) return []
    try {
      return fs.readdirSync(dirPath)
    } catch (err) {
      return []
    }
  }

  // 清空文件夹内所有文件(保留文件夹)
  clearDir(dirPath: string) {
    const files = this.listDirectory(dirPath)
    for (let name of files) {
      const fullPath = dirPath + "/" + name
      this.deleteFile(fullPath)
    }
  }
}

export default FileUtil.getInstance()

4.2 实战二:本地日志记录页面(文本读写追加)

ets

import FileUtil from '../utils/file_util'

@Entry
@Component
struct FileLogPage {
  @State logContent: string = ""
  logFilePath: string = ""

  aboutToAppear() {
    FileUtil.setContext(getContext(this))
    this.logFilePath = FileUtil.getFilesRoot() + "/app_log.txt"
    // 页面加载读取已有日志
    this.logContent = FileUtil.readText(this.logFilePath)
  }

  // 新增一条日志
  addLog() {
    const time = new Date().toLocaleString()
    const text = `【${time}】用户操作页面`
    FileUtil.appendText(this.logFilePath, text)
    // 刷新显示
    this.logContent = FileUtil.readText(this.logFilePath)
  }

  // 清空全部日志
  clearLog() {
    FileUtil.writeText(this.logFilePath, "")
    this.logContent = ""
  }

  build() {
    Column({ space: 16 }) {
      Text("本地日志文件读写演示")
        .fontSize(22)
        .fontWeight(FontWeight.Bold)

      TextArea({ text: this.logContent, placeholder: "暂无日志记录" })
        .width("95%")
        .layoutWeight(1)
        .backgroundColor("#fff")
        .fontSize(14)

      Row({ space: 20 }) {
        Button("添加日志记录").width(160).height(44).onClick(() => this.addLog())
        Button("清空日志").width(160).height(44).backgroundColor("#f56c6c").onClick(() => this.clearLog())
      }
      .width("100%")
      .justifyContent(FlexAlign.Center)
      .margin({ bottom: 20 })
    }
    .width("100%")
    .height("100%")
    .padding(12)
    .backgroundColor("#f5f5f5")
  }
}

4.3 实战三:相机图片缓存目录遍历页面

ets

import FileUtil from '../utils/file_util'

@Entry
@Component
struct FileImageListPage {
  @State imgNameList: string[] = []
  imageDir: string = ""

  aboutToAppear() {
    FileUtil.setContext(getContext(this))
    // 创建图片缓存文件夹
    this.imageDir = FileUtil.getFilesRoot() + "/camera_img"
    FileUtil.mkdir(this.imageDir)
    // 读取目录下所有图片文件名
    this.imgNameList = FileUtil.listDirectory(this.imageDir)
  }

  // 清空全部缓存图片
  clearAllImage() {
    FileUtil.clearDir(this.imageDir)
    this.imgNameList = FileUtil.listDirectory(this.imageDir)
  }

  build() {
    Column({ space: 15 }) {
      Row() {
        Text("相机缓存图片列表").fontSize(20).layoutWeight(1)
        Button("清空缓存").backgroundColor("#f56c6c").onClick(() => this.clearAllImage())
      }
      .width("95%")

      List({ space: 8 }) {
        ForEach(this.imgNameList, (name: string) => {
          ListItem() {
            Text(name)
              .width("100%")
              .padding(12)
              .backgroundColor(Color.White)
              .borderRadius(6)
          }
        })
      }
      .layoutWeight(1)
      .width("95%")
    }
    .width("100%")
    .height("100%")
    .padding(15)
    .backgroundColor("#f5f5f5")
  }
}

五、API23+ 文件系统适配与 IO 性能优化规范

5.1 文件句柄强制释放规范

  1. openSync 打开文件获取 fd 后,无论读写成功失败,必须执行 closeSync;
  2. 使用 try-catch 包裹文件操作,catch 分支内也要关闭句柄防止泄漏;
  3. 批量循环读写文件不要重复打开关闭,单次 fd 完成全部操作再释放。

5.2 沙盒路径使用规范

  1. 所有文件根目录统一通过页面上下文获取 filesDir/cacheDir,禁止手写绝对路径;
  2. 图片、长期保存数据存 filesDir,临时缓存、可清理文件存 cacheDir;
  3. 多层目录拼接统一使用/分隔,工具内部统一路径拼接逻辑。

5.3 IO 性能优化规范

  1. 小于 10KB 小文本可使用同步读写,日志、配置文件适用;
  2. 超过 100KB 图片、大文档禁止主线程同步一次性读取,改用异步分片;
  3. 频繁追加日志不要每次打开关闭 fd,可短时缓存 fd 统一批量写入再释放;
  4. 遍历文件夹删除文件先收集文件名,循环逐个 unlinkSync,不可直接 rmdirSync 非空目录。

5.4 业务分层存储规范

  1. 持久配置文本:Preferences(优先),简单日志小文件:fs 文本;
  2. 结构化多条数据:RDB 数据库;
  3. 图片、离线本地资源:fs filesDir 目录存储;
  4. 临时缓存、可自动清理文件:cacheDir 目录。

六、API23 升级高频兼容问题与解决方案

问题 1:文件写入后无法删除,提示文件被占用 解决:每次 openSync 操作结束一定调用 closeSync 释放 fd 句柄。

问题 2:路径不存在,创建文件直接报错崩溃 解决:操作前先用 existsSync 判断,不存在先创建文件夹。

问题 3:读取文件中文乱码 解决:读取 ArrayBuffer 后通过 Uint8Array 转字符串,不直接截取二进制。

问题 4:rmdirSync 删除文件夹报错 解决:该接口仅支持空目录,先遍历删除内部所有文件再执行删除。

问题 5:升级 API23 后硬编码路径找不到文件 解决:全部替换为 context.filesDir 动态获取沙盒根路径。

问题 6:多次写入同一文件内容错乱、覆盖丢失 解决:追加模式使用 OpenMode.APPEND,覆盖写入用 WRITE_ONLY,区分两种写入模式。

七、总结

fs 文件系统是 OpenHarmony 沙盒本地文件唯一标准操作模块,API Version23 强化文件句柄生命周期管理,强制读写后关闭 fd,统一沙盒路径获取方式,解决旧版文件占用、内存泄漏、路径失效、大文件读取卡顿等核心兼容问题。项目封装通用文件工具类,覆盖文件夹创建、文本读写追加、文件删除、目录遍历、缓存清空全套能力,适配本地日志、相机图片缓存两大高频业务场景。

Logo

开源鸿蒙跨平台开发社区汇聚开发者与厂商,共建“一次开发,多端部署”的开源生态,致力于降低跨端开发门槛,推动万物智联创新。

更多推荐