摘要

项目中日志、临时图片、缓存、本地配置文件都需要文件 IO 操作,原生@ohos.fileio@ohos.file.fs接口零散,缺少统一封装:目录创建、文本读写、二进制复制、文件删除、遍历文件夹、缓存大小计算、清空缓存等通用逻辑重复编写。封装全局 FileUtil 统一管理沙盒私有目录操作,包含文本读写、文件复制删除、文件夹遍历、缓存统计清理、临时文件管理能力,配合前文图片工具、网络上传工具使用。API23 重构文件句柄生命周期、沙盒路径权限、异步文件锁机制,解决文件句柄泄漏、并发读写错乱、缓存无法彻底删除、外部文件访问权限不足等问题。

关键词

OpenHarmony;ArkTS;API23;fileio;fs;文件读写;沙盒目录;缓存清理;文件工具

一、引言

1.1 文件开发痛点

  1. 每次读写文件都要手动判断目录是否存在,不存在需递归创建文件夹;
  2. 文件打开后忘记 close 句柄,长期运行导致文件句柄泄漏,无法读写新文件;
  3. 文本读写、二进制复制、删除文件逻辑分散,大量重复模板代码;
  4. 缓存文件夹无法一键清理,缺少缓存占用大小计算功能;
  5. 页面销毁临时文件残留占用存储空间;
  6. 并发读写同一文件出现内容错乱、文件损坏。

1.2 应用沙盒内置目录说明(无需动态权限)

表格

目录 用途
context.filesDir 持久化私有文件,重启不删除
context.cacheDir 缓存目录,系统可自动清理,适合临时图片 / 日志
context.tempDir 临时目录,APP 后台休眠自动清空

API23 文件模块核心升级:

  1. 所有文件操作强制异步优先,同步 IO 禁止在主线程使用;
  2. 自动文件锁,同一文件并发读写串行执行,防止内容损坏;
  3. 新增批量删除目录递归接口,简化缓存清理;
  4. 统一沙盒路径访问校验,杜绝越权访问外部文件;
  5. 文件句柄自动回收机制,异常场景自动关闭 fd。

二、完整文件工具封装 utils/file_util.ets

ets

