返回博客

微信小程序 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.widthcanvas.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,内容早已飞出屏幕。

坑:重置 canvas.width 不会重置 WeChat Canvas 2D 的 transform 矩阵

标准 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)
      // ... 绘图
    })
}
正解:setTransform 重置 + 序列号防竞态 + onLoad 缓存 dpr

三个修复点一起上:

  1. ctx.setTransform(1, 0, 0, 1, 0, 0) — 每次绘制前重置变换矩阵(ctx.resetTransform() 在部分基础库版本不可用,用 setTransform 更稳)
  2. _drawSeq 序列号 — exec 回调是异步的,快速切换会多个回调并发,序列号保证只有最新一次真正上屏
  3. 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 上永久叠加。