《HarmonyOS技术精讲-Media Library Kit》之文件操作进阶
文件操作进阶:不只是存,还要管
很多人在用 HarmonyOS 的 Media Library Kit 时,都停留在“能存能读”的阶段。但实际开发中,对已存在媒体文件的精细控制——比如修改图片的标题和描述、给文件改名、把一张照片从一个相册移动到另一个相册——才是高频需求。
这种场景非常多。例如一个用户相册管理功能,用户想给某张照片加个说明,或者想把一堆截图统一改名为“工作截图_01”这种格式。再或者一个自定义相册整理工具,需要把“待处理”相册里的照片移动到“已归档”相册。
这些操作看起来不大,但涉及的是 Media Library Kit 的文件级操作能力,具体就是三个 API:setAttributes(修改元数据)、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}from'@kit.MediaLibraryKit';exportclassPhotoFileDetail{uri:string='';title:string='';description:string='';displayName:string='';}exportclassMediaEditModel{privatecontext:Context;privatehelper:photoAccessHelper.PhotoAccessHelper;constructor(context:Context){this.context=context;this.helper=photoAccessHelper.getPhotoAccessHelper(context);}// 获取文件的当前属性asyncgetFileDetail(uri:string):Promise<PhotoFileDetail>{constpredicates=photoAccessHelper.MediaFetchOptions.getQueryPredicate('FileAsset',['title','description','uri','display_name']);predicates.equalTo('uri',uri);constfetchResult=awaitthis.helper.getAssets(predicates);if(fetchResult.getCount()===0){thrownewError('文件未找到');}constasset=awaitfetchResult.getObjectByIndex(0);constdetail=newPhotoFileDetail();detail.uri=asset.uri;detail.title=asset.get('title');detail.description=asset.get('description');detail.displayName=asset.get('display_name');fetchResult.close();returndetail;}// 修改标题和描述asyncsetAttributes(uri:string,title:string,description:string):Promise<void>{constpredicates=photoAccessHelper.MediaFetchOptions.getQueryPredicate('FileAsset',['uri']);predicates.equalTo('uri',uri);constfetchResult=awaitthis.helper.getAssets(predicates);if(fetchResult.getCount()===0){thrownewError('文件未找到');}constasset=awaitfetchResult.getObjectByIndex(0);// 关键:setAttributes 需要传入 MediaAsset 对象asset.set('title',title);asset.set('description',description);awaitthis.helper.setAttributes(asset);fetchResult.close();}// 重命名asyncrename(uri:string,newName:string):Promise<void>{constpredicates=photoAccessHelper.MediaFetchOptions.getQueryPredicate('FileAsset',['uri']);predicates.equalTo('uri',uri);constfetchResult=awaitthis.helper.getAssets(predicates);if(fetchResult.getCount()===0){thrownewError('文件未找到');}constasset=awaitfetchResult.getObjectByIndex(0);// 注意:rename 需要传入新文件名(含扩展名)awaitthis.helper.rename(asset,newName);fetchResult.close();}// 移动到目标相册asyncmoveToAlbum(sourceUri:string,targetAlbumId:string):Promise<void>{constsourcePredicates=photoAccessHelper.MediaFetchOptions.getQueryPredicate('FileAsset',['uri']);sourcePredicates.equalTo('uri',sourceUri);constsourceFetchResult=awaitthis.helper.getAssets(sourcePredicates);if(sourceFetchResult.getCount()===0){thrownewError('文件未找到');}constasset=awaitsourceFetchResult.getObjectByIndex(0);// 获取目标相册constalbumPredicates=photoAccessHelper.MediaFetchOptions.getQueryPredicate('Album',['album_id']);albumPredicates.equalTo('album_id',targetAlbumId);constalbumFetchResult=awaitthis.helper.getAlbums(albumPredicates);if(albumFetchResult.getCount()===0){thrownewError('目标相册未找到');}consttargetAlbum=awaitalbumFetchResult.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}from'@kit.MediaLibraryKit';@Entry@Componentstruct PhotoEditPage{@StatefileDetail:PhotoFileDetail=newPhotoFileDetail();@StateeditTitle:string='';@StateeditDescription:string='';@StateeditDisplayName:string='';@StateisSaving:boolean=false;@StatesourceUri:string='';privateeditModel:MediaEditModel=newMediaEditModel(getContext());aboutToAppear(){// 从路由参数获取 sourceUriconstparams=router.getParams()asRecord<string,string>;if(params&¶ms['sourceUri']){this.sourceUri=params['sourceUri'];this.loadDetail();}}asyncloadDetail(){try{constdetail=awaitthis.editModel.getFileDetail(this.sourceUri);this.fileDetail=detail;this.editTitle=detail.title;this.editDescription=detail.description;this.editDisplayName=detail.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.editTitle=value;}).margin({bottom:12})// 描述输入TextArea({placeholder:'请输入描述',text:this.editDescription}).onChange((value:string)=>{this.editDescription=value;}).height(100).margin({bottom:12})// 重命名输入TextInput({placeholder:'新文件名(含后缀)',text:this.editDisplayName}).onChange((value:string)=>{this.editDisplayName=value;}).margin({bottom:12})// 操作按钮组Row(){Button('修改属性').onClick(async()=>{if(this.isSaving)return;this.isSaving=true;try{awaitthis.editModel.setAttributes(this.sourceUri,this.editTitle,this.editDescription);// 刷新显示this.fileDetail.title=this.editTitle;this.fileDetail.description=this.editDescription;}catch(error){console.error('修改属性失败',JSON.stringify(error));}finally{this.isSaving=false;}}).margin({right:8})Button('重命名').onClick(async()=>{if(this.isSaving)return;this.isSaving=true;try{awaitthis.editModel.rename(this.sourceUri,this.editDisplayName);// 重命名后 uri 不变,但 display_name 变了// 注意刷新列表}catch(error){console.error('重命名失败',JSON.stringify(error));}finally{this.isSaving=false;}}).margin({right:8})Button('移动相册').onClick(async()=>{// 这个按钮的完整功能需要弹窗选择目标相册// 这里简化:弹出一个 picker 或者对话框// 真实场景中需要获取相册列表// 示例中假定目标相册 ID 为 "album_123"if(this.isSaving)return;this.isSaving=true;try{awaitthis.editModel.moveToAlbum(this.sourceUri,'album_123');// 移动成功后,当前页面应该返回,因为文件不在原相册了router.back();}catch(error){console.error('移动失败',JSON.stringify(error));}finally{this.isSaving=false;}})}.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(resolve=>setTimeout(resolve,200));// 然后刷新列表最佳实践
不要依赖名称查询移动后的文件。
move操作会改变文件的物理位置,但uri不变。始终用uri作为唯一标识,不要用文件名或路径。批量操作时控制并发。如果有 10 个文件要重命名,不要同时发 10 个异步请求。建议串行执行或限流(比如一次 3 个),因为 Media Library Kit 的写操作有隐式锁,并发太高容易导致死锁或失败。
每次操作后主动释放资源。
fetchResult使用完后必须调用close(),否则会占用底层句柄,导致后续操作报错ERR_RESOURCE_NOT_AVAILABLE。
FAQ
Q:为什么真机测试正常,模拟器上setAttributes没有效果?
A:模拟器上的 Media Library Kit 底层存储实现不同,有些属性(如description)可能不支持写入。建议始终以真机为准。
Q:页面上改了标题和描述,点了“修改属性”后,为什么输入框里的内容又变回原来的?
A:你没有在成功回调里更新@State绑定的变量。比如setAttributes成功后,你应该把this.editTitle和this.editDescription也赋值为新值,否则 UI 会保持上次渲染的结果。
Q:移动文件后,原相册列表没有自动移除这个文件?
A:UI 层需要主动刷新。建议使用@Observed装饰的列表数据,移动成功后手动从列表中移除这一项,然后调用List的refresh方法刷新显示。
如果你也在写类似的文件管理功能,建议先把这篇文章里提到的三个 API 在真机上跑一遍,重点观察重命名后索引更新和移动后 UI 同步这两个问题。官方文档对这个行为描述得比较简单,建议结合实际运行效果一起验证。
