前言

原生file.picker相册选择 API 使用繁琐,每次调用都要重复写权限判断、图片读取、尺寸压缩、沙盒缓存写入逻辑,大量冗余代码。本文封装一套完整 ImagePickerUtil 工具,集成权限校验、单选 / 多选、图片自动压缩、图片保存到沙盒、释放 PixelMap 内存,搭配前文 PermissionUtil 权限工具,一行代码唤起相册,适配 HSP、entry、所有 HAR 页面直接调用。

一、工具完整源码 har_base/utils/image_picker_util.ets

ets

import picker from '@ohos.file.picker';
import image from '@ohos.multimedia.image';
import fs from '@ohos.file.fs';
import common from '@ohos.app.ability.common';
import PermissionUtil, { PermType } from './permission_util';
import FileUtil from './file_util';
import LogUtil from './log_util';

// 图片压缩配置
export interface ImageCompressOption {
  maxWidth: number;
  maxHeight: number;
  quality: number; // 0-100
}

// 相册返回图片实体
export interface MediaImage {
  uri: string;
  sandBoxPath: string;
  pixelMap?: image.PixelMap;
}

class ImagePickerUtil {
  private ctx: common.UIAbilityContext | null = null;
  // 默认压缩规则
  private readonly defaultOption: ImageCompressOption = {
    maxWidth: 1080,
    maxHeight: 1920,
    quality: 70
  };

  setContext(ctx: common.UIAbilityContext) {
    this.ctx = ctx;
  }

  // 释放图片内存,防止内存泄漏
  private releasePixelMap(pixelMap: image.PixelMap | undefined) {
    if (pixelMap) {
      pixelMap.release();
      LogUtil.debug("ImagePicker", "PixelMap资源释放完成");
    }
  }

  // 图片压缩并写入沙盒缓存目录
  private async compressAndSave(pixelMap: image.PixelMap, opt: ImageCompressOption): Promise<string | null> {
    try {
      // 缩放图片尺寸
      const info = pixelMap.getImageInfo();
      let targetW = info.width;
      let targetH = info.height;
      if (targetW > opt.maxWidth || targetH > opt.maxHeight) {
        const scale = Math.min(opt.maxWidth / targetW, opt.maxHeight / targetH);
        targetW = Math.floor(targetW * scale);
        targetH = Math.floor(targetH * scale);
      }
      const resizer = image.createImageResizer(pixelMap);
      const resizeMap = resizer.resizeSync(targetW, targetH);
      resizer.release();

      // 生成缓存文件路径
      const cacheDir = FileUtil.getCacheDir();
      const fileName = `img_${new Date().getTime()}.jpg`;
      const savePath = `${cacheDir}/${fileName}`;
      await FileUtil.mkdir(cacheDir);

      // 写入文件
      const packer = image.createImagePacker();
      const packOpt: image.PackingOption = {
        format: image.ImageFormat.JPEG,
        quality: opt.quality
      };
      const fileFd = await fs.open(savePath, fs.OpenMode.CREATE | fs.OpenMode.WRITE_ONLY);
      await packer.packToFile(resizeMap, fileFd.fd, packOpt);
      packer.release();
      resizeMap.release();
      fs.closeSync(fileFd);
      return savePath;
    } catch (e) {
      LogUtil.error("ImagePicker", "图片压缩失败", e);
      return null;
    }
  }

  // 单选相册图片
  async pickSingle(compressOpt?: ImageCompressOption): Promise<MediaImage | null> {
    // 先校验媒体读取权限
    const hasPerm = await PermissionUtil.requestPermission(PermType.IMAGE);
    if (!hasPerm) return null;
    if (!this.ctx) {
      LogUtil.error("ImagePicker", "上下文未初始化");
      return null;
    }
    try {
      // 创建相册选择器
      const photoPicker = new picker.PhotoViewPicker();
      const res = await photoPicker.select({
        MIMEType: picker.PhotoViewMIMETypes.IMAGE_TYPE,
        maxSelectNumber: 1
      });
      if (res.photoUris.length === 0) return null;
      const uri = res.photoUris[0];
      // 读取图片PixelMap
      const imgSource = image.createImageSource(uri);
      const pixelMap = imgSource.createPixelMapSync();
      imgSource.release();
      // 压缩保存
      const opt = compressOpt ?? this.defaultOption;
      const sandBoxPath = await this.compressAndSave(pixelMap, opt);
      if (!sandBoxPath) {
        this.releasePixelMap(pixelMap);
        return null;
      }
      const result: MediaImage = {
        uri,
        sandBoxPath,
        pixelMap
      };
      return result;
    } catch (err) {
      LogUtil.error("ImagePicker", "单选图片异常", err);
      return null;
    }
  }

  // 多选相册图片
  async pickMulti(maxCount: number, compressOpt?: ImageCompressOption): Promise<MediaImage[]> {
    const hasPerm = await PermissionUtil.requestPermission(PermType.IMAGE);
    if (!hasPerm) return [];
    if (!this.ctx) return [];
    const list: MediaImage[] = [];
    try {
      const photoPicker = new picker.PhotoViewPicker();
      const res = await photoPicker.select({
        MIMEType: picker.PhotoViewMIMETypes.IMAGE_TYPE,
        maxSelectNumber: maxCount
      });
      for (const uri of res.photoUris) {
        const imgSource = image.createImageSource(uri);
        const pixelMap = imgSource.createPixelMapSync();
        imgSource.release();
        const opt = compressOpt ?? this.defaultOption;
        const sandBoxPath = await this.compressAndSave(pixelMap, opt);
        if (sandBoxPath) {
          list.push({ uri, sandBoxPath, pixelMap });
        } else {
          this.releasePixelMap(pixelMap);
        }
      }
      return list;
    } catch (err) {
      LogUtil.error("ImagePicker", "多选图片异常", err);
      return [];
    }
  }

