:繪制、導(dǎo)出與性能優(yōu)化)
簡介針對微信小程序開發(fā)者制作分享海報的常見痛點(diǎn)這份組件資源基于新版canvas2d接口實現(xiàn)。老版canvas官方已停止維護(hù)新版文檔零散且上手門檻高組件采用高效繪制方式性能優(yōu)于普通canvas并參考淘寶、京東、拼多多等主流電商的分享樣式封裝而成。包體為4個文件的精簡結(jié)構(gòu)含js邏輯、wxml結(jié)構(gòu)、wxss樣式與json配置合計僅4KB便于直接引入項目或二次改造。目前已有605人學(xué)習(xí)下載。開發(fā)者只需傳入?yún)?shù)即可快速生成分享海報無需關(guān)心底層復(fù)雜坐標(biāo)轉(zhuǎn)換與繪制細(xì)節(jié)也支持按自身品牌風(fēng)格調(diào)整尺寸、顏色和文案排版靈活適配多種裂變分享需求尤其適合需要用canvas2d重構(gòu)海報能力的初級及進(jìn)階開發(fā)者。 前兩天剛幫一個做電商的朋友把項目里的海報分享功能從老版 canvas 遷移到 canvas 2d正好趕上平臺對舊接口的收緊算是踩了一路坑也把新版 canvas 2d 的完整用法摸了個透。這個能力做電商小程序的同學(xué)應(yīng)該都不陌生——淘寶、京東、拼多多的小程序里那個生成海報分享給好友的功能本質(zhì)都是先把商品圖、價格、二維碼畫到一張 canvas 上再導(dǎo)出成圖片讓用戶保存或轉(zhuǎn)發(fā)。這篇文章不聊虛的直接把新版 canvas 2d 從畫布初始化、單位換算、圖片繪制到導(dǎo)出保存的全流程走一遍把我實測過的代碼、踩過的坑、優(yōu)化的思路都放出來。如果你正要實現(xiàn)類似的海報分享功能或者想把老項目里的 canvas 接口升級到 canvas 2d這篇應(yīng)該能幫你少走不少彎路。1. 舊版 canvas 接口為什么必須淘汰canvas 2d 的底層差異先說一個很多人沒搞明白的事微信小程序里一直有兩套 canvas 接口。一套是舊版的canvas canvas-idxxx配合wx.createCanvasContext使用另一套是新版的canvas type2d idxxx配合wx.createSelectorQuery拿到節(jié)點(diǎn)后通過節(jié)點(diǎn)上的canvas對象獲取 2d 上下文。那為什么說舊版該淘汰了最直接的原因是性能和清晰度。舊版 canvas 走的是 WebView 里的 canvas 實現(xiàn)繪制復(fù)雜海報時容易出現(xiàn)內(nèi)存占用偏高、圖片發(fā)糊的問題尤其在高分屏上導(dǎo)出圖片的清晰度經(jīng)常達(dá)不到運(yùn)營要求。而 canvas 2d 是原生渲染繪制效率和出圖清晰度都有明顯提升這也是大廠小程序都在遷移的根本原因。另一個關(guān)鍵點(diǎn)是接口的維護(hù)方向?;A(chǔ)庫 2.9.0 開始微信官方正式推薦使用 canvas 2d舊版接口雖然還能用但新功能基本不再往那邊加后續(xù)的兼容性風(fēng)險會越來越大。如果你現(xiàn)在還在新項目里用wx.createCanvasContext我的建議是早點(diǎn)改別等項目上線了再被迫遷移到時候改造成本更高。從 API 層面看canvas 2d 的繪圖 API 和 Web 標(biāo)準(zhǔn) Canvas API 基本對齊fillRect、drawImage、fillText、measureText這些方法名你都認(rèn)識。這意味著如果你之前寫過 H5 的 canvas 代碼遷移成本很低大部分繪圖邏輯可以直接復(fù)用只是初始化畫布的姿勢不太一樣。2. 搭建畫布節(jié)點(diǎn)獲取、尺寸計算與單位換算2.1 WXML 里怎么聲明畫布節(jié)點(diǎn)新版 canvas 2d 在 WXML 里的聲明方式很關(guān)鍵有一個必須注意的屬性type2d。漏掉這個屬性后面通過SelectorQuery查到的節(jié)點(diǎn)結(jié)構(gòu)就不對拿不到canvas對象。canvas type2d idposterCanvas classposter-canvas/canvas還有個細(xì)節(jié)我在第一次寫的時候也踩了canvas 的樣式尺寸會直接影響繪制區(qū)域的尺寸。如果你在 WXSS 里不給 canvas 設(shè)置寬高它默認(rèn)是 300px × 150px繪制內(nèi)容會溢出看不到。所以需要顯式設(shè)置寬高同時把定位設(shè)為 fixed 并移到屏幕外避免生成過程中畫布閃現(xiàn)影響體驗。.poster-canvas { width: 750rpx; height: 1334rpx; position: fixed; left: -9999rpx; top: 0; z-index: -1; }2.2 節(jié)點(diǎn)查詢與畫布初始化的正確姿勢拿到 canvas 節(jié)點(diǎn)的代碼大概是這樣的initCanvas() { const query wx.createSelectorQuery() query.select(#posterCanvas) .fields({ node: true, size: true }) .exec((res) { if (!res[0]) { console.error(canvas 節(jié)點(diǎn)未找到) return } const canvas res[0].node const ctx canvas.getContext(2d) const dpr wx.getWindowInfo().pixelRatio canvas.width res[0].width * dpr canvas.height res[0].height * dpr ctx.scale(dpr, dpr) this.canvas canvas this.ctx ctx }) }這段代碼里有幾個點(diǎn)要重點(diǎn)解釋。首先是.fields({ node: true, size: true })node是獲取 canvas 節(jié)點(diǎn)實例size是拿節(jié)點(diǎn)布局尺寸兩者都要寫缺一個后面都不好辦。其次是canvas.width res[0].width * dpr這是 canvas 2d 和舊版最大的區(qū)別之一canvas 元素自身有像素寬度WXML 里的 CSS 尺寸是邏輯像素兩者不一致時必須用設(shè)備像素比 dpr 去縮放否則繪制出來的圖會發(fā)虛。2.3 rpx 轉(zhuǎn) px整個繪制過程最繞的一環(huán)小程序里我們習(xí)慣用 rpx 做響應(yīng)式布局但 canvas 2d 的繪圖坐標(biāo)用的是物理像素所以畫圖之前必須把設(shè)計稿上的 rpx 數(shù)值轉(zhuǎn)成 px。我的做法是在頁面頂部定義一個全局的換算系數(shù)const sysInfo wx.getWindowInfo() const pxRatio sysInfo.windowWidth / 750 // windowWidth 是 px750 是設(shè)計稿基準(zhǔn)寬度 const rpx2px (rpx) rpx * pxRatio之后所有繪制坐標(biāo)和尺寸統(tǒng)一用rpx2px()包一層。比如畫一個寬 690rpx 的海報主體代碼里就是rpx2px(690)。這么做的好處是一套代碼在不同屏幕寬度的機(jī)型上表現(xiàn)一致不會出現(xiàn)在 iPhone 15 Pro Max 上正常、在老舊安卓機(jī)上錯位的尷尬情況。3. 核心繪制流程從背景圖到二維碼的完整 demo3.1 繪制前必須做的數(shù)據(jù)準(zhǔn)備畫海報之前我習(xí)慣先把所有需要的數(shù)據(jù)整理成一個對象再交給繪制函數(shù)處理。這樣做的好處是繪制邏輯只關(guān)心怎么畫不關(guān)心畫什么數(shù)據(jù)和渲染層分離后期改版只需要改數(shù)據(jù)組裝邏輯。一份典型的海報數(shù)據(jù)大概是這樣的背景圖、商品主圖、商品標(biāo)題、價格文本、促銷標(biāo)簽、二維碼圖片地址。這里最麻煩的是圖片資源。canvas 2d 直接畫網(wǎng)絡(luò)圖片地址是不行的必須先把網(wǎng)絡(luò)圖片下載成本地臨時文件這一塊下面單獨(dú)講。3.2 繪制函數(shù)的主體結(jié)構(gòu)下面這個函數(shù)是我項目里一直在用的精簡版覆蓋了背景、商品圖、文本、二維碼這幾個核心元素async drawPoster(data) { const { ctx, canvas } this const W rpx2px(750) const H rpx2px(1334) ctx.clearRect(0, 0, W, H) // 1. 畫背景色 ctx.fillStyle #ffffff ctx.fillRect(0, 0, W, H) // 2. 畫背景圖網(wǎng)絡(luò)圖片需先下載 const bgImg await this.loadImage(data.bgImage) ctx.drawImage(bgImg, 0, 0, W, H) // 3. 畫商品主圖居中裁剪 const mainImg await this.loadImage(data.mainImage) const mainW rpx2px(690) const mainH rpx2px(690) const mainX rpx2px(30) const mainY rpx2px(30) ctx.drawImage(mainImg, mainX, mainY, mainW, mainH) // 4. 畫商品標(biāo)題兩行超出省略 ctx.fillStyle #333333 ctx.font normal ${rpx2px(30)}px sans-serif const titleLines this.truncateText(data.title, rpx2px(640)) titleLines.slice(0, 2).forEach((line, i) { ctx.fillText(line, rpx2px(30), rpx2px(760) i * rpx2px(44)) }) // 5. 畫價格 ctx.fillStyle #ff3b30 ctx.font bold ${rpx2px(36)}px sans-serif ctx.fillText(¥${data.price}, rpx2px(30), rpx2px(850)) // 6. 畫二維碼 const qrImg await this.loadImage(data.qrCode) const qrSize rpx2px(160) ctx.drawImage(qrImg, rpx2px(530), rpx2px(850), qrSize, qrSize) // 7. 導(dǎo)出圖片 const tempFilePath await this.canvasToTempFilePath() return tempFilePath }3.3 網(wǎng)絡(luò)圖片加載drawImage 的攔路虎loadImage是這個方案里繞不開的一個函數(shù)。canvas 2d 的drawImage不認(rèn)網(wǎng)絡(luò)地址只認(rèn)本地路徑。所以在每次繪制前必須先通過wx.getImageInfo或wx.downloadFile把圖片下載到本地拿到臨時文件路徑后再用canvas.createImage()創(chuàng)建圖片對象。我踩過最大的坑就在這一步wx.downloadFile拿到的臨時路徑不能直接用new Image()去加載canvas 2d 里圖片對象必須是canvas.createImage()創(chuàng)建的。loadImage(url) { return new Promise((resolve, reject) { wx.getImageInfo({ src: url, success: (res) { const img this.canvas.createImage() img.onload () resolve(img) img.onerror reject img.src res.path }, fail: reject }) }) }這里用wx.getImageInfo而不是wx.downloadFile有個額外好處getImageInfo自帶緩存機(jī)制同一個圖片地址第二次訪問時直接走緩存不用重新下載對海報這種頻繁生成多次的場景優(yōu)化效果很明顯。3.4 導(dǎo)出圖片canvasToTempFilePath 的參數(shù)陷阱繪制完成后導(dǎo)出圖片用的 API 是wx.canvasToTempFilePath。在 canvas 2d 模式里這個 API 的調(diào)用方式和舊版不太一樣必須把 canvas 對象作為參數(shù)傳進(jìn)去canvasToTempFilePath() { return new Promise((resolve, reject) { wx.canvasToTempFilePath({ canvas: this.canvas, success: (res) resolve(res.tempFilePath), fail: reject }) }) }注意canvas參數(shù)直接傳節(jié)點(diǎn)實例不要傳 canvas-id。另外導(dǎo)出圖片默認(rèn)格式是 png如果需要 jpg可以在參數(shù)里加fileType: jpg和quality: 1能夠顯著減小圖片體積方便用戶分享到聊天會話。4. 實戰(zhàn)中一定會踩的坑文字排版與圖片適配4.1 中文字體寬度計算與手動換行canvas 的fillText不會自動換行英文單詞還能靠空格勉強(qiáng)斷行中文文本如果不手動處理超長標(biāo)題就會直接畫出邊界。這個問題不做處理的話測試時隨便填幾個字看不出來一旦上了真實商品數(shù)據(jù)標(biāo)題多幾個字就露餡。我的做法是先測量字符串寬度再按寬度截斷。核心邏輯是用ctx.measureText逐字累加寬度超過最大寬度時手動插入換行truncateText(text, maxWidth) { const lines [] let currentLine for (let i 0; i text.length; i) { const char text[i] const testLine currentLine char const testWidth this.ctx.measureText(testLine).width if (testWidth maxWidth currentLine) { lines.push(currentLine) currentLine char } else { currentLine testLine } } if (currentLine) { lines.push(currentLine) } return lines }這個方法的時間復(fù)雜度是 O(n2)因為每次加一個字都重新 measure 整行。但海報標(biāo)題一般不超過 50 個字實際性能影響可以忽略。如果遇到超長文案需要頻繁繪制可以先在循環(huán)外把每個字符的寬度測好存進(jìn)數(shù)組再按數(shù)組累加這樣能優(yōu)化成 O(n)但一般場景沒必要這么較真。4.2 drawImage 的九宮格裁剪商品圖比例不統(tǒng)一的解法電商海報的商品主圖不一定是正方形有的是 1:1有的是 3:4硬塞進(jìn) 690rpx × 690rpx 的方形容器里圖片會被拉伸變形。這個問題我在第一次做的時候被運(yùn)營懟過商品圖看起來扁了。解決方案是覆蓋模式object-fit: cover 的效果。先計算圖片原始寬高和目標(biāo)容器的寬高比按比例縮放后裁剪超出部分drawImageCover(img, dx, dy, dw, dh) { const iw img.width const ih img.height const scale Math.max(dw / iw, dh / ih) const sw dw / scale const sh dh / scale const sx (iw - sw) / 2 const sy (ih - sh) / 2 this.ctx.drawImage(img, sx, sy, sw, sh, dx, dy, dw, dh) }drawImage 接收 9 個參數(shù)時前 4 個代表從源圖片的哪個位置取多大的區(qū)域后 4 個代表繪制到目標(biāo)區(qū)域的什么位置和尺寸。這里sx、sy取圖片中心點(diǎn)sw、sh取按比例縮放后的寬高實現(xiàn)的效果就是居中裁剪和 CSS 的background-size: cover一模一樣。4.3 離屏 Canvas 與多層繪制的性能考量如果海報上需要疊加的圖片很多比如背景圖、商品圖、頭像、二維碼、優(yōu)惠券角標(biāo)一次drawImage接一次drawImage其實沒問題但要注意避免在循環(huán)里反復(fù)查詢節(jié)點(diǎn)或重復(fù)創(chuàng)建圖片對象。另一個優(yōu)化思路是用離屏 Canvas。把靜態(tài)不變的層比如背景圖 品牌 logo先在內(nèi)存里的一個 canvas 上畫好生成海報時直接把這張合成好的圖 drawImage 過去可以減少主 canvas 的繪制次數(shù)。對性能要求高的場景這個收益非常明顯尤其是一些配置比較低的安卓機(jī)繪制時間能縮短三分之一以上。5. 保存到相冊與分享鏈路的權(quán)限細(xì)節(jié)5.1 saveImageToPhotosAlbum 的授權(quán)處理圖片生成之后常規(guī)操作是彈窗讓用戶保存到相冊。這個接口需要scope.writePhotosAlbum權(quán)限第一次調(diào)用會自動彈出授權(quán)框但用戶一旦拒絕過之后調(diào)用會直接走 fail而且不再彈框。這也是很多人在熱詞里搜保存圖片 fail的原因。正確做法是fail 回調(diào)里判斷錯誤信息如果是權(quán)限拒絕就引導(dǎo)用戶去設(shè)置頁手動開啟。我在項目里封裝了一個通用的保存函數(shù)savePosterToAlbum(tempFilePath) { wx.saveImageToPhotosAlbum({ filePath: tempFilePath, success: () { wx.showToast({ title: 保存成功, icon: success }) }, fail: (err) { if (err.errMsg.includes(auth deny) || err.errMsg.includes(authorize)) { wx.showModal({ title: 提示, content: 需要您授權(quán)保存圖片到相冊, confirmText: 去授權(quán), success: (res) { if (res.confirm) { wx.openSetting() } } }) } } }) }5.2 分享朋友圈與轉(zhuǎn)發(fā)卡片的海報尺寸要求如果海報是給用戶轉(zhuǎn)發(fā)到朋友圈用的長寬比需要額外注意。朋友圈分享圖片沒有硬性尺寸限制但太長的圖會被壓縮得很厲害觀感很差。實測下來750rpx × 1334rpx約 5:8.9這個比例在朋友圈里的展示效果比較理想不會顯得太長也不會太方。轉(zhuǎn)發(fā)卡片場景則不同onShareAppMessage里的imageUrl官方建議尺寸是 5:4不是你生成的海報原圖。所以如果同時支持朋友圈和轉(zhuǎn)發(fā)卡片通常需要生成兩張圖一張 5:8.9 的全幅海報一張 5:4 的方形卡片圖。我見過不少團(tuán)隊在這塊圖省事直接用同一張圖結(jié)果轉(zhuǎn)發(fā)卡片里商品信息被截掉一半轉(zhuǎn)化率上不去。5.3 臨時文件并不是終點(diǎn)上傳 CDN 的必要性wx.canvasToTempFilePath生成的臨時文件路徑有效期很短用戶退出小程序后就失效了。如果后續(xù)有用戶保存海報到相冊后點(diǎn)擊相冊圖片跳轉(zhuǎn)小程序這種分享回流需求就得把臨時文件上傳到自己的 CDN換一個永久鏈接用于分享出去的落地頁或朋友圈配文跳轉(zhuǎn)。上傳流程就是先wx.uploadFile把臨時文件傳到服務(wù)端服務(wù)端再轉(zhuǎn)存到對象存儲返回一個正式的 https 地址。這個地址后續(xù)可以放進(jìn)生成二維碼的數(shù)據(jù)里實現(xiàn)掃碼識別用戶身份、追蹤分享鏈路的效果。我這邊的做法是服務(wù)端用一個專門的上傳接口接收圖片流返回的 URL 再拼上參數(shù)作為二維碼跳轉(zhuǎn)地址整體鏈路就閉環(huán)了。6. 進(jìn)階優(yōu)化Canvas 繪制性能與代碼組織6.1 控制重繪頻率與圖片緩存海報生成是一個異步且相對耗時的操作如果用戶反復(fù)點(diǎn)擊生成海報按鈕理論上會有多次繪制流程并發(fā)執(zhí)行到頭來后一次的結(jié)果可能被前一次覆蓋。為了避免這個問題我做了兩件事生成期間用 loading 態(tài)鎖住按鈕同一個頁面實例上維護(hù)一個generating標(biāo)記不滿足條件直接 return。繪制完之后再把標(biāo)記置空保證同一時刻只有一個生成任務(wù)。圖片緩存方面wx.getImageInfo自帶緩存機(jī)制是個好東西但它緩存在微信內(nèi)部不受我們控制。如果海報里的商品圖和二維碼變化不頻繁我建議自己在全局維護(hù)一個 Mapkey 是圖片 URLvalue 是loadImage返回的 Promise。第二次請求同一個 URL 時直接復(fù)用之前的 Promise避免重復(fù)下載和重復(fù)解碼const imageCache new Map() function loadWithCache(url) { if (!imageCache.has(url)) { imageCache.set(url, new Promise((resolve, reject) { // 原有 loadImage 邏輯 })) } return imageCache.get(url) }6.2 復(fù)雜海報抽離為獨(dú)立的 Canvas 繪制模塊海報繪制邏輯一旦長起來全堆在 Page 里會非常痛苦。商品信息的改動、運(yùn)營位的替換、樣式的微調(diào)都會牽扯到頁面代碼出 bug 的概率直線上升。我的做法是把整套海報繪制能力抽成一個獨(dú)立的模塊比如PosterBuilder類頁面只負(fù)責(zé)傳入數(shù)據(jù)對象拿回臨時文件路徑。上面代碼里的initCanvas、loadImage、drawPoster、truncateText都?xì)w到模塊內(nèi)部對外只暴露一個build(data)方法。這樣多個頁面共用同一套海報能力時只需要各自維護(hù)自己的 canvas 節(jié)點(diǎn)繪制邏輯完全復(fù)用。我現(xiàn)在的項目里有商品詳情頁、分享有禮頁、簽到頁三處會生成海報全部走同一個模塊改一次樣式三處生效。6.3 真機(jī)調(diào)試時容易忽略的環(huán)境差異最后提醒一個坑canvas 2d 在開發(fā)者工具里的表現(xiàn)和真機(jī)表現(xiàn)不完全一致。工具里圖片加載快、繪制秒出真機(jī)上遇到弱網(wǎng)環(huán)境圖片下載耗時會被明顯放大。我的習(xí)慣是所有圖片繪制前先做超時處理loadImage里加一個 10 秒的定時器超時直接 reject避免白屏等半天。另外真機(jī)上首次調(diào)用canvasToTempFilePath會比之后慢這是正常的不要誤判為卡死。如果發(fā)現(xiàn)長時間無響應(yīng)可以在生成前先調(diào)用一次空繪制提前完成 canvas 的初始化流程。海報布局的調(diào)試我也有個土辦法canvas 在position: fixed移出屏幕外以后工具里沒法實時預(yù)覽繪制效果。所以我通常會先改成正常定位放在頁面中間保留一個調(diào)試開關(guān)開發(fā)階段打開預(yù)覽發(fā)布前再關(guān)掉。這樣能直接在頁面上看到每一步繪制的位置對不對比在代碼里盲調(diào)坐標(biāo)高效得多。本文還有配套的精品資源點(diǎn)擊獲取