前阵子做一个图片标注功能,需求听起来很简单:用户从相册里选一张图,加一层轻量处理,页面上能预览,点保存以后导出一张新图。
刚开始我也没太当回事。图片选择器拿到路径,页面里解码,拿到 PixelMap,再做一点像素改写,最后用 ImagePacker 编码。跑 demo 很顺,换到真机上的 5000px 原图,问题就出来了:预览偶发卡顿,连续点两次保存会生成黑图,页面返回以后内存没有立刻下来,有时日志里还夹着一堆不稳定的 BusinessError。
后来把这块重新拆了一遍,我的感受是:HarmonyOS 上做图片处理,不能把它当成“一个 API 调一下”的事情。它更像一条小型流水线,ImageSource 管解码入口,PixelMap 管内存里的像素对象,ImagePacker 管重新编码。中间任何一步偷懒,页面上看起来就是卡、黑、慢、偶现。
图片处理不是 UI 逻辑,别直接堆在 Page 里
我见过不少项目这么写:在页面 onClick 里选图,选完直接 createImageSource,然后 createPixelMap,处理完塞给 Image 组件展示。功能能跑,但后面会变得很难维护。
原因很直接:页面关心的是状态,图片链路关心的是资源。
页面需要知道:现在是不是处理中、预览图是什么、保存成功没有、失败原因能不能给用户看。图片链路需要知道:源图尺寸多大、是否需要降采样、像素格式是什么、PixelMap 什么时候释放、编码失败怎么兜底。
这两类事情混在一个 @Component 里,调试时会特别痛苦。尤其是用户连续选择、连续保存、处理中返回页面这几种场景,页面状态和底层对象生命周期很容易错位。
我现在比较习惯把结构拆成这样:
entry/src/main/ets/
├── common/
│ └── image/
│ ├── ImageJob.ets // 任务参数、状态、错误码
│ ├── ImagePipeline.ets // 解码、像素处理、编码
│ └── ImageReleaseBag.ets // 统一释放对象
└── pages/
└── ImageEditPage.ets // 只处理 UI 状态
页面不直接碰 ImageSource 和 ImagePacker。PixelMap 如果要用于预览,可以短时间交给页面持有,但持有权要说清楚:谁创建,谁释放;谁交给 UI,谁在页面退出时兜底释放。
先把三个角色分清楚
ImageSource 是图片源。它适合做两件事:读图片基本信息、按解码参数创建 PixelMap。这里最值得注意的是,不要上来就把原图完整解到内存里。移动端相机图动不动几千像素宽,真按 RGBA 展开,内存占用不是文件大小那点数。
PixelMap 是内存里的像素对象。它不是普通字符串,也不是轻量 DTO。你可以把它交给 Image 显示,也可以读取像素缓冲区做算法处理,但用完要释放。图片类问题里很多“偶现”都和它有关:重复引用、跨页面持有、失败分支忘了释放、预览图和导出图混用。
ImagePacker 是重新编码。它负责把处理后的 PixelMap 编成 JPEG、PNG、WebP、HEIC 这类可保存、可上传、可分享的数据。这里别只关注 quality,还要考虑输出格式、文件体积、透明通道、保存路径、编码失败后的清理。
这三个角色分清楚以后,代码会自然变成管线,而不是一坨页面回调。
一条更稳的处理链路
我的习惯是把图片任务拆成五步:
输入源 -> 读取图片信息 -> 按目标尺寸解码 -> PixelMap 处理 -> 编码输出
这里有个小取舍:预览和导出不一定要用同一张 PixelMap。
用户刚选完图,最重要的是页面别空着。可以先解一张长边 1280 左右的预览图,马上给 UI;用户真正点保存时,再按业务需要解更高质量的版本。很多时候用户只是看一眼效果,并不会保存。为了一个可能不会发生的保存动作,提前把原图完整处理一遍,体验上并不划算。
下面这段是我会放到 ImageJob.ets 里的基础类型。实际项目可以再细分错误码,这里保留核心结构。
// common/image/ImageJob.ets
import { image } from '@kit.ImageKit';
export enum ImageJobState {
IDLE = 'IDLE',
DECODING = 'DECODING',
PROCESSING = 'PROCESSING',
ENCODING = 'ENCODING',
DONE = 'DONE',
FAILED = 'FAILED'
}
export interface ImageProcessOptions {
// 预览建议 1280~1600,导出按业务再放大
maxSide: number;
// 是否允许改写像素
editable: boolean;
// 导出质量,JPEG/WebP 有意义
quality: number;
// 输出格式,例如 image/jpeg、image/png
format: string;
}
export interface ImageProcessResult {
jobId: number;
width: number;
height: number;
data: ArrayBuffer;
}
export interface ImageRuntimeState {
jobId: number;
state: ImageJobState;
message?: string;
preview?: image.PixelMap;
}
jobId 看着不起眼,实际很有用。用户连续选两张图时,第一张图的任务可能后返回。如果没有 jobId,旧任务会把新页面状态覆盖掉,表现出来就是“明明选了 B 图,预览忽然跳回 A 图”。
解码前先读尺寸,别赌设备内存
下面是管线里最关键的一段:先用 ImageSource 读取图片信息,再决定解码尺寸。
// common/image/ImagePipeline.ets
import { image } from '@kit.ImageKit';
import { BusinessError } from '@kit.BasicServicesKit';
import { ImageProcessOptions, ImageProcessResult } from './ImageJob';
export class ImagePipeline {
async runToEncodedData(
jobId: number,
filePath: string,
options: ImageProcessOptions
): Promise<ImageProcessResult> {
let source: image.ImageSource | undefined = undefined;
let pixelMap: image.PixelMap | undefined = undefined;
let packer: image.ImagePacker | undefined = undefined;
try {
source = image.createImageSource(filePath);
const info = await source.getImageInfo();
const decodingOptions = this.buildDecodingOptions(info, options);
pixelMap = await source.createPixelMap(decodingOptions);
if (options.editable) {
await this.applySoftGray(pixelMap);
}
const imageInfo = await pixelMap.getImageInfo();
packer = image.createImagePacker();
const data = await packer.packToData(pixelMap, {
format: options.format,
quality: options.quality
});
return {
jobId,
width: imageInfo.size.width,
height: imageInfo.size.height,
data
};
} catch (err) {
const e = err as BusinessError;
throw new Error(`图片处理失败:${e.code ?? '-'} ${e.message ?? ''}`);
} finally {
// 注意:如果 PixelMap 已经交给 UI 展示,不要在这里释放。
// 本方法返回的是编码数据,PixelMap 只在管线内部使用,所以这里可以释放。
await this.safeReleasePixelMap(pixelMap);
await this.safeReleaseImageSource(source);
await this.safeReleasePacker(packer);
}
}
private buildDecodingOptions(
info: image.ImageInfo,
options: ImageProcessOptions
): image.DecodingOptions {
const width = info.size.width;
const height = info.size.height;
const maxSide = Math.max(width, height);
const ratio = maxSide > options.maxSide ? options.maxSide / maxSide : 1;
return {
desiredSize: {
width: Math.max(1, Math.floor(width * ratio)),
height: Math.max(1, Math.floor(height * ratio))
},
desiredPixelFormat: image.PixelMapFormat.RGBA_8888,
editable: options.editable
};
}
private async safeReleasePixelMap(pixelMap?: image.PixelMap): Promise<void> {
if (!pixelMap) {
return;
}
try {
await pixelMap.release();
} catch (_) {
// release 失败不再向上抛,避免覆盖主错误
}
}
private async safeReleaseImageSource(source?: image.ImageSource): Promise<void> {
if (!source) {
return;
}
try {
await source.release();
} catch (_) {}
}
private async safeReleasePacker(packer?: image.ImagePacker): Promise<void> {
if (!packer) {
return;
}
try {
await packer.release();
} catch (_) {}
}
}
这段代码有几个点我会坚持保留。
getImageInfo() 要放在真正解码之前。它不是为了“显示图片尺寸”这么简单,而是为了决定这张图该不该被完整解码。只要业务不是专业修图,很多场景根本不需要原图级像素进入页面。
desiredPixelFormat 尽量明确写出来。后面如果要读写像素,像素格式不明确,处理函数就会变成猜谜。你以为自己按 RGBA 读,实际格式不一致,轻则偏色,重则整张图异常。
finally 里做释放。不要只在成功分支释放,也不要只在页面退出时释放。图片处理链路的失败分支很多:源文件不可读、格式不支持、解码失败、像素写回失败、编码失败。每个分支都指望业务代码记得释放,最后一定会漏。
像素改写:少做花活,先把格式和范围管住
下面这个 applySoftGray 只是示例:读取像素缓冲区,把图片轻微降饱和,再写回 PixelMap。实际项目里可以替换成水印、马赛克、局部遮挡、截图隐私高亮等逻辑。
// common/image/ImagePipeline.ets 片段
private async applySoftGray(pixelMap: image.PixelMap): Promise<void> {
const bytes = pixelMap.getPixelBytesNumber();
if (bytes <= 0) {
return;
}
const buffer = new ArrayBuffer(bytes);
await pixelMap.readPixelsToBuffer(buffer);
const data = new Uint8Array(buffer);
// 前面解码时指定了 RGBA_8888,这里才敢按 4 字节步长处理。
for (let i = 0; i + 3 < data.length; i += 4) {
const r = data[i];
const g = data[i + 1];
const b = data[i + 2];
// 整数近似亮度,少一点浮点运算开销。
const gray = (r * 77 + g * 150 + b * 29) >> 8;
// 不做纯灰,保留一点原图色彩,预览观感会自然些。
data[i] = Math.floor(r * 0.82 + gray * 0.18);
data[i + 1] = Math.floor(g * 0.82 + gray * 0.18);
data[i + 2] = Math.floor(b * 0.82 + gray * 0.18);
// data[i + 3] 是 alpha,这里不动。
}
await pixelMap.writeBufferToPixels(buffer);
}
这类代码不要一上来就追求“算法高级”。先把三件事做好:格式明确、边界明确、失败可退。
如果是局部处理,不一定非要整图读出来。能按区域读写就按区域做。整张 4000 × 3000 的 RGBA 图,一次缓冲区就是四十多 MB,用户多点两次,内存曲线立刻难看。
还有一个细节:不要在 UI 线程里连续做重像素循环。轻量预览可以接受,重处理要么降尺寸,要么拆任务,要么把保存动作放到用户真正确认之后。很多图片需求不是不能做,是不该在用户刚进入页面时就全做。
页面侧只订阅状态,不接管管线
页面可以很薄。它负责启动任务、展示状态、处理过期结果。
// pages/ImageEditPage.ets
import { image } from '@kit.ImageKit';
import { ImagePipeline } from '../common/image/ImagePipeline';
import { ImageJobState, ImageRuntimeState } from '../common/image/ImageJob';
@Entry
@Component
struct ImageEditPage {
private pipeline: ImagePipeline = new ImagePipeline();
private currentJobId: number = 0;
@State runtime: ImageRuntimeState = {
jobId: 0,
state: ImageJobState.IDLE
};
async startExport(filePath: string): Promise<void> {
const jobId = Date.now();
this.currentJobId = jobId;
this.runtime = {
jobId,
state: ImageJobState.DECODING,
message: '正在处理图片...'
};
try {
const result = await this.pipeline.runToEncodedData(jobId, filePath, {
maxSide: 1920,
editable: true,
quality: 88,
format: 'image/jpeg'
});
// 旧任务后返回,直接丢弃,不要覆盖新图状态。
if (result.jobId !== this.currentJobId) {
return;
}
this.runtime = {
jobId,
state: ImageJobState.DONE,
message: `导出完成:${result.width} × ${result.height}`
};
// result.data 可以继续写文件、上传或进入分享链路。
} catch (err) {
if (jobId !== this.currentJobId) {
return;
}
this.runtime = {
jobId,
state: ImageJobState.FAILED,
message: `${err}`
};
}
}
build() {
Column({ space: 16 }) {
Text('图片处理示例')
.fontSize(22)
.fontWeight(FontWeight.Bold)
Text(this.runtime.message ?? '请选择图片')
.fontSize(14)
.fontColor('#666666')
Button(this.runtime.state === ImageJobState.DECODING ? '处理中...' : '开始导出')
.enabled(this.runtime.state !== ImageJobState.DECODING)
.onClick(() => {
// 示例里省略选择器代码,真实项目里传入 picker 返回的沙箱路径或文件路径。
this.startExport('/data/storage/el2/base/haps/entry/files/demo.jpg');
})
}
.width('100%')
.height('100%')
.padding(20)
}
}
这里用了一个很土但很好用的判断:result.jobId !== this.currentJobId 就丢。别觉得它简陋,线上很多“图片串了”的问题,就是没有这个判断。
如果你还要做预览,建议单独做 decodePreview(),返回 PixelMap 给页面持有。页面退出时释放它,不要让预览图跟导出任务共用同一个对象。
// 页面持有预览 PixelMap 时,退出页面要主动释放
aboutToDisappear(): void {
const preview: image.PixelMap | undefined = this.runtime.preview;
if (preview) {
preview.release();
}
}
常见坑位:不是 API 难,是边界太多
1. 原图直接解码,内存曲线很快失控
图片文件 12MB,不代表解码后只占 12MB。JPEG 是压缩格式,进到 PixelMap 后按像素展开。粗略估算一下:
4000 × 3000 × 4 ≈ 48MB
再加上缓冲区、编码临时对象、页面预览引用,内存压力很容易上去。预览场景一定要限制长边。导出场景也要问清业务:是真的需要原图尺寸,还是只是“看起来清楚”。
2. ImageSource 复用过头,容易把并发搞乱
ImageSource 适合一次任务内部使用,不建议做成全局单例复用。尤其是同一个页面可能连续处理多张图时,每张图单独创建、单独释放,反而更稳。
如果业务上要做队列,也不要让多个任务同时操作同一个 ImageSource。图片链路里共享对象越少,问题越好定位。
3. PixelMap 给了 UI,就别在管线里顺手 release
这是一个很常见的黑图来源。
有时为了预览,会把 PixelMap 直接赋给 Image 组件。这个时候它的生命周期就已经被页面接管了。管线函数如果在 finally 里顺手 release(),UI 还没来得及渲染,底层资源已经没了。
我的规则是:
返回 ArrayBuffer / 文件路径:管线内部 release PixelMap
返回 PixelMap 给 UI:页面负责 release PixelMap
不要两边都管,也不要两边都不管。
4. 编码格式别乱选
JPEG 适合照片,体积小,但没有透明通道。PNG 适合透明图、截图、图标类内容,但照片体积可能比较大。WebP 适合压缩收益更明显的业务,HEIC 则要看你的分发和兼容要求。
做头像、封面、帖子图片这类业务,我一般会给一层策略:
export function chooseOutputFormat(hasAlpha: boolean, isPhoto: boolean): string {
if (hasAlpha) {
return 'image/png';
}
if (isPhoto) {
return 'image/jpeg';
}
return 'image/webp';
}
这段只是策略示意,项目里还要看上传服务、审核服务、分享链路是否支持对应格式。
5. 失败提示别把 BusinessError 原样甩给用户
日志里保留错误码,界面上给人话。
export function toUserMessage(err: Error): string {
const text = `${err.message ?? err}`;
if (text.includes('decode')) {
return '图片读取失败,可以换一张图片试试';
}
if (text.includes('pack') || text.includes('encode')) {
return '图片保存失败,请稍后重试';
}
return '图片处理失败,请重新选择图片';
}
调试时你当然需要完整堆栈,但用户不需要看到一串模块名。这个细节对工具类应用尤其重要,很多人并不关心你底层用了哪个 API,他只关心这张图为什么没保存上。
稳定性优化:把“能跑”变成“敢上线”
我会给图片链路加几条硬规则。
长边限制要前置。 预览和导出用不同配置。预览不超过 1280 或 1600,导出按业务走 1920、2560 或原图。别用一个配置打天下。
页面状态要可取消。 HarmonyOS 里异步任务返回顺序不可控,用户操作更不可控。jobId、cancelToken、旧结果丢弃,这些东西写起来不高级,但能挡住很多线上问题。
像素处理要有预算。 你处理的是 width × height 的数据,不是一个普通数组。每多一次整图遍历,耗时和耗电都会上去。能局部处理就局部处理,能复用缓冲区就不要重复申请。
释放必须统一。 不要在十几个 catch 里散着写 release()。写一个 ReleaseBag 也行,写 safeReleaseXxx 也行,总之要能保证失败分支不漏。
保存和预览拆开。 用户选图后的第一秒要让他看到东西,不要让完整导出流程挡住首屏。预览可以轻,保存可以慢一点,只要进度提示清楚。
一个 ReleaseBag 的小封装
项目稍微复杂一点,我会用一个小工具收口释放逻辑。它不复杂,但能减少很多漏网之鱼。
// common/image/ImageReleaseBag.ets
export interface Releasable {
release(): Promise<void>;
}
export class ImageReleaseBag {
private items: Releasable[] = [];
add<T extends Releasable | undefined>(item: T): T {
if (item) {
this.items.push(item);
}
return item;
}
async releaseAll(): Promise<void> {
for (let i = this.items.length - 1; i >= 0; i--) {
try {
await this.items[i].release();
} catch (_) {}
}
this.items = [];
}
}
管线里就可以这样用:
const bag = new ImageReleaseBag();
try {
const source = bag.add(image.createImageSource(filePath));
const pixelMap = bag.add(await source.createPixelMap(decodingOptions));
const packer = bag.add(image.createImagePacker());
return await packer.packToData(pixelMap, {
format: 'image/jpeg',
quality: 88
});
} finally {
await bag.releaseAll();
}
但还是那句话:如果 PixelMap 要返回给 UI,就不要放进这个 bag。释放权一定要跟对象去向绑定。
适合落地的场景
这条链路不只适合“图片滤镜”。很多业务都能用上。
比如截图整理工具,导入截图后先生成预览,再做敏感区域遮挡,最后导出一张可分享图。比如医疗、教育、金融类应用,用户上传凭证前需要压缩和脱敏。比如内容社区,发帖前统一限制尺寸和质量,减少上传失败率。再比如元服务或卡片场景,只需要轻量缩略图,完全没必要把原图处理链路塞进去。
我个人最推荐的落地方式是:把图片处理封成一个内部基础能力,不要散落在各个页面。等第二个、第三个页面也要选图压缩时,你会感谢前面那个多写半小时封装的自己。
收个尾
ImageSource / PixelMap / ImagePacker 这套东西并不难用,难的是工程边界。
小 demo 里,选图、处理、保存写在一个按钮回调里,看起来很直观。真到项目里,大图、重复点击、页面返回、编码失败、内存释放、预览和导出的质量差异都会一起冒出来。
我的经验是:别把图片处理写成页面逻辑。把它当成一条管线,输入、解码、像素处理、编码、释放,每一步都有自己的边界。代码不会显得多炫,但上线以后会稳很多。