别让一张 12MB 的照片拖垮页面:ImageSource / PixelMap / ImagePacker 的工程化处理链路

0 阅读11分钟

前阵子做一个图片标注功能,需求听起来很简单:用户从相册里选一张图,加一层轻量处理,页面上能预览,点保存以后导出一张新图。

刚开始我也没太当回事。图片选择器拿到路径,页面里解码,拿到 PixelMap,再做一点像素改写,最后用 ImagePacker 编码。跑 demo 很顺,换到真机上的 5000px 原图,问题就出来了:预览偶发卡顿,连续点两次保存会生成黑图,页面返回以后内存没有立刻下来,有时日志里还夹着一堆不稳定的 BusinessError。

后来把这块重新拆了一遍,我的感受是:HarmonyOS 上做图片处理,不能把它当成“一个 API 调一下”的事情。它更像一条小型流水线,ImageSource 管解码入口,PixelMap 管内存里的像素对象,ImagePacker 管重新编码。中间任何一步偷懒,页面上看起来就是卡、黑、慢、偶现。

image.png

图片处理不是 UI 逻辑,别直接堆在 Page 里

我见过不少项目这么写:在页面 onClick 里选图,选完直接 createImageSource,然后 createPixelMap,处理完塞给 Image 组件展示。功能能跑,但后面会变得很难维护。

原因很直接:页面关心的是状态,图片链路关心的是资源。

页面需要知道:现在是不是处理中、预览图是什么、保存成功没有、失败原因能不能给用户看。图片链路需要知道:源图尺寸多大、是否需要降采样、像素格式是什么、PixelMap 什么时候释放、编码失败怎么兜底。

这两类事情混在一个 @Component 里,调试时会特别痛苦。尤其是用户连续选择、连续保存、处理中返回页面这几种场景,页面状态和底层对象生命周期很容易错位。

我现在比较习惯把结构拆成这样:

entry/src/main/ets/
├── common/
│   └── image/
│       ├── ImageJob.ets          // 任务参数、状态、错误码
│       ├── ImagePipeline.ets     // 解码、像素处理、编码
│       └── ImageReleaseBag.ets   // 统一释放对象
└── pages/
    └── ImageEditPage.ets         // 只处理 UI 状态

页面不直接碰 ImageSourceImagePackerPixelMap 如果要用于预览,可以短时间交给页面持有,但持有权要说清楚:谁创建,谁释放;谁交给 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 难,是边界太多

image.png

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 里异步任务返回顺序不可控,用户操作更不可控。jobIdcancelToken、旧结果丢弃,这些东西写起来不高级,但能挡住很多线上问题。

像素处理要有预算。 你处理的是 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 里,选图、处理、保存写在一个按钮回调里,看起来很直观。真到项目里,大图、重复点击、页面返回、编码失败、内存释放、预览和导出的质量差异都会一起冒出来。

我的经验是:别把图片处理写成页面逻辑。把它当成一条管线,输入、解码、像素处理、编码、释放,每一步都有自己的边界。代码不会显得多炫,但上线以后会稳很多。