
HarmonyOS技术精讲-Image Kit:实战 - 多图批量编辑与导出工具
在HarmonyOS NEXT开发中,图片处理是一个常见场景。但很多人第一次接触Image Kit时,会发现官方示例能运行,但放到实际项目里还需要处理很多细节,尤其是批量操作这个需求。
如果只是单张图片处理,问题不大。但一旦涉及多图批量编辑------比如统一旋转、统一缩放、统一转换格式------一个简单的循环解码/编码很容易就把内存打爆,或者导致UI卡顿。
这个功能本身不复杂,但真正麻烦的是并发控制 和错误回退。一个问题:如果某张图片解码失败,是直接中断整个流程,还是跳过它?如果跳过,如何保证输出目录里有正确的结果?
这篇文章就来拆解这个场景,写一个能直接用的批量处理工具。核心思路是用TaskPool并行处理,同时处理解码失败的异常情况。
它解决什么问题
在相册类应用、办公文档导出工具、社交媒体批量上传工具里,经常会遇到这类需求:用户选了20张图片,想一次性调整为统一尺寸、统一质量,全部转为WebP格式保存。
适用场景
| 场景 | 说明 |
|---|---|
| 相册压缩导出 | 批量缩小图片,节省空间 |
| 编辑器工具 | 统一旋转/缩放后批量导出 |
| 社交分享 | 转换为WebP提升加载速度 |
| 文档扫描 | 生成标准化尺寸的输出文件 |
不适用场景
- 单张图片处理:杀鸡用牛刀,直接调用Image Kit单图API更简单。
- 需要实时预览:本方案是纯后台任务,不适合UI实时反馈。
- 超大图(10000万像素以上):TaskPool和解码器都有内存限制,需要额外分片处理。
环境说明
text
DevEco Studio 版本:DevEco Studio 6.1.0 及以上
HarmonyOS SDK 版本:HarmonyOS 6.1.0(23) 及以上
目标设备:手机
核心实现
架构思路
设计一个 ImageBatchProcessor 类,提供一个公开方法 processImages,传入图片路径数组和输出目录,返回处理结果。
内部采用 TaskPool 并行解码 + 逐张编码 的方式。解码是耗时操作,适合并行;编码是轻量级操作,单线程执行即可。
为什么不全部并行?
- 编码阶段需要写文件到磁盘,同时并行写入多个文件可能导致文件描述符竞争和写入失败。
- 编码后需要组装返回结果,并行编码会打乱顺序,增加后续逻辑复杂度。
- 解码是瓶颈,并行解码能显著提升吞吐量。
时序图(伪代码描述):
主线程 -> ImageBatchProcessor.processImages
|
|-- 创建 WorkerGroup,分配每个图片解码任务
|-- TaskPool 并行解码,每个任务返回 PixelMap
|-- 等待所有任务完成
|-- 逐张:旋转 -> 缩放 -> 编码 WebP -> 写文件
|-- 收集结果,返回
代码实现
新建 common/ImageBatchProcessor.ets:
typescript
import { image, imageReceiver } from '@kit.ImageKit';
import { taskpool } from '@kit.ArkTS';
import { fileIo } from '@kit.CoreFileKit';
import { BusinessError } from '@kit.BasicServicesKit';
/**
* 图片处理配置
*/
interface ProcessConfig {
rotation: number; // 旋转角度,默认0
targetWidth: number; // 缩放目标宽度,默认800
encodeQuality: number; // 编码质量(0-100),默认70
format: string; // 输出格式,默认'webp'
}
/**
* 单张图片处理结果
*/
interface ProcessResult {
inputPath: string; // 原图路径
outputPath: string; // 成功时的输出路径
succeeded: boolean; // 是否成功
errorMessage?: string; // 失败原因
}
/**
* 批量图片处理工具类
*/
export class ImageBatchProcessor {
private config: ProcessConfig = {
rotation: 0,
targetWidth: 800,
encodeQuality: 70,
format: 'image/webp'
};
/**
* 设置处理配置
*/
setConfig(config: Partial<ProcessConfig>): void {
this.config = { ...this.config, ...config };
}
/**
* 批量处理图片
*/
async processImages(
inputPaths: string[],
outputDir: string,
onProgress?: (index: number, total: number) => void
): Promise<ProcessResult[]> {
if (inputPaths.length === 0) {
return [];
}
const results: ProcessResult[] = [];
// 第一步:并行解码所有图片
// 使用 TaskPool 创建多个任务,每个任务解码一张图片
const availableCores = osCoreCount(); // 建议限制并行数,避免内存溢出
const batchSize = Math.min(availableCores, 4); // 最多4个并行
// 分批处理:每批解码 batchSize 张
for (let i = 0; i < inputPaths.length; i += batchSize) {
const batch = inputPaths.slice(i, i + batchSize);
const batchPromises: Promise<image.PixelMap | null>[] = batch.map((path, idx) => {
return this.decodeImage(path);
});
// 等待当前批次解码完成
const pixelMaps = await Promise.all(batchPromises);
// 解码失败的图片,记录错误
pixelMaps.forEach((pixelMap, idx) => {
const originalIndex = i + idx;
if (pixelMap === null) {
results[originalIndex] = {
inputPath: inputPaths[originalIndex],
outputPath: '',
succeeded: false,
errorMessage: '解码失败'
};
} else {
results[originalIndex] = {
inputPath: inputPaths[originalIndex],
outputPath: '',
succeeded: true,
errorMessage: undefined
};
// 暂存 PixelMap,待后续编码
// 注意:这里需要把 PixelMap 和路径关联起来
this.pendingPixelMaps.set(inputPaths[originalIndex], pixelMap);
}
});
if (onProgress) {
onProgress(i + batch.length, inputPaths.length);
}
}
// 第二步:逐张进行旋转、缩放、编码、写入
// 注意:编码需要写文件,串行更稳定
for (let i = 0; i < inputPaths.length; i++) {
const path = inputPaths[i];
const pixelMap = this.pendingPixelMaps.get(path);
if (!pixelMap) {
// 解码失败的已经在上面记录了,跳过
continue;
}
try {
// 1. 旋转
const rotated = await this.rotateImage(pixelMap, this.config.rotation);
// 2. 缩放
const scaled = await this.resizeImage(rotated, this.config.targetWidth);
// 3. 编码并写文件
const outputName = this.buildOutputName(path, this.config.format);
const outputPath = `${outputDir}/${outputName}`;
await this.encodeAndWrite(scaled, outputPath, this.config.encodeQuality, this.config.format);
// 更新结果
results[i].outputPath = outputPath;
results[i].succeeded = true;
// 释放 PixelMap 内存
pixelMap.release();
this.pendingPixelMaps.delete(path);
} catch (error) {
console.error(`处理失败: ${path}, 错误: ${error}`);
results[i].succeeded = false;
results[i].errorMessage = `编码/写入失败: ${error}`;
}
if (onProgress) {
onProgress(inputPaths.length + i, inputPaths.length * 2);
}
}
return results;
}
// ---------- 私有方法 ----------
private pendingPixelMaps: Map<string, image.PixelMap> = new Map();
/**
* 解码单张图片,返回 PixelMap
* 使用单独的 TaskPool 任务执行
*/
private async decodeImage(sourcePath: string): Promise<image.PixelMap | null> {
return taskpool.execute(decodeTask, sourcePath);
}
/**
* 旋转图像
*/
private async rotateImage(
source: image.PixelMap,
rotation: number
): Promise<image.PixelMap> {
// 使用 Image Kit 的 transform API
const receiver = imageReceiver.createImageReceiver({
width: source.getImageInfoSync().size.width,
height: source.getImageInfoSync().size.height,
format: image.ImageFormat.RGBA_8888
});
const target = await receiver.readNextImageSync();
// 实际开发中,这里需要构建 transform 矩阵
// 简化实现:直接对 PixelMap 进行像素操作
// 由于 API 限制,这里返回源图
return source;
}
/**
* 缩放图像到目标宽度
*/
private async resizeImage(
source: image.PixelMap,
targetWidth: number
): Promise<image.PixelMap> {
const info = source.getImageInfoSync();
const aspectRatio = info.size.height / info.size.width;
const targetHeight = Math.round(targetWidth * aspectRatio);
// 使用 PixelMap 的 scale API
// 注意:scale 需要传入缩放因子
const sx = targetWidth / info.size.width;
const sy = targetHeight / info.size.height;
// 实际开发中,PixelMap 的 scale 方法可能不支持从源图创建目标
// 这里使用 Image Kit 的 decodeOptions 调整
// 简化版:直接返回源图,只演示流程
return source;
}
/**
* 编码并写入文件
*/
private async encodeAndWrite(
pixelMap: image.PixelMap,
outputPath: string,
quality: number,
format: string
): Promise<void> {
// 使用 Image Kit 的编码 API
const buffer = pixelMap.getPixelBytes();
// 创建 WebP 编码器
const packer = new image.ImagePacker();
// 配置编码参数
const packOpts = {
format: format,
quality: quality,
// 其他参数
};
// 编码
const packedData = await packer.packing(pixelMap, packOpts);
// 写入文件
const file = await fileIo.open(outputPath, fileIo.OpenMode.CREATE | fileIo.OpenMode.WRITE_ONLY);
await fileIo.write(file.fd, packedData);
fileIo.close(file);
}
/**
* 构建输出文件名
*/
private buildOutputName(inputPath: string, format: string): string {
const extension = format === 'image/webp' ? '.webp' : '.jpg';
const basename = inputPath.substring(inputPath.lastIndexOf('/') + 1);
const nameWithoutExt = basename.substring(0, basename.lastIndexOf('.'));
return `${nameWithoutExt}${extension}`;
}
}
/**
* TaskPool 可执行任务
*/
@Concurrent
async function decodeTask(sourcePath: string): Promise<image.PixelMap | null> {
try {
// 创建源文件描述
const file = await fileIo.open(sourcePath, fileIo.OpenMode.READ_ONLY);
const fd = file.fd;
// 创建 ImageSource
const imageSource = image.createImageSource(fd);
// 解码:默认选项
const pixelMap = await imageSource.createPixelMap();
// 释放资源
imageSource.release();
return pixelMap;
} catch (error) {
console.error(`解码失败: ${sourcePath}, 错误: ${error}`);
return null;
}
}
代码说明
- 并行解码 :
decodeTask在独立的 TaskPool 线程中执行,不会阻塞主线程 UI。 - 异常处理 :解码失败返回
null,上层记录错误但不中断流程。 - 进度回调 :
onProgress参数可以实时通知 UI 更新进度条。 - 内存管理:每张图片解码后立即编码写文件,然后释放 PixelMap,避免内存膨胀。
常见问题与踩坑
问题 1:解码后旋转/缩放 API 没有明确支持
现象 :官方文档对 PixelMap 的 transform 方法描述不够详细,实际使用中发现 transform() 可能不支持旋转和缩放。
原因 :Image Kit 的 PixelMap 对象设计为只读或简单修改,复杂的几何变换(旋转、缩放)需要通过 ImagePicker 的 DecodeOptions 参数在解码时完成,而不是解码后再变换。
解法 :在解码阶段就设定好目标尺寸和旋转角度。修改 decodeTask:
typescript
@Concurrent
async function decodeTask(
sourcePath: string,
targetWidth: number,
rotation: number
): Promise<image.PixelMap | null> {
try {
const file = await fileIo.open(sourcePath, fileIo.OpenMode.READ_ONLY);
const fd = file.fd;
const imageSource = image.createImageSource(fd);
// 解码时直接设定缩放和旋转
const decodeOptions: image.DecodeOptions = {
desiredSize: {
width: targetWidth,
height: 0 // 0 表示自动计算
},
desiredRotation: rotation // 旋转角度(90的倍数)
};
const pixelMap = await imageSource.createPixelMap(decodeOptions);
imageSource.release();
return pixelMap;
} catch (error) {
console.error(`解码失败: ${sourcePath}, 错误: ${error}`);
return null;
}
}
这样解码后的 PixelMap 已经按要求缩放和旋转,省去了后续的 transform 操作。
问题 2:TaskPool 任务在低内存设备上崩溃
现象 :同时启动多个 TaskPool 解码任务,在 4GB 或 6GB 内存设备上偶尔出现 Out of Memory 崩溃。
原因:TaskPool 每个任务都会分配独立的内存空间,解码大图时内存占用高。并行任务数过多会超过设备限制。
解法:控制并行数,增加内存检查。
typescript
private async decodeBatch(
batchPaths: string[],
targetWidth: number,
rotation: number
): Promise<(image.PixelMap | null)[]> {
// 不超过4个并行
const maxConcurrent = 4;
const promises: Promise<image.PixelMap | null>[] = [];
for (let i = 0; i < batchPaths.length; i++) {
promises.push(taskpool.execute(decodeTask, batchPaths[i], targetWidth, rotation));
// 每4个等待一批
if (promises.length >= maxConcurrent || i === batchPaths.length - 1) {
const results = await Promise.all(promises);
return results;
}
}
return [];
}
同时,在调用方建议对输入图片进行大小判断,超过 4096x4096 的图片先降采样。
问题 3:编码写入时出现文件描述符泄漏
现象:批量处理 100 张图片后,文件描述符达到上限,后续操作失败。
原因 :fileIo.open 后如果发生异常,没有进入 close。
解法 :使用 try/finally 确保关闭。
typescript
let file: fileIo.File | null = null;
try {
file = await fileIo.open(outputPath, fileIo.OpenMode.CREATE | fileIo.OpenMode.WRITE_ONLY);
await fileIo.write(file.fd, packedData);
} finally {
if (file) {
fileIo.close(file);
}
}
最佳实践
1. 解码阶段完成所有几何变换
不要在解码后再调用 transform API。解码阶段通过 DecodeOptions 同时设置 desiredSize 和 desiredRotation,不仅简化代码,还能节约内存(解码后直接拿到目标尺寸,无需中间大图)。
2. 限制并发数不超过 4
即使设备有 8 个核心,也不要并行开满。Android 和 HarmonyOS 的多线程调度有差异,过高的并发数会导致内存碎片和 IO 竞争。实测 4 个并行在大部分设备上性能和稳定性最优。
3. 优先使用 image.ImageFormat.RGBA_8888
编码 WebP 时,RGBA_8888 格式能保留透明度,且质量损失最小。如果使用默认的 JPEG 格式,WebP 编码器可能无法处理。
Demo 入口
typescript
// entry/src/main/ets/pages/Index.ets
import { ImageBatchProcessor, ProcessResult } from '../common/ImageBatchProcessor';
import { fileIo } from '@kit.CoreFileKit';
import { promptAction } from '@kit.ArkUI';
@Entry
@Component
struct Index {
@State results: ProcessResult[] = [];
@State progress: number = 0;
@State total: number = 0;
build() {
Column({ space: 10 }) {
Button('选择图片并处理')
.onClick(async () => {
// 这里使用文件选择器获取图片路径列表
// 简化版:模拟路径
const inputPaths = [
'/data/storage/el2/base/haps/entry/test1.jpg',
'/data/storage/el2/base/haps/entry/test2.webp'
];
const outputDir = '/data/storage/el2/base/haps/entry/output/';
// 创建输出目录
try {
await fileIo.mkdir(outputDir, true);
} catch (e) {
// 忽略已存在错误
}
const processor = new ImageBatchProcessor();
const result = await processor.processImages(
inputPaths,
outputDir,
(index, total) => {
this.progress = index;
this.total = total;
}
);
this.results = result;
promptAction.showToast({ message: `处理完成,成功${result.filter(r => r.succeeded).length}张` });
})
.width('100%')
.height(48)
ProgressBar({
value: this.progress,
total: this.total
})
.width('90%')
.height(20)
List() {
ForEach(this.results, (item: ProcessResult) => {
ListItem() {
Text(`[${item.succeeded ? '✓' : '✗'}] ${item.inputPath}`)
.fontSize(14)
.width('100%')
}
})
}
.width('100%')
.layoutWeight(1)
}
.padding(10)
.width('100%')
.height('100%')
}
}
FAQ
Q:为什么模拟器解码很慢,但真机正常?
A:模拟器的 GPU 支持有限,解码大图时 CPU 负载过高。Image Kit 在模拟器上会使用软解码而非硬件加速。建议在真机上测试性能。
Q:旋转角度不支持任意值吗?
A:desiredRotation 只支持 0、90、180、270 四个值。如果需要任意角度,需要在解码后再处理(通过 PixelMap 的像素操作 API),但这会增加复杂度。
Q:编码后的 WebP 文件与源文件相比,大小变化不明显?
A:检查 quality 参数。对于无损源图(如 PNG),WebP 的无损编码效果更好。建议对 PNG 格式使用 100% 质量,对 JPG 使用 70% - 80%。
Q:处理过程中断后,输出目录里会有不完整的文件吗?
A:有。代码中没有处理中断情况。实际项目中建议增加临时文件机制,先写入 .tmp 后缀,全部完成后重命名。
示例代码地址:项目地址