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

HarmonyOS技术精讲-Media Library Kit之查询媒体资源全攻略
开篇:一个容易被忽略的查询问题
HarmonyOS NEXT开发中,Media Library Kit的媒体资源查询是基础能力之一,但很多人在第一次使用时会发现一个奇怪现象:getMediaAssets方法能执行,返回的FetchResult却经常拿不到数据,或者回传的数据结构和预期不一致。官方示例看起来很简单,但放到实际项目里,从权限申请到分页加载,再到UI同步,中间藏着不少容易被忽略的细节。
这个问题在社区里反复出现。有人升级API版本后查询结果变空,有人在真机上能获取到文件但模拟器不行,还有人发现FetchOptions配置好类型之后,依然会混入不期望的媒体类型。这篇文章会从getMediaAssets的核心API出发,把过滤、排序、分页这几个配置项讲透,并给出可运行的图片和音频查询示例。
先搞清楚:MediaAssetManager 解决了什么
媒体文件在HarmonyOS中由媒体库统一管理。要访问相册图片、系统音频或下载的文档,不能直接读文件路径,必须通过MediaAssetManager提供的查询接口。
这个能力解决的核心问题有两个:
- 统一的媒体资源查询入口:不再需要为图片、音频、视频分别去读不同的目录或db表。
- 按类型、日期、大小高效过滤:
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_IMAGE、IS_AUDIO、IS_VIDEO等查询键。不要自己写字符串"IS_IMAGE",容易写错,官方枚举避免了拼写错误。
分页参数offset和limit必须同时设置,否则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()是手动触发组件刷新的方法。如果直接对thumbnails做set操作,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 = 1或IS_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命令推送一些测试图片或音频到设备的Pictures或Music目录下,再运行程序。另外,模拟器的media service可能不稳定,出现这类问题时先用hdc shell bm install确认媒体库服务是否正常。
Q:第一次授权同意后,第二次打开App又弹权限申请?
A:检查requestPermissionsFromUser的调用时机。如果在onCreate里每次启动都调用一次,即使用户之前同意过,系统会重复弹窗。正确做法是先检查权限状态,只有未授权时才申请。使用abilityAccessCtrl.checkAccessToken判断。
Q:为什么图片列表滚动时缩略图闪一下才显示?
A:这是因为缩略图是在组件aboutToAppear之后才异步加载的。列表快速滚动时,LazyForEach会反复创建和销毁组件,导致缩略图反复加载。建议使用图片缓存库或配合内存缓存策略,避免重复创建ImageSource。一个简单的临时方案是增大pageSize,减少组件重建频率。
如果你也遇到本文中提到的空数据或状态同步问题,可以重点检查FetchOptions的写法和页面的生命周期控制。官方文档对MediaQuery枚举的描述比较简洁,建议多跑几次调试日志确认查询条件和返回结果。不同设备(尤其是华为平板和手表)上的媒体库行为可能存在差异,真机测试是最后的验证手段。
更多推荐



所有评论(0)