HarmonyOS 读取系统相册图片并预览

2025-08-06 14:19:00
108次阅读
0个评论

HarmonyOS 读取系统相册图片并预览

概述

本文将详细介绍如何在 HarmonyOS 应用中实现读取用户相册图片并进行预览的功能。通过使用 HarmonyOS 提供的媒体库 API 和权限管理机制,我们可以安全、高效地访问用户的图片资源。

效果图

image-20250806140827228


image-20250806140844041


image-20250806140732186

核心 API 介绍

1. PhotoAccessHelper API

PhotoAccessHelper 是 HarmonyOS 提供的媒体库访问助手,用于管理和访问用户的图片、视频等媒体资源。

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

// 获取 PhotoAccessHelper 实例
this.phAccessHelper = photoAccessHelper.getPhotoAccessHelper(this.context);

主要功能:

  • 获取媒体资源列表
  • 支持条件查询和排序
  • 获取媒体文件的缩略图
  • 访问媒体文件的元数据

2. 权限管理 API

访问用户相册需要申请 ohos.permission.READ_IMAGEVIDEO 权限。

import { abilityAccessCtrl, PermissionRequestResult } from '@kit.AbilityKit';

// 检查和请求权限
const atManager = abilityAccessCtrl.createAtManager();
const permission = 'ohos.permission.READ_IMAGEVIDEO';

3. 数据查询 API

使用 DataSharePredicates 进行数据查询和排序。

import { dataSharePredicates } from '@kit.ArkData';

// 创建查询条件
const fetchOptions: photoAccessHelper.FetchOptions = {
  fetchColumns: [
    photoAccessHelper.PhotoKeys.URI,
    photoAccessHelper.PhotoKeys.DISPLAY_NAME,
    photoAccessHelper.PhotoKeys.SIZE,
    photoAccessHelper.PhotoKeys.DATE_ADDED
  ],
  predicates: new dataSharePredicates.DataSharePredicates()
};

4. 文件系统 API

获取文件详细信息,如文件大小。

import { fileIo as fs } from '@kit.CoreFileKit';

// 获取文件统计信息
const file = fs.openSync(asset.uri, fs.OpenMode.READ_ONLY);
const stat = await fs.stat(file.fd);
const fileSize = stat.size;

核心组件介绍

1. 数据模型组件

ImageItem 类

图片项数据模型,使用 V2 状态管理。

@ObservedV2
class ImageItem {
  @Trace id: string = '';           // 图片唯一标识
  @Trace name: string = '';         // 图片名称
  @Trace size: number = 0;          // 文件大小
  @Trace createTime: string = '';   // 创建时间
  @Trace thumbnail: image.PixelMap | null = null;  // 缩略图
  @Trace photoAsset: photoAccessHelper.PhotoAsset | null = null;  // 原始资源
}

GalleryModel 类

图片浏览页面的状态管理模型。

@ObservedV2
class GalleryModel {
  @Trace images: ImageItem[] = [];          // 所有图片列表
  @Trace filteredImages: ImageItem[] = [];  // 过滤后的图片列表
  @Trace isLoading: boolean = false;       // 加载状态
  @Trace hasPermission: boolean = false;   // 权限状态
  @Trace isSearchMode: boolean = false;    // 搜索模式
}

2. UI 组件

主页面组件

使用 V2 组件架构,支持响应式状态管理。

@Entry
@ComponentV2
export struct Page01 {
  @Local localModel: GalleryModel = new GalleryModel();
  private context: common.UIAbilityContext = getContext(this) as common.UIAbilityContext;
  private phAccessHelper: photoAccessHelper.PhotoAccessHelper | null = null;
}

图片网格项组件

自定义 Builder 函数,用于构建单个图片项的 UI。

@Builder
buildImageItem(image: ImageItem) {
  Stack({ alignContent: Alignment.TopEnd }) {
    Column() {
      // 图片缩略图显示
      if (image.thumbnail) {
        Image(image.thumbnail)
          .width('100%')
          .height(120)
          .objectFit(ImageFit.Cover)
      } else {
        // 默认占位图
        Image($r('app.media.startIcon'))
          .backgroundColor('#F0F0F0')
      }
      
      // 图片信息显示
      Column() {
        Text(image.name)  // 图片名称
        Row() {
          Text(this.formatDateTime(image.createTime))  // 创建时间
          Text(this.formatFileSize(image.size))        // 文件大小
        }
      }
    }
  }
}

