摘要

原生媒体图库、相机拍照接口逻辑繁琐:需要先校验动态权限、区分相册单选 / 多选、相机拍摄、图片压缩、沙盒路径转换、保存到相册、权限拒绝引导弹窗、回调封装。封装统一图片工具,整合相册单选、相册多选、相机拍照、图片压缩、本地保存五大能力,依赖前文 PermissionUtil 权限工具、DialogUtil 弹窗工具、文件工具统一串联,省去每个页面重复写权限判断、媒体拉起逻辑。API23 重构媒体选择器、图片沙盒访问、媒体存储写入权限逻辑,修复多选图片路径失效、拍照保存相册失败、高分辨率大图 OOM、权限回调页面销毁报错等问题。

关键词

OpenHarmony;媒体图库;ImagePicker;相机拍照;图片压缩;媒体存储;动态权限;沙盒文件

一、引言

1.1 原生媒体开发痛点

  1. 每次打开相册 / 相机都要手动校验读写媒体、相机两套动态权限,代码重复;
  2. 媒体选择器无统一回调,单选、多选参数写法不一致;
  3. 拍摄原图分辨率过高,直接上传容易内存溢出、上传缓慢;
  4. 拍摄图片默认存在应用沙盒,需要额外逻辑写入系统相册;
  5. 用户永久拒绝媒体权限无弹窗引导跳转设置;
  6. 页面销毁后媒体选择回调执行,操作已销毁页面组件崩溃。

1.2 依赖模块

ets

import picker from '@ohos.file.picker'
import camera from '@ohos.multimedia.camera'
import image from '@ohos.multimedia.image'
import fileIo from '@ohos.fileio'
import mediaLibrary from '@ohos.multimedia.mediaLibrary'
import common from '@ohos.app.ability.common'

配套依赖工具:PermissionUtil、DialogUtil、FileUtil

API23 媒体核心变更

  1. 废弃旧版 photoAccessHelper,统一使用 file.picker 图库选择器;
  2. 媒体文件路径区分沙盒私有路径、公共媒体路径,转换规则标准化;
  3. 新增图片解码压缩 API,限制宽高降低内存占用;
  4. 媒体写入相册强制校验 WRITE_MEDIA 动态权限;
  5. 媒体选择弹窗生命周期与页面绑定,页面销毁自动关闭选择器。

二、完整图片工具封装 utils/image_picker_util.ets

ets

import picker from '@ohos.file.picker'
import image from '@ohos.multimedia.image'
import mediaLibrary from '@ohos.multimedia.mediaLibrary'
import fileIo from '@ohos.fileio'
import common from '@ohos.app.ability.common'
import promptAction from '@ohos.promptAction'
import PermissionUtil, { PERMISSION } from './permission_util'
import DialogUtil from './dialog_util'

// 图片返回结果类型
export interface ImageResult {
  uri: string
  path: string
  pixelMap?: image.PixelMap
}

// 图片压缩配置
interface CompressOption {
  width: number
  height: number
  quality: number // 0-100
}

class ImagePickerUtil {
  private static instance: ImagePickerUtil
  private ctx: common.UIAbilityContext | null = null

  static getInstance(): ImagePickerUtil {
    if (!ImagePickerUtil.instance) {
      ImagePickerUtil.instance = new ImagePickerUtil()
    }
    return ImagePickerUtil.instance
  }

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

  // 压缩图片,返回PixelMap
  async compressImage(uri: string, opt: CompressOption): Promise<image.PixelMap | null> {
    try {
      const source = image.createImageSource(uri)
      const decodeOpt: image.DecodingOptions = {
        desiredSize: { width: opt.width, height: opt.height }
      }
      const pixelMap = await source.createPixelMap(decodeOpt)
      source.release()
      return pixelMap
    } catch (err) {
      promptAction.showToast({ message: "图片压缩失败" })
      console.error("compress err", err)
      return null
    }
  }

  // PixelMap保存到应用沙盒
  async savePixelToSandbox(pixelMap: image.PixelMap, fileName: string, quality: number = 70): Promise<string | null> {
    if (!this.ctx) return null
    const outPath = this.ctx.filesDir + "/" + fileName + ".jpg"
    const packer = image.createImagePacker()
    const packOpt: image.PackingOption = {
      format: image.ImageFormat.JPEG,
      quality: quality
    }
    const packData = await packer.packing(pixelMap, packOpt)
    const fd = await fileIo.open(outPath, fileIo.OpenMode.CREATE | fileIo.OpenMode.WRITE_ONLY)
    await fileIo.write(fd, packData)
    fileIo.close(fd)
    packer.release()
    return outPath
  }

