在这里插入图片描述

从“读取不了相册”到“自动刷新列表”:Media Library Kit 的真实坑与解法

很多人在 HarmonyOS NEXT 开发里第一次接触 Media Library Kit 时,会发现官方示例能跑通,能读到图片、视频。但一放到实际项目里,问题就来了:权限弹出之后点“禁止”怎么办?用户在相册里删了一张图,应用列表为什么不刷新?多删几张,列表直接白屏加报错 MEDIA_LIBRARY_ERROR。这些都不是编的,是反复出现的问题。

本文就针对这几个高频痛点,提供一个能直接运行的、自动监听并刷新媒体列表的 Demo,并把权限适配、状态同步、内存释放这几个节点的正确做法拆开讲清楚。

它解决什么问题,适合什么场景

Media Library Kit 是 HarmonyOS 提供的统一媒体文件管理服务。它封装了对相册、音频、视频的读写操作,并且提供了 onMediaChangeonAlbumChange 这类的监听接口。

适合的场景:

  • 相册/文件管理类 App
  • 应用内预览媒体文件
  • 媒体选择器

不适合的场景:

  • 大量非媒体文件的目录遍历(这种情况直接走文件系统更合适)
  • 纯流媒体播放场景(不需要管文件管理)

跟直接操作文件系统相比,Media Library Kit 的最大优势是提供了“资源变更通知”,这是 saveFile + 定时扫描做不到的。

环境说明

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

核心实现

这一段实现一个页面:进入页面后申请权限,权限通过后读取媒体文件并展示列表,同时注册监听器,当相册有新文件或文件被删除时,自动刷新列表。

1. 权限模型

HarmonyOS NEXT 的权限模型有一个关键变化:不再像老版本那样在 config.json 里声明一次就行。NEXT 要求运行时动态申请,并且用户可以选择“仅本次允许”或者“禁止”。App 不能假设权限一定会被通过。

所以第一步,代码里必须处理权限被拒的情况。

import { abilityAccessCtrl, bundleManager, common, Permissions } from '@kit.AbilityKit';
import { BusinessError } from '@kit.BasicServicesKit';

@Entry
@Component
struct MediaListPage {
  @State mediaList: Array<MediaData> = []; // 媒体文件列表
  private context = getContext(this) as common.UIAbilityContext;

  aboutToAppear() {
    this.requestPermission();
  }

  async requestPermission(): Promise<boolean> {
    const permissions: Array<Permissions> = ['ohos.permission.READ_MEDIA'];
    // 额外提示:API 版本不同,权限声明方式可能不同,建议统一动态申请
    const bundleInfo = bundleManager.getBundleInfoForSelfSync(bundleManager.BundleFlag.GET_BUNDLE_INFO_WITH_PERMISSION);
    const grantStatus: Array<number> = abilityAccessCtrl.createAtManager().checkAccessSync(
      bundleInfo.appInfo.accessTokenId.toString(),
      permissions[0]
    );
    if (grantStatus[0] === abilityAccessCtrl.GrantStatus.PERMISSION_GRANTED) {
      // 已经授权,直接初始化
      this.initializeMediaFetch();
      return true;
    } else {
      // 需要请求授权
      try {
        await abilityAccessCtrl.createAtManager().requestPermissionsFromUser(
          this.context,
          permissions
        );
        this.initializeMediaFetch();
        return true;
      } catch (err) {
        let bizErr = err as BusinessError;
        if (bizErr.code === common.UIAbilityErrorCode.UIKIT_INNER_ERROR) {
          // 用户拒绝授权
          console.error('Permission denied');
        }
        return false;
      }
    }
  }
}

为什么这样写checkAccessSync 同步检查当前权限状态,可以避免在没有权限时直接调用 Media Library API 抛出异常。requestPermissionsFromUser 的 catch 分支必须处理,否则应用会直接 crash。

2. 注册 onMediaChange 监听

onMediaChange 用于监听所有媒体资源的变更(新增、删除、修改)。
onAlbumChange 用于监听相册级别的变更(重命名相册、删除相册等)。

大部分场景只需要 onMediaChange

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

class MediaData {
  uri: string = '';
  displayName: string = '';
  dateAdded: number = 0;
  // 根据实际需要扩展字段
}

@Component
struct MediaListPage {
  // ... 省略 aboveToAppear 等上下文
  private helper: photoAccessHelper.PhotoAccessHelper | null = null;
  private mediaListener: photoAccessHelper.MediaChangeCallback | null = null;

  initializeMediaFetch() {
    this.helper = photoAccessHelper.getPhotoAccessHelper(this.context);
    this.fetchMediaList();
    this.registerMediaChange();
  }

  registerMediaChange() {
    this.mediaListener = {
      onMediaChange: (uris: Array<string>) => {
        // 注意:这个回调可能在子线程触发,不要直接在回调里 setState
        console.info('Media changed, uris:', JSON.stringify(uris));
        this.fetchMediaList();
      }
    };
    // 注册监听,返回 listenerId 用于后续解注册
    this.helper?.registerChange(this.mediaListener, (err) => {
      if (err) {
        console.error('registerChange failed', err.code);
      } else {
        console.info('registerChange success');
      }
    });
  }
}

这里有一个容易被忽略的问题onMediaChange 回调不会携带完整的文件 URI,只给一个变更的 URI 列表。所以最稳妥的做法是重新 fetch 全量数据。如果列表很大,可以考虑用 onAlbumChange 进一步细化。

3. 实现 fetchMediaList

