第3.4篇:图片压缩与 Base64 编解码

系列:HarmonyOS 从入门到实践 · 画伴梦工厂实战
难度:⭐⭐ 进阶
前置知识:2.4 涂鸦画布进阶
涉及源文件products/default/src/main/ets/services/AIGenerationService.etsproducts/default/src/main/ets/services/ImageRecognitionService.ets


在这里插入图片描述

在之前的文章中,我们已经学习了如何通过 HTTP 网络请求调用 AI 服务。但无论是文生图还是图生视频 API,AI 服务接收的图片数据通常以 Base64 编码 的形式传输,而非直接传递文件 URI。同时,大尺寸图片直接编码会导致请求体过大、传输缓慢甚至超时。因此,图片压缩 + Base64 编解码 成为连接本地图片与远程 AI 服务的核心中间环节。

本文将基于"画伴梦工厂"中 AIGenerationService(图生视频服务)和 ImageRecognitionService(图像识别服务)的真实代码,完整拆解 HarmonyOS 下图片文件读取、压缩、编码、解码的全流程。


一、为什么需要图片压缩 + Base64?

1.1 图片传输的三大挑战

向 AI API 发送图片时,我们面临三个核心约束:

约束 说明 典型值
API 请求体大小限制 部分 API 对请求体有隐性或显性上限 2MB~10MB
传输效率 Base64 编码后体积膨胀约 33%(每 3 字节→4 字符) 原始 1MB → Base64 约 1.37MB
识别/生成质量 过高的图片分辨率对 AI 识别增益有限,但传输成本剧增 1024px 边缘已足够

1.2 项目中的目标值

项目中两个服务分别设定了不同的目标压缩大小:

// AIGenerationService(图生视频)
const TARGET_UPLOAD_IMAGE_BYTES: number = 900 * 1024;    // 900KB 目标
const COMPRESSED_IMAGE_QUALITY: number = 78;
const COMPRESSED_IMAGE_MAX_EDGE: number = 1280;

// ImageRecognitionService(图像识别)
const RECOGNITION_IMAGE_TARGET_BYTES: number = 520 * 1024; // 520KB 目标
const RECOGNITION_IMAGE_MAX_EDGE: number = 1024;

可以看到,图生视频服务容忍更高的图片大小(900KB),因为生成的视频质量依赖于输入图片细节;而图像识别服务仅需理解画面内容,目标更小(520KB),边缘也限制在 1024px。


二、fileIo 流式读取:从本地文件到 ArrayBuffer

所有图片处理的第一步,是将图片文件从磁盘读取为内存中的 ArrayBuffer。HarmonyOS 提供了 @kit.CoreFileKit 中的 fileIo 模块来完成这一操作。

2.1 基础读取流程

ImageRecognitionService.readImageAsArrayBuffer 为例:

private static readImageAsArrayBuffer(imageUri: string): ArrayBuffer {
  const candidates: string[] = ImageRecognitionService.getReadableUriCandidates(imageUri);
  let lastError: Error | undefined = undefined;

  for (let i = 0; i < candidates.length; i++) {
    try {
      const file = fileIo.openSync(candidates[i], fileIo.OpenMode.READ_ONLY);
      try {
        const stat = fileIo.statSync(file.fd);
        if (stat.size > MAX_IMAGE_BYTES) {    // 12MB 硬限制
          throw new Error('图片超过 12MB,请重新拍摄或压缩后再识别');
        }
        const buffer = new ArrayBuffer(stat.size);
        const readSize = fileIo.readSync(file.fd, buffer);
        if (readSize === stat.size) {
          return buffer;
        }
        return buffer.slice(0, readSize);
      } finally {
        fileIo.closeSync(file);
      }
    } catch (error) {
      lastError = error as Error;
    }
  }
  throw new Error('无法读取图片');
}

2.2 fileIo 关键 API 拆解

