在这里插入图片描述

文件操作进阶:不只是存,还要管

很多人在用 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) 及以上
目标设备:手机

核心实现:一个编辑界面

这个编辑界面包含:

  1. 显示当前图片的标题和描述
  2. 允许用户修改标题和描述(调用 setAttributes)
  3. 支持重命名文件(调用 rename)
  4. 支持将文件移动到目标相册(调用 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 只是修改了磁盘上的元数据,不会自动触发前一个页面的状态更新。因为前一个页面持有的数据还是老的,它不知道数据变了。

解决方案:
修改成功后,需要显式通知列表页面刷新。常见做法是:

  1. 在编辑页修改成功后,通过路由传一个 needRefresh: true 标志回去。
  2. 列表页的 aboutToAppear 判断这个标志,重新 fetch 数据。
  3. 或者用状态管理库(如 Observable)共享数据。
// 编辑页修改成功后
router.back({ url: 'pages/AlbumPage', params: { needRefresh: true } });

常见问题 2:重命名后文件“消失”

现象:
执行 rename 后,列表里找不到这个文件了。但是用文件管理器去看,文件名确实改了。

原因:
rename 改的是文件名,但 Media Library Kit 的查询逻辑可能依赖于文件名的匹配索引。如果重命名后的文件名不符合 Media Library 的索引规则(比如改了扩展名),或者索引没有及时更新,查询结果就会为空。

解法:

  1. 保持文件名正确:rename 的新名必须包含原文件格式的扩展名。
  2. 重命名后等待一小段时间(建议 100ms)再刷新查询,让索引更新。
  3. 如果仍然找不到,可以尝试用 uri 查询(uri 不会变)。
// 重命名后延迟刷新
await this.editModel.rename(this.sourceUri, this.editDisplayName);
await new Promise(resolve => setTimeout(resolve, 200));
// 然后刷新列表

最佳实践

  1. 不要依赖名称查询移动后的文件。 move 操作会改变文件的物理位置,但 uri 不变。始终用 uri 作为唯一标识,不要用文件名或路径。

  2. 批量操作时控制并发。 如果有 10 个文件要重命名,不要同时发 10 个异步请求。建议串行执行或限流(比如一次 3 个),因为 Media Library Kit 的写操作有隐式锁,并发太高容易导致死锁或失败。

  3. 每次操作后主动释放资源。 fetchResult 使用完后必须调用 close(),否则会占用底层句柄,导致后续操作报错 ERR_RESOURCE_NOT_AVAILABLE


FAQ

Q:为什么真机测试正常,模拟器上 setAttributes 没有效果?
A:模拟器上的 Media Library Kit 底层存储实现不同,有些属性(如 description)可能不支持写入。建议始终以真机为准。

Q:页面上改了标题和描述,点了“修改属性”后,为什么输入框里的内容又变回原来的?
A:你没有在成功回调里更新 @State 绑定的变量。比如 setAttributes 成功后,你应该把 this.editTitlethis.editDescription 也赋值为新值,否则 UI 会保持上次渲染的结果。

Q:移动文件后,原相册列表没有自动移除这个文件?
A:UI 层需要主动刷新。建议使用 @Observed 装饰的列表数据,移动成功后手动从列表中移除这一项,然后调用 Listrefresh 方法刷新显示。


如果你也在写类似的文件管理功能,建议先把这篇文章里提到的三个 API 在真机上跑一遍,重点观察重命名后索引更新和移动后 UI 同步这两个问题。官方文档对这个行为描述得比较简单,建议结合实际运行效果一起验证。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