《HarmonyOS技术精讲-Media Library Kit》之文件操作进阶
文件操作进阶不只是存还要管很多人在用 HarmonyOS 的 Media Library Kit 时都停留在“能存能读”的阶段。但实际开发中对已存在媒体文件的精细控制——比如修改图片的标题和描述、给文件改名、把一张照片从一个相册移动到另一个相册——才是高频需求。这种场景非常多。例如一个用户相册管理功能用户想给某张照片加个说明或者想把一堆截图统一改名为“工作截图_01”这种格式。再或者一个自定义相册整理工具需要把“待处理”相册里的照片移动到“已归档”相册。这些操作看起来不大但涉及的是 Media Library Kit 的文件级操作能力具体就是三个 APIsetAttributes修改元数据、rename重命名、move移动资源。这篇文章就拿一个完整的编辑界面作为例子把这三个操作走一遍。它解决什么问题操作解决的问题适用场景setAttributes修改文件的标题、描述等元数据用户给图片加备注、编辑相册名称rename改变文件在磁盘上的名称批量重命名、修复无效文件名move将文件从一个相册/目录移到另一个相册分类整理、垃圾箱功能这三个操作是配合使用的。比如重命名时你可能也想同时更新文件的title属性移动文件后需要刷新前一个相册的列表。如果只改其中一个经常会导致 UI 状态不同步这是最容易踩坑的地方。环境说明DevEco Studio 版本DevEco Studio 6.1.0 及以上 HarmonyOS SDK 版本HarmonyOS 6.1.0(23) 及以上 目标设备手机核心实现一个编辑界面这个编辑界面包含显示当前图片的标题和描述允许用户修改标题和描述调用 setAttributes支持重命名文件调用 rename支持将文件移动到目标相册调用 move代码分两个文件数据模型 UI 页面。1. 数据模型与状态管理// models/MediaEditModel.etsimport{photoAccessHelper}fromkit.MediaLibraryKit;exportclassPhotoFileDetail{uri:string;title:string;description:string;displayName:string;}exportclassMediaEditModel{privatecontext:Context;privatehelper:photoAccessHelper.PhotoAccessHelper;constructor(context:Context){this.contextcontext;this.helperphotoAccessHelper.getPhotoAccessHelper(context);}// 获取文件的当前属性asyncgetFileDetail(uri:string):PromisePhotoFileDetail{constpredicatesphotoAccessHelper.MediaFetchOptions.getQueryPredicate(FileAsset,[title,description,uri,display_name]);predicates.equalTo(uri,uri);constfetchResultawaitthis.helper.getAssets(predicates);if(fetchResult.getCount()0){thrownewError(文件未找到);}constassetawaitfetchResult.getObjectByIndex(0);constdetailnewPhotoFileDetail();detail.uriasset.uri;detail.titleasset.get(title);detail.descriptionasset.get(description);detail.displayNameasset.get(display_name);fetchResult.close();returndetail;}// 修改标题和描述asyncsetAttributes(uri:string,title:string,description:string):Promisevoid{constpredicatesphotoAccessHelper.MediaFetchOptions.getQueryPredicate(FileAsset,[uri]);predicates.equalTo(uri,uri);constfetchResultawaitthis.helper.getAssets(predicates);if(fetchResult.getCount()0){thrownewError(文件未找到);}constassetawaitfetchResult.getObjectByIndex(0);// 关键setAttributes 需要传入 MediaAsset 对象asset.set(title,title);asset.set(description,description);awaitthis.helper.setAttributes(asset);fetchResult.close();}// 重命名asyncrename(uri:string,newName:string):Promisevoid{constpredicatesphotoAccessHelper.MediaFetchOptions.getQueryPredicate(FileAsset,[uri]);predicates.equalTo(uri,uri);constfetchResultawaitthis.helper.getAssets(predicates);if(fetchResult.getCount()0){thrownewError(文件未找到);}constassetawaitfetchResult.getObjectByIndex(0);// 注意rename 需要传入新文件名含扩展名awaitthis.helper.rename(asset,newName);fetchResult.close();}// 移动到目标相册asyncmoveToAlbum(sourceUri:string,targetAlbumId:string):Promisevoid{constsourcePredicatesphotoAccessHelper.MediaFetchOptions.getQueryPredicate(FileAsset,[uri]);sourcePredicates.equalTo(uri,sourceUri);constsourceFetchResultawaitthis.helper.getAssets(sourcePredicates);if(sourceFetchResult.getCount()0){thrownewError(文件未找到);}constassetawaitsourceFetchResult.getObjectByIndex(0);// 获取目标相册constalbumPredicatesphotoAccessHelper.MediaFetchOptions.getQueryPredicate(Album,[album_id]);albumPredicates.equalTo(album_id,targetAlbumId);constalbumFetchResultawaitthis.helper.getAlbums(albumPredicates);if(albumFetchResult.getCount()0){thrownewError(目标相册未找到);}consttargetAlbumawaitalbumFetchResult.getObjectByIndex(0);awaitthis.helper.move(asset,targetAlbum);sourceFetchResult.close();albumFetchResult.close();}}注意事项setAttributes传的是MediaAsset对象而不是直接传属性值。很多人在这里出错以为可以传一个 Map 进去。rename要求的新文件名必须包含文件扩展名例如.jpg否则会导致文件无法访问。move需要目标相册的albumId不能用相册名称直接匹配。2. UI 编辑页面// pages/PhotoEditPage.etsimport{MediaEditModel,PhotoFileDetail}from../models/MediaEditModel.ets;import{photoAccessHelper}fromkit.MediaLibraryKit;EntryComponentstruct PhotoEditPage{StatefileDetail:PhotoFileDetailnewPhotoFileDetail();StateeditTitle:string;StateeditDescription:string;StateeditDisplayName:string;StateisSaving:booleanfalse;StatesourceUri:string;privateeditModel:MediaEditModelnewMediaEditModel(getContext());aboutToAppear(){// 从路由参数获取 sourceUriconstparamsrouter.getParams()asRecordstring,string;if(paramsparams[sourceUri]){this.sourceUriparams[sourceUri];this.loadDetail();}}asyncloadDetail(){try{constdetailawaitthis.editModel.getFileDetail(this.sourceUri);this.fileDetaildetail;this.editTitledetail.title;this.editDescriptiondetail.description;this.editDisplayNamedetail.displayName;}catch(error){// 简单处理console.error(加载文件详情失败,JSON.stringify(error));}}build(){Column(){// 标题栏Text(编辑照片信息).fontSize(20).fontWeight(FontWeight.Bold).margin({bottom:20})// 标题输入TextInput({placeholder:请输入标题,text:this.editTitle}).onChange((value:string){this.editTitlevalue;}).margin({bottom:12})// 描述输入TextArea({placeholder:请输入描述,text:this.editDescription}).onChange((value:string){this.editDescriptionvalue;}).height(100).margin({bottom:12})// 重命名输入TextInput({placeholder:新文件名含后缀,text:this.editDisplayName}).onChange((value:string){this.editDisplayNamevalue;}).margin({bottom:12})// 操作按钮组Row(){Button(修改属性).onClick(async(){if(this.isSaving)return;this.isSavingtrue;try{awaitthis.editModel.setAttributes(this.sourceUri,this.editTitle,this.editDescription);// 刷新显示this.fileDetail.titlethis.editTitle;this.fileDetail.descriptionthis.editDescription;}catch(error){console.error(修改属性失败,JSON.stringify(error));}finally{this.isSavingfalse;}}).margin({right:8})Button(重命名).onClick(async(){if(this.isSaving)return;this.isSavingtrue;try{awaitthis.editModel.rename(this.sourceUri,this.editDisplayName);// 重命名后 uri 不变但 display_name 变了// 注意刷新列表}catch(error){console.error(重命名失败,JSON.stringify(error));}finally{this.isSavingfalse;}}).margin({right:8})Button(移动相册).onClick(async(){// 这个按钮的完整功能需要弹窗选择目标相册// 这里简化弹出一个 picker 或者对话框// 真实场景中需要获取相册列表// 示例中假定目标相册 ID 为 album_123if(this.isSaving)return;this.isSavingtrue;try{awaitthis.editModel.moveToAlbum(this.sourceUri,album_123);// 移动成功后当前页面应该返回因为文件不在原相册了router.back();}catch(error){console.error(移动失败,JSON.stringify(error));}finally{this.isSavingfalse;}})}.width(100%).justifyContent(FlexAlign.SpaceAround)// 当前文件信息if(this.fileDetail.uri){Text(当前路径:${this.fileDetail.uri}).fontSize(12).fontColor(Color.Gray).margin({top:20})}}.padding(16).width(100%).height(100%)}}几点说明aboutToAppear时从路由参数获取sourceUri保证页面复用性。每个按钮都加了isSaving锁定防止并发操作。移动成功后直接router.back()因为文件已经不在当前相册UI 需要刷新。常见问题 1修改属性后 UI 不刷新现象调用setAttributes修改了标题和描述返回上一页再回来看到的是旧数据。或者当前页面的输入框已经变了但列表页不刷新。原因setAttributes只是修改了磁盘上的元数据不会自动触发前一个页面的状态更新。因为前一个页面持有的数据还是老的它不知道数据变了。解决方案修改成功后需要显式通知列表页面刷新。常见做法是在编辑页修改成功后通过路由传一个needRefresh: true标志回去。列表页的aboutToAppear判断这个标志重新 fetch 数据。或者用状态管理库如 Observable共享数据。// 编辑页修改成功后router.back({url:pages/AlbumPage,params:{needRefresh:true}});常见问题 2重命名后文件“消失”现象执行rename后列表里找不到这个文件了。但是用文件管理器去看文件名确实改了。原因rename改的是文件名但 Media Library Kit 的查询逻辑可能依赖于文件名的匹配索引。如果重命名后的文件名不符合 Media Library 的索引规则比如改了扩展名或者索引没有及时更新查询结果就会为空。解法保持文件名正确rename的新名必须包含原文件格式的扩展名。重命名后等待一小段时间建议 100ms再刷新查询让索引更新。如果仍然找不到可以尝试用uri查询uri 不会变。// 重命名后延迟刷新awaitthis.editModel.rename(this.sourceUri,this.editDisplayName);awaitnewPromise(resolvesetTimeout(resolve,200));// 然后刷新列表最佳实践不要依赖名称查询移动后的文件。move操作会改变文件的物理位置但uri不变。始终用uri作为唯一标识不要用文件名或路径。批量操作时控制并发。如果有 10 个文件要重命名不要同时发 10 个异步请求。建议串行执行或限流比如一次 3 个因为 Media Library Kit 的写操作有隐式锁并发太高容易导致死锁或失败。每次操作后主动释放资源。fetchResult使用完后必须调用close()否则会占用底层句柄导致后续操作报错ERR_RESOURCE_NOT_AVAILABLE。FAQQ为什么真机测试正常模拟器上setAttributes没有效果A模拟器上的 Media Library Kit 底层存储实现不同有些属性如description可能不支持写入。建议始终以真机为准。Q页面上改了标题和描述点了“修改属性”后为什么输入框里的内容又变回原来的A你没有在成功回调里更新State绑定的变量。比如setAttributes成功后你应该把this.editTitle和this.editDescription也赋值为新值否则 UI 会保持上次渲染的结果。Q移动文件后原相册列表没有自动移除这个文件AUI 层需要主动刷新。建议使用Observed装饰的列表数据移动成功后手动从列表中移除这一项然后调用List的refresh方法刷新显示。如果你也在写类似的文件管理功能建议先把这篇文章里提到的三个 API 在真机上跑一遍重点观察重命名后索引更新和移动后 UI 同步这两个问题。官方文档对这个行为描述得比较简单建议结合实际运行效果一起验证。