別讓一張 12MB 的照片拖垮頁面:ImageSource / PixelMap / ImagePacker 的工程化處理鏈路

前陣子做一個圖片標註功能,需求聽起來很簡單:使用者從相簿裡選一張圖,加一層輕量處理,頁面上能預覽,點儲存以後匯出一張新圖。

剛開始我也沒太當回事。圖片選擇器拿到路徑,頁面裡解碼,拿到 PixelMap,再做一點像素改寫,最後用 ImagePacker 編碼。跑 demo 很順,換到真機上的 5000px 原圖,問題就出來了:預覽偶發卡頓,連續點兩次儲存會生成黑圖,頁面返回以後記憶體沒有立刻下來,有時日誌裡還夾著一堆不穩定的 BusinessError。

後來把這塊重新拆了一遍,我的感受是:HarmonyOS 上做圖片處理,不能把它當成「一個 API 呼叫一下」的事情。它更像一條小型流水線,ImageSource 管解碼入口,PixelMap 管記憶體裡的像素物件,ImagePacker 管重新編碼。中間任何一步偷懶,頁面上看起來就是卡、黑、慢、偶現。

image.png

圖片處理不是 UI 邏輯,別直接堆在 Page 裡

我見過不少專案這麼寫:在頁面 onClick 裡選圖,選完直接 createImageSource,然後 createPixelMap,處理完塞給 Image 元件展示。功能能跑,但後面會變得很難維護。

原因很直接:頁面關心的是狀態,圖片鏈路關心的是資源。

頁面需要知道:現在是不是處理中、預覽圖是什麼、儲存成功沒有、失敗原因能不能給使用者看。圖片鏈路需要知道:源圖尺寸多大、是否需要降採樣、像素格式是什麼、PixelMap 什麼時候釋放、編碼失敗怎麼兜底。

這兩類事情混在一個 @Component 裡,除錯時會特別痛苦。尤其是使用者連續選擇、連續儲存、處理中返回頁面這幾種場景,頁面狀態和底層物件生命週期很容易錯位。

我現在比較習慣把結構拆成這樣:

text 體驗AI代碼助手 代碼解讀複製代碼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 體驗AI代碼助手 代碼解讀複製代碼輸入源 -> 讀取圖片資訊 -> 按目標尺寸解碼 -> PixelMap 處理 -> 編碼輸出

這裡有個小取捨:預覽和匯出不一定要用同一張 PixelMap

使用者剛選完圖,最重要的是頁面別空著。可以先解一張長邊 1280 左右的預覽圖,馬上給 UI;使用者真正點儲存時,再按業務需要解更高品質的版本。很多時候使用者只是看一眼效果,並不會儲存。為了一個可能不會發生的儲存動作,提前把原圖完整處理一遍,體驗上並不划算。

下面這段是我會放到 ImageJob.ets 裡的基礎型別。實際專案可以再細分錯誤碼,這裡保留核心結構。

ts 體驗AI代碼助手 代碼解讀複製代碼// 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 體驗AI代碼助手 代碼解讀複製代碼// 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 體驗AI代碼助手 代碼解讀複製代碼// 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 體驗AI代碼助手 代碼解讀複製代碼// 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 體驗AI代碼助手 代碼解讀複製代碼// 頁面持有預覽 PixelMap 時,退出頁面要主動釋放
aboutToDisappear(): void {
  const preview: image.PixelMap | undefined = this.runtime.preview;
  if (preview) {
    preview.release();
  }
}

常見坑位:不是 API 難,是邊界太多

image.png

1. 原圖直接解碼,記憶體曲線很快失控

圖片檔案 12MB,不代表解碼後只占 12MB。JPEG 是壓縮格式,進到 PixelMap 後按像素展開。粗略估算一下:

text 體驗AI代碼助手 代碼解讀複製代碼4000 × 3000 × 4 ≈ 48MB

再加上緩衝區、編碼臨時物件、頁面預覽引用,記憶體壓力很容易上去。預覽場景一定要限制長邊。匯出場景也要問清業務:是真的需要原圖尺寸,還是只是「看起來清楚」。

2. ImageSource 複用過頭,容易把並發搞亂

ImageSource 適合一次任務內部使用,不建議做成全域單例複用。尤其是同一個頁面可能連續處理多張圖時,每張圖單獨建立、單獨釋放,反而更穩。

如果業務上要做佇列,也不要讓多個任務同時操作同一個 ImageSource。圖片鏈路裡共享物件越少,問題越好定位。

3. PixelMap 給了 UI,就別在管線裡順手 release

這是一個很常見的黑圖來源。

有時為了預覽,會把 PixelMap 直接賦給 Image 元件。這個時候它的生命週期就已經被頁面接管了。管線函式如果在 finally 裡順手 release(),UI 還沒來得及渲染,底層資源已經沒了。

我的規則是:

text 體驗AI代碼助手 代碼解讀複製代碼回傳 ArrayBuffer / 檔案路徑:管線內部 release PixelMap
回傳 PixelMap 給 UI:頁面負責 release PixelMap

不要兩邊都管,也不要兩邊都不管。

4. 編碼格式別亂選

JPEG 適合照片,體積小,但沒有透明通道。PNG 適合透明圖、截圖、圖示類內容,但照片體積可能比較大。WebP 適合壓縮收益更明顯的業務,HEIC 則要看你的分發和相容要求。

做頭像、封面、貼文圖片這類業務,我一般會給一層策略:

ts 體驗AI代碼助手 代碼解讀複製代碼export function chooseOutputFormat(hasAlpha: boolean, isPhoto: boolean): string {
  if (hasAlpha) {
    return 'image/png';
  }
  if (isPhoto) {
    return 'image/jpeg';
  }
  return 'image/webp';
}

這段只是策略示意,專案裡還要看上傳服務、審核服務、分享鏈路是否支援對應格式。

5. 失敗提示別把 BusinessError 原樣丟給使用者

日誌裡保留錯誤碼,介面上給人話。

ts 體驗AI代碼助手 代碼解讀複製代碼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 體驗AI代碼助手 代碼解讀複製代碼// 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 體驗AI代碼助手 代碼解讀複製代碼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 裡,選圖、處理、儲存寫在一個按鈕回呼裡,看起來很直觀。真到專案裡,大圖、重複點擊、頁面返回、編碼失敗、記憶體釋放、預覽和匯出的品質差異都會一起冒出來。

我的經驗是:別把圖片處理寫成頁面邏輯。把它當成一條管線,輸入、解碼、像素處理、編碼、釋放,每一步都有自己的邊界。程式碼不會顯得多炫,但上線以後會穩很多。


原文出處:https://juejin.cn/post/7633696737098481705


精選技術文章翻譯,幫助開發者持續吸收新知。

共有 0 則留言


精選技術文章翻譯,幫助開發者持續吸收新知。
🏆 本月排行榜
🥇
站長阿川
📝11   💬10   ❤️1
237
🥈
我愛JS
💬2  
8
🥉
Gigi
2
評分標準:發文×10 + 留言×3 + 獲讚×5 + 點讚×1 + 瀏覽數÷10
本數據每小時更新一次
📢 贊助商廣告 · 我要刊登