主要实现步骤

步骤 1:声明权限

modules.json5中声明权限

requestPermissions: [
    {
        name: "ohos.permission.READ_IMAGEVIDEO",
        reason: "$string:EntryAbility_label",
        usedScene: { abilities: [] },
    },
],

步骤 2:权限申请

在访问用户相册之前,必须先申请相应的权限。

private async requestPermission(): Promise<boolean> {
  try {
    const atManager = abilityAccessCtrl.createAtManager();
    const permission = 'ohos.permission.READ_IMAGEVIDEO';

    // 检查权限状态
    const grantStatus = await atManager.checkAccessToken(
      this.context.applicationInfo.accessTokenId, 
      permission
    );

    if (grantStatus === abilityAccessCtrl.GrantStatus.PERMISSION_GRANTED) {
      return true;
    }

    // 请求权限
    const requestResult: PermissionRequestResult = 
      await atManager.requestPermissionsFromUser(this.context, [permission]);

    return requestResult.authResults[0] === abilityAccessCtrl.GrantStatus.PERMISSION_GRANTED;
  } catch (error) {
    console.error('权限请求失败:', error);
    return false;
  }
}

步骤 3:初始化 PhotoAccessHelper

获取权限后,初始化媒体库访问助手。

private async initializeGallery(): Promise<void> {
  try {
    this.localModel.isLoading = true;

    // 请求权限
    const hasPermission = await this.requestPermission();
    if (!hasPermission) {
      this.localModel.hasPermission = false;
      return;
    }

    this.localModel.hasPermission = true;

    // 初始化 PhotoAccessHelper
    this.phAccessHelper = photoAccessHelper.getPhotoAccessHelper(this.context);

    // 加载图片
    await this.loadImagesFromAlbum();
  } catch (error) {
    console.error('初始化图库失败:', error);
  } finally {
    this.localModel.isLoading = false;
  }
}

步骤 4:查询图片资源

配置查询选项,获取用户相册中的图片。

private async loadImagesFromAlbum(): Promise<void> {
  if (!this.phAccessHelper) {
    console.error('PhotoAccessHelper未初始化');
    return;
  }

  try {
    // 创建获取选项
    const fetchOptions: photoAccessHelper.FetchOptions = {
      fetchColumns: [
        photoAccessHelper.PhotoKeys.URI,
        photoAccessHelper.PhotoKeys.DISPLAY_NAME,
        photoAccessHelper.PhotoKeys.SIZE,
        photoAccessHelper.PhotoKeys.DATE_ADDED
      ],
      predicates: new dataSharePredicates.DataSharePredicates()
    };

    // 按时间倒序排列
    fetchOptions.predicates?.orderByDesc(photoAccessHelper.PhotoKeys.DATE_ADDED);

    // 获取图片资源
    const fetchResult = await this.phAccessHelper.getAssets(fetchOptions);
    const photoAssets = await fetchResult.getAllObjects();

    // 处理图片数据...
  } catch (error) {
    console.error('加载相册图片失败:', error);
  }
}

步骤 5:处理图片数据

将获取的图片资源转换为应用内的数据模型。

// 转换为 ImageItem 数组
const imageItems: ImageItem[] = [];

