在这里插入图片描述

HarmonyOS技术精讲-Media Library Kit之查询媒体资源全攻略

开篇:一个容易被忽略的查询问题

HarmonyOS NEXT开发中,Media Library Kit的媒体资源查询是基础能力之一,但很多人在第一次使用时会发现一个奇怪现象:getMediaAssets方法能执行,返回的FetchResult却经常拿不到数据,或者回传的数据结构和预期不一致。官方示例看起来很简单,但放到实际项目里,从权限申请到分页加载,再到UI同步,中间藏着不少容易被忽略的细节。

这个问题在社区里反复出现。有人升级API版本后查询结果变空,有人在真机上能获取到文件但模拟器不行,还有人发现FetchOptions配置好类型之后,依然会混入不期望的媒体类型。这篇文章会从getMediaAssets的核心API出发,把过滤、排序、分页这几个配置项讲透,并给出可运行的图片和音频查询示例。

先搞清楚:MediaAssetManager 解决了什么

媒体文件在HarmonyOS中由媒体库统一管理。要访问相册图片、系统音频或下载的文档,不能直接读文件路径,必须通过MediaAssetManager提供的查询接口。

这个能力解决的核心问题有两个:

  1. 统一的媒体资源查询入口:不再需要为图片、音频、视频分别去读不同的目录或db表。
  2. 按类型、日期、大小高效过滤FetchOptions封装了媒体库的筛选逻辑,不需要自己写SQL。

适合的场景:

  • 相册App列表页
  • 音乐播放器的本地音频库
  • 文件管理器的媒体分类视图

不适合的场景:

  • 需要实时监控文件变化的场景(媒体库没有文件变动回调,得配合监听器)
  • 需要访问系统相册以外的自定义目录(媒体库只能访问用户已授权的文件)

与直接使用FilePicker相比,MediaAssetManager不弹选择框,直接获取已授权的媒体文件列表,适用于需要批量展示的场景。

对比项 MediaAssetManager FilePicker
用户交互 无界面,直接获取列表 弹出选择框让用户选文件
授权方式 需提前申请ohos.permission.READ_MEDIA 使用Picker自带授权
适用场景 自己的App需要展示相册列表 用户一次性选择少量文件
数据量支持 支持分页,无上限 适合少量选择

实际项目里,这两个方案会组合使用:MediaAssetManager做列表页,FilePicker做选择导入。

环境与前置准备

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

核心实现:图片和音频查询完整流程

1. 权限请求(必须先做)

媒体库查询需要ohos.permission.READ_MEDIA权限,这个权限属于user_grant级别,必须在运行时弹窗申请。不能只写在module.json5里,运行时还得主动执行requestPermissionsFromUser

如果你跳过权限申请直接调用getMediaAssets,会静默返回空数据,不抛异常——这个行为让很多人排查了好几天。

// PermissionManager.ets
import { common, abilityAccessCtrl, Permissions, bundleManager } from '@kit.AbilityKit';

export async function requestMediaPermission(context: common.UIAbilityContext): Promise<boolean> {
  try {
    let atManager = abilityAccessCtrl.createAtManager();
    let permissions: Array<Permissions.Permission> = [
      'ohos.permission.READ_MEDIA'
    ];
    let grantStatus = await atManager.requestPermissionsFromUser(context, permissions);
    // 通常返回的对象里包含权限列表和授权结果
    if (grantStatus?.authResults?.length > 0) {
      // 检查第一个权限是否授权
      if (grantStatus.authResults[0] === abilityAccessCtrl.GrantStatus.PERMISSION_GRANTED) {
        console.info('Media permission granted');
        return true;
      }
    }
    // 如果被拒绝,可以弹提示引导用户进设置页
    console.warn('Media permission denied');
    return false;
  } catch (error) {
    console.error(`Permission request failed: ${JSON.stringify(error)}`);
    return false;
  }
}

这段代码用于在页面初始化时申请一次权限。需要注意的是,用户拒绝后最好引导到系统设置页,否则后续查询一直为空。很多人在提交应用后才发现这个权限陷阱。

