HarmonyOS技术精讲-Camera Kit(相机服务)第18篇:媒体库集成与管理

在这里插入图片描述

拍完的照片究竟藏哪了?

HarmonyOS NEXT 开发里,Camera Kit 本身只管拍照和录像,不负责文件管理。这意味着你拍完一张照片,如果不去主动保存到媒体库,它就是个临时文件,系统相册里根本看不到。

很多人第一次对接这个链路时,会习惯性地调用 save() 方法保存到应用沙箱,然后发现系统相册里找不到,用户会直接干懵。这个问题在应用市场反馈里非常常见。

这里的关键在于:Camera Kit 只管拍摄,MediaLibrary Kit 才管文件的持久化存储和系统索引。 两者是上下游关系,不是同层关系。

本文通过一个带管理功能的相册页面,完整演示从拍摄到存储、从查询到删除的全链路。

环境说明

DevEco Studio 版本:DevEco Studio 6.1.0 及以上
HarmonyOS SDK 版本:HarmonyOS 6.1.0(23) 及以上
目标设备:手机

核心实现

整个功能分两部分:相册页面展示和管理逻辑。下面按步骤拆解。

步骤 1:权限申请

MediaLibrary Kit 的操作需要 ohos.permission.READ_MEDIAohos.permission.WRITE_MEDIA 权限。在 module.json5 中声明:

{
  "module": {
    "requestPermissions": [
      {
        "name": "ohos.permission.CAMERA",
        "reason": "用于拍照"
      },
      {
        "name": "ohos.permission.READ_MEDIA",
        "reason": "用于读取媒体文件"
      },
      {
        "name": "ohos.permission.WRITE_MEDIA",
        "reason": "用于保存媒体文件"
      }
    ]
  }
}

注意:READ_MEDIAWRITE_MEDIA 是用户授权类权限,真机和模拟器上都需要弹窗确认。

步骤 2:创建媒体文件并保存

拍照后,调用 photoOutput.capture() 得到 Photo 对象。然后通过 MediaLibrary API 创建媒体 Asset 并写入数据。

import { photoAccessHelper } from '@kit.MediaLibraryKit';
import { image } from '@kit.ImageKit';
import { photoOutput } from '@kit.CameraKit';

async function savePhoto(photo: photoOutput.Photo): Promise<string> {
  // 获取照片的 Buffer 数据
  const imageSource: image.ImageSource = image.createImageSource(photo.main.uri);
  const pixelMap: image.PixelMap = await imageSource.createPixelMap();
  const buffer: ArrayBuffer = await pixelMap.readPixelsToBuffer();

  // 创建媒体库实例
  const context: Context = getContext(this);
  const mediaLibrary: photoAccessHelper.PhotoAccessHelper = photoAccessHelper.getPhotoAccessHelper(context);

  // 创建媒体文件,指定类型为图片
  const uri: string = await mediaLibrary.createAsset(photoAccessHelper.PhotoType.IMAGE, 'jpg');
  const file: fileIo.File = await fileIo.open(uri, fileIo.OpenMode.WRITE_ONLY);
  await fileIo.write(file.fd, buffer);
  await fileIo.close(file);

  return uri;
}

几个关键点:

  1. createAsset 会先在媒体库中创建一个 Asset 记录,返回一个 URI。这个 URI 可以理解为系统相册的引用点。
  2. 写入数据时一定要用 WRITE_ONLY 模式,如果传 READ_WRITE 会导致写入失败。
  3. 写入完成必须 close,否则文件句柄泄露。

步骤 3:查询相册列表

查询已保存的照片,用于相册页面展示。这里使用 getAssets 方法,配合 FetchOptions 进行筛选。

import { photoAccessHelper } from '@kit.MediaLibraryKit';

async function queryPhotos(): Promise<photoAccessHelper.PhotoAsset[]> {
  const context: Context = getContext(this);
  const mediaLibrary: photoAccessHelper.PhotoAccessHelper = photoAccessHelper.getPhotoAccessHelper(context);

  // 设置查询条件:只查询图片类型,按日期倒序
  const fetchOptions: photoAccessHelper.FetchOptions = {
    selections: `${photoAccessHelper.PhotoKeys.PHOTO_TYPE} = ?`,
    selectionArgs: [photoAccessHelper.PhotoType.IMAGE.toString()],
    order: 'date_added DESC' // 按添加时间降序
  };

  const fetchResult: photoAccessHelper.FetchResult<photoAccessHelper.PhotoAsset> = 
    await mediaLibrary.getAssets(fetchOptions);

  const assets: photoAccessHelper.PhotoAsset[] = [];
  // 遍历结果集
  for (let i = 0; i < fetchResult.count; i++) {
    const asset: photoAccessHelper.PhotoAsset = await fetchResult.getObjectByIndex(i);
    assets.push(asset);
  }
  return assets;
}

这里有一个容易忽略的地方:order 参数的值必须严格按照 PhotoKeys 定义的字符串写,否则查询会静默失败(返回空结果集)。

步骤 4:删除操作

删除媒体文件需要先获取 Asset 对象,然后调用 deleteAssets

import { photoAccessHelper } from '@kit.MediaLibraryKit';

async function deletePhoto(uri: string): Promise<void> {
  const context: Context = getContext(this);
  const mediaLibrary: photoAccessHelper.PhotoAccessHelper = photoAccessHelper.getPhotoAccessHelper(context);

  // 通过 URI 查询到具体 Asset
  const fetchOptions: photoAccessHelper.FetchOptions = {
    selections: `${photoAccessHelper.PhotoKeys.URI} = ?`,
    selectionArgs: [uri]
  };
  const fetchResult: photoAccessHelper.FetchResult<photoAccessHelper.PhotoAsset> = 
    await mediaLibrary.getAssets(fetchOptions);
  const asset: photoAccessHelper.PhotoAsset = await fetchResult.getObjectByIndex(0);

  // 删除,支持批量删除,这里只删除一个
  await mediaLibrary.deleteAssets([asset]);
}

