在这里插入图片描述

HarmonyOS NEXT 的开发中,文件系统的操作是很多功能模块的基础。很多人第一次接触 fileManager 这个目录操作 API 时,会觉得很简单:不就是创建目录、删除文件、遍历一下吗?官方示例在模拟器上也能运行。

但实际项目里,问题往往出现在对目录结构动态变化的管理上。比如,你需要在应用启动时,根据用户 ID 创建多级目录来缓存图片和数据;比如,你需要持续监控某个数据目录,一旦有新的文件生成就去处理;又或者,你需要在用户退出登录时,干净地清理掉用户目录。

这个功能本身不复杂,但真正麻烦的是 目录的生命周期管理遍历时与 UI 的状态同步。这篇笔记重点拆解 fileManager 里的四个核心 API:mkdirrmdirlistFilewatch

它解决什么问题

在 HarmonyOS NEXT 中,访问应用沙箱内的文件目录,绕不开 fileManager。它主要解决三个场景:

  1. 结构化存储:应用可以按照业务逻辑,在沙箱内创建多级目录来存放不同类型的文件,而不是把所有文件堆在一个目录下。
  2. 资源管理:在文件下载、日志存储、缓存清理等场景,需要程序化地遍历目录,获取指定的子文件或子目录。
  3. 事件驱动watch 接口允许监听目录的变化,比如当用户或其它进程向目录写入新文件时,应用可以自动响应,而无需轮询。

不适合的场景

  • 当需要批量操作海量文件(数万级别)时,直接遍历整个目录会导致明显的性能卡顿,此时应结合索引或数据库来管理文件路径。
  • watch 监控不适合用于全局文件系统的变化,它局限于应用自己的沙箱目录。

环境说明

DevEco Studio 版本:DevEco Studio 6.1.0 及以上
HarmonyOS SDK 版本:HarmonyOS 6.1.0(23) 及以上
目标设备:手机 / 平板

核心实现

1. 创建多层目录

官方的 mkdir 只能创建单层目录。如果需要创建 a/b/c 这种多级路径,需要逐级创建,或者自己封装一个工具函数。

// 文件: utils/DirUtils.ets
import { fileIo as fs, common } from '@kit.CoreFileKit';

export class DirUtils {
  /**
   * 递归创建目录
   * @param context 应用上下文,用于获取沙箱路径
   * @param relativePath 相对于沙箱根目录的路径,例如: "datas/user/2024/images"
   */
  static async createDirectories(context: common.Context, relativePath: string): Promise<boolean> {
    let basePath: string = context.filesDir; // 应用沙箱根目录
    let targetDir: string = `${basePath}/${relativePath}`;

    // 1. 先判断目标目录是否已经存在
    let isExist: boolean = await this.fileAccessExists(targetDir);
    if (isExist) {
      console.info("目录已存在:", targetDir);
      return true; // 已存在,视为成功
    }

    // 2. 逐级创建。使用 'mkdir' 本身不支持递归,我们手动拆分路径
    let pathSegments: string[] = targetDir.replace(basePath, '').split('/').filter(segment => segment.length > 0);
    let currentPath: string = basePath;

    for (let segment of pathSegments) {
      currentPath = `${currentPath}/${segment}`;
      try {
        // 注意:fileIo.mkdirSync 不常用,推荐使用异步的 fs.mkdir
        // 这里使用同步方式创建,避免回调地狱,但注意不要在UI主线程长时间运行大循环
        let stat = await fs.mkdir(currentPath, false); // false 表示非递归
        console.info("创建目录成功:", currentPath);
      } catch (err) {
        // 如果目录已存在,会报错。我们在上层已经判断了 targetDir 是否存在,
        // 但如果中间目录已经存在,这里会捕获到错误,这是正常的。
        console.error("创建目录失败或目录已存在:", err.message);
        // 继续循环,不影响后续目录的创建
        continue;
      }
    }

    // 3. 再次确认最终目录是否存在
    return await this.fileAccessExists(targetDir);
  }

  private static async fileAccessExists(filePath: string): Promise<boolean> {
    try {
      await fs.stat(filePath); // 如果文件或目录不存在,stat会抛出异常
      return true;
    } catch (err) {
      return false;
    }
  }
}

注意事项:这个实现是同步风格的异步操作。如果 pathSegments 非常长,比如有 20 级,它会依次创建。对于大多数场景,10 级以内的目录结构是够用的。不建议在 aboutToAppear 或者 build() 里直接 await 这个函数,最好放在 aboutToAppear 里用 TaskPool 或者 async 异步执行,避免阻塞页面初始化。

2. 删除目录

rmdir 只能删除空目录。如果目录里有文件,需要先遍历并删除文件,或者使用 unlink 逐个删除文件。

