HarmonyOS技术精讲-Image Kit:实战 - 多图批量编辑与导出工具

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 对象设计为只读或简单修改,复杂的几何变换(旋转、缩放)需要通过 ImagePickerDecodeOptions 参数在解码时完成,而不是解码后再变换。

解法 :在解码阶段就设定好目标尺寸和旋转角度。修改 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 同时设置 desiredSizedesiredRotation,不仅简化代码,还能节约内存(解码后直接拿到目标尺寸,无需中间大图)。

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 后缀,全部完成后重命名。

示例代码地址:项目地址