for (let i = 0; i < Math.min(photoAssets.length, 50); i++) {
  const asset = photoAssets[i];
  try {
    // 获取缩略图
    const thumbnail = await asset.getThumbnail({ width: 300, height: 300 });

    // 获取文件大小
    let fileSize = 0;
    try {
      const file = fs.openSync(asset.uri, fs.OpenMode.READ_ONLY);
      const stat = await fs.stat(file.fd);
      fileSize = stat.size;
      fs.closeSync(file.fd);
    } catch (sizeError) {
      console.error(`获取文件大小失败:`, sizeError);
      fileSize = 0;
    }

    // 获取创建时间
    const dateAdded = asset.get(photoAccessHelper.PhotoKeys.DATE_ADDED) as number;
    const createTime = new Date(dateAdded * 1000).toISOString();

    // 创建 ImageItem 实例
    const imageItem = new ImageItem(
      asset.uri,
      asset.displayName || `图片_${i + 1}`,
      fileSize,
      createTime,
      thumbnail,
      asset
    );

    imageItems.push(imageItem);
  } catch (error) {
    console.error(`处理图片失败:`, error);
  }
}

步骤 6:UI 状态管理

根据不同状态显示相应的 UI 界面。

build() {
  Column() {
    if (this.localModel.isLoading) {
      // 加载状态
      Column() {
        LoadingProgress()
        Text('正在加载图片...')
      }
    } else if (!this.localModel.hasPermission) {
      // 权限提示
      Column() {
        Image($r('app.media.lightbulb'))
        Text('需要访问相册权限')
        Button('重新授权')
          .onClick(() => {
            this.initializeGallery();
          })
      }
    } else if (this.localModel.images.length === 0) {
      // 空状态
      Column() {
        Image($r('app.media.grid_horizontal'))
        Text('暂无图片')
      }
    } else {
      // 图片网格
      Scroll() {
        Grid() {
          ForEach(this.localModel.images, (image: ImageItem) => {
            GridItem() {
              this.buildImageItem(image)
            }
          })
        }
        .columnsTemplate('1fr 1fr 1fr')
        .rowsGap(12)
        .columnsGap(12)
      }
    }
  }
}

完整代码

import { dataSharePredicates } from '@kit.ArkData';
import { image } from '@kit.ImageKit';
import { abilityAccessCtrl, common, PermissionRequestResult } from '@kit.AbilityKit';
import { photoAccessHelper } from '@kit.MediaLibraryKit';
import { fileIo as fs } from '@kit.CoreFileKit';

// 图片项数据模型
@ObservedV2
class ImageItem {
  @Trace id: string = '';
  @Trace name: string = '';
  @Trace size: number = 0;
  @Trace createTime: string = '';
  @Trace thumbnail: image.PixelMap | null = null;
  @Trace photoAsset: photoAccessHelper.PhotoAsset | null = null;

  constructor(id: string, name: string, size: number, createTime: string,
    thumbnail: image.PixelMap | null = null, photoAsset: photoAccessHelper.PhotoAsset | null = null) {
    this.id = id;
    this.name = name;
    this.size = size;
    this.createTime = createTime;
    this.thumbnail = thumbnail;
    this.photoAsset = photoAsset;
  }
}

// 图片浏览页面数据模型
@ObservedV2
class GalleryModel {
  @Trace images: ImageItem[] = [];
  @Trace filteredImages: ImageItem[] = [];
  @Trace isLoading: boolean = false;
  @Trace hasPermission: boolean = false;
  @Trace isSearchMode: boolean = false;
}

@Entry
@ComponentV2
export struct Page01 {
  @Local localModel: GalleryModel = new GalleryModel();
  private context: common.UIAbilityContext = getContext(this) as common.UIAbilityContext;
  private phAccessHelper: photoAccessHelper.PhotoAccessHelper | null = null;

  aboutToAppear() {
    this.initializeGallery();
    // 初始化过滤后的图片列表
    this.localModel.filteredImages = this.localModel.images;
  }

