导语 总结在小程序canvas开发实践中遇到的一些问题和解决方法。
在 MDN 是这样定义 canvas 的:
canvas
是 HTML5 新增的元素,可用于通过使用 JavaScript 中的脚本来绘制图形。例如,它可以用于绘制图形、制作照片、创建动画,甚至可以进行实时视频处理或渲染。
Canvas 是由 HTML 代码配合高度和宽度属性而定义出的可绘制区域。JavaScript 代码可以访问该区域,类似于其他通用的二维 API,通过一套完整的绘图函数来动态生成图形。
微信小程序从基础库
1.0.0
开始支持 canvas,2.9.0
起支持一套新 Canvas 2D 接口(需指定 type 属性),同时支持同层渲染,原有接口不再维护。
微信小程序一开始就支持 canvas,但早期的 canvas 存在许多不足,canvas 层级过高覆盖其他组件的问题一直令人诟病。2.9.0
起,小程序发布了一套新的 Canvas 2D 接口,可以支持同层渲染,解决了这个“心头大患”。
现阶段小程序内生成活动的分享海报,一般采用以下两种方法:
在当前的业务场景下,客户端合成是优于服务端合成,可以避免造成不必要的 CPU 和 带宽 浪费。而且后端爸爸会摆事实讲道理地拒绝这个需求,服务端合成,no way!
现阶段小程序内简易的动画绘制常用的方案主要有以下四种:
动画类型 | 实现原理 | 存在缺陷 |
---|---|---|
CSS animations | 使用 CSS渐变和 CSS动画来创建简易的界面动画 | 真机上偶现 闪烁和 抖动现象 |
wx.createAnimation | 使用 wx.createAnimation接口来动态创建简易的动画效果 | 性能不好,出现卡顿,ios 机型页面偶现 闪烁现象 |
关键帧动画 | 使用 this.animate创建关键帧动画化,具有更好的性能和更可控的接口 | ios 机型页面偶现 闪烁现象 |
gif 动画 | 将动画生成 gif 文件,使用小程序的 image或 cover-image标签展示 | 在真机上出现 锯齿和 白边情况,引人诟病 |
以上四种方案,仅能实现 简易
的动画绘制,且在 ios 真机上会偶现 闪烁
和 抖动
现象。而 canvas 通过 JavaScript 脚本来绘制图形,稳定性更强,且能 cover
复杂的动画逻辑,比如模拟转盘抽奖、直播间点赞动画、刮刮乐等效果。
总而言之,canvas 在微信小程序开发中占据一席之地,但也有许多不得不填的“坑”。现阶段,并没有同类的文章系统的记录这些问题,本文主要记录在 canvas 开发实践中遇见的问题和解决方案。
众所周知,小程序当中有一类特殊的内置组件——原生组件,这类组件有别于 WebView 渲染的内置组件,他们是交由原生客户端渲染的。原生组件作为 Webview 的补充,为小程序带来了更丰富的特性和更高的性能,但同时由于脱离 Webview 渲染也给开发者带来了不小的困扰,戳这里了解原生组件的使用限制。
小程序基础库 1.0.0
开始支持的 canvas API 就是原生组件,原生组件的层级总是最高,不受 z-index 属性的控制,无法与 view、image 等内置组件相互覆盖。因此,canvas 绘图往往在最顶层,在实际的开发过程中,会出现透出的问题。如下图所示,点赞动画和购物袋动画都是由 canvas 绘制,当打开商品列表弹窗时,这两个动画会透出:
canvasToTempFilePath
临时将 canvas 转成图片,然后隐藏 canvas,显示 tempImage 即可。这种方法适用于静态的 canvas 绘图,对于 canvas 动画而言,每 16ms 刷新一次,将 canvas 画布转成图片十分影响性能。所幸,小程序开发团队也意识到了原生组件带来的种种限制,对小程序原生组件进行了一次重构,引入了「同层渲染」。想要进一步了解同层渲染的原理,可以参考这篇文章——《小程序同层渲染原理剖析》。
小程序基础库 2.9.0
起支持一套新 Canvas 2D 接口(需指定 type 属性),同时支持同层渲染。所以对于 Canvas 开发,想要解决层级覆盖的问题,最有效的方法是将旧的 API 改造成新的 Canvas 2D API。
微信开放社区有人提问,为啥我做了如下设置,在模拟器上可以加粗,安卓机上加粗却没有效果。
this.ctx.font = '700 14px normal';
这行代码有两个问题:
font必须包含字体大小和字体族名
,此处缺乏字体族名。font-weight CSS属性指定了字体的粗细程度。一些字体只提供normal和bold两种值。
,为了安全起见,加粗用 bold
。因此,对代码做出了如下优化:
this.ctx.font = '${fontWeight >= 700 || fontWeight === 'bold' ?'normal'} ${fontSize}px ${fontFamily || 'sans-serif'}';
在海报绘制的业务场景中, 太阳码
或 二维码
需要用户提供部分参数,由服务端生成图片返回给前端,这时一般不会返回图片的 URL,而是将图片进行 base64 转码后返回给前端。然而,canvas 用户绘图的 API- drawImage
无法识别 base64 格式。
以下是解决方案:
wx.base64ToArrayBuffer
将 base64 数据转换为 ArrayBuffer 数据。FileSystemManager.writeFile
将 ArrayBuffer 数据写为本地用户路径的二进制图片文件。wx.env.USER_DATA_PATH
中, wx.getImageInfo
接口能正确获取到这个图片资源并 drawImage 至 canvas 上。const fsm = wx.getFileSystemManager();const FILE_BASE_NAME = 'tmp_base64src'; const base64src = function(base64data) { return new Promise((resolve, reject) => { // 写文件时记得去掉base64的头部信息 const [, format, bodyData] = /data:image\/(\w+);base64,(.*)/.exec(base64data) || []; if (!format) { reject(new Error('ERROR_BASE64SRC_PARSE')); } const filePath = `${wx.env.USER_DATA_PATH}/${FILE_BASE_NAME}.${format}`; const buffer = wx.base64ToArrayBuffer(bodyData); fsm.writeFile({ filePath, data: buffer, encoding: 'binary', success() { resolve(filePath); }, fail() { reject(new Error('ERROR_BASE64SRC_WRITE')); }, }); });}; export default base64src;
小程序的 canvas.drawImage
是不支持网络图片的,只支持本地图片。所以,任何的网络图片都需要先缓存到本地,再通过 drawImage
调用存储的本地资源进行绘制,缓存可以通过 wx.getImageInfo
和 wx.downloadFile
实现。
wx.getImageInfo
获取到图片的临时路径const ctx = wx.createCanvasContext('myCanvas'); //获取canvas画布对象wx.getImageInfo({ src: 'https://******.com/example.png', //网络图片路径 success: res => { const path = res.path; //图片临时本地路径 ctx.drawImage(path, 0, 0, 100, 100); //绘制画布上的路径 ctx.draw(true); }});
wx.downloadFile
获取到图片的临时路径const ctx = wx.createCanvasContext('myCanvas'); //获取canvas画布对象wx.downloadFile({ url: 'https://******.com/example.png', //网络路径 success: res => { const path = res.tempFilePath; //临时本地路径 ctx.drawImage(path, 0, 0, 100, 100); //绘制画布上的路径 ctx.draw(true); //绘制 },});
然而有人会问,为啥我在 ide 上能绘制图片,而真机却拿不到图片。这是因为微信安全域名的问题,需要在 小程序后台 > 设置 > 服务器域名 > downloadFile 合法域名里设置网络图片的域名。ps.因为域名要求是 https 的, 并且一个月只能修改五次,建议把需要下载的网络图片放在自己的 https 的服务器上,再走个 CDN 什么的。
我猜,还会有人问,为啥设置了安全域名后,在真机上还是无法显示绘图。这里需要考虑图片加载的时间,如果图片还未加载就开始绘制,那么就会报错。可以用 image 的 bindload
事件或者 downloadTask.onProgressUpdate
来监听图片加载过程。
基础库 2.7.0 起,小程序方发布了 Canvas.createImage()
,使用这个 api 可以加载网络图片。使用方法如下:
const tempImgae = canvas.createImage();tempImage.src = 'https://******.com/example.png';tempImage.onload = () => { // 图片加载完后,可以对 tempImage 随意操作};
和 CSS 相比,SVG 以及 canvas 对文字排版的支持很弱。CSS 一个 word-break
能解决的问题,canvas 却不行。
CanvasContext.measureText(stringtext)
用于测量文本尺寸信息。目前仅返回文本宽度 。
canvas 自动换行的实现原理在于 CanvasContext.measureText(stringtext)
这个 API,可以返回一个 TextMetrics 对象,其中包含了当前上下文环境下 text double 精度的占据宽度,于是我们就可以通过每个字符宽度的不断累加,精确计算哪个位置应该可以换行。
参考代码如下:
wrapText( ctx, text: string, x: number, y: number, maxWidth = 300, lineHeight = 16, ) { // 字符分隔为数组 const arrText = text.split(''); let line = '';
for (let n = 0; n < arrText.length; n++) { const testLine = line + arrText[n]; const metrics = ctx.measureText(testLine); const testWidth = metrics.width; if (testWidth > maxWidth && n > 0) { ctx.fillText(line, x, y); line = arrText[n]; y += lineHeight; } else { line = testLine; } } ctx.fillText(line, x, y); }
当然,在实际的业务场景中,更多的需求是实现固定行数文本换行且溢出省略的功能,实现原理一致,这里不做陈述。
文字竖直排列,英文可以使用 context.rotate()
旋转 90deg 实现,但这对于中文,是完全不适用的。在 CSS 中,我们可以使用 writing-mode
改变文档流的方向,从而实现文字竖排。使用 canvas 实现需要混合计算逐字排列,计算规则如下:全角字符竖排,英文数字等半角字符旋转排列。
参考代码如下,源自张鑫旭-canvas 文本绘制自动换行、字间距、竖排等实现:
fillTextVertical(text, x, y) { var context = this; var canvas = context.canvas;
var arrText = text.split(''); var arrWidth = arrText.map((letter) => { return context.measureText(letter).width; });
var align = context.textAlign; var baseline = context.textBaseline;
if (align == 'left') { x = x + Math.max.apply(null, arrWidth) / 2; } else if (align == 'right') { x = x - Math.max.apply(null, arrWidth) / 2; } if (baseline == 'bottom' || baseline == 'alphabetic' || baseline == 'ideographic') { y = y - arrWidth[0] / 2; } else if (baseline == 'top' || baseline == 'hanging') { y = y + arrWidth[0] / 2; }
context.textAlign = 'center'; context.textBaseline = 'middle';
// 开始逐字绘制 arrText.forEach((letter, index) => { // 确定下一个字符的纵坐标位置 var letterWidth = arrWidth[index]; // 是否需要旋转判断 var code = letter.charCodeAt(0); if (code <= 256) { context.translate(x, y); // 英文字符,旋转90° context.rotate(90 * Math.PI / 180); context.translate(-x, -y); } else if (index > 0 && text.charCodeAt(index - 1) < 256) { // y修正 y = y + arrWidth[index - 1] / 2; } context.fillText(letter, x, y); // 旋转坐标系还原成初始态 context.setTransform(1, 0, 0, 1, 0, 0); // 确定下一个字符的纵坐标位置 var letterWidth = arrWidth[index]; y = y + letterWidth; }); // 水平垂直对齐方式还原 context.textAlign = align; context.textBaseline = baseline;};
微信小程序允许对普通元素通过 border-radius
的设置来进行圆角的绘制,但有时候在使用 canvas 绘图的时候,也需要圆角,但 canvas 并未提供绘制圆角矩形的 kpi,这时候,就需要“曲线救国”。首先,了解一下画圆的 api:
CanvasContext.arc(number x, number y, number r, number sAngle, number eAngle, boolean counterclockwise)
因此,我们可以先绘制四段圆弧,再利用 closePath
方法会连接路径的特点,即可画出圆角矩形。封装函数如下:
const drawRoundedRect = (ctx, width, height, radius, type='fill') => { ctx.moveTo(0, radius); ctx.beginPath(); ctx.arc(radius, radius, radius, Math.PI, 1.5 * Math.PI); ctx.arc(width - radius, radius, radius, 1.5 * Math.PI, 2 * Math.PI); ctx.arc(width - radius, height - radius, radius, 0, 0.5 * Math.PI); ctx.arc(radius, height - radius, radius, 0.5 * Math.PI, Math.PI); ctx.closePath(); ctx[method]();};
当然,仅仅绘制圆角矩形是不够的。实际业务需求中,更多的是,给图片添加圆角。这里,需要用到如下 api:
CanvasContext.createPattern(string image, string repetition)
const drawRoundRectImage = (ctx, x, y, width, height, radius, image) => { //圆的直径必然要小于矩形的宽高 if (2 * radius > width || 2 * radius > height) { return false; } // 创建图片纹理 const pattern = ctx.createPattern(obj, "no-repeat"); cxt.save(); cxt.translate(x, y); //绘制圆角矩形的各个边 this.drawRoundedRect(ctx, width, height, radius); cxt.fillStyle = pattern; cxt.fill(); cxt.restore(); }
近期因为业务开发需要,接触了 canvas 动画,在开发中发现绘制的点赞图标异常模糊,如下图所示,左图是最初开发时绘制的图标,右图是修复这个问题后绘制的图标,清晰度得到质的飞跃。
在浏览器的 window 变量中有一个 devicePixelRatio
属性,该属性决定了浏览器会用几个像素点来渲染 1 个像素,举例来说,假设某个屏幕的 devicePixelRatio 的值为 2,一张 100x100 像素大小的图片,在此屏幕下,会用 2 个像素点的宽度去渲染图片的 1 个像素点,因此该图片在此屏幕上实际会占据 200x200 像素的空间,相当于图片被放大了一倍,因此图片会变得模糊。
从上面的图可以看出,在同样大小的逻辑像素下,高清屏所具有的物理像素更多。普通屏幕下,1 个逻辑像素对应 1 个物理像素,而在 dpr = 2 的高清屏幕下,1 个逻辑像素由 4 个物理像素组成。
相信所有了解过 Canvas 绘图的同行都知道 canvas 绘制的是位图,位图又叫像素图或栅格图,它是通过记录图像中每一个点的颜色、深度等信息来存储和显示图像。具象一点讲,可以将位图想象成一个巨大的拼图,这个拼图有无数的拼块,每个拼块代表了一个纯色的像素点。理论上,1 个位图像素对应着 1 个物理像素。但假如说你使用了高清屏,比如苹果的 retina 屏去查看一幅图画,又会是什么样子呢?
假设我们有如下代码,该代码将展示在 iphoneX(Dpr=3)的 retina 屏上:
<canvas width="320" height="150" style="width: 320px; height: 150px"></canvas>
其中,style 中的 width 和 height 分别代表 canvas 这个元素在界面上所占据的宽高,即样式上的宽高。attribute 中的 width 和 height 则代表 canvas 实际像素的宽高。
iphoneX 本身的物理像素为 1125 _ 2436,而设备独立像素为 375 _ 812,这代表着 1 个 css 像素实际由 9 个物理像素构成,canvas 的像素为 320 _ 150,其 css 像素为 320 _ 150,则代表 1 个 css 像素将会由 1 个 canvas 元素构成,这样进行换算,在 retina 屏幕下,1 个 canvas 像素(或者说是 1 个位图像素)将会填充 9 个物理像素,由于单个位图像素不可以再进一步分割,所以只能就近取色,从而导致图片模糊。
上图说明位图在 retina 屏幕下是如何填充的,上图中左侧的是在普通屏幕下的显示规则,可以看出有 4 个位图像素点,而右侧的高清屏幕下则有 16 个像素点。由于像素点不可切割的原因,颜色产生了改变。
了解了问题出现的原因,解决问题就很容易。要做 Retina 屏适配,关键是让 1 个 canvas 像素和一个物理像素挂等号。
参考代码如下:
this.ctx = canvas.getContext('2d');// 获取retina屏幕的设备像素比const dpr = wx.getSystemInfoSync().pixelRatio;// 根据设备像素比,扩大canvas画布的像素,使1个canvas像素和1个物理像素相等canvas.width = this.realWidth * dpr;canvas.height = this.realHeight * dpr;// 由于画布扩大,canvas的坐标系也跟着扩大,如果按照原先的坐标系绘图内容会缩小// 所以需要将绘制比例放大this.ctx.scale(dpr, dpr);
最近接到了如下图所示的挂件和购物袋动画的优化需求。最初的版本使用设计大大们给的 gif 图片,gif 图片在真机上的白边和锯齿问题“遭人诟病”。在设计大大们的“威逼利诱”下,只能考虑动画实现。前面也提到过,CSS 动画在真机上会偶现 闪烁
和 抖动
现象, wx.createAnimation
和 this.animate
在部分 iphone 机型中无法获取动画周期,页面偶现 闪烁
现象,比如一个动画周期是 2s,有时候 iphone 机型无法获取这个时间,会在 1s 甚至更短的时间内执行这个动画,造成“闪烁”的效果。
总而言之,Canvas 动画才是最佳实践。然而小程序的 canvas2dAPI
也存在不足,比如图片绘制过多的情况下,会自动清空画布。如下图所示,倒计时的动画执行到第 8 秒的时候,画布突然清空。左边的活动挂件也遇见过同样的问题,画布突然清空。
网上也有很多类似的问题,比如“ios 上重复跳转到某页面并用 canvas 画图时会导致运行内存不足或意外退出”, “canvas 2D 真机不显示,开发工具上无任何问题?”。总结一下就是,ios 机型上绘制 canvas 过于频繁可能会导致画布清空、小程序崩溃。
排查了这个问题很久,推断出一种原因,可能是动画执行过程中,倒计时文本刷新,导致需要重新绘制图片,两次绘制的时间间隔太短,导致程序崩溃,画布清空。优化方法如下:
因为业务开发需要,作者接触 canvas 开发两个月,总结分享实践中遇到的一些问题。