使用 photoAccessHelper 获取相册中所有图片和视频。

async fetchMediaList() {
  if (!this.helper) {
    return;
  }
  try {
    // 获取系统相册
    const albumUri = photoAccessHelper.PhotoType.IMAGE | photoAccessHelper.PhotoType.VIDEO;
    const fetchResult = await this.helper.getAssets({});
    if (!fetchResult) {
      return;
    }
    const totalCount = fetchResult.getCount();
    if (totalCount === 0) {
      this.mediaList = [];
      return;
    }
    // 一次性获取所有媒体(谨慎使用,大量文件可能 OOM)
    const assets: Array<photoAccessHelper.PhotoAsset> = [];
    for (let i = 0; i < totalCount; i++) {
      const asset = fetchResult.getObjectByIndex(i);
      if (asset) {
        assets.push(asset);
      }
    }
    // 映射为 UI 数据
    this.mediaList = assets.map(item => {
      return {
        uri: item.uri,
        displayName: item.displayName,
        dateAdded: item.dateAdded ?? 0,
      };
    });
    fetchResult.close(); // 重要:释放资源
  } catch (err) {
    let mediaErr = err as BusinessError;
    if (mediaErr.code === photoAccessHelper.PhotoAccessHelperErrorCode.MEDIA_LIBRARY_ERROR) {
      // 处理 Media Library 错误(通常是权限、资源耗尽等)
      console.error('Media Library error');
    } else {
      console.error('fetchMediaList error', mediaErr.code);
    }
  }
}

代码里有一个 fetchResult.close(),这是很多人会忘掉的点。getAssets 返回的 FetchResult 对象内部持有媒体库资源引用,如果不释放,会导致内存泄漏,严重时会造成应用 OOM。官方文档虽然提到了这个 API,但没有强调不 close 的风险

4. 在 aboutToDisappear 中解注册

如果不解注册,页面销毁后监听回调仍然在运行,会触发已经被销毁页面的状态更新,造成异常。

aboutToDisappear() {
  if (this.helper && this.mediaListener) {
    this.helper.unRegisterChange(this.mediaListener, (err) => {
      if (err) {
        console.error('unregister failed');
      }
    });
  }
  this.helper = null;
  this.mediaListener = null;
}

常见问题 1:onMediaChange 触发后页面卡死

现象:在相册里批量删除 20 张图片后,App 直接白屏或者 ANR。

原因onMediaChange 回调里执行了 fetchMediaList(),而 fetchMediaList 里用了 getAssets 获取全部资源。删除大量文件时,onMediaChange 可能被连续触发多次,导致重复的全量查询,阻塞主线程。

解决方案:加防抖。

private mediaChangeThrottleTimer: number | null = null;

registerMediaChange() {
  this.mediaListener = {
    onMediaChange: (uris) => {
      if (this.mediaChangeThrottleTimer) {
        clearTimeout(this.mediaChangeThrottleTimer);
      }
      this.mediaChangeThrottleTimer = setTimeout(() => {
        this.fetchMediaList();
      }, 300); // 300ms 内合并多次变更
    }
  };
}

常见问题 2:权限被拒后直接调 API 抛出 MEDIA_LIBRARY_ERROR

现象:用户点击“禁止”后,getPhotoAccessHelper 没报错,但 getAssets 抛出了 MEDIA_LIBRARY_ERROR。代码没处理这个异常,页面 crash。

原因getPhotoAccessHelper 的创建不需要权限,但后续的读写操作需要。如果权限被拒,所有操作都会返回 MEDIA_LIBRARY_ERROR。这个错误码是通用错误,还需要进一步检查 code 才确定是权限问题(photoAccessHelper.PhotoAccessHelperErrorCode.MEDIA_LIBRARY_ERROR 是通用错误,实际会带子错误码,建议统一走 BusinessError)。

解决方案:在 requestPermission 失败后,不要初始化 Media Library,直接展示“需要授权”页面。不要尝试偷偷调用 API。

// 改进 requestPermission 的返回处理
const granted = await this.requestPermission();
if (!granted) {
  // 设置一个标记,UI 层判断后展示授权引导界面
  this.isPermissionDenied = true;
}

最佳实践

  1. 不要在 build() 中初始化 Media Library 或注册监听
    ArkUI 的 build() 会被频繁调用,每次创建 helper 是资源浪费,而且重复注册会导致多个回调同时运行。在 aboutToAppear 中只做一次初始化。

  2. 养成解注册和 close FetchResult 的习惯
    这两个步骤写在一起,不容易遗漏。可以封装一个统一的生命周期管理方法。

  3. 优先使用 onMediaChange 来触发列表刷新,而不是轮询
    轮询既要耗电、又存在更新滞后。onMediaChange 可以做到实时响应。

FAQ

Q:为什么真机正常,模拟器不生效?
A:模拟器没有真实的相册资源,getAssets 可能直接返回空列表。另外模拟器不支持 onMediaChange 监听,只在真机上有效。

Q:为什么页面返回后状态丢失?
A:aboutToDisappear 中解除了监听,但再次进入页面时 aboutToAppear 重新执行了申请权限的逻辑,如果权限状态未变,checkAccessSync 返回已授权,然后重新初始化,用户可以接收。

Q:为什么第一次授权成功,第二次进入却弹出权限框?
A:用户可能在系统设置中手动关闭了相册权限。需要在 aboutToAppearonPageShow 中重新检查一次权限状态,如果被拒,再次申请。

Logo

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

更多推荐