  // 保存图片到系统公共相册
  async saveImageToAlbum(filePath: string): Promise<boolean> {
    if (!this.ctx) return false
    // 校验相册写入权限
    const hasWrite = PermissionUtil.checkPermission(PERMISSION.WRITE_MEDIA)
    if (!hasWrite) {
      promptAction.showToast({ message: "无相册写入权限" })
      return false
    }
    try {
      const media = mediaLibrary.getMediaLibrary(this.ctx)
      const fileInfo = await media.createAsset(mediaLibrary.MediaType.IMAGE, "img_" + Date.now() + ".jpg", mediaLibrary.DirectoryType.DIR_IMAGE)
      const targetFd = await fileInfo.open(mediaLibrary.FileMode.WRITE)
      const sourceFd = await fileIo.open(filePath, fileIo.OpenMode.READ_ONLY)
      const buf = new ArrayBuffer(1024 * 1024)
      let len: number
      while ((len = await fileIo.read(sourceFd, buf)) > 0) {
        await fileIo.write(targetFd, buf.slice(0, len))
      }
      fileIo.close(sourceFd)
      fileIo.close(targetFd)
      await fileInfo.close()
      await media.release()
      return true
    } catch (err) {
      console.error("save album err", err)
      return false
    }
  }

  // 打开相册 单选图片
  async pickSingleImage(compressOpt?: CompressOption): Promise<ImageResult | null> {
    // 校验相册读写权限
    let authOk = false
    await new Promise<void>(res => {
      PermissionUtil.reqImage((granted) => {
        authOk = granted
        res()
      })
    })
    if (!authOk) return null

    try {
      const pickerOpts = new picker.PhotoPickerOptions()
      pickerOpts.MIMEType = picker.MIMEType.IMAGE
      pickerOpts.isMultiSelect = false
      const photoPicker = new picker.PhotoPicker(pickerOpts)
      const resultList = await photoPicker.select()
      if (resultList.length === 0) return null
      const item = resultList[0]
      const res: ImageResult = {
        uri: item.uri,
        path: item.fileUri
      }
      // 压缩处理
      if (compressOpt) {
        res.pixelMap = await this.compressImage(item.uri, compressOpt)
      }
      return res
    } catch (err) {
      promptAction.showToast({ message: "选择图片失败" })
      return null
    }
  }

  // 打开相册 多选图片
  async pickMultiImage(maxCount: number, compressOpt?: CompressOption): Promise<ImageResult[]> {
    let authOk = false
    await new Promise<void>(res => {
      PermissionUtil.reqImage((granted) => {
        authOk = granted
        res()
      })
    })
    if (!authOk) return []

    try {
      const pickerOpts = new picker.PhotoPickerOptions()
      pickerOpts.MIMEType = picker.MIMEType.IMAGE
      pickerOpts.isMultiSelect = true
      pickerOpts.maxSelectNumber = maxCount
      const photoPicker = new picker.PhotoPicker(pickerOpts)
      const resultList = await photoPicker.select()
      const outList: ImageResult[] = []
      for (const item of resultList) {
        const obj: ImageResult = { uri: item.uri, path: item.fileUri }
        if (compressOpt) {
          obj.pixelMap = await this.compressImage(item.uri, compressOpt)
        }
        outList.push(obj)
      }
      return outList
    } catch (err) {
      promptAction.showToast({ message: "多选图片失败" })
      return []
    }
  }

  // 唤起相机拍照(简化封装)
  async takePhoto(compressOpt?: CompressOption): Promise<ImageResult | null> {
    let cameraAuth = false
    await new Promise<void>(res => {
      PermissionUtil.reqCamera((granted) => {
        cameraAuth = granted
        res()
      })
    })
    if (!cameraAuth) return null
    // 简易相机拍摄流程,实际项目可封装独立相机页面
    DialogUtil.alert({ content: "当前为工具模拟相机,真实项目需独立相机页面承载camera组件" })
    return null
  }
}

export default ImagePickerUtil.getInstance()

三、页面实战调用示例

3.1 头像上传页面(相册单选 + 压缩)

ets

import ImagePickerUtil, { ImageResult } from '../utils/image_picker_util'
import HttpUtil from '../utils/http_util'

@Entry
@Component
struct AvatarPage {
  @State avatarUri: ResourceStr = $r("sys.media.ohos_ic_public_camera")

