微信小程序 Canvas 2D 重绘失真:scale 矩阵累积导致图表越刷越放大
做一个带筛选维度的折线图页面,首次渲染一切正常,但用户切换月份或数据维度后,图表开始「漂移」——越来越放大,第三次切换后图表已经只剩左上角一小块内容可见。复现路径很稳定,却完全不知道从哪里下手。
最终定位到一个 WeChat Canvas 2D 特有的反直觉行为:在重复调用 drawChart() 时,ctx.scale(dpr, dpr) 会在同一个 context 上叠加,而不是替换。
现象
- 图表第 1 次渲染:正常
- 切换筛选项第 2 次渲染:图表整体放大约
dpr倍(手机上约 2x) - 第 3 次:放大
dpr²倍(约 4x) - 第 N 次:放大
dpr^N倍,iPhone 上dpr=2,三次后约 8x,已远超画布边界
与此同时,快速连续切换筛选项还会出现旧帧内容残留、图表闪烁叠影等问题。
根因
HTML5 Canvas 规范里,给 canvas.width 或 canvas.height 赋值会重置像素缓冲区并清空 transform 矩阵。但微信小程序的 Canvas 2D(<canvas type="2d">)实现中,重新设置 canvas.width/height 仅清空像素,transform 矩阵依然保留。
于是每次 drawChart() 都调用一次 ctx.scale(dpr, dpr),实际变换矩阵变成:
第1次: scale(dpr)
第2次: scale(dpr) × scale(dpr) = scale(dpr²)
第3次: scale(dpr³)
iPhone 12 的 dpr=3,三次切换后有效缩放 3³ = 27x,内容早已飞出屏幕。
标准 Web Canvas 中 canvas.width = canvas.width 这个 trick 可以同时清空像素和矩阵,但在微信 Canvas 2D 的实现里只有前者有效。每次调用 ctx.scale(dpr, dpr) 都会在已有矩阵上再乘一层,导致 dpr^N 指数放大。
正解
在每次重绘的 ctx.scale() 之前,先用 ctx.setTransform(1, 0, 0, 1, 0, 0) 显式将矩阵重置为单位矩阵,再缩放,再清空像素。
// 错误写法:transform 叠加
drawChart() {
wx.createSelectorQuery().select('#myCanvas')
.fields({ node: true, size: true })
.exec(function(res) {
var canvas = res[0].node
var ctx = canvas.getContext('2d')
var dpr = wx.getSystemInfoSync().pixelRatio
canvas.width = res[0].width * dpr
canvas.height = res[0].height * dpr
ctx.scale(dpr, dpr) // 每次叠加!
// ... 绘图
})
}// 正确写法:重绘前显式重置矩阵
drawChart() {
var that = this
// 序列号防竞态
that._drawSeq = (that._drawSeq || 0) + 1
var seq = that._drawSeq
wx.createSelectorQuery().select('#myCanvas')
.fields({ node: true, size: true })
.exec(function(res) {
if (seq !== that._drawSeq) return // 过期请求,丢弃
if (!res || !res[0] || !res[0].node) return
var canvas = res[0].node
var ctx = canvas.getContext('2d')
var dpr = that._dpr // 在 onLoad 缓存,不每次同步调用
canvas.width = res[0].width * dpr
canvas.height = res[0].height * dpr
ctx.setTransform(1, 0, 0, 1, 0, 0) // 重置矩阵
ctx.scale(dpr, dpr)
ctx.clearRect(0, 0, res[0].width, res[0].height)
// ... 绘图
})
}三个修复点一起上:
ctx.setTransform(1, 0, 0, 1, 0, 0)— 每次绘制前重置变换矩阵(ctx.resetTransform()在部分基础库版本不可用,用setTransform更稳)_drawSeq序列号 —exec回调是异步的,快速切换会多个回调并发,序列号保证只有最新一次真正上屏this._dpr缓存 —wx.getSystemInfoSync()是同步阻塞调用,每次重绘都执行没有必要,onLoad时缓存一次即可
另一个雷:wx:if 切换后立刻调用 drawChart
如果画布节点被包在 wx:if="{{hasData}}" 里,setData({ hasData: true }) 后立刻调用 drawChart(),SelectorQuery 往往拿不到节点(DOM 还没挂载)。
// 有问题
this.setData(updates)
this.drawChart() // 此时 canvas 节点还不在 DOM 树里
// 正确:等 WXML 渲染完
this.setData(updates)
wx.nextTick(() => { this.drawChart() })
// 或 setTimeout(fn, 100) 兜底exec 回调里也应该加 if (!res[0]?.node) return 守卫,避免未挂载时崩溃。
一句话外卖
微信 Canvas 2D 不遵循"赋值 width/height 重置矩阵"的标准行为,每次重绘必须手动
ctx.setTransform(1,0,0,1,0,0)重置,否则 scale 会在 context 上永久叠加。