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

📅 2026/7/1 15:46:15
《HarmonyOS技术精讲-Core File Kit》第4篇:目录操作与文件遍历
在HarmonyOS NEXT的开发中文件系统的操作是很多功能模块的基础。很多人第一次接触fileManager这个目录操作 API 时会觉得很简单不就是创建目录、删除文件、遍历一下吗官方示例在模拟器上也能运行。但实际项目里问题往往出现在对目录结构动态变化的管理上。比如你需要在应用启动时根据用户 ID 创建多级目录来缓存图片和数据比如你需要持续监控某个数据目录一旦有新的文件生成就去处理又或者你需要在用户退出登录时干净地清理掉用户目录。这个功能本身不复杂但真正麻烦的是目录的生命周期管理和遍历时与 UI 的状态同步。这篇笔记重点拆解fileManager里的四个核心 APImkdir、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.etsimport{fileIoasfs,common}fromkit.CoreFileKit;exportclassDirUtils{/** * 递归创建目录 * param context 应用上下文用于获取沙箱路径 * param relativePath 相对于沙箱根目录的路径例如: datas/user/2024/images */staticasynccreateDirectories(context:common.Context,relativePath:string):Promiseboolean{letbasePath:stringcontext.filesDir;// 应用沙箱根目录lettargetDir:string${basePath}/${relativePath};// 1. 先判断目标目录是否已经存在letisExist:booleanawaitthis.fileAccessExists(targetDir);if(isExist){console.info(目录已存在:,targetDir);returntrue;// 已存在视为成功}// 2. 逐级创建。使用 mkdir 本身不支持递归我们手动拆分路径letpathSegments:string[]targetDir.replace(basePath,).split(/).filter(segmentsegment.length0);letcurrentPath:stringbasePath;for(letsegmentofpathSegments){currentPath${currentPath}/${segment};try{// 注意fileIo.mkdirSync 不常用推荐使用异步的 fs.mkdir// 这里使用同步方式创建避免回调地狱但注意不要在UI主线程长时间运行大循环letstatawaitfs.mkdir(currentPath,false);// false 表示非递归console.info(创建目录成功:,currentPath);}catch(err){// 如果目录已存在会报错。我们在上层已经判断了 targetDir 是否存在// 但如果中间目录已经存在这里会捕获到错误这是正常的。console.error(创建目录失败或目录已存在:,err.message);// 继续循环不影响后续目录的创建continue;}}// 3. 再次确认最终目录是否存在returnawaitthis.fileAccessExists(targetDir);}privatestaticasyncfileAccessExists(filePath:string):Promiseboolean{try{awaitfs.stat(filePath);// 如果文件或目录不存在stat会抛出异常returntrue;}catch(err){returnfalse;}}}注意事项这个实现是同步风格的异步操作。如果pathSegments非常长比如有 20 级它会依次创建。对于大多数场景10 级以内的目录结构是够用的。不建议在aboutToAppear或者build()里直接await这个函数最好放在aboutToAppear里用TaskPool或者async异步执行避免阻塞页面初始化。2. 删除目录rmdir只能删除空目录。如果目录里有文件需要先遍历并删除文件或者使用unlink逐个删除文件。// 追加在 DirUtils.ets 中/** * 递归删除目录及所有内容 */staticasyncdeleteDirectory(context:common.Context,relativePath:string):Promiseboolean{lettargetPath${context.filesDir}/${relativePath};// 1. 先获取目录下的所有条目letentries:fs.DirEntry[]awaitfs.listFile(targetPath,{recursive:true});// 2. 倒序遍历先删除文件再删除目录for(letientries.length-1;i0;i--){letentryentries[i];letfullPath${targetPath}/${entry.name};if(entry.isDirectory()){// 如果是子目录直接尝试删除因为已经在递归列表里了里面的文件应该已经被删掉了awaitfs.rmdir(fullPath).catch(econsole.error(删除目录失败:,fullPath,e.message));}else{// 如果是文件使用 unlink 删除awaitfs.unlink(fullPath).catch(econsole.error(删除文件失败:,fullPath,e.message));}}// 3. 最后删除顶层目录awaitfs.rmdir(targetPath).catch(econsole.error(删除顶层目录失败:,e.message));returntrue;}为什么选择recursive: true因为listFile如果不开启recursive只会返回当前目录下的条目。我们需要递归删除所以获取所有子条目是必要的。注意这里没有使用fsPromise.rm如果有这个 API而是用最原生的rmdir和unlink兼容性更好。3. 遍历目录并打印路径这个操作在展示文件列表时非常常见。// 文件: pages/FileListPage.etsimport{fileIoasfs,common}fromkit.CoreFileKit;import{BusinessError}fromkit.BasicServicesKit;EntryComponentstruct FileListPage{StatefilePaths: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%)}asynctraverseDirectory(){letcontextgetContext(this)ascommon.Context;lettargetDir${context.filesDir}/datas/user/2024;try{letentries:fs.DirEntry[]awaitfs.listFile(targetDir,{recursive:true,filter:true});// filter: true 表示过滤掉隐藏文件以 . 开头通常不需要隐藏文件// 打印路径letpaths:string[][];for(letentryofentries){paths.push(${targetDir}/${entry.name});}// 更新UI状态必须在UI主线程this.filePathspaths;console.info(成功遍历文件列表:,JSON.stringify(paths));}catch(err){leterrorerrasBusinessError;console.error(遍历目录失败:,error.message);}}}性能注意当recursive: true并且目录层级很深、文件很多时比如超过 2000 个文件这个listFile操作可能会耗时超过 200ms。如果在 UI 主线程直接await会导致页面掉帧。对于超大规模目录推荐在子线程如TaskPool里执行listFile然后将结果通过emitter发送回主线程更新 UI。4. 设置目录变化监控watch接口可以监听指定目录的 {add, remove, update, move} 事件。这个能力在下载管理、日志实时追踪场景下很有价值。// 文件: utils/WatchManager.etsimport{fileIoasfs}fromkit.CoreFileKit;import{BusinessError}fromkit.BasicServicesKit;exportclassWatchManager{privatewatchId:number-1;privateonFileChange:((event:string,fileName:string)void)|nullnull;/** * 开始监控某个目录 * param targetPath 要监控的目录绝对路径 * param callback 事件回调 */startWatch(targetPath:string,callback:(event:string,fileName:string)void){this.onFileChangecallback;// watch 返回一个 watcher 实例letwatcherfs.createWatcher(targetPath,{recursive:false,// 默认只监控当前目录不递归监控子目录});// 注册事件监听watcher.on(change,(event:string,fileName:string){// event: add | remove | update | moveconsole.info(文件变化事件:,event,fileName);if(this.onFileChange){// 注意这里回调是在监听线程不能直接修改UI状态需要抛回主线程// 推荐使用 AppStorage 或者 emitter 来同步this.onFileChange(event,fileName);}});// 开启监控watcher.start();// 返回的 watchId 可以用于后续停止监控this.watchIdwatcher.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.listFileArkUI 会频繁触发组件重建。推荐把目录列表数据缓存到StorageLink或AppStorage中。使用 try-catch 保护文件操作文件系统操作很容易抛异常如权限拒绝、磁盘空间不足、路径不存在。不捕获异常会导致应用闪退。上述代码中几乎每一个await都包裹了catch这是项目稳定性的关键。合理设置recursive参数监控目录变化时如果业务场景不需要监控子目录不要开启recursive: true虽然目前watch本身也不支持。遍历目录时如果只需要当前层级不要加recursive否则会降低性能。Demo 入口文件:pages/Index.etsEntryComponentstruct Index{build(){Column(){Text(目录操作与文件遍历 Demo).fontSize(20).margin({bottom:20})Button(创建测试目录结构).onClick(async(){letcontextgetContext(this)ascommon.Context;awaitDirUtils.createDirectories(context,test/images/2024);awaitDirUtils.createDirectories(context,test/docs);console.info(目录创建完成);})Button(遍历并打印所有文件).onClick(async(){letcontextgetContext(this)ascommon.Context;letentriesawaitfs.listFile(${context.filesDir}/test,{recursive:true});for(leteofentries){console.info(发现条目:,e.name);}})// 其他按钮...}.padding(20).width(100%).height(100%)}}FAQQ为什么真机正常模拟器上 watch 回调不触发A模拟器上对 inotify 的支持不完整。建议所有文件变化相关的功能都以真机为准进行测试。模拟器常被用于 UI 调试不适合验证这类系统 API 行为。Q页面返回后如何停止 watch 监控A可以在页面的onPageHide或aboutToDisappear生命周期中调用stopWatch()方法。如果不在页面销毁时主动停止监控可能会持续存在导致内存泄漏或意外回调。Q为什么创建目录时明明路径写对了但还是报ERRNO_EEXIST错误A这个错误表示目录已存在。在官方示例里mkdir没有exists判断。写法上应该先stat判断不存在后再创建。我们的createDirectories工具函数已经做了这件事使用时直接调用即可。示例代码地址项目地址