> 這篇不聊「會不會調 API」,聊的是一個更像線上專案的問題:
使用者選了十幾張圖,頁面不能卡;處理完了,結果要能穩定回存;頁面退到後台以後,上傳也別說沒就沒。

我最近在做圖片整理類功能時,重新把這條鏈路理了一遍。以前很多寫法放在 Demo 裡沒問題,一旦到了真機、真使用者、真批量場景,問題就全出來了:
後來我換了一個思路:入口盡量交給系統選取器(Picker),重活交給 TaskPool,編碼收口到 ImagePacker,回存盡量走 SaveButton / 授權式保存,後台上傳交給標準後台任務機制。
這個改完以後,最大的變化不是「程式碼更優雅」,而是整條使用者路徑沒那麼慌了。
HarmonyOS 這幾年給圖片、檔案、後台任務這些場景配的系統能力,其實已經很完整了。真正容易踩坑的,不是能力不夠,而是鏈路設計還停留在「能跑就行」。
對圖片整理、票據掃描、相簿工具、內容發佈這類應用,我更推薦下面這套拆法:
這套方案的核心不是「炫 API」,而是四個字:責任拆分。

我把它總結成一句話:
系統能力負責邊界,業務程式碼負責規則。
1)資源入口盡量輕
很多圖片工具第一步就想着「我要讀圖庫,所以先申請圖庫權限」。
但從產品體驗看,這一步往往太重了。
如果你的場景只是「讓使用者選幾張圖來處理」,那更自然的做法是:
這樣做有兩個好處:
第一,權限心智更輕。
第二,系統對資源邊界更清晰,後續排查問題也更容易。
2)圖片處理一定要和 UI 拆開
這是最關鍵的一點。
圖片整理裡最容易「偷懶」的地方,就是把下面這些步驟串著寫在一個按鈕回呼裡:
程式碼短期看挺順,長期看基本就是掉幀製造機。
尤其是一次選多張圖時,問題會非常明顯。
所以我的建議一直很明確:
這時候你的頁面就只剩三類狀態:
一旦狀態層次清楚了,很多「玄學 Bug」會立刻少一半。
3)結果先寫到沙箱,再決定是否匯出
很多人一開始會把「處理完成」直接等同於「保存完成」,這在工程上是兩件事。
我一般會分兩步:
這麼拆的好處很實際:
下面這段不是「最短 Demo」,而是更接近業務專案裡的組織方式。
我故意沒有寫成一屏程式碼,而是按模組拆開。這樣後面你要加格式策略、清晰度分級、失敗重試,都比較好擴。
說明:下面是 ArkTS 專案化示例,媒體 Picker、SaveButton 以及後台任務相關 import 在不同 SDK 版本中命名可能有細微差異,落地時請以你當前使用的 HarmonyOS SDK 文件為準。
1)定義任務資料結構
<div><div><div></div><span>ts</span></div><div><div> <span>體驗AI程式碼助理</span></div><div> <span>程式碼解讀</span></div><div>複製程式碼</div></div></div>```
<span><span>export</span> <span>interface</span> <span>CompressJob</span> {</span>
<span> <span>id</span>: <span>string</span></span>
<span> <span>sourceUri</span>: <span>string</span></span>
<span> <span>targetFormat</span>: <span>'image/jpeg'</span> | <span>'image/png'</span> | <span>'image/webp'</span> | <span>'image/heif'</span></span>
<span> <span>quality</span>: <span>number</span></span>
<span> <span>maxEdge</span>: <span>number</span></span>
<span>}</span>
<span></span>
<span><span>export</span> <span>interface</span> <span>CompressResult</span> {</span>
<span> <span>id</span>: <span>string</span></span>
<span> <span>success</span>: <span>boolean</span></span>
<span> <span>tempFileUri</span>: <span>string</span></span>
<span> <span>width</span>: <span>number</span></span>
<span> <span>height</span>: <span>number</span></span>
<span> message?: <span>string</span></span>
<span>}</span>
2)頁面只維護狀態,不直接做重活
<div><div><div></div><span>ts</span></div><div><div> <span>體驗AI程式碼助理</span></div><div> <span>程式碼解讀</span></div><div>複製程式碼</div></div></div>`` <span><span>@Entry</span></span> <span><span>@Component</span></span> <span>struct <span>BatchImagePage</span> {</span> <span> <span>@State</span> <span>jobs</span>: <span>CompressJob</span>[] = []</span> <span> <span>@State</span> <span>results</span>: <span>CompressResult</span>[] = []</span> <span> <span>@State</span> <span>running</span>: <span>boolean</span> = <span>false</span></span> <span> <span>@State</span> <span>progressText</span>: <span>string</span> = <span>'等待選擇圖片'</span></span> <span></span> <span> <span>async</span> <span>pickImages</span>(<span></span>) {</span> <span> <span>// 這裡用系統媒體 Picker 選圖</span></span> <span> <span>// 實際 API 名稱請以當前 SDK 為準</span></span> <span> <span>const</span> picker = <span>new</span> photoAccessHelper.<span>PhotoViewPicker</span>()</span> <span> <span>const</span> selected = <span>await</span> picker.<span>select</span>({</span> <span> <span>maxSelectNumber</span>: <span>12</span></span> <span> })</span> <span></span> <span> <span>this</span>.<span>jobs</span> = selected.<span>photoUris</span>.<span>map</span>(<span>(<span>uri: <span>string</span>, index: <span>number</span></span>) =></span> ({</span> <span> <span>id</span>: <span>job<span>${<span>Date</span>.now()}</span><span>${index}</span></span>,</span> <span> <span>sourceUri</span>: uri,</span> <span> <span>targetFormat</span>: <span>'image/jpeg'</span>,</span> <span> <span>quality</span>: <span>78</span>,</span> <span> <span>maxEdge</span>: <span>1600</span></span> <span> }))</span> <span> <span>this</span>.<span>progressText</span> = <span>已選擇 <span>${<span>this</span>.jobs.length}</span> 張圖片</span></span> <span> }</span> <span></span> <span> <span>async</span> <span>startCompress</span>(<span></span>) {</span> <span> <span>if</span> (<span>this</span>.<span>jobs</span>.<span>length</span> === <span>0</span> || <span>this</span>.<span>running</span>) {</span> <span> <span>return</span></span> <span> }</span> <span> <span>this</span>.<span>running</span> = <span>true</span></span> <span> <span>this</span>.<span>results</span> = []</span> <span></span> <span> <span>for</span> (<span>let</span> i = <span>0</span>; i this</span>.<span>jobs</span>.<span>length</span>; i++) { <span> <span>const</span> job = <span>this</span>.<span>jobs</span>[i]</span> <span> <span>this</span>.<span>progressText</span> = <span>處理中 <span>${i + <span>1</span>}</span>/<span>${<span>this</span>.jobs.length}</span>`</span></span>
<span></span>
<span> <span>try</span> {</span>
<span> <span>const</span> result = <span>await</span> <span>ImagePipelineService</span>.<span>compressInTaskPool</span>(job)</span>
<span> <span>this</span>.<span>results</span> = [...<span>this</span>.<span>results</span>, result]</span>
<span> } <span>catch</span> (err) {</span>
<span> <span>this</span>.<span>results</span> = [</span>
<span> ...<span>this</span>.<span>results</span>,</span>
<span> {</span>
<span> <span>id</span>: job.<span>id</span>,</span>
<span> <span>success</span>: <span>false</span>,</span>
<span> <span>tempFileUri</span>: <span>''</span>,</span>
<span> <span>width</span>: <span>0</span>,</span>
<span> <span>height</span>: <span>0</span>,</span>
<span> <span>message</span>: <span>JSON</span>.<span>stringify</span>(err)</span>
<span> }</span>
<span> ]</span>
<span> }</span>
<span> }</span>
<span></span>
<span> <span>this</span>.<span>progressText</span> = <span>'處理完成'</span></span>
<span> <span>this</span>.<span>running</span> = <span>false</span></span>
<span> }</span>
<span></span>
<span> <span>build</span>(<span></span>) {</span>
<span> <span>Column</span>({ <span>space</span>: <span>16</span> }) {</span>
<span> <span>Text</span>(<span>'批量圖片整理'</span>)</span>
<span> .<span>fontSize</span>(<span>26</span>)</span>
<span> .<span>fontWeight</span>(<span>FontWeight</span>.<span>Bold</span>)</span>
<span></span>
<span> <span>Text</span>(<span>this</span>.<span>progressText</span>)</span>
<span> .<span>fontSize</span>(<span>14</span>)</span>
<span> .<span>opacity</span>(<span>0.75</span>)</span>
<span></span>
<span> <span>Row</span>({ <span>space</span>: <span>12</span> }) {</span>
<span> <span>Button</span>(<span>'選擇圖片'</span>).<span>onClick</span>(<span>() =></span> <span>this</span>.<span>pickImages</span>())</span>
<span> <span>Button</span>(<span>this</span>.<span>running</span> ? <span>'處理中...'</span> : <span>'開始整理'</span>)</span>
<span> .<span>enabled</span>(!<span>this</span>.<span>running</span> && <span>this</span>.<span>jobs</span>.<span>length</span> > <span>0</span>)</span>
<span> .<span>onClick</span>(<span>() =></span> <span>this</span>.<span>startCompress</span>())</span>
<span> }</span>
<span></span>
<span> <span>List</span>() {</span>
<span> <span>ForEach</span>(<span>this</span>.<span>results</span>, <span>(<span>item: CompressResult</span>) =></span> {</span>
<span> <span>ListItem</span>() {</span>
<span> <span>Row</span>({ <span>space</span>: <span>12</span> }) {</span>
<span> <span>Text</span>(item.<span>id</span>).<span>fontSize</span>(<span>14</span>)</span>
<span> <span>Text</span>(item.<span>success</span> ? <span>'成功'</span> : <span>'失敗'</span>)</span>
<span> .<span>fontColor</span>(item.<span>success</span> ? <span>'#26c281'</span> : <span>'#ff5d73'</span>)</span>
<span> <span>Text</span>(item.<span>message</span> ?? item.<span>tempFileUri</span>)</span>
<span> .<span>fontSize</span>(<span>12</span>)</span>
<span> .<span>opacity</span>(<span>0.7</span>)</span>
<span> }</span>
<span> }</span>
<span> })</span>
<span> }</span>
<span> .<span>layoutWeight</span>(<span>1</span>)</span>
<span> }</span>
<span> .<span>padding</span>(<span>20</span>)</span>
<span> .<span>width</span>(<span>'100%'</span>)</span>
<span> .<span>height</span>(<span>'100%'</span>)</span>
<span> }</span>
<span>}</span>
3)把解碼、縮放、編碼收口到服務層
<div><div><div></div><span>ts</span></div><div><div> <span>體驗AI程式碼助理</span></div><div> <span>程式碼解讀</span></div><div>複製程式碼</div></div></div>```
<span><span>export</span> <span>class</span> <span>ImagePipelineService</span> {</span>
<span> <span>static</span> <span>async</span> <span>compressInTaskPool</span>(<span>job</span>: <span>CompressJob</span>): <span>Promise</span>CompressResult</span>> {
<span> <span>// 偽程式碼:把真正耗時的圖片處理丟進 TaskPool</span></span>
<span> <span>return</span> <span>await</span> taskpool.<span>execute</span>(compressWorker, job)</span>
<span> }</span>
<span>}</span>
4)Worker / TaskPool 裡只做「純處理」
<div><div><div></div><span>ts</span></div><div><div> <span>體驗AI程式碼助理</span></div><div> <span>程式碼解讀</span></div><div>複製程式碼</div></div></div>```
span><span>@Concurrent</span</span>
<span><span>async</span> <span>function</span> <span>compressWorker</span>(<span>job: CompressJob</span>): <span>Promise</span>CompressResult</span>> {
<span> <span>// 偽程式碼:不同專案裡你可能會把這部分繼續拆成</span></span>
<span> <span>// read -> decode -> resize -> encode -> writeTempFile</span></span>
<span></span>
<span> <span>const</span> sourceImage = image.<span>createImageSource</span>(job.<span>sourceUri</span>)</span>
<span> <span>const</span> pixelMap = <span>await</span> sourceImage.<span>createPixelMap</span>()</span>
<span></span>
<span> <span>const</span> size = <span>calcTargetSize</span>(pixelMap.<span>getImageInfoSync</span>(), job.<span>maxEdge</span>)</span>
<span> <span>const</span> resizedPixelMap = <span>await</span> <span>resizePixelMap</span>(pixelMap, size.<span>width</span>, size.<span>height</span>)</span>
<span></span>
<span> <span>const</span> packer = image.<span>createImagePacker</span>()</span>
<span> <span>const</span> packedArrayBuffer = <span>await</span> packer.<span>packing</span>(</span>
<span> resizedPixelMap,</span>
<span> {</span>
<span> <span>format</span>: job.<span>targetFormat</span>,</span>
<span> <span>quality</span>: job.<span>quality</span></span>
<span> }</span>
<span> )</span>
<span></span>
<span> <span>const</span> tempFileUri = <span>await</span> <span>writeBufferToCache</span>(job.<span>id</span>, packedArrayBuffer)</span>
<span></span>
<span> <span>return</span> {</span>
<span> <span>id</span>: job.<span>id</span>,</span>
<span> <span>success</span>: <span>true</span>,</span>
<span> tempFileUri,</span>
<span> <span>width</span>: size.<span>width</span>,</span>
<span> <span>height</span>: size.<span>height</span></span>
<span> }</span>
<span>}</span>
5)保存不要和「處理完成」強綁死
<div><div><div></div><span>ts</span></div><div><div> <span>體驗AI程式碼助理</span></div><div> <span>程式碼解讀</span></div><div>複製程式碼</div></div></div>```
<span><span>@Component</span></span>
<span>struct <span>ExportPanel</span> {</span>
<span> <span>@Prop</span> <span>fileUri</span>: <span>string</span></span>
<span></span>
<span> <span>build</span>(<span></span>) {</span>
<span> <span>Column</span>({ <span>space</span>: <span>12</span> }) {</span>
<span> <span>Text</span>(<span>'處理完成後,可以先預覽,再決定是否匯出到系統相簿。'</span>)</span>
<span></span>
<span> <span>// 實際專案裡優先考慮安全元件 / SaveButton 的方案</span></span>
<span> <span>SaveButton</span>({</span>
<span> <span>text</span>: <span>'儲存到相簿'</span></span>
<span> })</span>
<span> .<span>onClick</span>(<span>async</span> () => {</span>
<span> <span>await</span> <span>exportResultToGallery</span>(<span>this</span>.<span>fileUri</span>)</span>
<span> })</span>
<span> }</span>
<span> }</span>
<span>}</span>
---
四、這套寫法最容易忽略的 4 個細節
------------------

細節 1:不要只存「任務成功」,要存「檔案可用」
很多程式碼會寫成這樣:
- TaskPool 回傳成功
- 列表顯示「已完成」
- 使用者點擊匯出
- 結果發現檔案並不可讀
這類問題本質上是:
你把「任務回傳成功」和「結果檔案可用」混成了一件事。
更穩的做法是分開記:
- taskStatus
- fileStatus
- exportStatus
狀態一旦拆開,排查就快很多。
細節 2:格式策略不要寫死
圖片場景裡,格式不是越統一越好,而是要看業務目標。
我自己一般會這樣分:
- 追求通用分享:優先 JPEG
- 需要透明背景:保留 PNG
- 更關注體積:視相容性考慮 WebP / HEIF
- 要保細節或特殊能力:另走高品質策略
所以我不太建議在專案初期就寫死成「所有圖都壓成 JPEG 80」。
後面你只要收到一次「為什麼透明背景沒了」的反饋,就會明白這坑有多真實。
細節 3:頁面狀態不要和上傳狀態綁在一起
很多專案會在圖片處理頁裡順手把上傳也一起做掉。
從流程上看沒毛病,但從生命週期看問題很大。
更推薦的方式是:
- 頁面內只負責發起上傳任務;
- 上傳狀態由任務系統維護;
- 頁面返回後,再次進入時讀取任務狀態。
這樣你才能真正做到:
- 頁面退了,任務還在;
- 網路恢復了,任務還能繼續;
- 失敗了,可以單獨重試,不必重新壓圖。
細節 4:別把「系統資源存取」當普通本地檔案讀寫
很多 Bug 到最後都不是圖像演算法問題,而是 URI、授權、資源歸屬、目錄可見性這些邊界沒想清楚。
尤其是下面幾件事,最好一開始就想明白:
- 你拿到的是暫時 URI 還是長期可讀資源?
- 壓縮結果先落哪裡?
- 誰來觸發最終匯出?
- 匯出失敗後是否還能重試?
- 使用者沒按保存,但按了上傳,這是不是允許?
這些問題想通以後,程式碼層面的複雜度反而會下降。
---
五、給一個我自己現在更認可的工程拆法
------------------

如果讓我現在從 0 到 1 再搭一次圖片整理功能,我會直接按下面分層:
1)UI 層
只負責:
- 選取入口
- 任務列表展示
- 進度與錯誤提示
- 預覽結果
- 使用者點擊匯出 / 上傳
2)任務編排層
只負責:
- 隊列管理
- 串行 / 並行策略
- 失敗重試
- 結果狀態彙總
3)影像處理層
只負責:
- 解碼
- 縮放
- 編碼
- 哈希 / 校驗
- 臨時檔案輸出
4)資源與系統能力層
只負責:
- Picker 取得資源
- SaveButton / 授權保存
- 後台任務托管
- 檔案系統 / URI 管理
這樣一來,後面你要加這些功能都很自然:
- 批量浮水印
- 多規格匯出
- 上傳前秒傳校驗
- 壓縮失敗自動降級
- 低電量 / 弱網場景策略切換
---
六、最後說一句真話:圖片功能拼的不是「壓縮演算法」,是鏈路穩定性
-------------------------------
很多時候大家討論圖片處理,第一反應都是:
- 品質參數設多少?
- 壓縮率能不能更高?
- WebP 和 JPEG 哪個更小?
這些當然重要,但在實際專案裡,使用者第一時間感知到的並不是「你比別人小了 8%」,而是:
- 點了以後會不會卡
- 處理過程中會不會慌
- 保存會不會莫名失敗
- 退到後台以後會不會白跑
所以我現在更在意的排序是:
1. 不卡
2. 穩定
3. 邊界清楚
4. 最後才是壓得漂亮
這也是我為什麼越來越傾向於把系統選取器、TaskPool、ImagePacker、SaveButton、後台任務這些能力串成一條完整鏈路,而不是各自零散使用。
真正上線以後你會發現,工程上的「順」,比演算法上的「狠」更值錢。
---
原文出處:https://juejin.cn/post/7627665051667283983