《HarmonyOS技术精讲-Media Library Kit》之视频资源管理
《HarmonyOS技术精讲-Media Library Kit》之视频资源管理

从一次“视频时长显示异常”说起
HarmonyOS NEXT开发中,Media Library Kit提供的视频资源查询能力很常用。但很多人在使用时会遇到一个问题:明明拿到了视频文件,却读不出正确的元数据。官方示例只展示了基础查询,实际项目里需要处理元数据获取、缩略图生成、UI刷新等多方面问题。
这篇文章不讲概念,只讲具体实现。我们会一步步完成一个视频列表页面,支持展示缩略图、时长、视频分辨率信息,并提供删除功能。
Media Library Kit 在视频管理中的定位
Media Library Kit是HarmonyOS提供的媒体文件管理服务。对视频资源来说,它负责从设备存储中查询、读取、修改、删除视频文件。官方API封装了底层的文件操作,但实际使用时需要理解几个关键点:
- 查询结果是延迟加载的,不是一次性返回全量数据
- 元数据获取需要指定正确的查询列,否则字段值为空
- 缩略图生成有性能影响,需要异步处理
适合的场景:本地视频列表展示、视频信息提取、视频文件管理
不适合的场景:视频播放、在线视频流(这是AVPlayer的领域)
环境说明
DevEco Studio 版本:DevEco Studio 6.1.0 及以上
HarmonyOS SDK 版本:HarmonyOS 6.1.0(23) 及以上
目标设备:手机
核心实现:视频查询 + 元数据 + 缩略图 + 删除
第一步:权限与媒体库初始化
Media Library Kit要求在开始操作前完成权限申请和MediaLibrary实例创建。
// utils/MediaHelper.ets
import { mediaLibrary } from '@kit.MediaLibraryKit';
import { common } from '@kit.AbilityKit';
export class MediaHelper {
private mediaLib: mediaLibrary.MediaLibrary | null = null;
constructor(private context: common.Context) {}
// 初始化媒体库
async init(): Promise<void> {
// 注意:传入的是UIAbilityContext或UIExtensionContext
this.mediaLib = mediaLibrary.getMediaLibrary(this.context);
}
// 获取媒体库实例
getMediaLibrary(): mediaLibrary.MediaLibrary {
if (!this.mediaLib) {
throw new Error('MediaLibrary not initialized. Call init() first.');
}
return this.mediaLib;
}
}
关键点:getMediaLibrary需要传入一个生命周期有效的Context。很多人在页面返回后MediaLibrary实例失效,是因为Context生命周期比页面短。
第二步:查询视频列表
查询视频需要构造FetchOptions,指定查询条件和排序。这里我们按日期降序排列。
// pages/VideoListPage.ets
import { mediaLibrary } from '@kit.MediaLibraryKit';
import { image } from '@kit.ImageKit';
import { BusinessError } from '@kit.BasicServicesKit';
@Entry
@Component
struct VideoListPage {
@State videoList: VideoItem[] = [];
private mediaHelper: MediaHelper | null = null;
aboutToAppear(): void {
this.mediaHelper = new MediaHelper(getContext(this));
this.mediaHelper.init().then(() => {
this.queryVideos();
});
}
// 查询视频文件
async queryVideos(): Promise<void> {
const mediaLib = this.mediaHelper?.getMediaLibrary();
if (!mediaLib) return;
// 获取视频文件类型的FetchFileResult
const fileKey = mediaLibrary.FileKey;
// 构建查询条件:只查视频文件,按日期降序
const fetchOptions: mediaLibrary.FetchOptions = {
selections: `${fileKey.MEDIA_TYPE} = ?`,
selectionArgs: [mediaLibrary.MediaType.VIDEO.toString()],
order: `${fileKey.DATE_ADDED} DESC`,
};
try {
const fetchFileResult: mediaLibrary.FetchFileResult =
await mediaLib.getFileAssets(fetchOptions);
const count = fetchFileResult.getCount();
if (count > 0) {
// 获取全部视频文件
const allAssets = await fetchFileResult.getAllObject();
this.videoList = allAssets.map(asset => ({
uri: asset.uri,
displayName: asset.displayName,
dateAdded: asset.dateAdded,
// 此时width、height、duration可能为空
width: asset.width || 0,
height: asset.height || 0,
duration: asset.duration || 0,
size: asset.size || 0,
name: asset.displayName.replace(/\.[^/.]+$/, '')
}));
}
fetchFileResult.close(); // 记得关闭资源
} catch (error) {
console.error('Failed to query videos:', (error as BusinessError).message);
}
}
}
interface VideoItem {
uri: string;
displayName: string;
dateAdded: number;
width: number;
height: number;
duration: number;
size: number;
name: string;
}
这里有个坑:第一次查询时,asset.width、asset.duration可能为0。官方文档没有明确说明,但实际开发中发现这些元数据需要单独获取。
第三步:获取完整元数据
上面的查询只拿到了基础的file属性。要获取分辨率、时长、大小,需要重新查询视频文件的元数据。
// 补充:获取视频详细元数据
async getVideoMetaData(uri: string): Promise<{width: number, height: number, duration: number, size: number}> {
const mediaLib = this.mediaHelper?.getMediaLibrary();
if (!mediaLib) {
return {width: 0, height: 0, duration: 0, size: 0};
}
const fileKey = mediaLibrary.FileKey;
const fetchOptions: mediaLibrary.FetchOptions = {
selections: `${fileKey.URI} = ?`,
selectionArgs: [uri],
// 指定需要查询的列,不指定可能拿不到
uri: 'internal://media/video_options' // 这个参数容易被忽略
};
// 官方推荐的方式:使用getFileAssetByUri
const asset = await mediaLib.getFileAssetByUri(uri);
// 获取元数据
const details = await asset.getDetails();
return {
width: asset.width || 0,
height: asset.height || 0,
duration: asset.duration || 0,
size: asset.size || 0
};
}
注意事项:getFileAssetByUri返回的asset对象包含了完整的元数据信息。但要注意,如果前面的查询没有请求对应的列,此时拿到的值可能依然为空。
第四步:生成缩略图
缩略图生成是UI渲染中最影响性能的一环。不能一次性生成所有视频的缩略图,否则会卡死UI线程。
// 生成指定视频的缩略图
async generateThumbnail(uri: string): Promise<image.PixelMap | null> {
const mediaLib = this.mediaHelper?.getMediaLibrary();
if (!mediaLib) return null;
try {
const asset = await mediaLib.getFileAssetByUri(uri);
// 生成缩略图,size参数控制缩略图质量
const pixelMap = await asset.getThumbnail(
{ width: 200, height: 200 },
{ sampleMode: image.SampleMode.CENTER_CROP, rotate: 0 }
);
return pixelMap;
} catch (error) {
console.error('Failed to generate thumbnail:', (error as BusinessError).message);
return null;
}
}
把这个逻辑整合到列表页面中:
// VideoListPage.ets 完整实现
@State videoList: VideoItem[] = [];
@State thumbnails: Map<string, image.PixelMap> = new Map();
// 为列表中的视频生成缩略图(分批处理)
async batchGenerateThumbnails(): Promise<void> {
// 只处理前10个视频的缩略图,避免性能问题
const batch = this.videoList.slice(0, 10);
for (const item of batch) {
if (this.thumbnails.has(item.uri)) continue; // 避免重复加载
const pixelMap = await this.generateThumbnail(item.uri);
if (pixelMap) {
// 更新状态触发UI刷新
this.thumbnails.set(item.uri, pixelMap);
// 注意:这里触发的是State状态更新
this.thumbnails = new Map(this.thumbnails);
}
}
}
性能优化:不要把generateThumbnail放在build()方法中或者循环里直接调用。应该使用aboutToAppear或加载更多逻辑来触发缩略图生成。
第五步:展示列表UI
// 列表页面UI
build() {
Column() {
// 顶部标题
Text('本地视频')
.fontSize(20)
.fontWeight(FontWeight.Bold)
.padding(16)
// 视频列表
if (this.videoList.length === 0) {
Text('暂无视频')
.fontSize(16)
.fontColor('#999')
.layoutWeight(1)
.textAlign(TextAlign.Center)
} else {
List() {
ForEach(this.videoList, (item: VideoItem, index: number) => {
ListItem() {
this.videoCard(item, index)
}
.swipeAction({
end: this.deleteButton(item) // 滑动删除
})
.onClick(() => this.previewVideo(item)) // 点击预览
})
}
.layoutWeight(1)
}
}
.width('100%')
.height('100%')
}
@Builder
videoCard(item: VideoItem, index: number) {
Row() {
// 缩略图
Image(this.thumbnails.get(item.uri) || $r('app.media.ic_video_placeholder'))
.width(80)
.height(60)
.borderRadius(8)
.objectFit(ImageFit.Cover)
Column() {
Text(item.name)
.fontSize(16)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
Row() {
// 时长格式化
Text(this.formatDuration(item.duration))
.fontSize(14)
.fontColor('#666')
// 视频尺寸
Text(`${item.width}x${item.height}`)
.fontSize(14)
.fontColor('#666')
.margin({ left: 12 })
}
.margin({ top: 8 })
}
.layoutWeight(1)
.margin({ left: 12 })
}
.width('100%')
.padding({ left: 16, right: 16, top: 12, bottom: 12 })
}
// 时长格式化(毫秒 -> 分:秒)
formatDuration(ms: number): string {
const totalSeconds = Math.floor(ms / 1000);
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
}
第六步:删除视频
删除操作需要权限支持,且删除后需要刷新列表。
// 删除视频
async deleteVideo(item: VideoItem): Promise<boolean> {
const mediaLib = this.mediaHelper?.getMediaLibrary();
if (!mediaLib) return false;
try {
// 方法1:通过asset对象删除
const asset = await mediaLib.getFileAssetByUri(item.uri);
await asset.deleteAsset();
// 方法2:通过MediaLibrary删除(推荐,更稳定)
// await mediaLib.deleteAsset(item.uri);
// 更新列表状态
this.videoList = this.videoList.filter(v => v.uri !== item.uri);
this.thumbnails.delete(item.uri);
this.thumbnails = new Map(this.thumbnails);
return true;
} catch (error) {
console.error('Failed to delete video:', (error as BusinessError).message);
return false;
}
}
// 滑动删除按钮
@Builder
deleteButton(item: VideoItem) {
Button('删除')
.onClick(() => {
AlertDialog.show({
title: '确认删除',
message: `确定删除视频 "${item.name}" 吗?`,
primaryButton: {
value: '取消',
action: () => {}
},
secondaryButton: {
value: '删除',
isPrimary: true,
action: async () => {
const success = await this.deleteVideo(item);
if (!success) {
AlertDialog.show({
title: '删除失败',
message: '视频删除失败,请检查权限',
confirm: { value: '确定' }
});
}
}
}
});
})
.backgroundColor(Color.Red)
.fontColor(Color.White)
}
常见问题
问题1:首次生成缩略图时性能极差
现象:页面加载时,缩略图生成速度很慢,甚至导致UI卡顿。
原因:getThumbnail是一个同步操作(内部会等待GPU完成解码),如果一次性生成几十个缩略图,会严重阻塞UI线程。
解决方案:
- 采用分批生成策略,每次只生成1-2个
- 使用
requestAnimationFrame或setTimeout控制生成节奏 - 对已经生成的缩略图做缓存(本文使用了Map缓存)
问题2:删除视频后列表没有刷新
现象:调用deleteAsset后,页面数据不变,或者刷新后删除的视频又重新出现了。
原因:deleteAsset删除的是文件系统中的实体,但videoList是纯内存状态,不会自动同步。
解决方案:删除后手动更新videoList和thumbnails状态,并重新查询确认。
问题3:运行时报错“参数错误”
现象:调用getMediaLibrary时抛出BusinessError,提示参数错误。
原因:getMediaLibrary要求传入的Context是UIAbilityContext类型,如果传入的是UIContext或UIExtensionContext,会报错。
解决方案:在页面组件中使用getContext(this)获取Context,而不是使用AppStorage.get('context')。
最佳实践
1. 不要一次性查询全部视频
当设备存储了上千个视频时,getAllObject()会返回大量数据,导致内存消耗和UI渲染压力。推荐使用分页查询:
const fetchOptions: mediaLibrary.FetchOptions = {
selections: `${fileKey.MEDIA_TYPE} = ?`,
selectionArgs: [mediaLibrary.MediaType.VIDEO.toString()],
order: `${fileKey.DATE_ADDED} DESC`,
// 一次只查20个
offset: 0,
limit: 20
};
2. 缩略图生成要配合状态管理
缩略图生成后必须显式触发UI刷新。使用@State装饰器管理的对象,需要保证其引用改变才能触发刷新。这就是为什么示例中使用new Map(thumbnails)而不是直接thumbnails.set()。
3. 删除操作要处理权限
运行在HarmonyOS NEXT上的应用需要动态申请媒体库权限。在aboutToAppear中检查并申请权限:
import { abilityAccessCtrl, common } from '@kit.AbilityKit';
import { BusinessError } from '@kit.BasicServicesKit';
async requestPermission(): Promise<boolean> {
const atManager: abilityAccessCtrl.AtManager =
abilityAccessCtrl.createAtManager();
try {
const result = await atManager.requestPermissionsFromUser(
getContext(this) as common.UIAbilityContext,
['ohos.permission.READ_MEDIA', 'ohos.permission.WRITE_MEDIA']
);
return result.authResults.every(r => r === 0);
} catch (error) {
console.error('Permission request failed:', (error as BusinessError).message);
return false;
}
}
FAQ
Q:为什么真机正常,模拟器不生效?
A:模拟器的媒体库API实现是模拟的,部分功能(如getThumbnail)可能返回空的PixelMap或抛出错误。视频元数据也可能不完整。建议在真机上测试所有功能。
Q:为什么页面返回后状态丢失?
A:这是因为MediaLibrary实例的Context生命周期比页面短。如果页面在后台被销毁,再重新进入时,MediaLibrary需要重新初始化。建议在aboutToAppear中重置状态并重新查询。
Q:为什么删除视频后,重新查询发现视频还在?
A:可能是删除操作没有成功,或者删除的是临时文件/缓存文件。检查Media Library的删除方法是异步的,确保await调用并处理了异常。另外,删除操作需要ohos.permission.WRITE_MEDIA权限。
Q:缩略图的质量能控制吗?
A:可以。getThumbnail方法的第二个参数是MediaAssetThumbnailOptions,可以设置width和height来控制缩略图分辨率。但注意,过大的分辨率会导致生成时间变长。
如果你也遇到类似问题,可以重点检查MediaLibrary的初始化时机和状态同步逻辑。官方文档对这个行为的描述比较简单,建议结合实际运行效果一起验证。不同设备上的行为可能会有差异,建议真机测试。
更多推荐



所有评论(0)