import fileio from '@ohos.fileio'
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
  }

  // 注入页面/Ability上下文,获取沙盒根目录
  setContext(context: common.UIAbilityContext) {
    this.ctx = context
  }

  // 获取持久化文件根目录 filesDir
  getFilesRoot(): string {
    if (!this.ctx) return ""
    return this.ctx.filesDir
  }

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

  // 获取临时目录 tempDir
  getTempRoot(): string {
    if (!this.ctx) return ""
    return this.ctx.tempDir
  }

  // 递归创建文件夹,不存在则创建
  async mkdir(dirPath: string): Promise<boolean> {
    try {
      const exists = await fs.access(dirPath)
      if (!exists) {
        await fs.mkdir(dirPath, true) // true代表递归创建多级目录
      }
      return true
    } catch (err) {
      console.error("mkdir failed", dirPath, err)
      return false
    }
  }

  // 写入文本文件(覆盖原有内容)
  async writeText(filePath: string, content: string): Promise<boolean> {
    try {
      // 先确保父目录存在
      const parentDir = filePath.substring(0, filePath.lastIndexOf("/"))
      await this.mkdir(parentDir)
      const fd = await fileio.open(filePath, fileio.OpenMode.CREATE | fileio.OpenMode.WRITE_ONLY)
      await fileio.write(fd, content)
      await fileio.close(fd)
      return true
    } catch (err) {
      promptAction.showToast({ message: "写入文件失败" })
      console.error("writeText error", err)
      return false
    }
  }

  // 读取文本文件
  async readText(filePath: string): Promise<string> {
    try {
      const exists = await fs.access(filePath)
      if (!exists) return ""
      const fd = await fileio.open(filePath, fileio.OpenMode.READ_ONLY)
      const stat = await fileio.fstat(fd)
      const buf = new ArrayBuffer(stat.size)
      await fileio.read(fd, buf)
      await fileio.close(fd)
      return String.fromCharCode(...new Uint8Array(buf))
    } catch (err) {
      console.error("readText error", err)
      return ""
    }
  }

  // 删除单个文件
  async deleteFile(filePath: string): Promise<boolean> {
    try {
      const exists = await fs.access(filePath)
      if (!exists) return true
      await fs.unlink(filePath)
      return true
    } catch (err) {
      console.error("deleteFile error", err)
      return false
    }
  }

  // 递归删除整个文件夹(含内部所有文件子目录)
  async deleteDir(dirPath: string): Promise<boolean> {
    try {
      const exists = await fs.access(dirPath)
      if (!exists) return true
      await fs.rmdir(dirPath, { recursive: true })
      return true
    } catch (err) {
      console.error("deleteDir error", err)
      return false
    }
  }

  // 计算文件夹总大小(单位:字节)
  async getDirSize(dirPath: string): Promise<number> {
    let totalSize = 0
    try {
      const exists = await fs.access(dirPath)
      if (!exists) return 0
      const dir = await fs.opendir(dirPath)
      let entry: fs.Dirent | null
      while ((entry = await dir.read()) !== null) {
        const fullPath = dirPath + "/" + entry.name
        if (entry.isFile()) {
          const stat = await fs.stat(fullPath)
          totalSize += stat.size
        } else if (entry.isDirectory()) {
          // 递归累加子文件夹大小
          totalSize += await this.getDirSize(fullPath)
        }
      }
      await dir.close()
      return totalSize
    } catch (err) {
      console.error("getDirSize error", err)
      return 0
    }
  }

  // 字节自动转换为 KB / MB 展示文本
  formatSize(byteNum: number): string {
    if (byteNum < 1024) return byteNum + " B"
    if (byteNum < 1024 * 1024) return (byteNum / 1024).toFixed(2) + " KB"
    return (byteNum / (1024 * 1024)).toFixed(2) + " MB"
  }

  // 清空缓存目录 cacheDir
  async clearCache(): Promise<boolean> {
    const cachePath = this.getCacheRoot()
    if (!cachePath) return false
    return await this.deleteDir(cachePath)
  }

  // 复制文件
  async copyFile(srcPath: string, destPath: string): Promise<boolean> {
    try {
      const parentDir = destPath.substring(0, destPath.lastIndexOf("/"))
      await this.mkdir(parentDir)
      await fs.copyFile(srcPath, destPath)
      return true
    } catch (err) {
      console.error("copyFile error", err)
      return false
    }
  }
}

export default FileUtil.getInstance()

三、页面实战调用示例

3.1 日志保存页面(文本读写)

ets

import FileUtil from '../utils/file_util'

@Entry
@Component
struct LogPage {
  @State logContent: string = ""
  logPath: string = ""

  aboutToAppear() {
    FileUtil.setContext(getContext(this))
    const root = FileUtil.getFilesRoot()
    this.logPath = root + "/app_log.txt"
    this.loadLog()
  }

  // 读取本地日志
  async loadLog() {
    this.logContent = await FileUtil.readText(this.logPath)
  }

  // 追加日志内容
  async writeLog(msg: string) {
    const oldText = await FileUtil.readText(this.logPath)
    const newText = oldText + "\n" + new Date().toLocaleString() + ":" + msg
    await FileUtil.writeText(this.logPath, newText)
    await this.loadLog()
  }

  build() {
    Column({ space: 15 }) {
      Text("本地日志文件")
        .fontSize(22)
      TextInput({ text: "", placeholder: "输入日志内容" })
        .width("90%")
        .onChange(async (val) => await this.writeLog(val))
      Scroll() {
        Text(this.logContent)
          .width("100%")
          .padding(10)
          .backgroundColor(Color.White)
      }
      .layoutWeight(1)
      .width("90%")
    }
    .width("100%")
    .height("100%")
    .padding(12)
  }
}

3.2 设置页:缓存大小查看与一键清理

ets

import FileUtil from '../utils/file_util'
import DialogUtil from '../utils/dialog_util'

@Entry
@Component
struct CacheSettingPage {
  @State cacheSizeText: string = "计算中..."

  async aboutToAppear() {
    FileUtil.setContext(getContext(this))
    await this.calcCacheSize()
  }

