图片/视频操作一条龙(1):图片的获取,预览与压缩(鸿蒙开发)
📖 文章目录
文章有点小长,所以先放个目录,方便大家对文章的总体结构有所了解
🎯 主要内容
- 参考文档 - 相关开发文档链接
- 操作步骤讲解 :
- 🔐 声明权限 - 配置媒体访问所需权限
- ✅ 检查/获取权限 - 运行时权限申请与管理
- 🖼️ 选取图片 - 两种图片选择方法及其对比
- 🎯 从相册选择媒体(系统选择器)
- 🔄 另一种选取图片的方法(直接访问媒体库)
- ⚖️ 两种方法的区别与使用场景
- 🗜️ 图片压缩并存到沙箱路径
- ⚠️ 重要说明(沙箱路径限制)
- 🚫 获取文件大小的注意事项
- ✅ 正确的文件大小获取方法
- 🔧 代码实现
- 完整代码实现与使用
- 🛠️ ImageOperationUtil 工具类 - 完整的图片操作工具封装
- 🖼️ MediaPreviewDialog 自定义组件 - 全屏预览组件实现
- 🎯 在组件中使用 - 实际使用示例
说明:虽然这这篇文章中重点是讲解图片操作,但是封装的函数都考虑到了视频的需求,对不同类型媒体文件(Image/Video)的操作在必要处做了区分,不过当前视频部分的处理还不够完善,比如对于图片有考虑是否进行压缩,视频目前仅是统一复制到沙箱中,下一期我们会来聊一聊视频转码,首帧获取等操作并完善视频部分。
📚 参考文档
1.操作详解
🔐 1.1 声明权限
在 module.json5
文件中声明所需权限(核心在于 requestPermissions
中的设置):
json
{
"module": {
"name": "community", //大家自行替换
"type": "shared",
"description": "$string:shared_desc",
"deviceTypes": [
"phone",
"tablet",
"2in1"
],
"requestPermissions": [
{ "name": "ohos.permission.INTERNET" },//网络权限,用于后续上传图片到服务端或存储端
{
"name": "ohos.permission.CAMERA",//相机权限--用于拍摄等
"reason": "$string:camera_permission_reason",
"usedScene": {
"abilities": ["EntryAbility"],
"when": "inuse"
}
},
{
"name": "ohos.permission.READ_MEDIA",//读取媒体库的权限
"reason": "$string:read_media_permission_reason",
"usedScene": {
"abilities": ["EntryAbility"],
"when": "inuse"
}
},
{
"name": "ohos.permission.WRITE_MEDIA",//写入媒体库的权限
"reason": "$string:write_media_permission_reason",
"usedScene": {
"abilities": ["EntryAbility"],
"when": "inuse"
},
}
],
"deliveryWithInstall": true,
"pages": "$profile:main_pages",
"routerMap": "$profile:router_map"
}
}
✅ 1.2 检查/获取权限
typescript
import { abilityAccessCtrl, PermissionRequestResult, Permissions } from '@kit.AbilityKit';
private async checkAndRequestCameraPermission(): Promise<boolean> {
try {
const atManager = abilityAccessCtrl.createAtManager()
const context = getContext(this) as common.UIAbilityContext;
// ⚠️ API ≥ 18 时需要改为使用以下方式获取 context
// const context: common.UIAbilityContext = this.getUIContext().getHostContext() as common.UIAbilityContext;
// 需要的权限
const permissions: Permissions[] = ['ohos.permission.CAMERA','ohos.permission.READ_MEDIA','ohos.permission.WRITE_MEDIA']
// 检查权限
for (const permission of permissions) {
const grantStatus = await atManager.checkAccessToken(context.applicationInfo.accessTokenId, permission)
if (grantStatus !== abilityAccessCtrl.GrantStatus.PERMISSION_GRANTED) {
// 请求权限
const result: PermissionRequestResult = await atManager.requestPermissionsFromUser(context, permissions)
return result.authResults.every(authResult => authResult === abilityAccessCtrl.GrantStatus.PERMISSION_GRANTED)
}
}
return true
} catch (error) {
console.error('权限检查失败:', error)
return false
}
}
💡 既然这里提到getContext()被废弃了,那么我们这里多唠一嘴 --关于 getContext() 被废弃的说明
kotlin
const context = getContext(this) as common.UIAbilityContext;
//api≥18改为使用:
//const context: common.UIAbilityContext = this.getUIContext().getHostContext() as common.UIAbilityContext;
// 需要的权限
为什么被废除?
getContext()
/ getContext(this)
属于早期(API 9~17)兼容方案,内部通过调用栈回溯去找上下文,容易在异步场景或组件复用场景下拿到错误的 Context,因此从 API 18 起官方正式废弃。
1️⃣ this.getUIContext() 是干嘛的?
背景: 在 Stage 模型里,一个应用可以:
- 同时存在多个 Ability(UIAbility、ExtensionAbility ...)
- 每个 Ability 又能开多个 Window
- 每个 Window 又会通过
loadContent()
生成一个独立的 ArkUI 实例
因此 "我到底在哪一个 ArkUI 实例里?" 必须先搞清楚,否则弹 Toast、开弹窗、拿资源都可能搞错窗口。
作用: this.getUIContext()
返回一个 UIContext 对象,它就是你当前组件所挂载的那个 ArkUI 实例的"把手"。
- 只有拿到这个把手,框架才知道后续所有 UI 操作(Toast、Dialog、Router...)应该落在哪个窗口
- 同时也只有这个把手才能再往下拿到真正的系统 Context
典型场景: 在异步回调里想弹 Toast:
typescript
let uiCtx = this.getUIContext(); // 1. 先锁定当前 UI 实例
let prompt = uiCtx.getPromptAction(); // 2. 再取该实例专属的弹窗管理器
prompt.showToast({ message: 'ok' });
如果直接写全局 promptAction.showToast()
,在多窗口场景下可能把 Toast 弹到别的窗口去。
2️⃣ getHostContext() 什么用?
一句话总结(最近一些简单的问答用kimi比较多,学一学kimi的口头禅哈哈): uiContext.getHostContext()
就是把"当前 ArkUI 实例"背后那个真正的 AbilityContext(通常是 UIAbilityContext) 取出来,让组件能够继续访问文件、数据库、权限、拉起 Service 等系统能力。
详细说明:
- 返回值:拿到的是 Ability 层真正的 Context(UIAbilityContext / ExtensionContext)
- 用途:所有需要 Context 的系统 API(preferences、resourceManager、startAbility、requestPermissions...)都必须用它
- 时机:仅在 ArkUI 层(组件内部)需要访问系统资源时才调用;纯 UI 逻辑(布局、事件)不需要它
🖼️ 1.3 选取图片(可限制单次最大选取数)
📋 定义数据接口
首先定义对应的接口便于管理数据:
typescript
export enum MediaType {
IMAGE = 'image',
VIDEO = 'video'
}
//媒体文件接口
export interface MediaFile {
uri: string; //原始路径,用于预览
type: MediaType; //方便后续加入视频,便于拓展
name: string;
isRemote?: boolean; // 标识是否为远程文件(已上传到服务器的文件),主要用于做diff:比如修改一个帖子时不在上传已有公网可访问地址的媒体文件
sandboxPath?: string; // 沙箱中的真实文件路径,用于上传
}
🎯 从相册选择媒体
typescript
import { photoAccessHelper } from '@kit.MediaLibraryKit';
// 从相册选择媒体
const maxSelectPhotoNumber: number = 9; // 设置最大可选择数
const maxSelectVideoNumber: number = 3;
static async pickMedia(
type: MediaType,
context: common.UIAbilityContext,
mediaFiles: MediaFile[],
onMediaSelected: (mediaFile: MediaFile) => void,
showToast: (message: string) => void
): Promise<void> {
try {
// 计算剩余可选择数量(基于总文件数量)
const totalCount = mediaFiles.filter(file => file.type === type).length;
const maxCount = type === MediaType.IMAGE ? maxSelectPhotoNumber : maxSelectVideoNumber;
const remainingCount = maxCount - totalCount;
if (remainingCount <= 0) {
showToast(`最多只能添加${maxCount}个${type === MediaType.IMAGE ? '图片' : '视频'}`);
return;
}
const photoSelectOptions = new photoAccessHelper.PhotoSelectOptions();
photoSelectOptions.MIMEType = type === MediaType.IMAGE
? photoAccessHelper.PhotoViewMIMETypes.IMAGE_TYPE
: photoAccessHelper.PhotoViewMIMETypes.VIDEO_TYPE;
photoSelectOptions.maxSelectNumber = remainingCount;
const photoPicker = new photoAccessHelper.PhotoViewPicker();
const result: photoAccessHelper.PhotoSelectResult =
await photoPicker.select(photoSelectOptions); //结果存在的属性:photoUris,isOriginalPhoto
if (result && result.photoUris && result.photoUris.length > 0) {
for (let index = 0; index < result.photoUris.length; index++) {
const uri = result.photoUris[index];
try {
// 根据类型选择处理方式
const sandboxPath = type === MediaType.IMAGE
? await ImageOperationUtil.compressAndCopyImage(uri, context)
: ImageOperationUtil.copyToCache(uri, context); //compressAndCopyImage和copyToCache都为单独封装的方法,这里视频处理暂时统一采用了直接copyToCache,下一期我们聊聊视频转码并完善视频相关的操作
// 从URI中提取文件扩展名
const getFileExtension = (uri: string): string => {
const extension = uri.toLowerCase().split('.').pop();
if (type === MediaType.IMAGE) {
return ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp'].includes(extension || '') ? extension! : 'jpg';
} else {
return ['mp4', 'avi', 'mov', 'wmv', '3gp', 'mkv'].includes(extension || '') ? extension! : 'mp4';
}
};
const mediaFile: MediaFile = {
uri: uri, // 原始安全URI,用于预览
type: type,
name: `gallery_${type === MediaType.IMAGE ? 'photo' :
'video'}_${Date.now()}_${index}.${getFileExtension(uri)}`,
isRemote: false, // 标识为本地文件
sandboxPath: sandboxPath // 沙箱路径,用于上传
};
onMediaSelected(mediaFile);
} catch (error) {
console.error(`文件拷贝失败 ${uri}:`, error);
showToast(`文件处理失败: ${uri}`);
}
}
showToast(type === MediaType.IMAGE ? '选择图片成功' : '选择视频成功');
}
} catch (error) {
console.error('选择文件失败:', error);
showToast('选择文件失败');
}
}
🔄 另一种选取图片的方法
除了上述方法,还可以直接从媒体库加载图片:
typescript
// 从相册加载图片(直接获取媒体库资源)
static async loadImagesFromAlbum(
context: common.UIAbilityContext,
onMediaSelected: (mediaFile: MediaFile) => void,
showToast?: (message: string) => void
): Promise<void> {
try {
// 检查媒体库权限
const atManager = abilityAccessCtrl.createAtManager();
const grantStatus = await atManager.checkAccessToken(
context.applicationInfo.accessTokenId,
'ohos.permission.READ_MEDIA'
);
if (grantStatus !== abilityAccessCtrl.GrantStatus.PERMISSION_GRANTED) {
const result: PermissionRequestResult = await atManager.requestPermissionsFromUser(
context,
['ohos.permission.READ_MEDIA']
);
if (result.authResults[0] !== abilityAccessCtrl.GrantStatus.PERMISSION_GRANTED) {
showToast?.('需要媒体库访问权限');
return;
}
}
// 创建获取选项
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?.equalTo(photoAccessHelper.PhotoKeys.PHOTO_TYPE, photoAccessHelper.PhotoType.IMAGE);
fetchOptions.predicates?.orderByDesc(photoAccessHelper.PhotoKeys.DATE_ADDED);
// 获取图片资源
const phAccessHelper = photoAccessHelper.getPhotoAccessHelper(context);
const fetchResult = await phAccessHelper.getAssets(fetchOptions);
const photoAssets = await fetchResult.getAllObjects();
console.log(`找到 ${photoAssets.length} 张图片`);
showToast?.(`找到 ${photoAssets.length} 张图片`);
// 处理图片数据(限制处理数量避免性能问题)
const processCount = Math.min(photoAssets.length, 10);
for (let i = 0; i < processCount; i++) {
const asset = photoAssets[i];
try {
// 创建 MediaFile 对象
const mediaFile: MediaFile = {
uri: asset.uri,
type: MediaType.IMAGE,
name: asset.displayName || `相册图片_${i + 1}.jpg`,
isRemote: false
};
// 调用回调函数
onMediaSelected(mediaFile);
console.log(`已添加图片: ${mediaFile.name}`);
} catch (error) {
console.error(`处理图片失败 [${i}]:`, error);
}
}
// 关闭资源
fetchResult.close();
} catch (error) {
console.error('加载相册图片失败:', error);
showToast?.('加载相册图片失败');
}
}
⚖️ 两种方法的区别
1. 使用的API不同
第一种方式:
- 使用
photoAccessHelper.PhotoViewPicker()
创建选择器 - 使用
PhotoSelectOptions
配置选择参数 - 调用
select()
方法打开系统选择界面
第二种方式:
- 使用
photoAccessHelper.getPhotoAccessHelper()
获取访问助手 - 使用
getAssets()
方法获取媒体资源 - 通过
FetchOptions
和DataSharePredicates
进行查询配置
2. 用户交互方式不同
第一种方式:
- 调用系统提供的标准媒体选择界面
- 用户在系统原生的选择器中进行选择
- 界面样式由系统决定,保持系统一致性
- 实现更简单,但定制性较低
第二种方式:
- 直接获取媒体库中的所有资源数据
- 需要开发者自己构建UI界面来展示和选择
- 可以完全自定义界面样式和交互逻辑
- 用户在应用内的自定义界面中选择
3. 数据获取方式不同
第一种方式:
- 直接获取用户选择的文件URI
- 主要关注用户的选择结果
- 适合简单的文件选择场景
第二种方式:
- 获取媒体文件的详细信息(URI、显示名称、大小、添加时间等)
- 可以进行复杂的筛选和排序(如按时间倒序)
- 适合需要展示媒体库详细信息的场景
4. 使用场景建议
- 第一种方式适合简单的文件选择需求,希望使用系统标准界面,快速实现选择功能的场景
- 第二种方式适合需要自定义媒体浏览界面、需要显示媒体详细信息、或需要复杂筛选功能的场景,也可以用于对图片的批量处理,因为可以直接加载相册中的所有图片
🗜️ 1.4 图片压缩并存到沙箱路径
⚠️ 注意
相册/相机返回的 原始 uri 属于系统沙箱之外的受保护资源 ,鸿蒙 request.uploadFile() 只能上传应用沙箱里的文件,所以 预览可以,上传不行。
解决方案: 先把图片 拷贝到自己的 cacheDir,再用新路径上传。
🚫 获取文件大小的注意事项
不要使用:
typescript
const stat = fs.lstatSync(uri);
fileSizeMB = stat.size / (1024 * 1024);
原因:
在鸿蒙(OpenHarmony)里:
-
从相册/相机返回的 URI 通常是:
rubydataability:///media/external/images/media/12345
这类 dataability:// 或 media:// 的 虚拟 URI ,并不是真正的文件路径
-
fs.lstatSync(uri)
只能处理 file:// 开头的 真实文件路径 ,遇到 dataability:// 会直接抛异常(ENOENT 或 URI not supported)
✅ 如何正确获取文件大小--通过文件描述符获取
asset.size
在鸿蒙 4.0+ 已提供,无需再 open 文件。
如果拿到的是纯 URI,则先 拿到 fd ,再 fs.stat(fd)
:
typescript
import fs from '@ohos.file.fs';
const fd = fs.openSync(uri, fs.OpenMode.READ_ONLY);
const stat = fs.stat(fd);
const fileSizeMB = stat.size / 1024 / 1024;
fs.closeSync(fd);
🔧 压缩与沙箱存储的实现代码
typescript
import fs from '@ohos.file.fs';
import image from '@ohos.multimedia.image';
import { fileIo } from '@kit.CoreFileKit';
import { common } from '@kit.AbilityKit';
static async compressAndCopyImage(uri: string, context: common.UIAbilityContext): Promise<string> {
try {
// 获取文件大小并打开文件
let srcFd: number | null = null;
let fileSizeMB: number | null = null;
try {
if (uri.includes('://')) {
const srcFile = fileIo.openSync(uri, fileIo.OpenMode.READ_ONLY);
srcFd = srcFile.fd;
const stat = fs.statSync(srcFd);
fileSizeMB = stat.size / (1024 * 1024);
} else {
const srcFile = fs.openSync(uri, fs.OpenMode.READ_ONLY);
srcFd = srcFile.fd;
const stat = fs.statSync(srcFd);
fileSizeMB = stat.size / (1024 * 1024);
}
} catch (e) {
console.warn('获取文件大小失败,忽略并继续压缩:', e);
}
if (fileSizeMB !== null && fileSizeMB < 1) {
console.log(`图片文件较小(${fileSizeMB.toFixed(2)}MB),直接拷贝无需压缩`);
// 关闭已打开的文件描述符
if (srcFd !== null) {
try {
fs.closeSync(srcFd);
} catch (error) {
console.warn('关闭文件描述符失败:', error);
}
}
return ImageOperationUtil.copyToCache(uri, context);
}
console.log('开始图片压缩处理(使用文件描述符创建 ImageSource,兼容 file://media URI)');
let imageSource: image.ImageSource | null = null;
try {
// 如果之前没有成功打开文件,这里重新尝试
if (srcFd === null) {
if (uri.includes('://')) {
const srcFile = fileIo.openSync(uri, fileIo.OpenMode.READ_ONLY);
srcFd = srcFile.fd;
} else {
const srcFile = fs.openSync(uri, fs.OpenMode.READ_ONLY);
srcFd = srcFile.fd;
}
}
imageSource = image.createImageSource(srcFd);
} catch (openErr) {
console.warn('通过fd打开媒体失败,尝试使用字符串路径创建 ImageSource(仅限本地路径):', openErr);
if (!uri.includes('://')) {
try {
imageSource = image.createImageSource(uri);
} catch (pathErr) {
console.error('字符串路径创建 ImageSource 失败:', pathErr);
}
}
}
if (!imageSource) {
throw new Error('创建 ImageSource 失败');
}
const imageInfo = await imageSource.getImageInfo();
const pixelMap = await imageSource.createPixelMap();
if (imageInfo.size.width > 1280 || imageInfo.size.height > 1280) {
const scale = Math.min(1280 / imageInfo.size.width, 1280 / imageInfo.size.height);
await pixelMap.scale(scale, scale);
console.log(`图片尺寸缩放: ${imageInfo.size.width}x${imageInfo.size.height} -> ${Math.floor(imageInfo.size.width *
scale)}x${Math.floor(imageInfo.size.height * scale)}`);
}
const imagePacker = image.createImagePacker();
const pixelCount = imageInfo.size.width * imageInfo.size.height;
let quality: number = 75;
if (pixelCount > 12_000_000) {
quality = 50;
} else if (pixelCount > 6_000_000) {
quality = 60;
}
let packOpts: image.PackingOption = { format: 'image/webp', quality: quality };
let compressedData: ArrayBuffer;
try {
compressedData = await imagePacker.packing(pixelMap, packOpts);
} catch (e) {
console.warn('WebP导出失败,回退到JPEG:', e);
packOpts = { format: 'image/jpeg', quality: quality };
compressedData = await imagePacker.packing(pixelMap, packOpts);
}
const targetExt: string = packOpts.format === 'image/webp' ? '.webp' : '.jpg';
// 压缩后直接写入沙箱
const fileName = uri.split('/').pop() || `compressed_${Date.now()}.jpg`;
const compressedFileName = `compressed_${Date.now()}_${fileName.replace(/\.[^.]+$/, targetExt)}`;
const dstPath = context.cacheDir + '/' + compressedFileName;
const dst = fs.openSync(dstPath, fs.OpenMode.CREATE | fs.OpenMode.WRITE_ONLY);
fs.writeSync(dst.fd, compressedData); // 直接写入 ArrayBuffer
fs.closeSync(dst);
pixelMap.release();
imagePacker.release();
imageSource.release();
if (srcFd !== null) {
try {
fs.closeSync(srcFd);
} catch (error) {
console.warn('关闭文件描述符失败:', error);
}
}
const compressedSizeMB = compressedData.byteLength / (1024 * 1024);
console.log(`图片压缩完成: ${fileName} (约 ${compressedSizeMB.toFixed(2)}MB, 格式${packOpts.format}, 质量${quality}%)`);
return dstPath;
} catch (error) {
console.error('图片压缩失败,使用原文件:', error);
return ImageOperationUtil.copyToCache(uri, context);
}
}
// 将文件拷贝到沙箱并返回真实路径(用于视频等非图片文件)
static copyToCache(uri: string, context: common.UIAbilityContext): string {
try {
let srcFd: number;
if (uri.includes('://')) {
const src = fileIo.openSync(uri, fileIo.OpenMode.READ_ONLY);
srcFd = src.fd;
} else {
const src = fs.openSync(uri, fs.OpenMode.READ_ONLY);
srcFd = src.fd;
}
const fileName = uri.split('/').pop() || `file_${Date.now()}`;
const dstPath = context.cacheDir + '/' + Date.now() + '_' + fileName;
const dst = fs.openSync(dstPath, fs.OpenMode.CREATE | fs.OpenMode.WRITE_ONLY);
let len = 0;
const buf = new ArrayBuffer(8192);
while ((len = fs.readSync(srcFd, buf)) > 0) {
fs.writeSync(dst.fd, buf.slice(0, len));
}
fs.closeSync(srcFd);
fs.closeSync(dst);
return dstPath;
} catch (error) {
console.error('文件拷贝失败:', error);
throw error as Error;
}
}
👁️ 1.5 全屏预览图片组件(和非全屏预览核心相同,都是利用Image(uri)组件而已,只是样式稍作补充,同时使用了CustomDialogController)
构建全屏预览组件
typescript
import { MediaFile, MediaType } from 'basic/src/main/ets/models/mediaFile'
@CustomDialog
export struct MediaPreviewDialog {
mediaFile: MediaFile | null = null
controller: CustomDialogController //CustomDialogController的作用:Display the content of the customized pop-up window,含有constructor,open和close方法
build() {
Stack({ alignContent: Alignment.TopStart }) {
// 媒体内容区域(全屏背景)
if (this.mediaFile) {
if (this.mediaFile.type === MediaType.IMAGE) {
// 图片预览
Image(this.mediaFile.uri)
.width('100%')
.height('100%')
.objectFit(ImageFit.Contain)
.backgroundColor('#000000')
} else if (this.mediaFile.type === MediaType.VIDEO) {
// 视频预览
Video({
src: this.mediaFile.uri,
previewUri: this.mediaFile.uri
})
.width('100%')
.height('100%')
.controls(true)
.autoPlay(false)
.objectFit(ImageFit.Contain)
.backgroundColor('#000000')
}
} else {
// 无媒体文件时的占位内容
Column() {
Text('无媒体文件')
.fontSize(16)
.fontColor('#999999')
.textAlign(TextAlign.Center)
}
.width('100%')
.height('100%')
.backgroundColor('#000000')
.justifyContent(FlexAlign.Center)
}
// 左上角回退按钮
Row() {
Image($r('app.media.back'))
.width(24)
.height(24)
.fillColor(Color.White)
.onClick(() => {
this.controller.close()
})
}
.width(44)
.height(44)
.justifyContent(FlexAlign.Center)
.alignItems(VerticalAlign.Center)
.backgroundColor('rgba(0, 0, 0, 0.3)')
.borderRadius(22)
.margin({ left: 20, top: 20 })
}
.width('100%')
.height('100%')
.backgroundColor('#000000')
}
// 设置要预览的媒体文件
setMediaFile(mediaFile: MediaFile): void {
this.mediaFile = mediaFile
}
}
2. 完整代码示例
🛠️ 2.1 ImageOperationUtil 工具类
📋 完整代码实现
typescript
import fs from '@ohos.file.fs';
import image from '@ohos.multimedia.image';
import { fileIo } from '@kit.CoreFileKit';
import { abilityAccessCtrl, common, PermissionRequestResult, Permissions } from '@kit.AbilityKit';
import { MediaType, MediaFile } from '../models/mediaFile';
import { promptAction } from '@kit.ArkUI';
import { camera, cameraPicker as picker } from '@kit.CameraKit';
import { photoAccessHelper } from '@kit.MediaLibraryKit';
import { BusinessError } from '@kit.BasicServicesKit';
import { dataSharePredicates } from '@kit.ArkData';
export class ImageOperationUtil {
// 选择媒体文件
static async selectMedia(
type: MediaType,
context: common.UIAbilityContext,
mediaFiles: MediaFile[],
onMediaSelected: (mediaFile: MediaFile) => void,
showToast: (message: string) => void,
showActionMenu: (options: promptAction.ActionMenuOptions) => Promise<promptAction.ActionMenuSuccessResponse>
): Promise<void> {
// 检查文件数量限制(只计算本地文件,远程文件不计入限制)
const totalCount = mediaFiles.filter(file => file.type === type).length
const maxCount = type === MediaType.IMAGE ? 9 : 3
if (totalCount >= maxCount) {
showToast(`最多只能添加${maxCount}个${type === MediaType.IMAGE ? '图片' : '视频'}`);
return
}
const actionMenuOptions: promptAction.ActionMenuOptions = {
title: type === MediaType.IMAGE ? '选择图片' : '选择视频',
buttons: [
{ text: '使用相机拍摄', color: '#007AFF' },
{ text: '从相册中选择', color: '#007AFF' }
]
};
const result = await showActionMenu(actionMenuOptions);
if (result.index === 0) {
// 后置相机拍摄
await ImageOperationUtil.captureMediaWithCamera(
type,
camera.CameraPosition.CAMERA_POSITION_BACK,
context,
onMediaSelected,
showToast
);
} else if (result.index === 1) {
// 从相册选择
await ImageOperationUtil.pickMedia(
type,
context,
mediaFiles,
onMediaSelected,
showToast
);
}
}
// 使用指定摄像头拍摄媒体
static async captureMediaWithCamera(
type: MediaType,
cameraPosition: camera.CameraPosition,
context: common.UIAbilityContext,
onMediaSelected: (mediaFile: MediaFile) => void,
showToast: (message: string) => void
): Promise<void> {
try {
// 检查并请求相机权限
const hasPermission = await ImageOperationUtil.checkAndRequestCameraPermission(context);
if (!hasPermission) {
showToast('应用需要相机权限');
return;
}
// 配置相机选择器
const pickerProfile: picker.PickerProfile = {
cameraPosition: cameraPosition
};
// 如果是视频录制,设置最大录制时长(30秒)
if (type === MediaType.VIDEO) {
pickerProfile.videoDuration = 30;
}
// 根据类型设置媒体类型
const mediaTypes: picker.PickerMediaType[] = type === MediaType.IMAGE
? [picker.PickerMediaType.PHOTO]
: [picker.PickerMediaType.VIDEO];
// 调用相机选择器
const pickerResult: picker.PickerResult = await picker.pick(context, mediaTypes, pickerProfile);
console.log("cameraPicker result:", JSON.stringify(pickerResult));
if (pickerResult.resultCode === 0 && pickerResult.resultUri) {
try {
// 根据类型选择处理方式
const sandboxPath = type === MediaType.IMAGE
? await ImageOperationUtil.compressAndCopyImage(pickerResult.resultUri, context)
: ImageOperationUtil.copyToCache(pickerResult.resultUri, context);
// 创建媒体文件对象
const mediaFile: MediaFile = {
uri: pickerResult.resultUri, // 原始URI,用于预览
type: type,
name: type === MediaType.IMAGE ? `camera_photo_${Date.now()}.jpg` : `camera_video_${Date.now()}.mp4`,
isRemote: false, // 标识为本地文件
sandboxPath: sandboxPath // 沙箱路径,用于上传
};
onMediaSelected(mediaFile);
showToast(type === MediaType.IMAGE ? '拍照成功' : '录制成功');
} catch (error) {
console.error('文件处理失败:', error);
showToast('文件处理失败,请重试');
}
} else {
showToast('拍摄取消');
}
} catch (error) {
console.error('拍摄失败:', error);
const err = error as BusinessError;
showToast(`拍摄失败: ${err.message || '未知错误'}`);
}
}
// 检查并请求相机权限
static async checkAndRequestCameraPermission(context: common.UIAbilityContext): Promise<boolean> {
try {
const atManager = abilityAccessCtrl.createAtManager();
// 需要的权限
const permissions: Permissions[] = ['ohos.permission.CAMERA', 'ohos.permission.MICROPHONE'];
// 检查权限
for (const permission of permissions) {
const grantStatus = await atManager.checkAccessToken(
context.applicationInfo.accessTokenId,
permission
);
if (grantStatus !== abilityAccessCtrl.GrantStatus.PERMISSION_GRANTED) {
// 请求权限
const result: PermissionRequestResult = await atManager.requestPermissionsFromUser(context, permissions);
return result.authResults.every(authResult => authResult ===
abilityAccessCtrl.GrantStatus.PERMISSION_GRANTED);
}
}
return true;
} catch (error) {
console.error('权限检查失败:', error);
return false;
}
}
// 从相册选择媒体
static async pickMedia(
type: MediaType,
context: common.UIAbilityContext,
mediaFiles: MediaFile[],
onMediaSelected: (mediaFile: MediaFile) => void,
showToast: (message: string) => void
): Promise<void> {
try {
// 计算剩余可选择数量(基于总文件数量)
const totalCount = mediaFiles.filter(file => file.type === type).length;
const maxCount = type === MediaType.IMAGE ? 9 : 3;
const remainingCount = maxCount - totalCount;
if (remainingCount <= 0) {
showToast(`最多只能添加${maxCount}个${type === MediaType.IMAGE ? '图片' : '视频'}`);
return;
}
const photoSelectOptions = new photoAccessHelper.PhotoSelectOptions();
photoSelectOptions.MIMEType = type === MediaType.IMAGE
? photoAccessHelper.PhotoViewMIMETypes.IMAGE_TYPE
: photoAccessHelper.PhotoViewMIMETypes.VIDEO_TYPE;
photoSelectOptions.maxSelectNumber = remainingCount;
const photoPicker = new photoAccessHelper.PhotoViewPicker();
const result: photoAccessHelper.PhotoSelectResult = await photoPicker.select(photoSelectOptions);
if (result && result.photoUris && result.photoUris.length > 0) {
for (let index = 0; index < result.photoUris.length; index++) {
const uri = result.photoUris[index];
try {
// 根据类型选择处理方式
const sandboxPath = type === MediaType.IMAGE
? await ImageOperationUtil.compressAndCopyImage(uri, context)
: ImageOperationUtil.copyToCache(uri, context);//目前视频暂时只是统一存储到沙箱中而没有做额外处理,下一期我们来聊一聊视频转码,首帧获取等操作并完善视频部分
// 从URI中提取文件扩展名
const getFileExtension = (uri: string): string => {
const extension = uri.toLowerCase().split('.').pop();
if (type === MediaType.IMAGE) {
return ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp'].includes(extension || '') ? extension! : 'jpg';
} else {
return ['mp4', 'avi', 'mov', 'wmv', '3gp', 'mkv'].includes(extension || '') ? extension! : 'mp4';
}
};
const mediaFile: MediaFile = {
uri: uri, // 原始安全URI,用于预览
type: type,
name: `gallery_${type === MediaType.IMAGE ? 'photo' :
'video'}_${Date.now()}_${index}.${getFileExtension(uri)}`,
isRemote: false, // 标识为本地文件
sandboxPath: sandboxPath // 沙箱路径,用于上传
};
onMediaSelected(mediaFile);
} catch (error) {
console.error(`文件拷贝失败 ${uri}:`, error);
showToast(`文件处理失败: ${uri}`);
}
}
showToast(type === MediaType.IMAGE ? '选择图片成功' : '选择视频成功');
}
} catch (error) {
console.error('选择文件失败:', error);
showToast('选择文件失败');
}
}
// 压缩拷贝到沙箱路径
static async compressAndCopyImage(uri: string, context: common.UIAbilityContext): Promise<string> {
try {
// 获取文件大小并打开文件
let srcFd: number | null = null;
let fileSizeMB: number | null = null;
try {
if (uri.includes('://')) {
const srcFile = fileIo.openSync(uri, fileIo.OpenMode.READ_ONLY);
srcFd = srcFile.fd;
const stat = fs.statSync(srcFd);
fileSizeMB = stat.size / (1024 * 1024);
} else {
const srcFile = fs.openSync(uri, fs.OpenMode.READ_ONLY);
srcFd = srcFile.fd;
const stat = fs.statSync(srcFd);
fileSizeMB = stat.size / (1024 * 1024);
}
} catch (e) {
console.warn('获取文件大小失败,忽略并继续压缩:', e);
}
if (fileSizeMB !== null && fileSizeMB < 1) {
console.log(`图片文件较小(${fileSizeMB.toFixed(2)}MB),直接拷贝无需压缩`);
// 关闭已打开的文件描述符
if (srcFd !== null) {
try {
fs.closeSync(srcFd);
} catch (_) {
}
}
return ImageOperationUtil.copyToCache(uri, context);
}
console.log('开始图片压缩处理(使用文件描述符创建 ImageSource,兼容 file://media URI)');
let imageSource: image.ImageSource | null = null;
try {
// 如果之前没有成功打开文件,这里重新尝试
if (srcFd === null) {
if (uri.includes('://')) {
const srcFile = fileIo.openSync(uri, fileIo.OpenMode.READ_ONLY);
srcFd = srcFile.fd;
} else {
const srcFile = fs.openSync(uri, fs.OpenMode.READ_ONLY);
srcFd = srcFile.fd;
}
}
imageSource = image.createImageSource(srcFd);
} catch (openErr) {
console.warn('通过fd打开媒体失败,尝试使用字符串路径创建 ImageSource(仅限本地路径):', openErr);
if (!uri.includes('://')) {
try {
imageSource = image.createImageSource(uri);
} catch (pathErr) {
console.error('字符串路径创建 ImageSource 失败:', pathErr);
}
}
}
if (!imageSource) {
throw new Error('创建 ImageSource 失败');
}
const imageInfo = await imageSource.getImageInfo();
const pixelMap = await imageSource.createPixelMap();
if (imageInfo.size.width > 1280 || imageInfo.size.height > 1280) {
const scale = Math.min(1280 / imageInfo.size.width, 1280 / imageInfo.size.height);
await pixelMap.scale(scale, scale);
console.log(`图片尺寸缩放: ${imageInfo.size.width}x${imageInfo.size.height} -> ${Math.floor(imageInfo.size.width *
scale)}x${Math.floor(imageInfo.size.height * scale)}`);
}
const imagePacker = image.createImagePacker();
const pixelCount = imageInfo.size.width * imageInfo.size.height;
let quality: number = 75;
if (pixelCount > 12_000_000) {
quality = 50;
} else if (pixelCount > 6_000_000) {
quality = 60;
}
let packOpts: image.PackingOption = { format: 'image/webp', quality: quality };
let compressedData: ArrayBuffer;
try {
compressedData = await imagePacker.packing(pixelMap, packOpts);
} catch (e) {
console.warn('WebP导出失败,回退到JPEG:', e);
packOpts = { format: 'image/jpeg', quality: quality };
compressedData = await imagePacker.packing(pixelMap, packOpts);
}
const targetExt: string = packOpts.format === 'image/webp' ? '.webp' : '.jpg';
// 压缩后直接写入沙箱
const fileName = uri.split('/').pop() || `compressed_${Date.now()}.jpg`;
const compressedFileName = `compressed_${Date.now()}_${fileName.replace(/\.[^.]+$/, targetExt)}`;
const dstPath = context.cacheDir + '/' + compressedFileName;
const dst = fs.openSync(dstPath, fs.OpenMode.CREATE | fs.OpenMode.WRITE_ONLY);
fs.writeSync(dst.fd, compressedData); // 直接写入 ArrayBuffer
fs.closeSync(dst);
pixelMap.release();
imagePacker.release();
imageSource.release();
if (srcFd !== null) {
try {
fs.closeSync(srcFd);
} catch (_) {
}
}
const compressedSizeMB = compressedData.byteLength / (1024 * 1024);
console.log(`图片压缩完成: ${fileName} (约 ${compressedSizeMB.toFixed(2)}MB, 格式${packOpts.format}, 质量${quality}%)`);
return dstPath;
} catch (error) {
console.error('图片压缩失败,使用原文件:', error);
return ImageOperationUtil.copyToCache(uri, context);
}
}
// 将文件拷贝到沙箱并返回真实路径
static copyToCache(uri: string, context: common.UIAbilityContext): string {
try {
let srcFd: number;
if (uri.includes('://')) {
const src = fileIo.openSync(uri, fileIo.OpenMode.READ_ONLY);
srcFd = src.fd;
} else {
const src = fs.openSync(uri, fs.OpenMode.READ_ONLY);
srcFd = src.fd;
}
const fileName = uri.split('/').pop() || `file_${Date.now()}`;
const dstPath = context.cacheDir + '/' + Date.now() + '_' + fileName;
const dst = fs.openSync(dstPath, fs.OpenMode.CREATE | fs.OpenMode.WRITE_ONLY);
let len = 0;
const buf = new ArrayBuffer(8192);
while ((len = fs.readSync(srcFd, buf)) > 0) {
fs.writeSync(dst.fd, buf.slice(0, len));
}
fs.closeSync(srcFd);
fs.closeSync(dst);
return dstPath;
} catch (error) {
console.error('文件拷贝失败:', error);
throw error as Error;
}
}
}
🖼️ 2.2 MediaPreviewDialog 自定义组件
📱 全屏预览组件实现
typescript
import { MediaFile, MediaType } from 'basic/src/main/ets/models/mediaFile'
@CustomDialog
export struct MediaPreviewDialog {
mediaFile: MediaFile | null = null
controller: CustomDialogController
build() {
Stack({ alignContent: Alignment.TopStart }) {
// 媒体内容区域(全屏背景)
if (this.mediaFile) {
if (this.mediaFile.type === MediaType.IMAGE) {
// 图片预览
Image(this.mediaFile.uri)
.width('100%')
.height('100%')
.objectFit(ImageFit.Contain)
.backgroundColor('#000000')
} else if (this.mediaFile.type === MediaType.VIDEO) {
// 视频预览
Video({
src: this.mediaFile.uri,
previewUri: this.mediaFile.uri
})
.width('100%')
.height('100%')
.controls(true)
.autoPlay(false)
.objectFit(ImageFit.Contain)
.backgroundColor('#000000')
}
} else {
// 无媒体文件时的占位内容
Column() {
Text('无媒体文件')
.fontSize(16)
.fontColor('#999999')
.textAlign(TextAlign.Center)
}
.width('100%')
.height('100%')
.backgroundColor('#000000')
.justifyContent(FlexAlign.Center)
}
// 左上角回退按钮
Row() {
Image($r('app.media.back'))
.width(24)
.height(24)
.fillColor(Color.White)
.onClick(() => {
this.controller.close()
})
}
.width(44)
.height(44)
.justifyContent(FlexAlign.Center)
.alignItems(VerticalAlign.Center)
.backgroundColor('rgba(0, 0, 0, 0.3)')
.borderRadius(22)
.margin({ left: 20, top: 20 })
}
.width('100%')
.height('100%')
.backgroundColor('#000000')
}
// 设置要预览的媒体文件
setMediaFile(mediaFile: MediaFile): void {
this.mediaFile = mediaFile
}
}
🎯 2.3 在组件中使用
💡 使用示例
typescript
import promptAction from '@ohos.promptAction';
import { common } from '@kit.AbilityKit';
import { MediaType, MediaFile } from 'basic/src/main/ets/models/mediaFile'
import { MediaPreviewDialog } from './MediaPreviewDialog'
import { ImageOperationUtil } from 'basic/src/main/ets/utils/ImageOperation';
@Component
export struct NewPost {
@Link title: string
@Link content: string
@Link mediaFiles: MediaFile[]
@Link wastedFiles: string [] //修改时通过在客户端明确删除掉的媒体文件免去在服务端重新做diff
@Link isUploading: boolean
@Link uploadProgress: number
private context: common.UIAbilityContext = this.getUIContext().getHostContext() as common.UIAbilityContext;
// 媒体预览对话框控制器
private mediaPreviewDialogController: CustomDialogController = new CustomDialogController({
builder: MediaPreviewDialog({}),
autoCancel: true,
alignment: DialogAlignment.Center,
customStyle: true
}) //CustomDialogController的作用:Display the content of the customized pop-up window,含有constructor,open和close方法
// 文本输入样式
@Styles
textInputStyle() {
.backgroundColor(Color.White)
.borderRadius(8)
.padding(16)
}
// 构建媒体操作按钮
@Builder
buildMediaActionButtons() {
Row() {
// 添加图片按钮
Row() {
Image($r('app.media.image')) //替换为自己的图片资源
.width(20)
.height(20)
Text('添加图片')
.fontSize(15)
.margin({ left: 8 })
.fontColor('#666666')
}
.padding({
left: 12,
right: 12,
top: 8,
bottom: 8
})
.onClick(() => {
this.selectMedia(MediaType.IMAGE)
})
Divider()
.vertical(true)
.height(20)
.color('#E5E5E5')
.margin({ left: 16, right: 16 })
Row() {
Image($r('app.media.video')) ////替换为自己的图片资源
.width(20)
.height(20)
Text('添加视频')
.fontSize(15)
.fontColor('#666666')
.margin({ left: 8 })
}
.padding({
left: 12,
right: 12,
top: 8,
bottom: 8
})
.onClick(() => {
this.selectMedia(MediaType.VIDEO)
})
Divider()
.vertical(true)
.height(20)
.color('#E5E5E5')
.margin({ left: 16, right: 16 })
}
.width('100%')
.height(44)
.backgroundColor(Color.Transparent)
.margin({ top: 8 })
.justifyContent(FlexAlign.Start)
.alignItems(VerticalAlign.Center)
}
// 构建媒体预览区域
@Builder
buildMediaPreview() {
if (this.mediaFiles.length > 0) {
Column() {
Grid() {
ForEach(this.mediaFiles,
async (media: MediaFile, index: number) => {
GridItem() {
Stack({ alignContent: Alignment.TopEnd }) {
if (media.type === MediaType.IMAGE) {
Image(media.uri)
.width('100%')
.height('100%')
.objectFit(ImageFit.Cover)
.borderRadius(8)
.onClick(() => {
this.previewImage(media)
})
} else {
// 视频预览缩略图
Stack() {
Video({
src: media.uri,
previewUri: media.uri
})
.width('100%')
.height('100%')
.controls(false)
.autoPlay(false)
.borderRadius(8)
Image($r('app.media.play'))
.width(32)
.height(32)
}
.onClick(() => {
this.playVideo(media)
})
}
// 删除按钮
Image($r('app.media.delete'))
.width(20)
.height(20)
.fillColor(Color.White)
.borderRadius(10)
.padding(2)
.margin(4)
.onClick(() => {
this.removeMediaFile(index)
})
}
.width('100%')
.height(80)
}
})
}
.columnsTemplate('1fr 1fr 1fr 1fr')
.rowsGap(8)
.columnsGap(8)
}
.width('100%')
.margin({ top: 8 })
}
}
build() {
Column() {
// 标题输入
TextInput({ placeholder: '请输入标题...', text: this.title })
.onChange((value: string) => {
this.title = value
})
.textInputStyle()
.fontSize(20)
.fontWeight(700)
Column() {
// 内容输入
TextArea({ placeholder: '分享你的旅行体验...', text: this.content })
.onChange((value: string) => {
this.content = value
})
.fontSize(16)
.height(300)
.maxLength(1000)
.margin({ top: 12 })
.backgroundColor("#fff6f5f5")
.borderColor("#FFFFF1")
// 媒体操作按钮
this.buildMediaActionButtons()
// 媒体预览区域
this.buildMediaPreview()
}
.textInputStyle()
}
.width('100%')
.padding({ top: 10 })
}
// 选择媒体文件
private async selectMedia(type: MediaType): Promise<void> {
await ImageOperationUtil.selectMedia(
type,
this.context,
this.mediaFiles,
(mediaFile: MediaFile) => {
this.mediaFiles.push(mediaFile);
},
(message: string) => {
this.getUIContext().getPromptAction().showToast({
message: message,
duration: 2000
});
},
(options: promptAction.ActionMenuOptions) => {
return this.getUIContext().getPromptAction().showActionMenu(options);
}
);
}
// 移除媒体文件
private removeMediaFile(index: number) {
if (this.mediaFiles[index].uri?.startsWith('http://') || this.mediaFiles[index].uri?.startsWith('https://')) {
this.wastedFiles.push(this.mediaFiles[index].uri)
}
this.mediaFiles.splice(index, 1)
}
// 播放视频
private playVideo(media: MediaFile): void {
if (media.type !== MediaType.VIDEO) {
return
}
this.showMediaPreview(media)
}
// 预览图片
private previewImage(media: MediaFile): void {
if (media.type !== MediaType.IMAGE) {
return
}
this.showMediaPreview(media)
}
// 显示媒体预览对话框
private showMediaPreview(media: MediaFile): void {
// 重新创建对话框控制器,传入要预览的媒体文件
this.mediaPreviewDialogController = new CustomDialogController({
builder: MediaPreviewDialog({ mediaFile: media }),
autoCancel: true,
alignment: DialogAlignment.Center,
customStyle: true,
isModal: true,
showInSubWindow: false
})
this.mediaPreviewDialogController.open()
}
}