  aboutToAppear() {
    ImagePickerUtil.setContext(getContext(this))
  }

  // 选择头像
  async selectAvatar() {
    // 压缩限制宽高500*500,画质75
    const compress = { width: 500, height: 500, quality: 75 }
    const imgRes = await ImagePickerUtil.pickSingleImage(compress)
    if (!imgRes || !imgRes.pixelMap) return
    // 保存压缩图到沙盒
    const savePath = await ImagePickerUtil.savePixelToSandbox(imgRes.pixelMap, "avatar_temp")
    if (!savePath) return
    this.avatarUri = imgRes.uri
    // 调用上传接口
    await HttpUtil.uploadFile("/upload/avatar", savePath, "avatar.jpg")
  }

  build() {
    Column({ space: 20 }) {
      Image(this.avatarUri)
        .width(120)
        .height(120)
        .borderRadius(60)
      Button("更换头像(相册单选)")
        .width(300)
        .onClick(() => this.selectAvatar())
    }
    .width("100%")
    .height("100%")
    .justifyContent(Center)
  }
}

3.2 发布动态页面(相册多选 9 张)

ets

// 最多选9张图
async function pickMultiImg() {
  const imgList = await ImagePickerUtil.pickMultiImage(9, { width: 800, height: 800, quality: 70 })
  console.info("选中图片数量:", imgList.length)
  // 循环上传
  for (const img of imgList) {
    if (!img.pixelMap) continue
    const path = await ImagePickerUtil.savePixelToSandbox(img.pixelMap, "dynamic_" + Date.now())
    await HttpUtil.uploadFile("/dynamic/upload", path, "img.jpg")
  }
}

3.3 保存网络图片到本地相册

ets

// 下载图片后存入公共相册
async function saveNetImage(localSandboxPath: string) {
  const ok = await ImagePickerUtil.saveImageToAlbum(localSandboxPath)
  if (ok) {
    promptAction.showToast({ message: "图片已保存到相册" })
  }
}

四、开发编码规范

4.1 权限规范

  1. 打开相册自动校验 READ_MEDIA_IMAGES + WRITE_MEDIA;
  2. 拍照仅校验 CAMERA;
  3. 保存到相册单独校验 WRITE_MEDIA,无权限拦截保存操作。

4.2 图片内存优化规范

  1. 从相册原图必须压缩后再解码、上传,防止 PixelMap 大图 OOM 闪退;
  2. 头像类小图限制 500px 以内,动态配图限制 800px;
  3. 使用完毕 ImageSource、PixelMap、ImagePacker 必须 release 释放图像内存。

4.3 沙盒路径规范

  1. 临时压缩图片统一存入this.ctx.filesDir私有沙盒,不占用公共相册;
  2. 用户主动保存操作才写入 mediaLibrary 公共相册;
  3. 临时图片文件名增加时间戳避免覆盖。

4.4 页面生命周期规范

  1. 页面 aboutToAppear 注入上下文;
  2. 页面退出无需手动关闭选择器,API23 页面销毁自动终止 picker 弹窗。

五、高频问题与解决方案

问题 1:选择大图应用闪退、内存溢出 解决:读取原图后立即使用 decode 压缩缩小尺寸,释放原始 ImageSource。

问题 2:保存图片到相册失败 解决:检查是否声明 WRITE_MEDIA 权限、调用 saveImageToAlbum 前校验权限。

问题 3:多选图片只返回一张 解决:创建 PhotoPickerOptions 时开启 isMultiSelect 并设置 maxSelectNumber。

问题 4:升级 API23 旧 PhotoAccessHelper 代码报错 解决:全部替换为 @ohos.file.picker 标准 PhotoPicker。

问题 5:图片 uri 无法直接在 Image 组件渲染 解决:优先使用返回的 uri 字段赋值 Image,沙盒 file 路径需转换资源标识。

六、总结

ImagePickerUtil 整合相册单选 / 多选、相机拍照、图片压缩、沙盒存储、相册保存全套媒体图片能力,深度联动前文 PermissionUtil 权限工具,自动处理权限校验、永久拒绝引导弹窗,统一封装压缩逻辑解决大图内存溢出问题。 工具完全适配 API23,可直接搭配网络上传 HttpUtil、弹窗 DialogUtil、权限 PermissionUtil 组合使用,是头像上传、朋友圈动态、图片笔记、文件上传类功能必备底层媒体工具。

Logo

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

更多推荐