正品庫拍照PWA應(yīng)用的實(shí)現(xiàn)與性能優(yōu)化
一、背景與難點(diǎn)
1. 背景
2. 難點(diǎn)
二、實(shí)現(xiàn)方案
1. 整體技術(shù)實(shí)現(xiàn)
2. 整體架構(gòu)
3. 產(chǎn)品使用流程
4. 操作時(shí)序
三、性能優(yōu)化
1. 為什么需要性能優(yōu)化
2. 做了哪些優(yōu)化
3. 方案選擇與實(shí)現(xiàn)
4. 優(yōu)化后對(duì)比
四、業(yè)務(wù)結(jié)果
五、規(guī)劃和展望
一、背景與難點(diǎn)
背景
目前得物ERP主要鑒別流程,是通過鑒別師鑒別提需到倉(cāng)庫,倉(cāng)庫庫工去進(jìn)行商品補(bǔ)圖拍照,現(xiàn)有正品庫59%的人力投入在線下商品借取/歸還業(yè)務(wù)的操作端,目前,線下借取的方式會(huì)占用商品資源,同時(shí)在使用用途上,每借出10件會(huì)出現(xiàn)1次拍照留檔,因此會(huì)有大量的線上閱圖量在日常鑒別和學(xué)習(xí)中發(fā)生;正品庫可通過圖庫搭建,提升圖庫質(zhì)量,大大節(jié)約線下用工和物流成本支出。
但目前庫內(nèi)存量10~20W件,待進(jìn)行拍照同步到正品庫中,且目前仍不斷有新品入庫,現(xiàn)有的補(bǔ)圖流程效率約每天30件,難以滿足快速正品庫建立的需要, 主要有以下問題:
補(bǔ)圖圖片上傳途徑繁瑣
倉(cāng)端接收到補(bǔ)圖任務(wù)后,需使用ERP網(wǎng)頁端完成圖片拍攝&上傳操作,流程繁瑣,操作冗余。
留檔圖拍攝上傳質(zhì)量壓縮
新品圖片&補(bǔ)圖圖片上傳ERP后,圖片質(zhì)量壓縮,部分留檔圖因不清晰需重新拍攝,浪費(fèi)作業(yè)人力。
鑒別借還操作途徑單一
鑒別借用&歸還只能于PC端操作,不利于鑒別在庫內(nèi)現(xiàn)場(chǎng)進(jìn)行借用&歸還。
正品流轉(zhuǎn)效率問題
在圖庫建立前有很多鑒別是需要借用到實(shí)物的,借用之后的登記、歸還等流程會(huì)大大影響流傳效率,同時(shí)存在異地倉(cāng)庫借閱的情況,成本和周期更高。
優(yōu)化前后整體方案對(duì)比
圖片
綜合來說,其實(shí)相當(dāng)于整體的操作都需要在手持設(shè)備上完成(包括上傳、拍攝、通知等),這減少了過程操作繁多而導(dǎo)致的效率問題和圖片質(zhì)量問題。
演示流程如下:
難點(diǎn)
在Web端上,去實(shí)現(xiàn)一個(gè)自定義的相機(jī)拍攝能力是相對(duì)簡(jiǎn)單的,實(shí)現(xiàn)一個(gè)獲取視頻流轉(zhuǎn)化為圖片的能力也不復(fù)雜的。我們的初版應(yīng)用的拍攝標(biāo)準(zhǔn)是1280x1280的圖片,但鑒別師希望有更高的分辨率,能夠得到原相機(jī)一模一樣的拍攝結(jié)果,所以必須需要提高分辨率,按照手機(jī)原相機(jī)的分辨率去加工處理圖片。以倉(cāng)庫的 iPhoneX 為例:若需分辨率達(dá)到超高清范疇的4032 * 3024,庫工需要連續(xù)拍攝幾十次甚至上百次的各個(gè)模板位的圖片,才能完成一件正品的存檔工作。
綜合難點(diǎn)
分辨率激增帶來的內(nèi)存壓力
- 內(nèi)存占用暴增,單個(gè)從6.4M左右躍升到48.8M,增長(zhǎng)7.6倍。
- 超高清分辨率需要更多的GPU內(nèi)存和計(jì)算資源。
- 高分辨率與流暢體驗(yàn)難以兼顧。
PWA內(nèi)存分配限制
- 多層內(nèi)存限制:拿iPhoneX為例,從3GB系統(tǒng)內(nèi)存到300~500MB的實(shí)際可用內(nèi)存,層層削減。若除去一些基礎(chǔ)的開銷(比如js引擎、WebKit開銷等開銷)后則更少,更容易達(dá)到系統(tǒng)限制的內(nèi)存紅線,進(jìn)而產(chǎn)生卡頓、失敗、被強(qiáng)制回收,降頻等情況。
- Webkit嚴(yán)格限制,瀏覽器對(duì)單個(gè)標(biāo)簽頁內(nèi)存使用有硬性上限。
視頻流與圖像處理的資源競(jìng)爭(zhēng)
- 視頻流和圖像處理同時(shí)占用大量?jī)?nèi)存。
- GPU資源競(jìng)爭(zhēng),視頻解碼和Canvas繪制爭(zhēng)奪GPU資源。
移動(dòng)設(shè)備性能差異化
- 硬件碎片化:不同設(shè)備內(nèi)存和性能差異巨大。
- 兼容性問題:需要為不同性能的設(shè)備提供不同策略,保障任務(wù)的進(jìn)行。
瀏覽器內(nèi)存管理的不可控性
- 內(nèi)存分配不可預(yù)測(cè):系統(tǒng)會(huì)根據(jù)整機(jī)的內(nèi)存壓力動(dòng)態(tài)調(diào)整分配。自身web應(yīng)用無法參與調(diào)控。
- GC時(shí)機(jī)不可控:垃圾回收可能在關(guān)鍵時(shí)刻觸發(fā),影響作業(yè)流程。
- 進(jìn)程終止風(fēng)險(xiǎn):極端情況下瀏覽器自己會(huì)終止頁面,reload。
二、實(shí)現(xiàn)方案
整體技術(shù)實(shí)現(xiàn)
我們整體的技術(shù)實(shí)現(xiàn)基于 WebRTC 和 HTML5 Canvas 以及Web worker。
WebRTC
navigator.mediaDevices.getUserMedia 是 WebRTC API 的一部分,用于訪問用戶設(shè)備的攝像頭和麥克風(fēng)。它可以請(qǐng)求用戶授權(quán)以獲取視頻或音頻流,并將實(shí)時(shí)媒體流綁定到 <video> 標(biāo)簽上。
HTML5 的 video
用于顯示攝像頭捕捉到的實(shí)時(shí)視頻流。
Canvas
通過 canvas 元素,可以從 <video> 標(biāo)簽的當(dāng)前幀中捕獲圖像(拍照),并將其轉(zhuǎn)換為圖片格式(如 PNG 或 JPEG)。
WebWorker
通過允許在后臺(tái)線程中運(yùn)行腳本,避免阻塞主線程(UI 線程),從而解決復(fù)雜計(jì)算導(dǎo)致的頁面卡頓問題。
整體架構(gòu)
整體方案簡(jiǎn)要
- 在pwa頁面中開啟攝像頭
- 獲取視頻流: CameraStreamManager管理相機(jī)流,提供video元素
- 等待幀穩(wěn)定
- 通過視頻流,創(chuàng)建ImageBitmap
- Worker處理: 將ImageBitmap傳遞給Worker進(jìn)行處理
- 策略選擇,根據(jù)設(shè)備情況做策略選擇
- Worker中使用chunked、chunkedConvert等策略分塊處理大圖像
- 生成結(jié)果: 返回ObjectUrl(內(nèi)存中的文件或二進(jìn)制數(shù)據(jù))
- 更新UI: 更新預(yù)覽和上傳隊(duì)列
- 資源回收
- 結(jié)束或下一步
其中的實(shí)現(xiàn)細(xì)節(jié)內(nèi)更多偏向于資源的精細(xì)化管理、回收釋放、重試機(jī)制、容錯(cuò)機(jī)制等。
最核心的準(zhǔn)則是:性能優(yōu)先,穩(wěn)定保底。
產(chǎn)品使用流程
圖片
操作流程里的核心是針對(duì)此前在電腦和手機(jī)中反復(fù)切換拍攝、錄入、上傳等復(fù)雜的操作,轉(zhuǎn)變?yōu)樵谑殖衷O(shè)備中一站式完成補(bǔ)圖、拍攝、上傳和通知等。
操作時(shí)序
圖片
三、性能優(yōu)化
性能優(yōu)化思維導(dǎo)圖
為什么需要性能優(yōu)化
- 頁面卡頓
- 低端機(jī)型無法順暢拍照
- 圖片轉(zhuǎn)化慢,手機(jī)熱..
- 高頻出現(xiàn)圖像轉(zhuǎn)化失敗
- 突破內(nèi)存峰值,系統(tǒng)回收內(nèi)存降頻等,程序reload
- ...
首先看下此前的策略中的性能表現(xiàn),首先我們用的的是超高分辨率的約束配置條件:
const videoConstraints = useRef({
video: {
facingMode: 'environment',
width: {
min: 1280,
ideal: 4032,
max: 4032
},
height: {
min: 720,
ideal: 3024,
max: 3024
},
frameRate: {
ideal: 30, // 適當(dāng)降低可以降低視頻緩沖區(qū)的內(nèi)存占用,我們先按照這樣的場(chǎng)景來看。
min: 15
},
advanced: [
{ focusMode: "continuous" },
]
} as MediaTrackConstraints,
});
如果單獨(dú)拍攝一張圖內(nèi)存,粗略計(jì)算為如下(主要以iPhoneX的情況做解析):
// 視頻流約束
const iphoneXStreamConfig = {
width: 4032,
height: 3024,
frameRate: 24,
format: 'RGBA' // 4字節(jié)/像素
};
// 單幀內(nèi)存計(jì)算
const frameMemoryCalculation = {
// 單幀大小
pixelCount: 4032 * 3024, // = 12,192,768 像素
bytesPerFrame: 4032 * 3024 * 4, // = 48,771,072 字節(jié)
mbPerFrame: (4032 * 3024 * 4) / (1024 * 1024), // ≈ 46.51 MB
};
// 實(shí)際運(yùn)行時(shí)內(nèi)存占用
const runtimeMemoryUsage = {
// 視頻流緩沖區(qū) (至少3-4幀)
streamBuffer: {
frameCount: 4,
totalBytes: 48771072 * 4, // ≈ 186.04 MB
description: '視頻流緩沖區(qū)(4幀)'
},
// 處理管道內(nèi)存
processingPipeline: {
captureBuffer: 46.51, // 一幀的大小
processingBuffer: 46.51, // 處理緩沖
encoderBuffer: 46.51 * 0.5, // 編碼緩沖(約半幀)
totalMB: 46.51 * 2.5, // ≈ 116.28 MB
description: '視頻處理管道內(nèi)存'
},
// 總體內(nèi)存
total: {
peakMemoryMB: 186.04 + 116.28, // ≈ 302.32 MB
stableMemoryMB: 186.04 + 93.02, // ≈ 279.06 MB
description: '預(yù)估總內(nèi)存占用'
}
};
單張圖的內(nèi)存占用
圖片
按照上文的視頻約束條件,單幀大?。杭s 46.51MB,實(shí)際單張內(nèi)存需要76.7M左右(15 + 15 + 46.5 + 0.2 「objectURL引用」),三五張圖大概就會(huì)達(dá)到內(nèi)存限制紅線,這樣的內(nèi)存占用對(duì)移動(dòng)設(shè)備來說太大了,實(shí)際上,在項(xiàng)目上線初期,業(yè)務(wù)使用也反饋:拍照幾張手機(jī)發(fā)熱嚴(yán)重,頁面經(jīng)??ㄋ馈?/p>
PWA相機(jī)應(yīng)用內(nèi)存占用情況
圖片
在移動(dòng)端中,特別是ios,內(nèi)存限制是動(dòng)態(tài)的,依賴多個(gè)因素,如:設(shè)備物理內(nèi)存總量,設(shè)備當(dāng)前可用內(nèi)存,后臺(tái)的軟件運(yùn)行情況。上文可以看出至少有300M是固定支出的,還需增加一些WebRtc視頻幀緩沖累積的占用、瀏覽器內(nèi)存緩存解碼幀的堆積。
在iPhone的WeKit的內(nèi)核瀏覽器下,官方內(nèi)存限制雖是1.5G,實(shí)際上可能在是800-1200M左右,在實(shí)際的測(cè)試場(chǎng)景下,甚至還要低很多。
拍攝過程內(nèi)存變化
圖片
秒數(shù)是為了更直觀的觀察區(qū)分內(nèi)存數(shù)據(jù)的變化。
有些并不能立即回收canvas對(duì)象,需要等之前的二進(jìn)制blob文件被回收后才可進(jìn)行,這無疑是在慢慢增加內(nèi)存的壓力。
內(nèi)存壓力趨勢(shì)分析
基于上文的單獨(dú)內(nèi)存占用和相機(jī)應(yīng)用的內(nèi)存占用(按照1.5G的分配),可以粗略分析出:
圖片
圖片
這些大部分都是官方的數(shù)據(jù)計(jì)算和累積,在實(shí)際操作中,如果操作過快,差不多會(huì)在第三、四張時(shí)開始出現(xiàn)問題了。因?yàn)樽兞勘容^多,比如充電或發(fā)熱情況;而連續(xù)作業(yè)時(shí)候的情況又各不同,但是整體規(guī)律是差不多的。上文分析的是5張開始危險(xiǎn),實(shí)際情況則是第三張就已經(jīng)出現(xiàn)問題了。
不僅如此,在拍攝作業(yè)流程中,還有CPU的熱節(jié)流風(fēng)險(xiǎn),如內(nèi)存85%使用率超過30秒,cpu會(huì)降頻至70%或更低的性能。
這其中的主要消耗是:視頻流處理(35-45%) + Canvas處理(25-35%) 及4032×3024這類大分辨率導(dǎo)致的計(jì)算密集型操作。
做了哪些優(yōu)化
- canvas主線程繪制更改為離屏渲染繪制
- 視頻流管理、前置設(shè)備參數(shù)預(yù)熱
- 分辨率管理
- 引入Webworker線程單獨(dú)繪制
- 優(yōu)化設(shè)備檢測(cè)策略
- 異步上傳管理
- 產(chǎn)品兜底,頁面reload,緩存歷史數(shù)據(jù)
- 內(nèi)存分配模型
方案選擇與實(shí)現(xiàn)
實(shí)現(xiàn)原相機(jī)拍攝的最初的一版,是通過把canvas內(nèi)容轉(zhuǎn)為base64后,同步上傳圖片,最初通過一些低端機(jī)的測(cè)試情況來看,最主要的問題是圖片比較大,生成的base64的code自然也比較大,在數(shù)據(jù)體積上會(huì)增大33%左右。 因?yàn)槭且苿?dòng)設(shè)備,這么大的圖片上傳的速度又相對(duì)緩慢,導(dǎo)致操作的過程需要等待和加載。
在這樣的場(chǎng)景下為什么要異步上傳呢,如果拍攝的快些,頁面會(huì)變得很卡頓。由于大量的字符串涌入到頁面中,再加上cavans轉(zhuǎn)化這么大的image到base64 code又會(huì)比較消耗內(nèi)存,所以整體有丟幀卡頓的表現(xiàn)。進(jìn)而考慮替換為blobUrl。
toDataURL 和 toBlob對(duì)比
圖片
如上所示,我們最終選擇了性能更好的canvas to Blob并使用二進(jìn)制的形式。
- 更快的回顯
- 更快的轉(zhuǎn)化
- 更小的內(nèi)存占用
在運(yùn)用了 Blob 后, 通過埋點(diǎn)等操作,頁面渲染和流暢度雖然有所緩解,但會(huì)在比較高頻的情況下出現(xiàn)圖片轉(zhuǎn)化失敗,而且也是間隔性的,如上文所示,我們根據(jù)渲染和一些實(shí)際案例分析過后,發(fā)現(xiàn)問題還是存在于內(nèi)存峰值和CPU資源。
canvas.convertToBlob失敗主要是因?yàn)閮?nèi)存的限制問題,特別是在處理大圖像時(shí)。編碼同一圖像可能在資源充足時(shí)成功,資源緊張時(shí)失敗,這也就解釋了為什么是間隔性的出現(xiàn)轉(zhuǎn)化失敗。
因?yàn)橛写罅康睦L制需在主線程完成,但由于JS的單線程問題,嚴(yán)重影響了頁面的操作和后續(xù)的渲染, 使得庫工的作業(yè)流程被迫等待。因此,我們引入了WebWorker以及OffscreenCanvas,開啟新線程專一用來做繪制。當(dāng)然Webworker中的內(nèi)存的管理也是比較復(fù)雜的,同樣會(huì)占據(jù)大量?jī)?nèi)存,也有數(shù)據(jù)通信成本,但是相較于用戶體驗(yàn),我們不得不做一定程度的平衡和取舍。
Web Worker + OffscreenCanvas 架構(gòu)
圖片
- 主線程不阻塞:圖像處理在Worker中進(jìn)行,UI保持響應(yīng)
- 更好的性能:OffscreenCanvas在獨(dú)立線程中渲染
- 內(nèi)存隔離:Worker獨(dú)立內(nèi)存空間,避免主線程內(nèi)存壓力
好處就是可以多張并發(fā),降低內(nèi)存泄漏風(fēng)險(xiǎn),劣勢(shì)是開發(fā)復(fù)雜度增加,調(diào)試?yán)щy, 數(shù)據(jù)傳輸開銷(ImageBitmap需要轉(zhuǎn)移所有權(quán))。
相機(jī)資源的動(dòng)態(tài)管理與釋放
我們知道每個(gè)機(jī)器的分辨率與他們對(duì)WebRtc相關(guān)能力的支持是不同的。比如iPhoneX 的最大分辨率支持是:4032 * 3024,其他的機(jī)器則會(huì)不同,所以固定的分辨率配置是行不通的,需要在進(jìn)入相機(jī)后檢查設(shè)備支持情況等。以及視頻通道的保留操作和暫時(shí)性暫停,也對(duì)操作流程產(chǎn)生著很大積極影響。在繼續(xù)服用的場(chǎng)景下僅暫停數(shù)據(jù)傳輸,保持活躍連接,在下一張拍攝的時(shí)候復(fù)用連接,而非重新進(jìn)行初始化、連接和檢查等操作。
圖片
ImageBitmap 直接創(chuàng)建策略
在繪制中,如果 imageData 是普通的 Image 或 Canvas,每次 drawImage 都可能涉及格式轉(zhuǎn)換和內(nèi)存拷貝,無疑增大了內(nèi)存支出。引入 ImageBitmap,因其是專門為高性能圖像作處理設(shè)計(jì),數(shù)據(jù)存儲(chǔ)在 GPU 內(nèi)存中,最重要的是:它支持內(nèi)存的復(fù)制轉(zhuǎn)義,可以交到Webworker中去處理,可以在主線程和 Worker 之間零拷貝傳輸,在worker中直接使用,無需解碼。
直接從視頻流創(chuàng)建ImageBitmap,跳過Canvas中間步驟。
...
let imageBitmap: ImageBitmap | null = null;
// 判斷是否為視頻元素,如果是則嘗試直接創(chuàng)建ImageBitmap
// 支持img 和 vedio
if ((source instanceof HTMLVideoElement || source instanceof HTMLImageElement) && supportsImageBitmap) {
try {
console.log('嘗試直接從視頻元素創(chuàng)建ImageBitmap');
// 直接從視頻元素創(chuàng)建ImageBitmap,跳過Canvas中間步驟
if (source instanceof HTMLVideoElement) {
imageBitmap = await createImageBitmap(
source,
0, 0, sourceWidth, sourceHeight
);
} else {
// 支持img
imageBitmap = await createImageBitmap(source);
}
console.log('直接創(chuàng)建ImageBitmap成功!!');
} catch (directError) {
console.warn('這直接從視頻創(chuàng)建ImageBitmap失敗,回退到Canvas:', directError);
// 失敗后將通過下面的Canvas方式創(chuàng)建
imageBitmap = null;
}
}
...
createImageBitmap 實(shí)際上是:
- 創(chuàng)建一個(gè)位圖引用
- 可能直接使用視頻解碼器的輸出緩沖區(qū)
- 在支持的平臺(tái)上,直接使用GPU內(nèi)存中的紋理
- 最重要的是:不涉及實(shí)際的像素繪制操作、高效的跨線程傳輸(支持通過結(jié)構(gòu)化克隆算法高效傳輸避免了序列化/反序列化開銷,能高效傳送到Worker)
綜合表現(xiàn)
- 性能最優(yōu): 避免Canvas繪制的中間步驟。
- 內(nèi)存效率: 直接從視頻幀創(chuàng)建位圖,占用更低。
- 硬件加速: 可利用GPU加速。
Worker中的圖像處理策略
在web端,主線程和Worker間的數(shù)據(jù)傳輸有三種方式,結(jié)構(gòu)化克隆和Transferable對(duì)象,ShareArrayBuffer(共享內(nèi)存訪問,支持度有問題),整體上使用Transferable對(duì)象的形式,可降低內(nèi)存消耗。接下來,我們簡(jiǎn)單介紹這里用到的兩種執(zhí)行策略。
chunked策略(chunked processing分塊處理)
主要源于內(nèi)存控制,避免圖像過大導(dǎo)致的內(nèi)存溢出。將大圖像分割成多個(gè)小塊,使用一個(gè)小的臨時(shí)畫布逐塊處理后繪制到最終畫布,通過"分而治之"的策略顯著降低內(nèi)存峰值使用,避免大圖像處理時(shí)的內(nèi)存溢出問題。
劣勢(shì)是處理時(shí)間增加,算法復(fù)雜度高。
chunked策略流程示意
class ChunkedProcessStrategy extends ImageProcessStrategy {
readonly name = 'chunked';
protected async doProcess(imageData: ImageBitmap, options: ProcessOptions): Promise<Blob> {
const { width, height, quality } = options;
const optimalChunkSize = ResourceManager.calculateOptimalChunkSize(width, height);
const chunkConfig: ChunkConfig = {
size: optimalChunkSize,
cols: Math.ceil(width / optimalChunkSize),
rows: Math.ceil(height / optimalChunkSize),
};
const { canvas: finalCanvas, ctx: finalCtx } = ResourceManager.createCanvas(width, height);
const { canvas: tempCanvas, ctx: tempCtx } = ResourceManager.createCanvas(optimalChunkSize, optimalChunkSize);
try {
for (let row = 0; row < chunkConfig.rows; row++) {
for (let col = 0; col < chunkConfig.cols; col++) {
await this.processChunk(
imageData,
tempCanvas,
tempCtx,
finalCtx,
row,
col,
chunkConfig,
width,
height
);
await new Promise(resolve => setTimeout(resolve, 0));
}
}
return await finalCanvas.convertToBlob({
type: 'image/jpeg',
quality,
});
} finally {
ResourceManager.releaseResources(tempCanvas, tempCtx);
ResourceManager.releaseResources(finalCanvas, finalCtx);
}
}
private async processChunk(
imageData: ImageBitmap,
tempCanvas: OffscreenCanvas,
tempCtx: OffscreenCanvasRenderingContext2D,
finalCtx: OffscreenCanvasRenderingContext2D,
row: number,
col: number,
chunkConfig: ChunkConfig,
width: number,
height: number
): Promise<void> {
const x = col * chunkConfig.size;
const y = row * chunkConfig.size;
const chunkWidth = Math.min(chunkConfig.size, width - x);
const chunkHeight = Math.min(chunkConfig.size, height - y);
tempCtx.clearRect(0, 0, chunkConfig.size, chunkConfig.size);
tempCtx.drawImage(
imageData,
x, y, chunkWidth, chunkHeight,
0, 0, chunkWidth, chunkHeight
);
finalCtx.drawImage(
tempCanvas,
0, 0, chunkWidth, chunkHeight,
x, y, chunkWidth, chunkHeight
);
}
}
...
主要針對(duì)中等性能的機(jī)型,適用于直接轉(zhuǎn)化可能失敗的情形。
chunkedConvert策略(分塊處理轉(zhuǎn)化)
將大圖像分塊后,每塊獨(dú)立轉(zhuǎn)換為壓縮的Blob存儲(chǔ),最后再將所有Blob重新解碼,同時(shí)合并到最終畫布,通過"分塊壓縮存儲(chǔ) + 最終合并"的策略實(shí)現(xiàn)極致的內(nèi)存控制,但代價(jià)是處理時(shí)間翻倍,屬于時(shí)間換內(nèi)存的策略。
chunkedConvert策略流程示意
// 分塊轉(zhuǎn)化 最終返回
class ChunkedProcessStrategy extends ImageProcessStrategy {
readonly name = 'chunked';
protected async doProcess(imageData: ImageBitmap, options: ProcessOptions): Promise<Blob> {
const { width, height, quality } = options;
const optimalChunkSize = ResourceManager.calculateOptimalChunkSize(width, height);
const chunkConfig: ChunkConfig = {
size: optimalChunkSize,
cols: Math.ceil(width / optimalChunkSize),
rows: Math.ceil(height / optimalChunkSize),
};
const { canvas: finalCanvas, ctx: finalCtx } = ResourceManager.createCanvas(width, height);
const { canvas: tempCanvas, ctx: tempCtx } = ResourceManager.createCanvas(optimalChunkSize, optimalChunkSize);
try {
for (let row = 0; row < chunkConfig.rows; row++) {
for (let col = 0; col < chunkConfig.cols; col++) {
await this.processChunk(
imageData,
tempCanvas,
tempCtx,
finalCtx,
row,
col,
chunkConfig,
width,
height
);
// 給GC機(jī)會(huì)
await new Promise(resolve => setTimeout(resolve, 0));
}
}
return await finalCanvas.convertToBlob({
type: 'image/jpeg',
quality,
});
} finally {
ResourceManager.releaseResources(tempCanvas, tempCtx);
ResourceManager.releaseResources(finalCanvas, finalCtx);
}
}
private async processChunk(
imageData: ImageBitmap,
tempCanvas: OffscreenCanvas,
tempCtx: OffscreenCanvasRenderingContext2D,
finalCtx: OffscreenCanvasRenderingContext2D,
row: number,
col: number,
chunkConfig: ChunkConfig,
width: number,
height: number
): Promise<void> {
const x = col * chunkConfig.size;
const y = row * chunkConfig.size;
const chunkWidth = Math.min(chunkConfig.size, width - x);
const chunkHeight = Math.min(chunkConfig.size, height - y);
tempCtx.clearRect(0, 0, chunkConfig.size, chunkConfig.size);
tempCtx.drawImage(
imageData,
x, y, chunkWidth, chunkHeight,
0, 0, chunkWidth, chunkHeight
);
finalCtx.drawImage(
tempCanvas,
0, 0, chunkWidth, chunkHeight,
x, y, chunkWidth, chunkHeight
);
}
}
...
...
class ChunkedConvertStrategy extends ImageProcessStrategy {
readonly name = 'chunkedConvert';
protected async doProcess(imageData: ImageBitmap, options: ProcessOptions): Promise<Blob> {
const { width, height, quality } = options;
const config = WorkerConfig.getInstance();
const chunks: Array<{
blob: Blob;
x: number;
y: number;
width: number;
height: number;
}> = [];
// 分塊處理
for (let y = 0; y < height; y += config.chunkSize) {
for (let x = 0; x < width; x += config.chunkSize) {
const chunkWidth = Math.min(config.chunkSize, width - x);
const chunkHeight = Math.min(config.chunkSize, height - y);
const chunk = await this.processSingleChunk(
imageData, x, y, chunkWidth, chunkHeight, quality
);
chunks.push({ ...chunk, x, y, width: chunkWidth, height: chunkHeight });
await new Promise(resolve => setTimeout(resolve, 0));
}
}
// 合并塊
return chunks.length === 1 ? chunks[0].blob : await this.mergeChunks(chunks, width, height, quality);
}
private async processSingleChunk(
imageData: ImageBitmap,
x: number,
y: number,
width: number,
height: number,
quality: number
): Promise<{ blob: Blob }> {
const { canvas, ctx } = ResourceManager.createCanvas(width, height);
try {
ctx.drawImage(imageData, x, y, width, height, 0, 0, width, height);
const blob = await canvas.convertToBlob({
type: 'image/jpeg',
quality,
});
return { blob };
} finally {
ResourceManager.releaseResources(canvas, ctx);
}
}
private async mergeChunks(
chunks: Array<{ blob: Blob; x: number; y: number; width: number; height: number }>,
width: number,
height: number,
quality: number
): Promise<Blob> {
const { canvas: finalCanvas, ctx: finalCtx } = ResourceManager.createCanvas(width, height);
try {
for (const chunk of chunks) {
const imgBitmap = await createImageBitmap(chunk.blob);
try {
finalCtx.drawImage(
imgBitmap,
0, 0, chunk.width, chunk.height,
chunk.x, chunk.y, chunk.width, chunk.height
);
} finally {
imgBitmap.close();
}
await new Promise(resolve => setTimeout(resolve, 0));
}
return await finalCanvas.convertToBlob({
type: 'image/jpeg',
quality,
});
} finally {
ResourceManager.releaseResources(finalCanvas, finalCtx);
}
}
}
會(huì)有更小的峰值,適配與更低端的機(jī)型和極大圖像。不會(huì)內(nèi)存溢出,但是也會(huì)降低轉(zhuǎn)化效率。在可用與效率方面,選擇了可用。
其中整體方案里還有一些其他的策略,如Direct直接轉(zhuǎn)化、邊轉(zhuǎn)化邊繪制等,會(huì)根據(jù)不同的機(jī)型進(jìn)行選擇。目前,重點(diǎn)保障低端機(jī)型,因?yàn)橹懈叨藱C(jī)器在使用過程中沒有性能上的卡點(diǎn)。
優(yōu)化后對(duì)比
首先,我們明確了這幾個(gè)主要策略:
- Web Worker架構(gòu) - 主線程內(nèi)存壓力分散
- ImageBitmap直接傳輸 - 減少內(nèi)存拷貝
- 繪制分塊處理 - 降低內(nèi)存峰值
- 資源管理優(yōu)化 - Canvas復(fù)用和及時(shí)釋放
最重要策略:增加很多管理器和優(yōu)化方式降低內(nèi)存的峰值,即那一瞬間的值。
同時(shí),將可以在后臺(tái)做轉(zhuǎn)化和運(yùn)算的操作,投入到web worker中去做,降低主線程的內(nèi)存壓力。
優(yōu)化后單圖內(nèi)存占用情況
圖片
優(yōu)化后PWA相機(jī)應(yīng)用內(nèi)存占用
優(yōu)化后的效果
內(nèi)存優(yōu)化結(jié)果
圖片
- 單張圖片處理峰值減少33% - 從123.2MB降至82.2MB。
- 單張圖片持久占用減少61% - 從76.7MB降至30.2MB。
- PWA應(yīng)用整體內(nèi)存優(yōu)化16-26% - 根據(jù)圖片數(shù)量不同。
- 內(nèi)存壓力等級(jí)顯著降低,如從3-4張開始有明顯警示壓力,到操作快速秒級(jí)拍攝速率時(shí)才出現(xiàn)(實(shí)際操作過程中大概10-15秒一張,因需要擺放和根據(jù)模版與提醒進(jìn)行拍攝)。
用戶體驗(yàn)
- 最終在高清圖片的繪制作業(yè)流程中,由原來的3張圖告警到一次性可以拍攝50張圖的情況,大大降低了失敗風(fēng)險(xiǎn)。提升了作業(yè)的流暢度。
- 用戶體驗(yàn)改善,消除UI阻塞,響應(yīng)時(shí)間減半。
四、業(yè)務(wù)結(jié)果
通過幾輪的策略優(yōu)化,整個(gè)pwa應(yīng)用已可以相對(duì)順暢、高效的繪制原相機(jī)標(biāo)準(zhǔn)的正品圖,已完全達(dá)到鑒別師高清圖的要求,同時(shí)不會(huì)有操作流的中斷。
- 目前日均的拍攝件數(shù)提升 330%,達(dá)成預(yù)期目標(biāo)。
- 將每件的人力投入成本降低 41.18%。
- 目前通過PWA項(xiàng)目快速搭建了圖庫項(xiàng)目,Q2拍照數(shù)據(jù)占比72.5%,預(yù)期后面比例會(huì)逐步升高,圖庫流轉(zhuǎn)效率提高到了20%,超出業(yè)務(wù)預(yù)期。
圖片
五、規(guī)劃和展望
在技術(shù)的實(shí)現(xiàn)上,許多時(shí)候要去做用空間換時(shí)間或用時(shí)間換空間的策略方案,本質(zhì)上還是根據(jù)我們當(dāng)前的業(yè)務(wù)場(chǎng)景和訴求,追求當(dāng)下收益。有些時(shí)候可能不止局限在實(shí)現(xiàn)上,需要從實(shí)際需求出發(fā),不應(yīng)該只停留在工具的層面,而深入到業(yè)務(wù)里剖析挖掘其潛在的業(yè)務(wù)價(jià)值,做更深遠(yuǎn)的思考,從工具思維轉(zhuǎn)向價(jià)值發(fā)現(xiàn)與傳遞的方向上。
未來我們還會(huì)思考:
- 前置對(duì)設(shè)備的綜合能力評(píng)估,更精細(xì)化的拆分低、中、高端設(shè)備和適配策略,收集更多的實(shí)際處理時(shí)間和內(nèi)存峰值、CPU 性能指標(biāo)等,用于不斷優(yōu)化策略選擇算法。
- 根據(jù)類目做區(qū)分(比如鞋服、奢品),這些在鑒別的時(shí)候圖片質(zhì)量有不同的品質(zhì)要求的分類。后續(xù)可能會(huì)進(jìn)行更加具有定制化屬性的方案,針對(duì)鑒別打標(biāo),針對(duì)當(dāng)前業(yè)務(wù)中圖片拍攝重試場(chǎng)景下的AI圖像識(shí)別,針對(duì)重復(fù)拍攝場(chǎng)景做優(yōu)化,進(jìn)一步提高效率。
- 針對(duì)目前 10 到 15 秒的拍攝時(shí)間,能進(jìn)一步壓縮問題,思考更加智能的拍攝能力。根據(jù)設(shè)備的真實(shí)情況,或基于色溫分析的光線評(píng)估,提高圖像質(zhì)量和降低重復(fù)率?;谡诽卣鬟M(jìn)行構(gòu)圖優(yōu)化,在設(shè)備上做實(shí)時(shí)拍攝指導(dǎo),不只以單一模板和示例進(jìn)行人工檢查,而是進(jìn)一步標(biāo)準(zhǔn)化,降低人力參與度。
- 針對(duì)于商研側(cè)業(yè)務(wù)和前置拍照流程,將拍照H5的方案也納入采賣商品入庫流程,同時(shí)支持鑒別師對(duì)于圖庫的驗(yàn)收,加快圖庫的驗(yàn)收入庫效率,縮短庫內(nèi)的拍照數(shù)據(jù)積壓周期。