前陣子做一個圖片標註功能,需求聽起來很簡單:使用者從相簿裡選一張圖,加一層輕量處理,頁面上能預覽,點儲存以後匯出一張新圖。
剛開始我也沒太當回事。圖片選擇器拿到路徑,頁面裡解碼,拿到 PixelMap,再做一點像素改寫,最後用 ImagePacker 編碼。跑 demo 很順,換到真機上的 5000px 原圖,問題就出來了:預覽偶發卡頓,連續點兩次儲存會生成黑圖,頁面返回以後記憶體沒有立刻下來,有時日誌裡還夾著一堆不穩定的 BusinessError。
後來把這塊重新拆了一遍,我的感受是:HarmonyOS 上做圖片處理,不能把它當成「一個 API 呼叫一下」的事情。它更像一條小型流水線,ImageSource 管解碼入口,PixelMap 管記憶體裡的像素物件,ImagePacker 管重新編碼。中間任何一步偷懶,頁面上看起來就是卡、黑、慢、偶現。

我見過不少專案這麼寫:在頁面 onClick 裡選圖,選完直接 createImageSource,然後 createPixelMap,處理完塞給 Image 元件展示。功能能跑,但後面會變得很難維護。
原因很直接:頁面關心的是狀態,圖片鏈路關心的是資源。
頁面需要知道:現在是不是處理中、預覽圖是什麼、儲存成功沒有、失敗原因能不能給使用者看。圖片鏈路需要知道:源圖尺寸多大、是否需要降採樣、像素格式是什麼、PixelMap 什麼時候釋放、編碼失敗怎麼兜底。
這兩類事情混在一個 @Component 裡,除錯時會特別痛苦。尤其是使用者連續選擇、連續儲存、處理中返回頁面這幾種場景,頁面狀態和底層物件生命週期很容易錯位。
我現在比較習慣把結構拆成這樣:
text 體驗AI代碼助手 代碼解讀複製代碼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,還要考慮輸出格式、檔案體積、透明通道、儲存路徑、編碼失敗後的清理。
這三個角色分清楚以後,程式碼會自然變成管線,而不是一坨頁面回呼。
我的習慣是把圖片任務拆成五步:
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();
}
}

圖片檔案 12MB,不代表解碼後只占 12MB。JPEG 是壓縮格式,進到 PixelMap 後按像素展開。粗略估算一下:
text 體驗AI代碼助手 代碼解讀複製代碼4000 × 3000 × 4 ≈ 48MB
再加上緩衝區、編碼臨時物件、頁面預覽引用,記憶體壓力很容易上去。預覽場景一定要限制長邊。匯出場景也要問清業務:是真的需要原圖尺寸,還是只是「看起來清楚」。
ImageSource 適合一次任務內部使用,不建議做成全域單例複用。尤其是同一個頁面可能連續處理多張圖時,每張圖單獨建立、單獨釋放,反而更穩。
如果業務上要做佇列,也不要讓多個任務同時操作同一個 ImageSource。圖片鏈路裡共享物件越少,問題越好定位。
這是一個很常見的黑圖來源。
有時為了預覽,會把 PixelMap 直接賦給 Image 元件。這個時候它的生命週期就已經被頁面接管了。管線函式如果在 finally 裡順手 release(),UI 還沒來得及渲染,底層資源已經沒了。
我的規則是:
text 體驗AI代碼助手 代碼解讀複製代碼回傳 ArrayBuffer / 檔案路徑:管線內部 release PixelMap
回傳 PixelMap 給 UI:頁面負責 release PixelMap
不要兩邊都管,也不要兩邊都不管。
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';
}
這段只是策略示意,專案裡還要看上傳服務、審核服務、分享鏈路是否支援對應格式。
日誌裡保留錯誤碼,介面上給人話。
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 裡非同步任務回傳順序不可控,使用者操作更不可控。jobId、cancelToken、舊結果丟棄,這些東西寫起來不高級,但能擋住很多線上問題。
像素處理要有預算。 你處理的是 width × height 的資料,不是一個普通陣列。每多一次整圖遍歷,耗時和耗電都會上去。能局部處理就局部處理,能複用緩衝區就不要重複申請。
釋放必須統一。 不要在十幾個 catch 裡散著寫 release()。寫一個 ReleaseBag 也行,寫 safeReleaseXxx 也行,總之要能保證失敗分支不漏。
儲存和預覽拆開。 使用者選圖後的第一秒要讓他看到東西,不要讓完整匯出流程擋住首屏。預覽可以輕,儲存可以慢一點,只要進度提示清楚。
專案稍微複雜一點,我會用一個小工具收口釋放邏輯。它不複雜,但能減少很多漏網之魚。
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 裡,選圖、處理、儲存寫在一個按鈕回呼裡,看起來很直觀。真到專案裡,大圖、重複點擊、頁面返回、編碼失敗、記憶體釋放、預覽和匯出的品質差異都會一起冒出來。
我的經驗是:別把圖片處理寫成頁面邏輯。把它當成一條管線,輸入、解碼、像素處理、編碼、釋放,每一步都有自己的邊界。程式碼不會顯得多炫,但上線以後會穩很多。