// 追加在 DirUtils.ets 中
  /**
   * 递归删除目录及所有内容
   */
  static async deleteDirectory(context: common.Context, relativePath: string): Promise<boolean> {
    let targetPath = `${context.filesDir}/${relativePath}`;
    // 1. 先获取目录下的所有条目
    let entries: fs.DirEntry[] = await fs.listFile(targetPath, { recursive: true });

    // 2. 倒序遍历,先删除文件,再删除目录
    for (let i = entries.length - 1; i >= 0; i--) {
      let entry = entries[i];
      let fullPath = `${targetPath}/${entry.name}`;
      if (entry.isDirectory()) {
        // 如果是子目录,直接尝试删除,因为已经在递归列表里了,里面的文件应该已经被删掉了
        await fs.rmdir(fullPath).catch(e => console.error("删除目录失败:", fullPath, e.message));
      } else {
        // 如果是文件,使用 unlink 删除
        await fs.unlink(fullPath).catch(e => console.error("删除文件失败:", fullPath, e.message));
      }
    }

    // 3. 最后删除顶层目录
    await fs.rmdir(targetPath).catch(e => console.error("删除顶层目录失败:", e.message));
    return true;
  }

为什么选择 recursive: true:因为 listFile 如果不开启 recursive,只会返回当前目录下的条目。我们需要递归删除,所以获取所有子条目是必要的。注意这里没有使用 fsPromise.rm(如果有这个 API),而是用最原生的 rmdirunlink,兼容性更好。

3. 遍历目录并打印路径

这个操作在展示文件列表时非常常见。

// 文件: pages/FileListPage.ets
import { fileIo as fs, common } from '@kit.CoreFileKit';
import { BusinessError } from '@kit.BasicServicesKit';

@Entry
@Component
struct FileListPage {
  @State filePaths: string[] = [];

  build() {
    Column() {
      Button("遍历并打印所有文件路径")
        .onClick(() => this.traverseDirectory())
      List({ space: 4 }) {
        ForEach(this.filePaths, (path: string) => {
          ListItem() {
            Text(path)
              .fontSize(14)
          }
        })
      }
      .layoutWeight(1)
    }
    .padding(20)
    .width('100%')
    .height('100%')
  }

  async traverseDirectory() {
    let context = getContext(this) as common.Context;
    let targetDir = `${context.filesDir}/datas/user/2024`;

    try {
      let entries: fs.DirEntry[] = await fs.listFile(targetDir, { recursive: true, filter: true });
      // filter: true 表示过滤掉隐藏文件(以 '.' 开头),通常不需要隐藏文件
      // 打印路径
      let paths: string[] = [];
      for (let entry of entries) {
        paths.push(`${targetDir}/${entry.name}`);
      }
      // 更新UI状态,必须在UI主线程
      this.filePaths = paths;
      console.info("成功遍历文件列表:", JSON.stringify(paths));
    } catch (err) {
      let error = err as BusinessError;
      console.error("遍历目录失败:", error.message);
    }
  }
}

性能注意:当 recursive: true 并且目录层级很深、文件很多时(比如超过 2000 个文件),这个 listFile 操作可能会耗时超过 200ms。如果在 UI 主线程直接 await,会导致页面掉帧。对于超大规模目录,推荐在子线程(如 TaskPool)里执行 listFile,然后将结果通过 emitter 发送回主线程更新 UI。

4. 设置目录变化监控

watch 接口可以监听指定目录的 {add, remove, update, move} 事件。这个能力在下载管理、日志实时追踪场景下很有价值。

// 文件: utils/WatchManager.ets
import { fileIo as fs } from '@kit.CoreFileKit';
import { BusinessError } from '@kit.BasicServicesKit';

export class WatchManager {
  private watchId: number = -1;
  private onFileChange: ((event: string, fileName: string) => void) | null = null;

  /**
   * 开始监控某个目录
   * @param targetPath 要监控的目录绝对路径
   * @param callback 事件回调
   */
  startWatch(targetPath: string, callback: (event: string, fileName: string) => void) {
    this.onFileChange = callback;
    // watch 返回一个 watcher 实例
    let watcher = fs.createWatcher(targetPath, {
      recursive: false, // 默认只监控当前目录,不递归监控子目录
    });

    // 注册事件监听
    watcher.on('change', (event: string, fileName: string) => {
      // event: "add" | "remove" | "update" | "move"
      console.info("文件变化事件:", event, fileName);
      if (this.onFileChange) {
        // 注意:这里回调是在监听线程,不能直接修改UI状态,需要抛回主线程
        // 推荐使用 AppStorage 或者 emitter 来同步
        this.onFileChange(event, fileName);
      }
    });

    // 开启监控
    watcher.start();
    // 返回的 watchId 可以用于后续停止监控
    this.watchId = watcher.id;
  }

