《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.ets
import { photoAccessHelper } from '@kit.MediaLibraryKit';
export class PhotoFileDetail {
uri: string = '';
title: string = '';
description: string = '';
displayName: string = '';
}
export class MediaEditModel {
private context: Context;
private helper: photoAccessHelper.PhotoAccessHelper;
constructor(context: Context) {
this.context = context;
this.helper = photoAccessHelper.getPhotoAccessHelper(context);
}
// 获取文件的当前属性
async getFileDetail(uri: string): Promise<PhotoFileDetail> {
const predicates = photoAccessHelper.MediaFetchOptions.getQueryPredicate(
'FileAsset',
['title', 'description', 'uri', 'display_name']
);
predicates.equalTo('uri', uri);
const fetchResult = await this.helper.getAssets(predicates);
if (fetchResult.getCount() === 0) {
throw new Error('文件未找到');
}
const asset = await fetchResult.getObjectByIndex(0);
const detail = new PhotoFileDetail();
detail.uri = asset.uri;
detail.title = asset.get('title');
detail.description = asset.get('description');
detail.displayName = asset.get('display_name');
fetchResult.close();
return detail;
}
// 修改标题和描述
async setAttributes(uri: string, title: string, description: string): Promise<void> {
const predicates = photoAccessHelper.MediaFetchOptions.getQueryPredicate(
'FileAsset',
['uri']
);
predicates.equalTo('uri', uri);
const fetchResult = await this.helper.getAssets(predicates);
if (fetchResult.getCount() === 0) {
throw new Error('文件未找到');
}
const asset = await fetchResult.getObjectByIndex(0);
// 关键:setAttributes 需要传入 MediaAsset 对象
asset.set('title', title);
asset.set('description', description);
await this.helper.setAttributes(asset);
fetchResult.close();
}
// 重命名
async rename(uri: string, newName: string): Promise<void> {
const predicates = photoAccessHelper.MediaFetchOptions.getQueryPredicate(
'FileAsset',
['uri']
);
predicates.equalTo('uri', uri);
const fetchResult = await this.helper.getAssets(predicates);
if (fetchResult.getCount() === 0) {
throw new Error('文件未找到');
}
const asset = await fetchResult.getObjectByIndex(0);
// 注意:rename 需要传入新文件名(含扩展名)
await this.helper.rename(asset, newName);
fetchResult.close();
}
// 移动到目标相册
async moveToAlbum(sourceUri: string, targetAlbumId: string): Promise<void> {
const sourcePredicates = photoAccessHelper.MediaFetchOptions.getQueryPredicate(
'FileAsset',
['uri']
);
sourcePredicates.equalTo('uri', sourceUri);
const sourceFetchResult = await this.helper.getAssets(sourcePredicates);
if (sourceFetchResult.getCount() === 0) {
throw new Error('文件未找到');
}
const asset = await sourceFetchResult.getObjectByIndex(0);
// 获取目标相册
const albumPredicates = photoAccessHelper.MediaFetchOptions.getQueryPredicate(
'Album',
['album_id']
);
albumPredicates.equalTo('album_id', targetAlbumId);
const albumFetchResult = await this.helper.getAlbums(albumPredicates);
if (albumFetchResult.getCount() === 0) {
throw new Error('目标相册未找到');
}
const targetAlbum = await albumFetchResult.getObjectByIndex(0);
await this.helper.move(asset, targetAlbum);
sourceFetchResult.close();
albumFetchResult.close();
}
}
注意事项:
setAttributes传的是MediaAsset对象,而不是直接传属性值。很多人在这里出错,以为可以传一个 Map 进去。rename要求的新文件名必须包含文件扩展名(例如.jpg),否则会导致文件无法访问。move需要目标相册的albumId,不能用相册名称直接匹配。
2. UI 编辑页面
// pages/PhotoEditPage.ets
import { MediaEditModel, PhotoFileDetail } from '../models/MediaEditModel.ets';
import { photoAccessHelper } from '@kit.MediaLibraryKit';
@Entry
@Component
struct PhotoEditPage {
@State fileDetail: PhotoFileDetail = new PhotoFileDetail();
@State editTitle: string = '';
@State editDescription: string = '';
@State editDisplayName: string = '';
@State isSaving: boolean = false;
@State sourceUri: string = '';
private editModel: MediaEditModel = new MediaEditModel(getContext());
aboutToAppear() {
// 从路由参数获取 sourceUri
const params = router.getParams() as Record<string, string>;
if (params && params['sourceUri']) {
this.sourceUri = params['sourceUri'];
this.loadDetail();
}
}
async loadDetail() {
try {
const detail = await this.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 {
await this.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 {
await this.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 {
await this.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 不会变)。
// 重命名后延迟刷新
await this.editModel.rename(this.sourceUri, this.editDisplayName);
await new Promise(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 同步这两个问题。官方文档对这个行为描述得比较简单,建议结合实际运行效果一起验证。
更多推荐



所有评论(0)