《HarmonyOS技术精讲-Core File Kit》第4篇:目录操作与文件遍历

在 HarmonyOS NEXT 的开发中,文件系统的操作是很多功能模块的基础。很多人第一次接触 fileManager 这个目录操作 API 时,会觉得很简单:不就是创建目录、删除文件、遍历一下吗?官方示例在模拟器上也能运行。
但实际项目里,问题往往出现在对目录结构动态变化的管理上。比如,你需要在应用启动时,根据用户 ID 创建多级目录来缓存图片和数据;比如,你需要持续监控某个数据目录,一旦有新的文件生成就去处理;又或者,你需要在用户退出登录时,干净地清理掉用户目录。
这个功能本身不复杂,但真正麻烦的是 目录的生命周期管理 和 遍历时与 UI 的状态同步。这篇笔记重点拆解 fileManager 里的四个核心 API:mkdir、rmdir、listFile 和 watch。
它解决什么问题
在 HarmonyOS NEXT 中,访问应用沙箱内的文件目录,绕不开 fileManager。它主要解决三个场景:
- 结构化存储:应用可以按照业务逻辑,在沙箱内创建多级目录来存放不同类型的文件,而不是把所有文件堆在一个目录下。
- 资源管理:在文件下载、日志存储、缓存清理等场景,需要程序化地遍历目录,获取指定的子文件或子目录。
- 事件驱动:
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),而是用最原生的 rmdir 和 unlink,兼容性更好。
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.mkdir 或 fs.listFile 时,返回 13900001 权限错误。
原因:HarmonyOS NEXT 对沙箱目录访问有严格管控。虽然 context.filesDir 是应用私有目录,但如果尝试创建目录时,路径包含非法字符(如 ..、绝对路径越权),或者尝试在非沙箱路径下操作,就会报权限错误。
解决方案:确保所有路径都基于 context.filesDir 进行拼接。不要尝试操作 external 或 download 目录,除非你申请了 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 在不设置 recursive 或 filter 时,只会返回可见的文件和目录。如果目录里只有隐藏文件(以 . 开头),且没有设置 filter: false,则列表会为空。
解决方案:明确设置 { filter: false } 来包含隐藏文件。如果是判断目录是否存在,应该使用 fs.stat 而非 listFile。
最佳实践
- 不要在 build() 中频繁创建目录对象:
fileIo的DirEntry对象创建成本不高,但如果在build()里用ForEach多次调用fs.stat或fs.listFile,ArkUI 会频繁触发组件重建。推荐把目录列表数据缓存到@StorageLink或AppStorage中。 - 使用 try-catch 保护文件操作:文件系统操作很容易抛异常,如权限拒绝、磁盘空间不足、路径不存在。不捕获异常会导致应用闪退。上述代码中几乎每一个
await都包裹了catch,这是项目稳定性的关键。 - 合理设置
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:可以在页面的 onPageHide 或 aboutToDisappear 生命周期中调用 stopWatch() 方法。如果不在页面销毁时主动停止,监控可能会持续存在,导致内存泄漏或意外回调。
Q:为什么创建目录时,明明路径写对了,但还是报 ERRNO_EEXIST 错误?
A:这个错误表示目录已存在。在官方示例里,mkdir 没有 exists 判断。写法上应该先 stat,判断不存在后再创建。我们的 createDirectories 工具函数已经做了这件事,使用时直接调用即可。
示例代码地址:项目地址
更多推荐


所有评论(0)