2. 创建FetchOptions(核心配置)

查询的关键在于FetchOptions的配置。它决定了返回哪些类型的文件、按什么顺序排列、一次返回多少条。

// MediaQueryHelper.ets
import { mediaLibrary } from '@kit.MediaLibraryKit';
import { image } from '@kit.ImageKit';
import { BusinessError } from '@kit.BasicServicesKit';

export interface MediaAssetItem {
  uri: string;
  displayName: string;
  mediaType: mediaLibrary.MediaType;
  dateAdded: number;
  size: number;
  // 图片/视频特有
  width?: number;
  height?: number;
  // 音频特有
  duration?: number;
  album?: string;
}

export async function queryImages(
  context: common.UIAbilityContext,
  pageSize: number = 20,
  pageOffset: number = 0
): Promise<MediaAssetItem[]> {
  try {
    // 创建MediaAssetManager实例,依赖UIAbilityContext
    let manager = mediaLibrary.getMediaAssetManager(context);
    
    // 配置FetchOptions
    let fetchOptions: mediaLibrary.FetchOptions = {
      // 按媒体类型过滤:只查询图片
      selections: `${mediaLibrary.MediaQuery.IS_IMAGE} = ?`,
      selectionArgs: ['1'],  // 1表示是,0表示否
      // 按添加时间降序排列(最新的在前)
      order: `${mediaLibrary.MediaQuery.DATE_ADDED} DESC`,
      // 分页:跳过N条,取N条
      offset: pageOffset,
      limit: pageSize
    };

    // 执行查询,返回FetchResult
    let fetchResult: mediaLibrary.FetchResult = await manager.getMediaAssets(fetchOptions);
    
    // 将FetchResult转为MediaAssetItem数组
    let items: MediaAssetItem[] = [];
    if (fetchResult && fetchResult.length > 0) {
      for (let i = 0; i < fetchResult.length; i++) {
        let asset = fetchResult.get(i);
        if (asset) {
          items.push({
            uri: asset.uri,
            displayName: asset.displayName,
            mediaType: asset.mediaType,
            dateAdded: asset.dateAdded,
            size: asset.size,
            width: asset.width,
            height: asset.height
          });
        }
      }
    }
    // 使用完毕后关闭FetchResult,释放资源
    fetchResult.close();
    return items;
  } catch (error) {
    console.error(`Query images failed: ${JSON.stringify(error)}`);
    return [];
  }
}

export async function queryAudio(
  context: common.UIAbilityContext,
  pageSize: number = 20,
  pageOffset: number = 0
): Promise<MediaAssetItem[]> {
  try {
    let manager = mediaLibrary.getMediaAssetManager(context);
    
    let fetchOptions: mediaLibrary.FetchOptions = {
      // 只查询音频
      selections: `${mediaLibrary.MediaQuery.IS_AUDIO} = ?`,
      selectionArgs: ['1'],
      order: `${mediaLibrary.MediaQuery.DATE_ADDED} DESC`,
      offset: pageOffset,
      limit: pageSize
    };

    let fetchResult = await manager.getMediaAssets(fetchOptions);
    let items: MediaAssetItem[] = [];
    if (fetchResult && fetchResult.length > 0) {
      for (let i = 0; i < fetchResult.length; i++) {
        let asset = fetchResult.get(i);
        if (asset) {
          items.push({
            uri: asset.uri,
            displayName: asset.displayName,
            mediaType: asset.mediaType,
            dateAdded: asset.dateAdded,
            size: asset.size,
            duration: asset.duration,
            album: asset.album
          });
        }
      }
    }
    fetchResult.close();
    return items;
  } catch (error) {
    console.error(`Query audio failed: ${JSON.stringify(error)}`);
    return [];
  }
}

注意这里的mediaLibrary.MediaQuery是一个枚举,提供了IS_IMAGEIS_AUDIOIS_VIDEO等查询键。不要自己写字符串"IS_IMAGE",容易写错,官方枚举避免了拼写错误。

