摘要
原生媒体图库、相机拍照接口逻辑繁琐:需要先校验动态权限、区分相册单选 / 多选、相机拍摄、图片压缩、沙盒路径转换、保存到相册、权限拒绝引导弹窗、回调封装。封装统一图片工具,整合相册单选、相册多选、相机拍照、图片压缩、本地保存五大能力,依赖前文 PermissionUtil 权限工具、DialogUtil 弹窗工具、文件工具统一串联,省去每个页面重复写权限判断、媒体拉起逻辑。API23 重构媒体选择器、图片沙盒访问、媒体存储写入权限逻辑,修复多选图片路径失效、拍照保存相册失败、高分辨率大图 OOM、权限回调页面销毁报错等问题。
关键词
OpenHarmony;媒体图库;ImagePicker;相机拍照;图片压缩;媒体存储;动态权限;沙盒文件
一、引言
1.1 原生媒体开发痛点
- 每次打开相册 / 相机都要手动校验读写媒体、相机两套动态权限,代码重复;
- 媒体选择器无统一回调,单选、多选参数写法不一致;
- 拍摄原图分辨率过高,直接上传容易内存溢出、上传缓慢;
- 拍摄图片默认存在应用沙盒,需要额外逻辑写入系统相册;
- 用户永久拒绝媒体权限无弹窗引导跳转设置;
- 页面销毁后媒体选择回调执行,操作已销毁页面组件崩溃。
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 媒体核心变更
- 废弃旧版 photoAccessHelper,统一使用 file.picker 图库选择器;
- 媒体文件路径区分沙盒私有路径、公共媒体路径,转换规则标准化;
- 新增图片解码压缩 API,限制宽高降低内存占用;
- 媒体写入相册强制校验 WRITE_MEDIA 动态权限;
- 媒体选择弹窗生命周期与页面绑定,页面销毁自动关闭选择器。
二、完整图片工具封装 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 权限规范
- 打开相册自动校验 READ_MEDIA_IMAGES + WRITE_MEDIA;
- 拍照仅校验 CAMERA;
- 保存到相册单独校验 WRITE_MEDIA,无权限拦截保存操作。
4.2 图片内存优化规范
- 从相册原图必须压缩后再解码、上传,防止 PixelMap 大图 OOM 闪退;
- 头像类小图限制 500px 以内,动态配图限制 800px;
- 使用完毕 ImageSource、PixelMap、ImagePacker 必须 release 释放图像内存。
4.3 沙盒路径规范
- 临时压缩图片统一存入
this.ctx.filesDir私有沙盒,不占用公共相册; - 用户主动保存操作才写入 mediaLibrary 公共相册;
- 临时图片文件名增加时间戳避免覆盖。
4.4 页面生命周期规范
- 页面 aboutToAppear 注入上下文;
- 页面退出无需手动关闭选择器,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 组合使用,是头像上传、朋友圈动态、图片笔记、文件上传类功能必备底层媒体工具。