  stopWatch() {
    if (this.watchId !== -1) {
      // 停止监控
      fs.stopWatch(this.watchId);
      this.watchId = -1;
      console.info("停止文件监控");
    }
  }
}

坑点提醒

  • recursive: false 是默认值,意思是只监控 targetPath 这个目录本身的变化。如果你需要监控其子目录下的文件变化,官方文档至今(API 13)仍不支持 recursive: true。这是一个比较明显的限制。
  • 回调运行在 watcher 的内部线程。如果你在回调里尝试 runOnMainThread 或者直接修改 @State 变量,会导致 ArkUI 的并发冲突。正确的做法是使用 emitter 或者共享的 @LocalStorageProp 来中转。

常见问题

1. 权限申请问题

现象:真机调试时,调用 fs.mkdirfs.listFile 时,返回 13900001 权限错误。

原因:HarmonyOS NEXT 对沙箱目录访问有严格管控。虽然 context.filesDir 是应用私有目录,但如果尝试创建目录时,路径包含非法字符(如 ..、绝对路径越权),或者尝试在非沙箱路径下操作,就会报权限错误。

解决方案:确保所有路径都基于 context.filesDir 进行拼接。不要尝试操作 externaldownload 目录,除非你申请了 ohos.permission.READ_MEDIA 等权限。

2. watch 回调粘滞

现象watch 回调被频繁触发,或者一次文件写入触发了多次 add 事件。

原因watch 底层基于 inotify,对于大文件的写入(比如视频),系统会触发多次 MODIFY 事件。官方提供了去抖机制,但默认行为是实时上报。

解决方案:在回调内部添加防抖逻辑:

watcher.on('change', debounce((event: string, fileName: string) => {
  // 你的业务逻辑
}, 300)); // 300ms 内只处理最后一次事件

3. 遍历时遇到空目录

现象listFile 返回的 entries 数组是空的,但目录确实存在。

原因:这是因为 listFile 在不设置 recursivefilter 时,只会返回可见的文件和目录。如果目录里只有隐藏文件(以 . 开头),且没有设置 filter: false,则列表会为空。

解决方案:明确设置 { filter: false } 来包含隐藏文件。如果是判断目录是否存在,应该使用 fs.stat 而非 listFile

最佳实践

  1. 不要在 build() 中频繁创建目录对象fileIoDirEntry 对象创建成本不高,但如果在 build() 里用 ForEach 多次调用 fs.statfs.listFile,ArkUI 会频繁触发组件重建。推荐把目录列表数据缓存到 @StorageLinkAppStorage 中。
  2. 使用 try-catch 保护文件操作:文件系统操作很容易抛异常,如权限拒绝、磁盘空间不足、路径不存在。不捕获异常会导致应用闪退。上述代码中几乎每一个 await 都包裹了 catch,这是项目稳定性的关键。
  3. 合理设置 recursive 参数:监控目录变化时,如果业务场景不需要监控子目录,不要开启 recursive: true(虽然目前 watch 本身也不支持)。遍历目录时,如果只需要当前层级,不要加 recursive,否则会降低性能。

Demo 入口

文件: pages/Index.ets

@Entry
@Component
struct Index {
  build() {
    Column() {
      Text("目录操作与文件遍历 Demo")
        .fontSize(20)
        .margin({ bottom: 20 })
      Button("创建测试目录结构")
        .onClick(async () => {
          let context = getContext(this) as common.Context;
          await DirUtils.createDirectories(context, "test/images/2024");
          await DirUtils.createDirectories(context, "test/docs");
          console.info("目录创建完成");
        })
      Button("遍历并打印所有文件")
        .onClick(async () => {
          let context = getContext(this) as common.Context;
          let entries = await fs.listFile(`${context.filesDir}/test`, { recursive: true });
          for (let e of entries) {
            console.info("发现条目:", e.name);
          }
        })
      // 其他按钮...
    }
    .padding(20)
    .width('100%')
    .height('100%')
  }
}

FAQ

Q:为什么真机正常,模拟器上 watch 回调不触发?
A:模拟器上对 inotify 的支持不完整。建议所有文件变化相关的功能都以真机为准进行测试。模拟器常被用于 UI 调试,不适合验证这类系统 API 行为。

Q:页面返回后,如何停止 watch 监控?
A:可以在页面的 onPageHideaboutToDisappear 生命周期中调用 stopWatch() 方法。如果不在页面销毁时主动停止,监控可能会持续存在,导致内存泄漏或意外回调。

Q:为什么创建目录时,明明路径写对了,但还是报 ERRNO_EEXIST 错误?
A:这个错误表示目录已存在。在官方示例里,mkdir 没有 exists 判断。写法上应该先 stat,判断不存在后再创建。我们的 createDirectories 工具函数已经做了这件事,使用时直接调用即可。


示例代码地址:项目地址

Logo

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

更多推荐