分页参数offsetlimit必须同时设置,否则getMediaAssets会返回全部数据。不设分页时,如果用户有几万张照片,内存直接撑爆。

3. UI绑定与缩略图显示

查询到URI后,需要用image.ResourceManager加载缩略图。这里有个性能坑:不要在build()里频繁调用requestImageData,否则ArkUI会频繁触发组件重建。

// ImageGallery.ets
import { common } from '@kit.AbilityKit';
import { image } from '@kit.ImageKit';
import { mediaLibrary } from '@kit.MediaLibraryKit';

@Component
export struct ImageGallery {
  @State imageItems: MediaAssetItem[] = [];
  private context: common.UIAbilityContext = getContext(this) as common.UIAbilityContext;
  @State thumbnails: Map<string, image.PixelMap> = new Map();

  aboutToAppear() {
    this.loadImageList();
  }

  async loadImageList() {
    // 先检查权限,假设已经在Ability里申请过了
    let items = await queryImages(this.context, 20, 0);
    this.imageItems = items;
    // 异步加载前20张缩略图
    this.loadThumbnails(items);
  }

  async loadThumbnails(items: MediaAssetItem[]) {
    // 使用image.ResourceManager加载缩略图
    let resourceMgr = this.context.resourceManager;
    for (let item of items) {
      try {
        // 媒体库的uri可以通过ResourceManager创建ImageSource
        let imageSource = image.createImageSource(item.uri);
        // 生成缩略图,宽高设为200px,减少内存占用
        let pixelMap = await imageSource.createPixelMap({
          desiredWidth: 200,
          desiredHeight: 200
        });
        // 更新状态,触发UI刷新
        this.thumbnails.set(item.uri, pixelMap);
        this.update();  // 手动触发一次刷新,不要用this.thumbnails = new Map(...),避免重建所有
      } catch (error) {
        console.warn(`Load thumbnail failed for ${item.displayName}: ${JSON.stringify(error)}`);
      }
    }
  }

  build() {
    Column() {
      List({ space: 8 }) {
        ForEach(this.imageItems, (item: MediaAssetItem, index: number) => {
          ListItem() {
            Column() {
              Image(this.thumbnails.get(item.uri) ? this.thumbnails.get(item.uri) : undefined)
                .objectFit(ImageFit.Cover)
                .width(100)
                .height(100)
              Text(item.displayName)
                .fontSize(12)
                .maxLines(1)
                .textOverflow({ overflow: TextOverflow.Ellipsis })
                .width(100)
            }
          }
        }, (item: MediaAssetItem) => item.uri) // 以uri作为key
      }
      .width('100%')
      .height('100%')
    }
  }
}

这段代码里有个值得注意的点:this.update()是手动触发组件刷新的方法。如果直接对thumbnailsset操作,ArkUI的响应式系统不会自动感知Map内部的变更,所以需要手动通知组件去重新读取this.thumbnails.get(item.uri)。这是HarmonyOS状态管理里一个容易被忽视的行为——@State只能追踪对象引用变化,无法追踪Map内部的变化。

4. 音频信息展示

音频查询结果和图片略有不同,需要展示时长、专辑等信息。

// AudioList.ets

@Component
export struct AudioList {
  @State audioItems: MediaAssetItem[] = [];
  private context: common.UIAbilityContext = getContext(this) as common.UIAbilityContext;

  aboutToAppear() {
    this.loadAudio();
  }

  async loadAudio() {
    let items = await queryAudio(this.context, 30, 0);
    this.audioItems = items;
  }

  // 格式化时长,单位毫秒
  formatDuration(ms: number): string {
    let totalSeconds = Math.floor(ms / 1000);
    let minutes = Math.floor(totalSeconds / 60);
    let seconds = totalSeconds % 60;
    return `${minutes}:${seconds.toString().padStart(2, '0')}`;
  }