  // 构建图片网格项
  @Builder
  buildImageItem(image: ImageItem) {
    Stack({ alignContent: Alignment.TopEnd }) {
      // 图片缩略图
      Column() {
        if (image.thumbnail) {
          Image(image.thumbnail)
            .width('100%')
            .height(120)
            .objectFit(ImageFit.Cover)
            .borderRadius({ topLeft: 8, topRight: 8 })
        } else {
          Image($r('app.media.startIcon'))
            .width('100%')
            .height(120)
            .objectFit(ImageFit.Cover)
            .borderRadius({ topLeft: 8, topRight: 8 })
            .backgroundColor('#F0F0F0')
        }

        // 图片信息
        Column() {
          Text(image.name)
            .fontSize(12)
            .fontColor('#333333')
            .maxLines(1)
            .textOverflow({ overflow: TextOverflow.Ellipsis })
            .width('100%')

          // 时间和文件大小在同一行
          Row() {
            Text(this.formatDateTime(image.createTime))
              .fontSize(10)
              .fontColor('#666666')
              .layoutWeight(1)
              .maxLines(1)
              .textOverflow({ overflow: TextOverflow.Ellipsis })

            Text(this.formatFileSize(image.size))
              .fontSize(10)
              .fontColor('#999999')
          }
          .width('100%')
          .margin({ top: 2 })
        }
        .alignItems(HorizontalAlign.Start)
        .padding(8)
        .width('100%')
      }
      .backgroundColor('#FFFFFF')
      .borderRadius(8)
      .shadow({
        radius: 4,
        color: '#1A000000',
        offsetX: 0,
        offsetY: 2
      })
    }
  }

  build() {
    Column() {
      if (this.localModel.isLoading) {
        // 加载状态
        Column() {
          LoadingProgress()
            .width(40)
            .height(40)
            .color('#2196F3')

          Text('正在加载图片...')
            .fontSize(14)
            .fontColor('#666666')
            .margin({ top: 16 })
        }
        .layoutWeight(1)
        .justifyContent(FlexAlign.Center)
        .alignItems(HorizontalAlign.Center)
      } else if (!this.localModel.hasPermission) {
        // 权限提示
        Column() {
          Image($r('app.media.lightbulb'))
            .width(64)
            .height(64)
            .fillColor('#FFA726')

          Text('需要访问相册权限')
            .fontSize(18)
            .fontWeight(FontWeight.Medium)
            .fontColor('#333333')
            .margin({ top: 16 })

          Text('请在设置中开启相册访问权限,以便浏览和管理您的图片')
            .fontSize(14)
            .fontColor('#666666')
            .textAlign(TextAlign.Center)
            .margin({ top: 8, left: 32, right: 32 })

          Button('重新授权')
            .type(ButtonType.Capsule)
            .backgroundColor('#2196F3')
            .fontColor('#FFFFFF')
            .fontSize(16)
            .padding({
              left: 24,
              right: 24,
              top: 12,
              bottom: 12
            })
            .margin({ top: 24 })
            .onClick(() => {
              this.initializeGallery();
            })
        }
        .layoutWeight(1)
        .justifyContent(FlexAlign.Center)
        .alignItems(HorizontalAlign.Center)
      } else if ((this.localModel.isSearchMode ? this.localModel.filteredImages : this.localModel.images).length === 0) {
        // 空状态
        Column() {
          Image($r(this.localModel.isSearchMode ? 'app.media.search' : 'app.media.grid_horizontal'))
            .width(64)
            .height(64)
            .fillColor('#BDBDBD')

          Text(this.localModel.isSearchMode ? '未找到相关图片' : '暂无图片')
            .fontSize(18)
            .fontWeight(FontWeight.Medium)
            .fontColor('#333333')
            .margin({ top: 16 })

          Text(this.localModel.isSearchMode ? '尝试使用其他关键词搜索' : '您的相册中还没有图片')
            .fontSize(14)
            .fontColor('#666666')
            .margin({ top: 8 })
        }
        .layoutWeight(1)
        .justifyContent(FlexAlign.Center)
        .alignItems(HorizontalAlign.Center)
      } else {
        // 图片网格
        Scroll() {
          Grid() {
            ForEach(this.localModel.isSearchMode ? this.localModel.filteredImages : this.localModel.images,
              (image: ImageItem) => {
                GridItem() {
                  this.buildImageItem(image)
                }
              })
          }
          .columnsTemplate('1fr 1fr 1fr')
          .rowsGap(12)
          .columnsGap(12)
          .padding(16)
        }
        .layoutWeight(1)
        .scrollable(ScrollDirection.Vertical)
        .scrollBar(BarState.Auto)
      }
    }
    .width('100%')
    .height('100%')
    .backgroundColor('#F5F5F5')
  }