deleteAssets 接收的是一个数组,意味着可以批量删除。但需要注意,删除操作会同时删除系统相册中的文件,无法通过应用侧恢复。

步骤 5:完整相册列表页面

把上面步骤串起来,做一个带管理功能的页面。

import { photoAccessHelper } from '@kit.MediaLibraryKit';

@Entry
@Component
struct AlbumPage {
  @State photoAssets: photoAccessHelper.PhotoAsset[] = [];

  aboutToAppear(): void {
    this.loadPhotos();
  }

  async loadPhotos(): Promise<void> {
    try {
      const assets: photoAccessHelper.PhotoAsset[] = await queryPhotos();
      this.photoAssets = assets;
    } catch (error) {
      console.error('查询相册失败: ' + JSON.stringify(error));
    }
  }

  build() {
    Column() {
      List({ space: 10 }) {
        ForEach(this.photoAssets, (asset: photoAccessHelper.PhotoAsset) => {
          ListItem() {
            Row() {
              Image(asset.uri)
                .width('40%')
                .aspectRatio(1)
                .objectFit(ImageFit.Cover)
                .onClick(async () => {
                  // 点击查看大图(此处可扩展)
                })

              Column() {
                Text(asset.title)
                  .fontSize(14)
                Text(asset.dateAdded.toString())
                  .fontSize(12)
                  .fontColor(Color.Gray)

                Button('删除')
                  .type(ButtonType.Normal)
                  .onClick(async () => {
                    await deletePhoto(asset.uri);
                    // 删除后刷新列表
                    await this.loadPhotos();
                  })
              }
              .padding({ left: 10 })
            }
            .width('100%')
          }
        }, (asset: photoAccessHelper.PhotoAsset) => asset.uri)
      }
      .width('100%')
    }
    .padding(10)
  }
}

为什么这里用 ForEach 而不是 LazyForEach 因为相册照片数量通常不会太长(几百张以内),用 ForEach 够用。如果业务上有大量图片(比如图库应用),换成 LazyForEach 避免一次性渲染所有列表项。

常见问题 1:删除后相册不刷新

现象: 调用 deleteAssets 成功后,再次查询依然能看到已删除的照片。

原因: 媒体库的文件删除是异步完成的。deleteAssets 返回只表示删请求已提交,不代表物理删除已完成。此时立即查询,结果可能包含尚未清理的缓存。

解决方案: 删除成功后,等待 500ms-1s 再重新查询;或者监听媒体库的变化事件(on('albumChange')),但监听回调需要额外管理生命周期,容易内存泄漏。

推荐做法是删除后延迟刷新:

async function deletePhotoAndRefresh(uri: string): Promise<void> {
  await deletePhoto(uri);
  // 延迟 1 秒以保证删除生效
  await new Promise(resolve => setTimeout(resolve, 1000));
  await loadPhotos();
}

常见问题 2:权限弹窗被拒绝后无法恢复

现象: 用户第一次拒绝权限弹窗后,后续再请求不会再弹窗,导致媒体库操作全部失败。

原因: HarmonyOS 对敏感权限的弹窗策略是:用户拒绝一次后,系统不会重复弹窗,需要用户手动到设置中开启。

解决方案: 在权限被拒绝后,给用户一个明确的提示,引导去设置页手动开启。

import { abilityAccessCtrl } from '@kit.AbilityKit';

async function requestPermission(context: Context): Promise<boolean> {
  const atManager: abilityAccessCtrl.AtManager = abilityAccessCtrl.createAtManager();
  const result: number = await atManager.requestPermissionsFromUser(context, [
    'ohos.permission.READ_MEDIA',
    'ohos.permission.WRITE_MEDIA'
  ]);
  if (result === abilityAccessCtrl.GrantStatus.PERMISSION_DENIED) {
    // 引导用户去设置页
    AlertDialog.show({
      title: '权限被拒绝',
      message: '请在系统设置中手动开启媒体库读写权限',
      confirm: {
        value: '去设置',
        action: () => {
          // 调用跳转系统设置页面的 API
        }
      }
    });
    return false;
  }
  return true;
}

最佳实践

  1. 不要在 build() 中创建 MediaLibrary 实例。 photoAccessHelper.getPhotoAccessHelper 是一个同步 API,放在 aboutToAppearonPageShow 中初始化,避免重复创建。

  2. 异步操作记得 try/catch 媒体库的读写可能因为存储空间不足、文件损坏等异常而失败。建议对每个关键步骤加异常处理,至少日志输出便于排查。

  3. 状态同步用 @State 响应,不要手动赋值。 上面示例中 this.photoAssets@State 修饰的,修改后会触发 UI 刷新。如果直接 let assets = this.photoAssets 然后 assets.push,是不会刷新列表的。

FAQ

Q:查询返回的 URI 是 content:// 格式,为什么用 Image 组件能直接显示?
A:HarmonyOS 的 Image 组件支持 content:// 协议开头的内容 URI,会自动解析并加载图片。不需要手动转换为文件路径。

Q:删除照片后,系统相册中的应用图标也会消失吗?
A:不会。删除文件只会删除该媒体文件本身,应用在系统相册中的项目依然存在,只是无法打开预览。

总结

MediaLibrary Kit 是 Camera Kit 的必备搭档。看似简单的增删查,实际上隐藏着权限状态、查询条件、异步延迟这些坑。实际项目中建议统一封装一个媒体管理服务,把查询、删除、刷新逻辑收敛到同一个 DataService 中,避免页面直接操作媒体库实例。

Logo

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

更多推荐