  build() {
    List({ space: 12 }) {
      ForEach(this.audioItems, (item: MediaAssetItem, index: number) => {
        ListItem() {
          Row() {
            Column() {
              Text(item.displayName)
                .fontSize(14)
                .fontWeight(FontWeight.Medium)
              Text(item.album ? item.album : '未知专辑')
                .fontSize(12)
                .fontColor(Color.Gray)
            }
            .layoutWeight(1)
            .margin({ left: 16 })

            Column() {
              Text(this.formatDuration(item.duration || 0))
                .fontSize(12)
                .fontColor(Color.Gray)
              Text(`${(item.size / 1024 / 1024).toFixed(1)} MB`)
                .fontSize(12)
                .fontColor(Color.Gray)
            }
            .width(80)
            .alignItems(HorizontalAlign.End)
            .margin({ right: 16 })
          }
          .height(64)
          .width('100%')
        }
      }, (item: MediaAssetItem) => item.uri)
    }
    .width('100%')
    .height('100%')
  }
}

音频的排序建议用DATE_ADDED,用户习惯看到最近导入的歌曲在最前面。如果使用DISPLAY_NAME排序,文件系统级的命名顺序可能不符合预期。

常见问题与踩坑记录

问题1:查询结果为空,但媒体库里确实有文件

现象:调用getMediaAssets返回的FetchResult.length为0,或者列表不显示任何数据。检查权限已授权,FetchOptions看起来没问题。

原因:最常见的原因是selections写错了表达式。比如使用MediaQuery.IS_IMAGE时,正确的查询示例如下:

selections: `${mediaLibrary.MediaQuery.IS_IMAGE} = ?`,
selectionArgs: ['1']

有人误写成IS_IMAGE = 1IS_IMAGE = true,在HarmonyOS的SQLite限制下不会报错但返回空。另外,模拟器上的媒体库可能没有预置文件,需要先导入测试文件。

解决方案:先简化FetchOptions,不做任何条件过滤:

let fetchOptions: mediaLibrary.FetchOptions = {
  order: `${mediaLibrary.MediaQuery.DATE_ADDED} DESC`,
  offset: 0,
  limit: 10
};

如果能返回数据,说明selections写法有问题。如果还是空,确认目标设备是否已通过hdc命令推入测试文件(模拟器默认没有相册内容)。

问题2:分页加载时重复或丢失数据

现象:用户连续滚动列表,触发多次分页请求。有时第二页和第一页数据重复,有时跳过了中间几项。

原因FetchResult内部维护了一个游标,每次get()调用会移动游标。如果多次调用getMediaAssets,需要保证offset参数的正确性。更关键的是,如果在两次查询之间媒体库有新增文件,DATE_ADDED顺序可能被打乱,导致分页错位。

解决方案:使用稳定排序字段。推荐使用DATE_ADDED结合_ID作为双重排序:

order: `${mediaLibrary.MediaQuery.DATE_ADDED} DESC, ${mediaLibrary.MediaQuery.ID} ASC`

这样可以确保即使时间相同,id递增也能保证顺序稳定。另外,每次分页加载时记录最后一个已加载项的URI,作为下一页的起点依据。

问题3:页面销毁后异步回调污染状态

现象:用户在加载列表时快速返回上一页,组件已经销毁。但loadThumbnails里的异步回调仍然执行,this.thumbnails.set()抛异常或更新已销毁组件的状态。

原因:HarmonyOS的组件的生命周期在aboutToDisappear后不应再修改@State。而异步操作的回调不会因为页面销毁而取消。

解决方案:使用一个isActive标志:

private isActive: boolean = true;

aboutToDisappear() {
  this.isActive = false;
}

async loadThumbnails(items: MediaAssetItem[]) {
  for (let item of items) {
    if (!this.isActive) break;
    // ...加载完成后判断
    if (this.isActive) {
      this.thumbnails.set(item.uri, pixelMap);
      this.update();
    }
  }
}

最佳实践

1. 权限申请放在Ability的onCreate中

不要在页面的aboutToAppear中申请权限。因为权限弹窗是一个系统级Dialog,如果页面还未完全渲染就弹窗,可能出现黑屏或者用户还未看到页面就已经授权。推荐在Ability的onCreate里先申请,降级到页面时权限已经就绪。