  // 格式化时间显示
  formatDateTime(dateTimeString: string): string {
    try {
      const date = new Date(dateTimeString);
      const now = new Date();
      const diffTime = now.getTime() - date.getTime();
      const diffDays = Math.floor(diffTime / (1000 * 60 * 60 * 24));

      if (diffDays === 0) {
        // 今天
        return `今天 ${date.getHours().toString().padStart(2, '0')}:${date.getMinutes().toString().padStart(2, '0')}`;
      } else if (diffDays === 1) {
        // 昨天
        return `昨天 ${date.getHours().toString().padStart(2, '0')}:${date.getMinutes().toString().padStart(2, '0')}`;
      } else if (diffDays < 7) {
        // 一周内
        const weekdays = ['周日', '周一', '周二', '周三', '周四', '周五', '周六'];
        return `${weekdays[date.getDay()]} ${date.getHours().toString().padStart(2, '0')}:${date.getMinutes()
          .toString()
          .padStart(2, '0')}`;
      } else {
        // 超过一周
        return `${date.getFullYear()}/${(date.getMonth() + 1).toString().padStart(2, '0')}/${date.getDate()
          .toString()
          .padStart(2, '0')}`;
      }
    } catch (error) {
      return dateTimeString; // 如果解析失败,返回原始字符串
    }
  }

  // 初始化图库
  private async initializeGallery(): Promise<void> {
    try {
      this.localModel.isLoading = true;

      // 请求权限
      const hasPermission = await this.requestPermission();
      if (!hasPermission) {
        console.error('权限被拒绝,无法访问相册');
        this.localModel.hasPermission = false;
        this.localModel.isLoading = false;
        return;
      }

      this.localModel.hasPermission = true;

      // 初始化PhotoAccessHelper
      this.phAccessHelper = photoAccessHelper.getPhotoAccessHelper(this.context);

      // 加载图片
      await this.loadImagesFromAlbum();

    } catch (error) {
      console.error('初始化图库失败:', error);
    } finally {
      this.localModel.isLoading = false;
    }
  }

  // 请求相册读取权限
  private async requestPermission(): Promise<boolean> {
    try {
      const atManager = abilityAccessCtrl.createAtManager();
      const permission = 'ohos.permission.READ_IMAGEVIDEO';

      // 检查权限状态
      const grantStatus = await atManager.checkAccessToken(this.context.applicationInfo.accessTokenId, permission);

      if (grantStatus === abilityAccessCtrl.GrantStatus.PERMISSION_GRANTED) {
        return true;
      }

      // 请求权限
      const requestResult: PermissionRequestResult =
        await atManager.requestPermissionsFromUser(this.context, [permission]);

      return requestResult.authResults[0] === abilityAccessCtrl.GrantStatus.PERMISSION_GRANTED;

    } catch (error) {
      console.error('权限请求失败:', error);
      return false;
    }
  }

