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

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

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

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

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

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

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

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

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

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

text 复制代码
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,还要考虑输出格式、文件体积、透明通道、保存路径、编码失败后的清理。

这三个角色分清楚以后,代码会自然变成管线,而不是一坨页面回调。

一条更稳的处理链路

我的习惯是把图片任务拆成五步:

text 复制代码
输入源 -> 读取图片信息 -> 按目标尺寸解码 -> PixelMap 处理 -> 编码输出

这里有个小取舍:预览和导出不一定要用同一张 PixelMap

用户刚选完图,最重要的是页面别空着。可以先解一张长边 1280 左右的预览图,马上给 UI;用户真正点保存时,再按业务需要解更高质量的版本。很多时候用户只是看一眼效果,并不会保存。为了一个可能不会发生的保存动作,提前把原图完整处理一遍,体验上并不划算。

下面这段是我会放到 ImageJob.ets 里的基础类型。实际项目可以再细分错误码,这里保留核心结构。

ts 复制代码
// 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 读取图片信息,再决定解码尺寸。

ts 复制代码
// 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。实际项目里可以替换成水印、马赛克、局部遮挡、截图隐私高亮等逻辑。

ts 复制代码
// 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 线程里连续做重像素循环。轻量预览可以接受,重处理要么降尺寸,要么拆任务,要么把保存动作放到用户真正确认之后。很多图片需求不是不能做,是不该在用户刚进入页面时就全做。

页面侧只订阅状态,不接管管线

页面可以很薄。它负责启动任务、展示状态、处理过期结果。

ts 复制代码
// 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 给页面持有。页面退出时释放它,不要让预览图跟导出任务共用同一个对象。

ts 复制代码
// 页面持有预览 PixelMap 时,退出页面要主动释放
aboutToDisappear(): void {
  const preview: image.PixelMap | undefined = this.runtime.preview;
  if (preview) {
    preview.release();
  }
}

常见坑位:不是 API 难,是边界太多

1. 原图直接解码,内存曲线很快失控

图片文件 12MB,不代表解码后只占 12MB。JPEG 是压缩格式,进到 PixelMap 后按像素展开。粗略估算一下:

text 复制代码
4000 × 3000 × 4 ≈ 48MB

再加上缓冲区、编码临时对象、页面预览引用,内存压力很容易上去。预览场景一定要限制长边。导出场景也要问清业务:是真的需要原图尺寸,还是只是"看起来清楚"。

2. ImageSource 复用过头,容易把并发搞乱

ImageSource 适合一次任务内部使用,不建议做成全局单例复用。尤其是同一个页面可能连续处理多张图时,每张图单独创建、单独释放,反而更稳。

如果业务上要做队列,也不要让多个任务同时操作同一个 ImageSource。图片链路里共享对象越少,问题越好定位。

3. PixelMap 给了 UI,就别在管线里顺手 release

这是一个很常见的黑图来源。

有时为了预览,会把 PixelMap 直接赋给 Image 组件。这个时候它的生命周期就已经被页面接管了。管线函数如果在 finally 里顺手 release(),UI 还没来得及渲染,底层资源已经没了。

我的规则是:

text 复制代码
返回 ArrayBuffer / 文件路径:管线内部 release PixelMap
返回 PixelMap 给 UI:页面负责 release PixelMap

不要两边都管,也不要两边都不管。

4. 编码格式别乱选

JPEG 适合照片,体积小,但没有透明通道。PNG 适合透明图、截图、图标类内容,但照片体积可能比较大。WebP 适合压缩收益更明显的业务,HEIC 则要看你的分发和兼容要求。

做头像、封面、帖子图片这类业务,我一般会给一层策略:

ts 复制代码
export function chooseOutputFormat(hasAlpha: boolean, isPhoto: boolean): string {
  if (hasAlpha) {
    return 'image/png';
  }
  if (isPhoto) {
    return 'image/jpeg';
  }
  return 'image/webp';
}

这段只是策略示意,项目里还要看上传服务、审核服务、分享链路是否支持对应格式。

5. 失败提示别把 BusinessError 原样甩给用户

日志里保留错误码,界面上给人话。

ts 复制代码
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 的小封装

项目稍微复杂一点,我会用一个小工具收口释放逻辑。它不复杂,但能减少很多漏网之鱼。

ts 复制代码
// 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 = [];
  }
}

管线里就可以这样用:

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

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

相关推荐
richard_yuu29 分钟前
鸿蒙心理测评模块实战|PHQ-9/GAD7双量表答题、实时计分与结果本地化存储
华为·harmonyos
不爱吃糖的程序媛3 小时前
2026年Electron 鸿蒙PC环境搭建指南
人工智能·华为·harmonyos
nashane4 小时前
HarmonyOS 6学习:长截图功能开发中的滚动拼接与权限处理实战
人工智能·华为·harmonyos
大师兄66685 小时前
从零开发一个 HarmonyOS 输入法——KikaInputMethod 完整拆解
harmonyos·服务卡片·harmonyos6·formkit
Python私教10 小时前
鸿蒙 NEXT 也能接 MCP?用 ArkTS 跑通 AI Agent 工具链
人工智能·华为·harmonyos
Swift社区13 小时前
分布式能力在鸿蒙 PC 上到底怎么用?
分布式·华为·harmonyos
nashane1 天前
HarmonyOS 6学习:外接键盘CapsLock与长截图功能的实战调试与完整解决方案
学习·华为·计算机外设·harmonyos
aqi001 天前
一文理清 HarmonyOS 6.0.2 涵盖的十个升级点
android·华为·harmonyos·鸿蒙·harmony
环信即时通讯云1 天前
环信Flutter UIKit适配鸿蒙实战指南
flutter·华为·harmonyos