2. 分页加载时使用大pageSize减少请求次数

实际测试中,一次请求20条和一次请求50条,在媒体库数据量在2000以内时,延迟差异极小。推荐使用30-50的pageSize,配合LazyForEach做懒加载,可以减少网络/IO次数。

3. 不要直接在build()中调用getMediaAssets

build()是一个纯函数式方法,在里面执行异步操作会导致组件无限重渲染。如果需要在页面/组件显示时立即触发加载,使用aboutToAppear生命周期钩子。

4. 关闭FetchResult

FetchResult内部持有媒体库的数据游标资源,如果不调用close(),会导致资源泄露。官方文档虽然说了要close,但很多示例里都没写。在返回数据之前务必加一个finally或显式close。

5. 缩略图加载不要阻塞UI

一次加载几十张缩略图,如果使用await串行加载,用户会看到列表从上到下逐步显示图片,体验不好。建议使用Promise.all并行加载,但要控制并发数(比如10个一批),避免同时创建过多ImageSource。

async loadThumbnailsBatch(items: MediaAssetItem[], batchSize: number = 10) {
  for (let i = 0; i < items.length; i += batchSize) {
    let batch = items.slice(i, i + batchSize);
    let promises = batch.map(item => this.loadSingleThumbnail(item));
    await Promise.all(promises);
  }
}

Demo完整入口

// pages/Index.ets
import { requestMediaPermission } from './PermissionManager';
import { ImageGallery } from './ImageGallery';
import { AudioList } from './AudioList';

@Entry
@Component
struct Index {
  @State currentTab: number = 0;
  @State permissionGranted: boolean = false;

  async aboutToAppear() {
    let context = getContext(this) as common.UIAbilityContext;
    let granted = await requestMediaPermission(context);
    this.permissionGranted = granted;
  }

  build() {
    Column() {
      if (!this.permissionGranted) {
        Text('请先授予媒体库访问权限')
          .fontSize(16)
          .margin({ top: 200 })
      } else {
        Row() {
          Button('图片')
            .onClick(() => { this.currentTab = 0; })
          Button('音频')
            .onClick(() => { this.currentTab = 1; })
        }
        .width('100%')
        .justifyContent(FlexAlign.Center)

        if (this.currentTab === 0) {
          ImageGallery()
        } else {
          AudioList()
        }
      }
    }
    .width('100%')
    .height('100%')
  }
}

FAQ(真实开发视角)

Q:为什么在真机上能正常查询,模拟器上返回空数据?
A:模拟器默认没有预置媒体文件。需要手动用hdc命令推送一些测试图片或音频到设备的PicturesMusic目录下,再运行程序。另外,模拟器的media service可能不稳定,出现这类问题时先用hdc shell bm install确认媒体库服务是否正常。

Q:第一次授权同意后,第二次打开App又弹权限申请?
A:检查requestPermissionsFromUser的调用时机。如果在onCreate里每次启动都调用一次,即使用户之前同意过,系统会重复弹窗。正确做法是先检查权限状态,只有未授权时才申请。使用abilityAccessCtrl.checkAccessToken判断。

Q:为什么图片列表滚动时缩略图闪一下才显示?
A:这是因为缩略图是在组件aboutToAppear之后才异步加载的。列表快速滚动时,LazyForEach会反复创建和销毁组件,导致缩略图反复加载。建议使用图片缓存库或配合内存缓存策略,避免重复创建ImageSource。一个简单的临时方案是增大pageSize,减少组件重建频率。

如果你也遇到本文中提到的空数据或状态同步问题,可以重点检查FetchOptions的写法和页面的生命周期控制。官方文档对MediaQuery枚举的描述比较简洁,建议多跑几次调试日志确认查询条件和返回结果。不同设备(尤其是华为平板和手表)上的媒体库行为可能存在差异,真机测试是最后的验证手段。

Logo

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

更多推荐