  // 计算缓存占用
  async calcCacheSize() {
    const cachePath = FileUtil.getCacheRoot()
    const byte = await FileUtil.getDirSize(cachePath)
    this.cacheSizeText = FileUtil.formatSize(byte)
  }

  // 清空缓存
  async clearAllCache() {
    DialogUtil.confirm({
      title: "清理缓存",
      content: "确定要清除全部缓存图片、临时文件吗?"
    }, async () => {
      await FileUtil.clearCache()
      await this.calcCacheSize()
      DialogUtil.alert({ content: "缓存清理完成" })
    })
  }

  build() {
    Column({ space: 30 }) {
      Text("缓存管理").fontSize(24).margin({ top: 60 })
      Row() {
        Text("当前缓存占用:").fontSize(18)
        Text(this.cacheSizeText).fontSize(18).fontColor("#007DFF")
      }
      Button("一键清理缓存")
        .width(300)
        .height(48)
        .backgroundColor("#f56c6c")
        .onClick(() => this.clearAllCache())
    }
    .width("100%")
    .height("100%")
    .justifyContent(Center)
  }
}

3.3 图片工具配套:临时图片存放至缓存目录

ets

// 拼接缓存目录下图片完整路径
function getTempImagePath(fileName: string): string {
  const cacheRoot = FileUtil.getCacheRoot()
  return cacheRoot + "/temp_img/" + fileName + ".jpg"
}

// 页面销毁清空临时图片文件夹
async function clearTempImg() {
  const tempDir = FileUtil.getCacheRoot() + "/temp_img"
  await FileUtil.deleteDir(tempDir)
}

四、API23 文件开发编码规范

4.1 上下文与目录规范

  1. 页面aboutToAppear调用FileUtil.setContext(getContext(this))初始化沙盒路径;
  2. 持久化数据存filesDir,临时图片、网络缓存存cacheDir,短时临时文件用tempDir
  3. 禁止硬编码绝对路径,统一通过工具方法获取根目录拼接子路径。

4.2 文件句柄安全规范

  1. 所有 open 打开的 fd 必须配套 close,工具内部封装自动关闭,业务层无需手动处理;
  2. 大文件读写分块 Buffer,避免一次性加载全部内容占用内存;
  3. 并发读写同一文件依靠 API23 内置文件锁串行执行,不额外加锁。

4.3 缓存清理规范

  1. APP 设置页提供一键清空 cacheDir 功能;
  2. 页面退出可删除自身业务临时文件夹,减少存储空间占用;
  3. 重要业务数据禁止存入 cacheDir,系统会自动回收删除。

4.4 性能规范

  1. 批量删除目录使用rmdir(recursive:true)递归删除,比循环删文件更快;
  2. 计算文件夹大小仅在设置页面加载时执行,不频繁轮询;
  3. 主线程全部使用 async/await 异步文件接口,禁用同步阻塞 IO。

五、高频报错问题解决方案

问题 1:文件写入提示目录不存在 解决:工具内 writeText 自动递归创建父目录,确认已注入上下文获取正确沙盒路径。

问题 2:多次操作文件后无法读写,提示句柄耗尽 解决:工具所有读写逻辑内部自动 close fd,避免遗漏关闭句柄。

问题 3:缓存清理后文件夹仍残留文件 解决:使用 deleteDir 递归删除整个缓存目录,而非遍历单文件删除。

问题 4:读取文件返回空字符串 解决:检查文件路径是否正确,文件是否存在,权限仅允许访问应用自身沙盒。

问题 5:升级 API23 旧版同步 fileio 代码卡顿、编译警告 解决:全部替换为 async/await 异步文件 API,主线程移除同步读写逻辑。

六、总结

FileUtil 统一封装鸿蒙沙盒全套文件操作,覆盖目录创建、文本读写、文件复制删除、缓存大小统计、一键清理缓存核心能力,区分持久化、缓存、临时三大沙盒目录,完美配合 ImagePickerUtil 图片压缩缓存、HttpUtil 网络临时下载文件、本地日志存储等业务模块。 API23 完善文件异步锁、句柄自动回收、递归目录操作能力,解决文件泄漏、并发损坏、缓存清理不干净等问题,是所有存在本地文件存储需求项目必备底层通用工具,可无缝接入前文整套分层架构工程。

Logo

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

更多推荐