API 作用 使用要点
fileIo.openSync(path, mode) 打开文件,返回 File 对象 READ_ONLY 只读模式;path 必须是沙箱内路径
fileIo.statSync(fd) 获取文件状态信息 .size 属性获取文件字节长度
fileIo.readSync(fd, buffer) 将文件内容读入 ArrayBuffer 返回实际读取的字节数(readSize
fileIo.closeSync(file) 关闭文件句柄 必须在 finally 块中执行,防止句柄泄漏

2.3 候选路径(Candidate Fallback)机制

细心的读者会发现,代码中并没有直接用原始 imageUri 打开文件,而是调用了 getReadableUriCandidates。这是因为从相机或相册获取的 URI 可能有多种格式(file:// 前缀、content:// 协议、URL 编码等),不同 HarmonyOS 版本或设备返回的 URI 格式存在差异。

AIGenerationService 中的实现更为完善:

private static getReadableUriCandidates(uri: string): string[] {
  const candidates: string[] = [];
  AIGenerationService.addCandidate(candidates, uri);
  const queryIndex = uri.indexOf('?');
  if (queryIndex > 0) {
    AIGenerationService.addCandidate(candidates, uri.substring(0, queryIndex));
  }
  if (uri.startsWith('file://')) {
    const path = uri.substring(7);
    AIGenerationService.addCandidate(candidates, path);
    const pathQueryIndex = path.indexOf('?');
    if (pathQueryIndex > 0) {
      AIGenerationService.addCandidate(candidates, path.substring(0, pathQueryIndex));
    }
  }
  // 对每个候选项尝试 decodeURIComponent
  const count = candidates.length;
  for (let i = 0; i < count; i++) {
    try {
      AIGenerationService.addCandidate(candidates, decodeURIComponent(candidates[i]));
    } catch (error) { /* 忽略解码失败 */ }
  }
  return candidates;
}

这种候选路径策略解决了三类问题:

  • file:// 前缀兼容:部分 API 返回带前缀的 URI,fileIo.openSync 需要去掉 file://
  • 查询参数剥离:URI 末尾的 ?timestamp=xxx 等参数会导致 openSync 失败
  • URL 编码还原:URI 中的 %20%E4%BD%A0 等编码需要解码后才能正确读取

2.4 AIGenerationService 的多源读取

AIGenerationService 更进一步,需要支持多种图片来源:

private static async readImageAsArrayBuffer(imageUri: string): Promise<ArrayBuffer> {
  if (imageUri.startsWith('data:')) {
    return AIGenerationService.dataUrlToArrayBuffer(imageUri);    // Base64 DataURL
  }
  if (imageUri.startsWith('http://') || imageUri.startsWith('https://')) {
    return AIGenerationService.downloadImageAsArrayBuffer(imageUri); // 远程 URL
  }
  return AIGenerationService.readLocalImageAsArrayBuffer(imageUri); // 本地文件
}

这个设计体现了多源统一处理的思想——无论图片来自本地磁盘、远程网络还是 Base64 DataURL,最终都统一输出为 ArrayBuffer,后续的压缩和编码流程无需关心图片来源。


三、图片解码:ImageSource + PixelMap

拿到 ArrayBuffer 后,我们需要将其解码为可操作的像素图(PixelMap),才能进行缩放和重新编码。

private static async compressImageBuffer(sourceBuffer: ArrayBuffer): Promise<ArrayBuffer> {
  const source: image.ImageSource = image.createImageSource(sourceBuffer);
  let pixelMap: image.PixelMap | null = null;
  const packer: image.ImagePacker = image.createImagePacker();
  try {
    const info: image.ImageInfo = await source.getImageInfo();
    const maxEdge = Math.max(info.size.width, info.size.height);
    const sampleSize = Math.max(1, Math.ceil(maxEdge / COMPRESSED_IMAGE_MAX_EDGE));
    const decodingOptions: image.DecodingOptions = {
      sampleSize: sampleSize,     // 降采样因子
      editable: true               // 允许后续编辑(Packer 需要)
    };
    pixelMap = await source.createPixelMap(decodingOptions);
    // ... 后续压缩
  } finally {
    // 资源释放
  }
}

3.1 降采样(Sample Size)——第一级压缩

这里实现了一个关键的优化点:在解码阶段就缩小图片尺寸

const maxEdge = Math.max(info.size.width, info.size.height);
const sampleSize = Math.max(1, Math.ceil(maxEdge / COMPRESSED_IMAGE_MAX_EDGE));

sampleSize 的含义是:解码时每 sampleSize 个像素采样 1 个像素。例如,一张 4000×3000 的图片,maxEdge = 4000COMPRESSED_IMAGE_MAX_EDGE = 1280,则 sampleSize = ceil(4000/1280) = 4。解码后的 PixelMap 分辨率变为约 1000×750——直接减少了 16 倍 的像素数量。

这种做法的好处是:

  • 减少内存占用:更大的图片解码为 PixelMap 后会占用大量内存(一张 4000×3000 的 RGBA 图片需要 48MB)
  • 加速后续处理:Packer 处理更小的 PixelMap 更快
  • 保留足够质量:1280px 或 1024px 的边缘长度对 AI 服务已经足够

3.2 ImageInfo 获取图片尺寸

const info: image.ImageInfo = await source.getImageInfo();
// info.size.width, info.size.height

在解码前先获取图片信息,便于动态计算合适的降采样倍数,而不是写死一个固定的缩放尺寸。


四、ImagePacker 编码——第二级压缩

解码并缩小后的 PixelMap,需要通过 ImagePacker 重新编码为 JPEG 格式。这是第二级——也是更精细的——压缩控制。

4.1 PackingOption 配置

const packOptions: image.PackingOption = {
  format: 'image/jpeg',                           // 输出格式
  quality: COMPRESSED_IMAGE_QUALITY,              // JPEG 质量(0-100)
  bufferSize: TARGET_UPLOAD_IMAGE_BYTES * 2       // 输出缓冲区大小
};
参数 说明
format 'image/jpeg' JPEG 格式支持有损压缩,体积远小于 PNG
quality 78(AI生成)/ 68(识别) 数值越低,体积越小但画质损失越大
bufferSize 目标大小 × 2 预分配足够大的输出缓冲区,避免多次扩容

选择 JPEG 而非 PNG 的原因:

  • JPEG 的有损压缩可以在相同画质下获得更小的文件体积
  • AI API 传输时对画质损失不敏感
  • 儿童绘画线条简单,JPEG 压缩伪影不明显

4.2 两阶段质量降级策略

项目中实现了一个"先尝试,不满足再降级"的两阶段策略:

// 第一阶段:用较高 quality 尝试
let compressed = await packer.packToData(pixelMap, packOptions);
// 如果还是太大,用更低 quality 重新压缩
if (compressed.byteLength > TARGET_UPLOAD_IMAGE_BYTES) {
  const smallerOptions: image.PackingOption = {
    format: 'image/jpeg',
    quality: 62,    // 从 78 降到 62(AIGenerationService)
    bufferSize: TARGET_UPLOAD_IMAGE_BYTES * 2
  };
  compressed = await packer.packToData(pixelMap, smallerOptions);
}
return compressed;

这种策略的巧妙之处在于:

  • 优先保证质量:第一次尝试用较高的 quality(78 或 68),多数图片在此阶段已达标
  • 降级有度:仅当首次压缩结果仍然超标时才降级,而不是一开始就用低质量
  • 无需二次解码:对同一个 PixelMap 多次调用 packToData 无需重新解码

五、util.Base64Helper 编解码

压缩完成后,ArrayBuffer 需要编码为 Base64 字符串才能放入 HTTP 请求体中。

5.1 编码(ArrayBuffer → Base64)

private static arrayBufferToBase64(buffer: ArrayBuffer): string {
  const bytes = new Uint8Array(buffer);
  const base64 = new util.Base64Helper();
  return base64.encodeToStringSync(bytes);
}

关键步骤:

  1. Uint8Array 包装Base64Helper.encodeToStringSync 接受 Uint8Array 而非 ArrayBuffer,需要用 new Uint8Array(buffer) 包装
  2. 同步编码encodeToStringSync 是同步方法,不会阻塞 UI 线程,因为编码是纯 CPU 计算,耗时通常小于 10ms

5.2 解码(Base64 → ArrayBuffer)

当遇到 Base64 格式的 DataURL 时,需要反向解码:

private static dataUrlToArrayBuffer(dataUrl: string): ArrayBuffer {
  const commaIndex = dataUrl.indexOf(',');
  const base64Text = commaIndex >= 0 ? dataUrl.substring(commaIndex + 1) : dataUrl;
  const base64 = new util.Base64Helper();
  const bytes = base64.decodeSync(base64Text);
  return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
}

这里处理了 DataURL 的 data:image/jpeg;base64, 前缀,通过 indexOf(',') 定位真正的 Base64 数据起始位置。decodeSync 返回 Uint8Array,通过 .buffer 属性获取底层的 ArrayBuffer

5.3 Base64Helper 的编解码矩阵

方法 输入 输出 用途
encodeToStringSync Uint8Array string 图片压缩后编码,用于 API 请求
decodeSync string Uint8Array 解码 API 返回的 Base64 图片数据

六、内存管理:finally 块中的资源释放

这是整个流程中最容易被忽视但也最重要的一环。HarmonyOS 的 ImageSourcePixelMapImagePacker 都是原生资源对象,不遵守 ArkTS 的垃圾回收机制,必须手动释放。

try {
  // ... 解码、压缩、编码
} finally {
  if (pixelMap !== null) {
    await pixelMap.release();   // 释放像素图内存
  }
  await packer.release();        // 释放打包器
  await source.release();        // 释放图片源
}

6.1 释放顺序与安全性

  • pixelMap 需要判空,因为它可能在 createPixelMap 抛出异常时为 null
  • packersourcecreateImagePackercreateImageSource 成功后始终非空
  • 释放顺序没有严格依赖,但建议按创建的反序释放
  • 即使某个 release() 抛出异常finally 块中后续的 release() 仍然会执行

6.2 不释放的后果

如果忘记调用 release()

  • 每次压缩操作都会泄漏数百 KB 到数 MB 的原生内存
  • 在连续多次压缩(如图生视频的轮询场景)中,内存会持续增长
  • 最终可能触发系统 OOM(Out of Memory)导致应用闪退

七、两大服务的策略对比

AIGenerationServiceImageRecognitionService 虽然使用了相同的技术栈(fileIo + ImageSource + ImagePacker + Base64Helper),但在具体参数上根据不同场景做了差异化配置:

对比维度 AIGenerationService ImageRecognitionService
目标大小 900KB 520KB
最大边缘 1280px 1024px
首次 quality 78 68
降级 quality 62 52
读取方式 支持本地/远程/DataURL 多源 仅本地文件
URI 候选 5+ 种候选路径 2 种(file:// 前缀处理)
压缩失败处理 降级使用原图 Base64 降级使用原图 Base64
用途 上传到图生视频 API 嵌入 GPT-4o-mini 请求体

可以看到,识别服务更加激进——更低的目标大小(520KB)、更小的边缘(1024px)、更低的质量(68 再降至 52)。这是因为 GPT-4o-mini 只需要理解画面内容,而非关注精细的像素细节;而图生视频服务需要保留足够多的视觉信息来生成流畅的动画。


八、错误处理与降级策略

整个图片处理链路中,每一步都可能失败,项目中设计了多层降级机制:

8.1 读取阶段的降级

readImageAsArrayBuffer 通过候选路径机制实现隐式降级——第一个候选路径失败后自动尝试下一个,所有候选都失败才抛出异常。

8.2 压缩阶段的降级

prepareUploadBase64 实现了显式降级:

try {
  const compressedBuffer = await AIGenerationService.compressImageBuffer(sourceBuffer);
  const compressedBase64 = AIGenerationService.arrayBufferToBase64(compressedBuffer);
  return compressedBase64;
} catch (error) {
  // 压缩失败,降级使用未压缩的原图
  const sourceBase64 = AIGenerationService.arrayBufferToBase64(sourceBuffer);
  return sourceBase64;
}

这种"压缩失败不阻断流程"的设计,保证了即使在极端情况下(如解码异常、内存不足),用户的动画生成请求也不会中断。

8.3 图片过大阻断

无论是读取还是压缩阶段,都有 12MB 的硬性上限检查:

if (stat.size > MAX_IMAGE_BYTES) {   // 12MB
  throw new Error('图片超过 12MB,请压缩后再生成');
}

这是出于 API 请求体大小的现实考量——12MB 的原图即使压缩也很难降到合理范围,不如尽早阻断并提示用户。


九、完整流程图

用户拍照/选择图片
      │
      ▼
getReadableUriCandidates(imageUri)
  ┌────┴────┐
  │ 尝试候选路径  │──失败→ throw Error
  └────┬────┘
      │ 成功
      ▼
fileIo.openSync() → fileIo.statSync() → fileIo.readSync() → fileIo.closeSync()
      │
      ▼
ArrayBuffer (原始图片数据)
      │
      ▼
image.createImageSource(buffer)
      │
      ▼
source.getImageInfo() → 计算 sampleSize
      │
      ▼
source.createPixelMap({ sampleSize, editable: true })  ← 第一级:降采样
      │
      ▼
packer.packToData(pixelMap, { format:'jpeg', quality:78 })  ← 第二级:质量压缩
      │
      ├── 达标(< 900KB)→ 继续
      │
      └── 未达标 → packToData(quality:62) → 继续
            │
            ▼
arrayBufferToBase64(compressed) → Base64 字符串
      │
      ▼
发送到 AI API

总结

本文通过"画伴梦工厂"两个核心服务的真实代码,完整拆解了 HarmonyOS 下图片压缩与 Base64 编解码的技术方案:

技术点 核心 API 作用
文件读取 fileIo.openSync/statSync/readSync/closeSync 将磁盘文件读入内存 ArrayBuffer
候选路径 getReadableUriCandidates 兼容不同格式的 URI
图片解码 image.createImageSourcePixelMap 将 ArrayBuffer 解码为可操作像素图
降采样 DecodingOptions.sampleSize 第一级压缩:缩小分辨率
图片编码 ImagePacker.packToData 第二级压缩:JPEG 质量控制
两阶段降级 首次 quality → 降级 quality 优先保质量,不达标则降级
Base64 编码 util.Base64Helper.encodeToStringSync ArrayBuffer → Base64 字符串
Base64 解码 util.Base64Helper.decodeSync Base64 字符串 → ArrayBuffer
内存管理 pixelMap.release() / packer.release() / source.release() 防止原生内存泄漏

此套方案在项目中经受住了真实 AI API 调用的考验——单次图生视频任务中,图片从可能的 5~10MB 压缩到 900KB 以内,传输效率提升 80% 以上,且 AI 生成的视频画质与使用原图几乎无差别。

下一篇: 第 3.5 篇将介绍 GPT-4o-mini 图像识别——如何通过结构化 Prompt 设计让 AI 理解儿童绘画内容,并将自由文本规范化为结构化的识别结果。


参考源码

本文所有代码均来自项目文件:

  • products/default/src/main/ets/services/AIGenerationService.ets — 图生视频服务,包含多源读取、两阶段压缩、Base64 编解码的完整实现,约 900 行
  • products/default/src/main/ets/services/ImageRecognitionService.ets — 图像识别服务,包含 fileIo 流式读取、候选路径 fallback、更激进的压缩策略
Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