HarmonyOS APP《画伴梦工厂》开发第20篇:图片压缩与 Base64 编解码
第3.4篇:图片压缩与 Base64 编解码
系列:HarmonyOS 从入门到实践 · 画伴梦工厂实战
难度:⭐⭐ 进阶
前置知识:2.4 涂鸦画布进阶
涉及源文件:products/default/src/main/ets/services/AIGenerationService.ets、products/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 = 4000,COMPRESSED_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);
}
关键步骤:
Uint8Array包装:Base64Helper.encodeToStringSync接受Uint8Array而非ArrayBuffer,需要用new Uint8Array(buffer)包装- 同步编码:
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 的 ImageSource、PixelMap、ImagePacker 都是原生资源对象,不遵守 ArkTS 的垃圾回收机制,必须手动释放。
try {
// ... 解码、压缩、编码
} finally {
if (pixelMap !== null) {
await pixelMap.release(); // 释放像素图内存
}
await packer.release(); // 释放打包器
await source.release(); // 释放图片源
}
6.1 释放顺序与安全性
pixelMap需要判空,因为它可能在createPixelMap抛出异常时为nullpacker和source在createImagePacker和createImageSource成功后始终非空- 释放顺序没有严格依赖,但建议按创建的反序释放
- 即使某个
release()抛出异常,finally块中后续的release()仍然会执行
6.2 不释放的后果
如果忘记调用 release():
- 每次压缩操作都会泄漏数百 KB 到数 MB 的原生内存
- 在连续多次压缩(如图生视频的轮询场景)中,内存会持续增长
- 最终可能触发系统 OOM(Out of Memory)导致应用闪退
七、两大服务的策略对比
AIGenerationService 和 ImageRecognitionService 虽然使用了相同的技术栈(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.createImageSource → PixelMap |
将 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、更激进的压缩策略
更多推荐


所有评论(0)