Wxml2Canvas -- 快速生成小程序分享图通用方案
Wxml2Canvas库,可以将指定的wxml节点直接转换成canvas元素,并且保存成分享图,极大地提升了绘制分享图的效率。目前被应用于微信游戏圈、王者荣耀、刺激战场助手等小程序中。
github地址:https://github.com/wg-front/wxml2canvas
一、背景
随着小程序应用的日渐成熟,多处场景需要能够生成分享图便于用户进行二次传播,从而提升小程序的传播率以及加强品牌效应。
对于简单的分享图,比如固定大小的背景图加几行简短文字构成的分享小图,我们可以利用官方提供的canvas接口将元素直接绘制, 虽然繁琐了些,但能满足基本要求。
对于复杂的分享图,比如用户在微信游戏圈发表完话题后,需要将图文混排的富文本内容生成分享图,对于这种长度不定,内容动态变化的图片生成需求,直接利用官方的canvas接口绘制是十分困难的,包括但不限于文字换行、表情文字图片混排、文字加粗、子标题等元素都需要一一绘制。又如王者荣耀助手小程序,需要将十人对局的详细战绩绘制成分享图,包含英雄数据、装备、技能、对局结果等信息,要绘制100多张图片和大量的文字信息,如果依旧使用官方的接口一步一步绘制,对开发者来说简直就是一场噩梦。我们急需一种通用、高效的方式完成上述的工作。
在这样的背景下,wxml2cavnas诞生了,作为一种分享图绘制的通用方案,它不仅能快速的绘制简单的固定小图,还能直接将wxml元素真实地转换成canvas元素,并且适配各种机型。无论是复杂的图文混排的富文本内容,还是展现形式多样的战绩结果页,都可以利用wxml2cavnas完美地快速绘制并生成所期望的分享图片。
二、Wxml2Canvas介绍及示例
1. 介绍
Wxml2Cavnas库,是一个生成小程序分享图的通用方案,提供了两种绘制方式:
封装基础图形的绘制接口,包括矩形、圆形、线条、图片、圆角图片、纯文本等,使用时只需要声明元素类型并提供关键数据即可,不需要再关注canvas的具体绘制过程;
wxml直接转换成canvas元素,使用时传入待绘制的wxml节点的class类名,并且声明绘制此节点的类型(图片、文字等),会自动读取此节点的computedStyle,利用这些数据完成元素的绘制。
2. 生成图示例
下面是两张极端复杂的分享图。
2.1 游戏圈话题
[图片]
点击查看完整长图
2.2.2 王者荣耀战绩
[图片]
点击查看完整大图
三、小程序的特性及局限
小程序提供了如下特性,可供我们便捷使用:
measureText接口能直接测量出文本的宽度;
SelectorQuery可以查询到节点对应的computedStyle。
利用第一条,我们在绘制超长文本时便于文本的省略或者换行,从而避免文字溢出。
利用第二条,我们可以根据class类名,直接拿到节点的样式,然后将style转换成canvas可识别的内容。
但是和html的canvas相比,小程序的canvas局限性很多。主要体现在如下几点:
不支持base64图片;
图片必须下载到本地后才能绘制到画布上;
图片域名需要在管理平台加入downFile安全域名;
canvas属于原生组件,在移动端会置于最顶层;
通过SelectorQuery只能拿到节点的style,而无法获取文本节点的内容以及图片节点的链接。
针对以上问题,我们需要将base64图片转换jpg或png格式的图片,实现图片的统一下载逻辑,并且离屏绘制内容。针对第五条,好在SelectorQuery可以获取到节点的dataset属性,所以我们需要在待绘制的节点上显示地声明其类型(imgae、text等),并且显示地传入文本内容或图片链接,后文会有示例。
四、Wxml2Canvas使用方式
1. 初始化
首先在wxml中创建canvas节点,指定宽高:
[代码] <canvas canvas-id="share"
style="height: {{ height * zoom }}px; width: {{ width * zoom }}px;">
</canvas>
[代码]
引入代码库,创建DrawImage实例,并传入如下参数:
[代码] let DrawImage = require('./wxml2canvas/index.js');
let zoom = this.device.windowWidth / 375;
let width = 375;
let height = width * 3;
let drawImage = new DrawImage({
element: 'share', // canvas节点的id,
obj: this, // 在组件中使用时,需要传入当前组件的this
width: width, // 宽高
height: height,
background: '#161C3A', // 默认背景色
gradientBackground: { // 默认的渐变背景色,与background互斥
color: ['#17326b', '#340821'],
line: [0, 0, 0, height]
},
progress (percent) { // 绘制进度
},
finish (url) {
// 画完后返回url
},
error (res) {
console.log(res);
// 画失败的原因
}
});
[代码]
所有的数字参数均以iphone6为基准,其中参数width和height决定了canvas画布的大小,规定值是在iphone6机型下的固定数值;
zoom参数的作用是控制画布的缩放比例,如果要求画布自适应,则应传入 windowWidth / 375,windowWidth为手机屏幕的宽度。
2. 传入数据,生成图片
执行绘制操作:
[代码] drawImage.draw(data, this);
[代码]
执行绘制时需要传入数据data,数据的格式分为两种,下面展开介绍。
2.1 基础图形
第一种为基础的图形、图文绘制,直接使用官方提供接口,下面代码是一个基本的格式:
[代码] let data = {
list: [{
type: 'image',
url: 'https://xxx',
class: 'background_image',
// delay: true,
x: 0,
y: 0,
style: {
width: width,
height: width
}
}, {
type: 'text',
text: '文字',
class: 'title',
x: 0,
y: 0,
style: {
fontSize: 14,
lineHeight: 20,
color: '#353535',
fontFamily: 'PingFangSC-Regular'
}
}]
}
[代码]
如上,type声明了要元素的类型,有image、text、rect、line、circle、redius_image(圆角图)等,能满足绝大多数情况。
class类名指定了使用的样式,需要在style中写出,符合css样式规范。
delay参数用来异步绘制元素,会把此元素放在第二个循环中绘制。
x,y用来指定元素的起始坐标。
将css样式与元素分离的目的是便于管理与复用。
此种方式每个元素都相互独立,互不影响,能够满足自由度要求高的情况,可控性高。
2.2 wxml转换
第二种方式为指定wxml元素,自动获取,下面是示例:
[代码] let data = {
list: [{
type: 'wxml',
class: '.panel .draw_canvas',
limit: '.panel'
x: 0,
y: 0
}]
}
[代码]
如上,type声明为wxml时,会查找所有类名为draw_canvas的节点,并且加入到绘制队列中。
class传入的第一个类名限定了查询的范围,可以不传,第二个用来指定查找的节点,可以定义为任意不影响样式展现的通用类名。
limit属性用来限定相对位置,例如,一个文本的位置(left, top) = (50, 80), class为panel的节点的位置为(left, top) = (20, 40),则文本canvas上实际绘制的位置(x, y) = (50 - 20, 80 -40) = (30, 40)。如果不传入limit,则以实际的位置(x, y) = (50, 80)绘制。
由于小程序节点元素查询接口的局限,无法直接获取节点的文本内容和图片标签的src属性,也无法直接区分是文本还是图片,但是可以获取到dataset,所以我们需要在节点上显示地声明data-type来指明类型,再声明data-text传入文字或data-url传入图片链接。下面是个示例:
[代码] <view class="panel">
<view class="panel__img draw_canvas" data-type="image" data-url="https://xxx"></view>
<view class="panel__text draw_canvas" data-type="text" data-text="文字">文字</view>
</view>
[代码]
如上,会查询到两个节点符合条件,第一个为image图片,第二个为text文本,利用SelectorQuery查询它们的computedStyle,分别得到left、top、width、height等数据后,转换成canvas支持的格式,完成绘制。
除此之外,下面的示例功能更加丰富:
[代码] <view class="panel">
<view class="panel__text draw_canvas"
data-type="background-image"
data-radius="1"
data-shadow=""
data-border="2px solid #000"></view>
<view class="panel__text draw_canvas"
data-type="text"
data-background="#ffffff"
data-padding="2 3 0 0"
data-delay="1"
data-left="10"
data-top="10"
data-maxlength="4"
data-text="这是个文字">这是个文字</view>
</view>
[代码]
如上,第一个data-type为background-image,表示读取此节点的背景图片,因为可以通过computedStyle直接获取图片链接,所以不需要显示传入url。声明data-radius属性,表示要将此图绘成乘圆形图片。data-border属性表示要绘制图片的边框,虽然也可以通过computedStyle直接获取,但是为了避免非预期的结果,还是要声明传入,border格式应符合css标准。此外,图片的box-shadow等样式都会根据声明绘制出来。
第二个文本节点,声明了data-background,则会根据节点的位置属性给文字增加背景。
data-padding属性用来修正背景的位置和宽高。data-delay属性用来延迟绘制,可以根据值的大小,来控制元素的层级,data-left和data-top用来修正位置,支持负值。data-maxlength用来限制文本的最大长度,超长时会截取并追加’…’。
此外,data-type还有inline-text,inline-image等行内元素的绘制,其实现较为复杂,会在后文介绍。
五、Wxml2Canvas实现原理
1. 绘制流程
整个绘制流程如下:
[图片]
因为小程序的限制,只能在画布上绘制本地图片,所以统一先对图片提前下载,然后再绘制,为了避免图片重复下载,内部维护一个图片列表,会对相同的图片链接去重,减少等待时间。
2. 基本图形的实现
基础图形的绘制比较简单,内部实现只是对基础能力的封装,使用者不用再关注canvas的绘制过程,只需要提供关键数据即可,下面是一个图片绘制的实现示例:
[代码] function drawImage (item, style) {
if(item.delay) {
this.asyncList.push({item, style});
}else {
if(item.y < 0) {
item.y = this.height + item.y * zoom - style.height * zoom;
}else {
item.y = item.y * zoom;
}
if(item.x < 0) {
item.x = this.width + item.x * zoom - style.width * zoom;
}else {
item.x = item.x * zoom;
}
ctx.drawImage(item.url, item.x, item.y, style.width * zoom, style.height * zoom);
ctx.draw(true);
}
}
[代码]
如上,x,y值坐标支持传入负值,表示从画布的底部和右侧计算位置。
3. Wxml转Canvas元素的实现
3.1 computedStyle的获取
首先需要获取wxml的样式,代码示例如下:
[代码] query.selectAll(`${item.class}`).fields({
dataset: true,
size: true,
rect: true,
computedStyle: ['width', 'height', ...]
}, (res) => {
self.drawWxml(res);
})
[代码]
3.2 块级元素的绘制
对于声明为image、text的元素,默认为块级元素,它们的绘制都是独立进行的,不需要考虑其他的元素的影响,以wxml节点为圆形的image为例,下面是部分代码:
[代码] if(sub.dataset.type === 'image') {
let r = sub.width / 2;
let x = sub.left + item.x * zoom;
let y = sub.top + item.y * zoom;
let leftFix = +sub.dataset.left || 0;
let topFix = +sub.dataset.top || 0;
let borderWidth = sub.borderWidth || 0;
let borderColor = sub.borderColor;
// 如果是圆形图片
if(sub.dataset.radius) {
// 绘制圆形的border
if(borderWidth) {
ctx.beginPath()
ctx.arc(x + r, y + r, r + borderWidth, 0, 2 * Math.PI)
ctx.setStrokeStyle(borderColor)
ctx.setLineWidth(borderWidth)
ctx.stroke()
ctx.closePath()
}
// 绘制圆形图片的阴影
if(sub.boxShadow !== 'none') {
ctx.beginPath()
ctx.arc(x + r, y + r, r + borderWidth, 0, 2 * Math.PI)
ctx.setFillStyle(borderColor);
setBoxShadow(sub.boxShadow);
ctx.fill()
ctx.closePath()
}
// 最后绘制圆形图片
ctx.save();
ctx.beginPath();
ctx.arc((x + r), (y + r) - limitTop, r, 0, 2 * Math.PI);
ctx.clip();
ctx.drawImage(url, x + leftFix * zoom, y + topFix * zoom, sub.width, sub.height);
ctx.closePath();
ctx.restore();
}else {
// 常规图片
}
}
[代码]
如上,块级元素的绘制和基础图形的绘制差异不大,理解起来也很容易,不再多述。
3.3 令人头疼的行内元素的绘制
当wxml的data-type声明为inline-image或者inline-text时,我们认为是行内元素。行内元素的绘制是一个难点,因为元素之前存在关联,所以不得不考虑各种临界情况。下面展开细述。
3.3.1 纯文本换行
对于长度超过一行的行内元素,需要计算出合适的换行位置,下图所示的是两种临界情况:
[图片]
[图片]
如上图所示,第一种情况为最后一行只有一个文字,第二种情况最后一行的文字长度和宽度相同。虽然长度不同,但都可通过下面代码绘制:
[代码] let lineNum = Math.ceil(measureWidth(text) / maxWidth); // 文字行数
let sinleLineLength = Math.floor(text.length / lineNume); // 向下取整,保证多于实际每行字数
let currentIndex = 0; // 记录文字的索引位置
for(let i = 0; i < lineNum; i++) {
let offset = 0; // singleLineLength并不是精确的每行文字数,要校正
let endIndex = currentIndex + sinleLineLength + offset;
let single = text.substring(currentIndex, endIndex); // 截取本行文字
let singleWidth = measureWidth(single);
// 超长时,左移一位,直至正好
while(singleWidth > maxWidth) {
offset--;
endIndex = currentIndex + sinleLineLength + offset;
single = text.substring(currentIndex, endIndex);
singleWidth = measureWidth(single);
}
currentIndex = endIndex;
ctx.fillText(single, item.x, item.y + i * style.lineHeight);
}
// 绘制剩余的
if(currentIndex < text.length) {
let last = text.substring(currentIndex, text.length);
ctx.fillText(last, item.x, item.y + lineNum * style.lineHeight);
}
[代码]
为了避免计算太多次,首先算出大致的行数,求出每行的文字数,然后移位索引下标,求出实际的每行的字数,再下移一行继续绘制,直到结束。
3.3.2 非换行的图文混排
[图片]
上图是一个包含表情图片和加粗文字的混排内容,当使用Wxml2Canvas查询元素时,会将第一行的内容分为五部分:
文本内容:这是段文字;
表情图片:发呆表情(非系统表情,image节点展现);
表情图片:发呆表情;
文本内容:这也;
加粗文本内容:是一段文字,这也是文字。
对于这种情况,执行查询computedStyle后,会返回相同的top值。我们把top值相同的元素聚合在一起,认为它们是同一行内容,事实也是如此。因为表情大小的差异以及其他影响,默认规定top值在±2的范围内都是同一行内容。然后将top值的聚合结果按照left的大小从左往右排列,再一一绘制,即可完美还原此种情况。
3.3.3 换行的图文混排
当混排内容出现了换行情况时,如下图所示:
[图片]
此时的加粗内容占据了两行,当我们依旧根据top值归类时,却发现加粗文字的left值取的是第二行的left值。这就导致加粗文字和第一部分的文字的top值和left值相同,如果直接绘制,两部分会发生重叠。
为了避免这种尴尬的情况,我们可以利用加粗文字的height值与第一部分文字的height值比较,显然前者是后者的两倍,可以得知加粗部分出现了换行情况,直接将其放在同组top列表的最后位置。换行的部分根据lineHeight下移绘制,同时做记录。
最后一部分的文本内容也出现了换行情况,同样无法得到真正的起始left值,并且其top值与上一部分换行后的top值相同。此时应该将他的left值追加加粗换行部分的宽度,正好得到真正的left值,最后再绘制。
大多数的行内元素的展现形式都能以上述的逻辑完美还原。
六、总结
基于基础图形封装和wxml转换这两种绘制方式,可以满足绝大多数的场景,能够极大地减少工作量,而不需要再关注内部实现。在实际使用中,二者并非孤立存在,而更多的是一起使用。
[图片]
如上图所示,对于列表内容我们利用wxml读取绘制,对于下部的白色区域,不是wxml节点内容,我们可以使用基础图形绘制方式实现。二者的结合更加灵活高效。
目前Wxml2Canvas已经在公司内部开源,不久会放到github上,同时也在不断完善中,旨在实现更多的样式展现与提升稳定性和绘制速度。
如果有更好的建议与想法,请联系我。