  // 从相册加载图片数据
  private async loadImagesFromAlbum(): Promise<void> {
    if (!this.phAccessHelper) {
      console.error('PhotoAccessHelper未初始化');
      return;
    }

    try {
      // 创建获取选项
      const fetchOptions: photoAccessHelper.FetchOptions = {
        fetchColumns: [
          photoAccessHelper.PhotoKeys.URI,
          photoAccessHelper.PhotoKeys.DISPLAY_NAME,
          photoAccessHelper.PhotoKeys.SIZE,
          photoAccessHelper.PhotoKeys.DATE_ADDED
        ],
        predicates: new dataSharePredicates.DataSharePredicates()
      };

      // 按时间倒序排列
      fetchOptions.predicates?.orderByDesc(photoAccessHelper.PhotoKeys.DATE_ADDED);

      // 获取图片资源
      const fetchResult = await this.phAccessHelper.getAssets(fetchOptions);
      const photoAssets = await fetchResult.getAllObjects();

      // 转换为ImageItem数组
      const imageItems: ImageItem[] = [];

      for (let i = 0; i < Math.min(photoAssets.length, 50); i++) {
        const asset = photoAssets[i];
        try {
          // 获取缩略图
          const thumbnail = await asset.getThumbnail({ width: 300, height: 300 });

          // 获取文件大小
          let fileSize = 0;
          try {
            const file = fs.openSync(asset.uri, fs.OpenMode.READ_ONLY);
            const stat = await fs.stat(file.fd);
            fileSize = stat.size;
            fs.closeSync(file.fd);
          } catch (sizeError) {
            console.error(`获取文件大小失败 ${asset.displayName}:`, sizeError);
            fileSize = 0;
          }

          // 获取图片的真实创建时间
          const dateAdded = asset.get(photoAccessHelper.PhotoKeys.DATE_ADDED) as number;
          const createTime = new Date(dateAdded * 1000).toISOString();

          const imageItem = new ImageItem(
            asset.uri,
            asset.displayName || `图片_${i + 1}`,
            fileSize,
            createTime,
            thumbnail,
            asset
          );

          imageItems.push(imageItem);
        } catch (thumbnailError) {
          console.error(`获取缩略图失败 ${asset.displayName}:`, thumbnailError);

          // 即使缩略图获取失败,也尝试获取文件大小
          let fileSize = 0;
          try {
            const file = fs.openSync(asset.uri, fs.OpenMode.READ_ONLY);
            const stat = await fs.stat(file.fd);
            fileSize = stat.size;
            fs.closeSync(file.fd);
          } catch (sizeError) {
            console.error(`获取文件大小失败 ${asset.displayName}:`, sizeError);
            fileSize = 0;
          }

          // 获取图片的真实创建时间
          const dateAdded = asset.get(photoAccessHelper.PhotoKeys.DATE_ADDED) as number;
          const createTime = new Date(dateAdded * 1000).toISOString();

          const imageItem = new ImageItem(
            asset.uri,
            asset.displayName || `图片_${i + 1}`,
            fileSize,
            createTime,
            null,
            asset
          );
          imageItems.push(imageItem);
        }
      }

      this.localModel.images = imageItems;
      this.localModel.filteredImages = imageItems;
      console.info(`成功加载 ${imageItems.length} 张图片`);

      // 关闭fetchResult
      fetchResult.close();

    } catch (error) {
      console.error('加载相册图片失败:', error);
      // 如果加载失败,显示空状态
      this.localModel.images = [];
      this.localModel.filteredImages = [];
    }
  }

  // 格式化文件大小
  private formatFileSize(bytes: number): string {
    if (bytes === 0) {
      return '0 B';
    }
    const k = 1024;
    const sizes = ['B', 'KB', 'MB', 'GB'];
    const i = Math.floor(Math.log(bytes) / Math.log(k));
    return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
  }
}

关键技术要点

1. 权限管理

  • 必须在 module.json5 中声明权限
  • 运行时动态申请权限
  • 处理权限被拒绝的情况

2. 性能优化

  • 限制加载图片数量(示例中限制为50张)
  • 使用缩略图而非原图显示
  • 异步加载,避免阻塞 UI 线程

3. 错误处理

  • 权限申请失败处理
  • 图片加载失败的降级方案
  • 文件访问异常的容错机制

4. 状态管理

  • 使用 V2 状态管理架构
  • 响应式数据更新
  • 组件状态与数据模型分离

总结

通过以上步骤,我们成功实现了 HarmonyOS 应用中读取相册图片并预览的功能。该实现方案具有以下特点:

  1. 安全性:严格的权限管理机制
  2. 性能:优化的图片加载和显示策略
  3. 用户体验:完善的加载状态和错误提示
  4. 可维护性:清晰的代码结构和状态管理

这个实现为开发者提供了一个完整的相册访问解决方案,可以作为类似功能开发的参考模板。

关于我们

关于青蓝逐码组织

如果你兴趣想要了解更多的鸿蒙应用开发细节和最新资讯甚至你想要做出一款属于自己的应用!欢迎在评论区留言或者私信或者看我个人信息,可以加入技术交流群。

image-20250622200325374


我们目前已经孵化了6个上架的鸿蒙作品

image-20250804212748706

收藏00

登录 后评论。没有帐号? 注册 一个。