《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.widthasset.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. 采用分批生成策略,每次只生成1-2个
  2. 使用requestAnimationFramesetTimeout控制生成节奏
  3. 对已经生成的缩略图做缓存(本文使用了Map缓存)

问题2:删除视频后列表没有刷新

现象:调用deleteAsset后,页面数据不变,或者刷新后删除的视频又重新出现了。

原因deleteAsset删除的是文件系统中的实体,但videoList是纯内存状态,不会自动同步。

解决方案:删除后手动更新videoListthumbnails状态,并重新查询确认。

问题3:运行时报错“参数错误”

现象:调用getMediaLibrary时抛出BusinessError,提示参数错误。

原因getMediaLibrary要求传入的Context是UIAbilityContext类型,如果传入的是UIContextUIExtensionContext,会报错。

解决方案:在页面组件中使用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,可以设置widthheight来控制缩略图分辨率。但注意,过大的分辨率会导致生成时间变长。

如果你也遇到类似问题,可以重点检查MediaLibrary的初始化时机和状态同步逻辑。官方文档对这个行为的描述比较简单,建议结合实际运行效果一起验证。不同设备上的行为可能会有差异,建议真机测试。

Logo

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

更多推荐