  // 清理页面图片资源
  clearImageResource(images: MediaImage[]) {
    images.forEach(item => this.releasePixelMap(item.pixelMap));
  }
}

export default new ImagePickerUtil();

二、工具初始化配置

1. EntryAbility 注入上下文(和前文统一)

ets

// EntryAbility onCreate
ImagePickerUtil.setContext(this.context);

三、页面调用示例

示例 1:编辑器页面单选图片(HSP 分包 EditorPage)

ets

import ImagePickerUtil, { MediaImage } from '@ohos:har_base/utils/image_picker_util'
import DialogUtil from '@ohos:har_base/utils/dialog_util'
import ThemeUtil from '@ohos:har_base/utils/theme'

@Entry
@Component
struct EditorPage {
  @State imgList: MediaImage[] = []

  // 唤起相册选图
  async selectImage() {
    const img = await ImagePickerUtil.pickSingle({
      maxWidth: 720,
      maxHeight: 1280,
      quality: 60
    });
    if (img) {
      this.imgList.push(img);
      DialogUtil.alert({ content: "图片选择成功,已自动压缩" });
    }
  }

  // 页面销毁释放图片内存
  aboutToDisappear() {
    ImagePickerUtil.clearImageResource(this.imgList);
  }

  build() {
    const color = ThemeUtil.getColor();
    const size = ThemeUtil.getSize();
    Column({ space: size.gapMd })
      .width("100%")
      .height("100%")
      .padding(size.gapMd)
      .backgroundColor(color.pageBg)
    {
      Button("选择相册图片")
        .width("100%")
        .height(size.btnNormal)
        .backgroundColor(color.primary)
        .onClick(() => this.selectImage())

      // 图片预览
      List({ space: size.gapSm }) {
        ForEach(this.imgList, (item: MediaImage) => {
          ListItem() {
            Image(item.sandBoxPath)
              .width("100%")
              .height(200)
              .objectFit(ImageFit.Contain)
              .backgroundColor("#00000010")
          }
        })
      }
      .layoutWeight(1)
    }
  }
}

示例 2:个人中心头像上传(entry/mine/Mine.ets)

ets

import ImagePickerUtil, { MediaImage } from '@ohos:har_base/utils/image_picker_util'
import ThemeUtil from '@ohos:har_base/utils/theme'

@Entry
@Component
struct MinePage {
  @State avatarPath: string = ""

  async changeAvatar() {
    // 头像专用压缩尺寸
    const img = await ImagePickerUtil.pickSingle({
      maxWidth: 300,
      maxHeight: 300,
      quality: 80
    });
    if (img) {
      this.avatarPath = img.sandBoxPath;
    }
  }

  build() {
    const color = ThemeUtil.getColor();
    const size = ThemeUtil.getSize();
    Column()
      .width("100%")
      .padding(size.gapLg)
      .backgroundColor(color.pageBg)
    {
      Image(this.avatarPath || $r("sys.media.ohos_ic_public_avatar"))
        .width(120)
        .height(120)
        .borderRadius(60)
        .margin({ bottom: size.gapLg })
        .onClick(() => this.changeAvatar())
      Text("点击头像更换相册图片")
        .fontColor(color.textSecondary)
    }
  }
}

四、工具核心能力说明

  1. 权限全自动管理 内部自动调用 PermissionUtil 校验媒体相册权限,未授权自动弹窗申请,永久拒绝则跳转系统设置,业务页面无需写任何权限判断代码。

  2. 自动压缩逻辑 支持自定义宽高上限、画质质量,大图自动等比例缩放,减少图片占用存储与内存,上传接口时降低流量消耗。

  3. 沙盒持久缓存 选择后的图片自动写入应用私有 cache 缓存目录,统一管理,可通过 FileUtil.clearCache () 一键全部清理。

  4. 内存自动管控 封装 releasePixelMap 统一释放图像资源,页面销毁调用 clearImageResource 彻底回收,避免反复选图造成内存持续上涨。

  5. 单选 / 多选双接口 单选用于头像、单图上传;多选用于笔记、朋友圈多图场景,限制最大选择数量。

五、常见踩坑解决方案

  1. 图片选择后页面内存持续升高 问题:未释放 PixelMap 图像资源。 解决:页面 aboutToDisappear 调用 ImagePickerUtil.clearImageResource 清空图片数组。

  2. 相册唤起直接失败无弹窗 问题:未提前注入上下文,或者权限未授予。 解决:确认 EntryAbility 执行 ImagePickerUtil.setContext,工具内部会自动申请媒体权限。

  3. 压缩后图片模糊 解决:调高 quality 参数(80-90),适当增大 maxWidth/maxHeight 尺寸。

  4. HSP 分包调用图片工具失效 解决:工具存放在 har_base,HSP 已依赖 har_base,导入路径@ohos:har_base/utils/image_picker_util即可正常使用。

  5. 缓存图片太多占用存储空间 解决:设置页面调用 FileUtil.clearCache () 一键清空所有压缩图片缓存。

六、搭配前文架构规范总结

  1. 所有图片相关操作统一使用 ImagePickerUtil,禁止直接调用原生 picker/image API;
  2. 页面退出必须释放 PixelMap 资源,遵守统一生命周期规范;
  3. 业务区分头像、笔记附件两套压缩配置,按需调整尺寸画质;
  4. 缓存图片统一存放在 cacheDir,不占用永久 files 目录空间。
Logo

开源鸿蒙跨平台开发社区汇聚开发者与厂商,共建“一次开发,多端部署”的开源生态,致力于降低跨端开发门槛,推动万物智联创新。

更多推荐