
《HarmonyOS技术精讲-Core File Kit》第18篇结合UI——文件选择器开发HarmonyOS NEXT 开发中文件选择是一个很常见的基础需求。官方提供的fileManagerAPI 能完成文件列表获取但直接用在 UI 上需要处理的问题比想象中多。目录导航、视图切换、状态同步、权限处理这些细节不处理好功能就跑不起来。很多人的做法是直接用系统弹窗选择文件。但如果你需要自定义 UI、支持多视图、目录自由导航、长按菜单这些能力就必须自己实现一个文件选择器组件。这篇文章会从零开始实现一个支持列表/网格切换、目录导航、文件预览、长按菜单的文件选择器。实际场景比如一个本地文件管理功能需要用户浏览设备存储目录、选择文件进行处理。系统文件选择器不能满足复杂交互需求就需要自定义实现。这个场景在文件管理、云盘客户端、上传工具类应用里都会遇到。环境说明DevEco Studio 版本DevEco Studio 6.1.0 及以上 HarmonyOS SDK 版本HarmonyOS 6.1.0(23) 及以上 目标设备手机核心实现整个实现分为四个部分文件列表获取、目录导航、UI 渲染、交互菜单。下面一步步完成。文件列表获取fileManager.listFile是最核心的 API。它的作用是指定目录路径返回该目录下的文件和子目录列表。这里的坑是返回值是FileInfo对象数组不是字符串路径必须自己解析。// FileList.etsimport{fileManager}fromkit.CoreFileKit;import{systemCapability}fromkit.AbilityKit;exportclassFileList{staticasyncgetFileList(dirPath:string):PromiseArrayfileManager.FileInfo{try{// 检查目录是否存在if(!fs.statSync(dirPath).isDirectory()){return[];}constfilesawaitfileManager.listFile(dirPath,{recursive:false,// 不递归子目录filter:undefined});returnfiles;}catch(error){console.error(listFile error:${error.message});return[];}}}注意recursive参数默认是false如果需要子目录遍历要改成true。这里设置为false是因为我们只需要当前目录一层目录导航由路径栈控制。路径栈管理目录导航的核心是一个栈结构。每次进入子目录 push 一次返回上一级 pop 一次。当前路径就是栈顶元素。// PathStack.etsexportclassPathStack{privatestack:Arraystring[];constructor(initialPath:string){this.stack.push(initialPath);}getCurrentPath():string{returnthis.stack[this.stack.length-1];}push(path:string):void{this.stack.push(path);}pop():string|undefined{if(this.stack.length1){returnthis.stack.pop();}returnundefined;}canBack():boolean{returnthis.stack.length1;}clear():void{this.stack[this.stack[0]];}}不要用数组的shift做栈操作性能差且逻辑复杂。这里用push/pop是最简单的。UI 组件UI 组件需要支持列表和网格两种视图模式。用一个State变量控制视图类型。// FileSelector.etsimport{fileManager}fromkit.CoreFileKit;import{PathStack}from./PathStack;EntryComponentstruct FileSelector{StatefileList:ArrayfileManager.FileInfo[];StateisGridView:booleanfalse;StatecurrentPath:string/storage/emulated/0;privatepathStack:PathStacknewPathStack(this.currentPath);aboutToAppear():void{this.loadFiles(this.currentPath);}asyncloadFiles(dirPath:string):Promisevoid{try{constfilesawaitfileManager.listFile(dirPath,{recursive:false});this.fileListfiles;}catch(error){console.error(loadFiles failed:${error.message});}}onDirectoryTap(file:fileManager.FileInfo):void{if(file.isDirectory){constnewPath${this.currentPath}/${file.name};this.pathStack.push(newPath);this.currentPathnewPath;this.loadFiles(newPath);}}onBackTap():void{if(this.pathStack.canBack()){constparentPaththis.pathStack.pop();if(parentPath){this.currentPathparentPath;this.loadFiles(parentPath);}}}build(){Column(){// 导航栏Row(){Button(返回).enabled(this.pathStack.canBack()).onClick(()this.onBackTap())Text(this.currentPath).fontSize(14).fontWeight(FontWeight.Medium).margin({left:10})}.width(100%).padding(10)// 视图切换Row(){Button(列表).onClick(()this.isGridViewfalse)Button(网格).onClick(()this.isGridViewtrue)}.width(100%).padding(10).justifyContent(FlexAlign.End)// 文件列表if(this.isGridView){Grid(){ForEach(this.fileList,(item:fileManager.FileInfo){GridItem(){this.FileItemView(item)}})}.columnsTemplate(1fr 1fr 1fr).columnsGap(8).rowsGap(8).padding(10)}else{List(){ForEach(this.fileList,(item:fileManager.FileInfo){ListItem(){this.FileItemView(item)}})}.padding(10)}}.width(100%).height(100%)}BuilderFileItemView(item:fileManager.FileInfo){Column(){// 文件图标根据isDirectory显示不同图标Image($r(app.media.ic_file)).width(40).height(40).objectFit(ImageFit.Contain)Text(item.name).fontSize(14).lineHeight(20).maxLines(1).textOverflow({overflow:TextOverflow.Ellipsis})}.width(100%).padding(10).onClick((){if(item.isDirectory){this.onDirectoryTap(item);}else{// 文件预览this.previewFile(item);}}).onLongPress((){this.showFileMenu(item);})}previewFile(file:fileManager.FileInfo):void{// 简单预览弹窗显示文件大小和信息AlertDialog.show({title:文件信息,message:文件名:${file.name}\n大小:${file.size}bytes\n修改时间:${file.modifyTime},confirm:{value:关闭,action:(){}}})}showFileMenu(file:fileManager.FileInfo):void{// 弹出菜单分享/删除ActionSheet.show({title:file.name,items:[{value:分享,action:()this.shareFile(file)},{value:删除,action:()this.deleteFile(file)}]})}asyncshareFile(file:fileManager.FileInfo):Promisevoid{// 分享功能需要调用对应API这里只做占位console.info(share file:${file.name});}asyncdeleteFile(file:fileManager.FileInfo):Promisevoid{try{constfilePath${this.currentPath}/${file.name};if(file.isDirectory){awaitfs.rmdirSync(filePath);}else{awaitfs.unlinkSync(filePath);}// 重新加载当前目录this.loadFiles(this.currentPath);}catch(error){console.error(delete failed:${error.message});}}}关键点aboutToAppear里初始加载保证页面创建后自动显示文件列表点击目录时同步更新currentPath和栈视图切换通过State isGridView控制ArkUI 会自动重建组件长按使用onLongPress触发菜单文件图标处理上面用了一个固定图标实际开发中需要根据文件类型显示不同图标。简单做法getFileIcon(name:string):Resource{constextname.split(.).pop()?.toLowerCase();switch(ext){casejpg:casepng:return$r(app.media.ic_image);casemp4:return$r(app.media.ic_video);casepdf:return$r(app.media.ic_pdf);default:return$r(app.media.ic_file);}}踩坑记录问题 1权限问题导致文件列表为空现象真机上运行listFile返回空数组没有报错。原因fileManager.listFile需要声明权限ohos.permission.READ_MEDIA和ohos.permission.WRITE_MEDIA。如果只在 module.json5 里声明没有在代码中动态申请API 会静默失败。解决方案import{abilityAccessCtrl}fromkit.AbilityKit;asyncrequestPermission():Promisevoid{constatManagerabilityAccessCtrl.createAtManager();constgrantsawaitatManager.requestPermissionsFromUser(this.context,[ohos.permission.READ_MEDIA,ohos.permission.WRITE_MEDIA]);if(grants.length0){// 权限被拒绝需要提示用户console.error(permission denied);}}问题 2异步回调与页面生命周期冲突现象快速切换页面时loadFiles返回后设置this.fileList但页面已经销毁导致报错。原因loadFiles是异步函数执行完毕时页面可能已经不在前台。修改State变量会触发无效的 UI 更新甚至崩溃。解决方案privateisActive:booleantrue;aboutToAppear():void{this.isActivetrue;this.loadFiles(this.currentPath);}aboutToDisappear():void{this.isActivefalse;}asyncloadFiles(dirPath:string):Promisevoid{try{constfilesawaitfileManager.listFile(dirPath,{recursive:false});if(this.isActive){this.fileListfiles;}}catch(error){if(this.isActive){console.error(loadFiles failed:${error.message});}}}用isActive标志位控制页面销毁后不再处理回调。最佳实践1. 不要在 build() 中频繁创建 FileList 实例每次组件的 build 被触发如果创建了 FileList 对象会导致不必要的内存分配。建议在aboutToAppear或构造函数中创建。2. 使用 Observed 管理文件数据如果文件列表数据量较大推荐用Observed装饰类避免整个列表都用一个State。这样可以只更新变化的行。3. 异步回调里检查页面状态所有listFile、delete等异步操作结束后务必检查页面是否仍然存活否则更新状态可能引发异常。Demo 入口// Index.etsEntryComponentstruct Index{build(){Stack(){FileSelector()}.width(100%).height(100%)}}FAQQ为什么真机正常模拟器不生效A模拟器的/storage/emulated/0可能不存在或没有充分模拟文件系统。建议使用getExternalStorageDirectory获取正确的存储路径。import{systemCapability}fromkit.AbilityKit;conststorageDirawaitfileManager.getExternalStorageDirectory();Q为什么页面返回后文件列表状态丢失AFileSelector组件被销毁后所有状态自然丢失。如果需要在页面回退后保持状态可以考虑使用Tabs保持在后台或使用AppStorage持久化。Q为什么长按菜单没有弹出A检查onLongPress事件是否被父容器拦截。Grid/List的滑动事件可能抢占触控。可以尝试设置.enableScroll(false)或调整触摸优先级。示例代码地址项目地址