评论

Wxml2Canvas -- 快速生成小程序分享图通用方案

Wxml2Canvas库,可以将指定的wxml节点直接转换成canvas元素,并且保存成分享图,极大地提升了绘制分享图的效率。目前被应用于微信游戏圈、王者荣耀、刺激战场助手等小程序中。

Wxml2Canvas库,可以将指定的wxml节点直接转换成canvas元素,并且保存成分享图,极大地提升了绘制分享图的效率。目前被应用于微信游戏圈、王者荣耀、刺激战场助手等小程序中。

github地址:https://github.com/wg-front/wxml2canvas

一、背景

随着小程序应用的日渐成熟,多处场景需要能够生成分享图便于用户进行二次传播,从而提升小程序的传播率以及加强品牌效应。

对于简单的分享图,比如固定大小的背景图加几行简短文字构成的分享小图,我们可以利用官方提供的canvas接口将元素直接绘制, 虽然繁琐了些,但能满足基本要求。

对于复杂的分享图,比如用户在微信游戏圈发表完话题后,需要将图文混排的富文本内容生成分享图,对于这种长度不定,内容动态变化的图片生成需求,直接利用官方的canvas接口绘制是十分困难的,包括但不限于文字换行、表情文字图片混排、文字加粗、子标题等元素都需要一一绘制。又如王者荣耀助手小程序,需要将十人对局的详细战绩绘制成分享图,包含英雄数据、装备、技能、对局结果等信息,要绘制100多张图片和大量的文字信息,如果依旧使用官方的接口一步一步绘制,对开发者来说简直就是一场噩梦。我们急需一种通用、高效的方式完成上述的工作。

在这样的背景下,wxml2cavnas诞生了,作为一种分享图绘制的通用方案,它不仅能快速的绘制简单的固定小图,还能直接将wxml元素真实地转换成canvas元素,并且适配各种机型。无论是复杂的图文混排的富文本内容,还是展现形式多样的战绩结果页,都可以利用wxml2cavnas完美地快速绘制并生成所期望的分享图片。

二、Wxml2Canvas介绍及示例

1. 介绍

Wxml2Cavnas库,是一个生成小程序分享图的通用方案,提供了两种绘制方式:

  1. 封装基础图形的绘制接口,包括矩形、圆形、线条、图片、圆角图片、纯文本等,使用时只需要声明元素类型并提供关键数据即可,不需要再关注canvas的具体绘制过程;

  2. wxml直接转换成canvas元素,使用时传入待绘制的wxml节点的class类名,并且声明绘制此节点的类型(图片、文字等),会自动读取此节点的computedStyle,利用这些数据完成元素的绘制。

2. 生成图示例

下面是两张极端复杂的分享图。

2.1 游戏圈话题

点击查看完整长图

2.2.2 王者荣耀战绩

点击查看完整大图

三、小程序的特性及局限

小程序提供了如下特性,可供我们便捷使用:

  1. measureText接口能直接测量出文本的宽度;
  2. SelectorQuery可以查询到节点对应的computedStyle。

利用第一条,我们在绘制超长文本时便于文本的省略或者换行,从而避免文字溢出。
利用第二条,我们可以根据class类名,直接拿到节点的样式,然后将style转换成canvas可识别的内容。

但是和html的canvas相比,小程序的canvas局限性很多。主要体现在如下几点:

  1. 不支持base64图片;
  2. 图片必须下载到本地后才能绘制到画布上;
  3. 图片域名需要在管理平台加入downFile安全域名;
  4. canvas属于原生组件,在移动端会置于最顶层;
  5. 通过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查询元素时,会将第一行的内容分为五部分:

  1. 文本内容:这是段文字;
  2. 表情图片:发呆表情(非系统表情,image节点展现);
  3. 表情图片:发呆表情;
  4. 文本内容:这也;
  5. 加粗文本内容:是一段文字,这也是文字。

对于这种情况,执行查询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上,同时也在不断完善中,旨在实现更多的样式展现与提升稳定性和绘制速度。

如果有更好的建议与想法,请联系我。

最后一次编辑于  2019-02-28  
点赞 21
收藏
评论

34 个评论

  • Xccc
    Xccc
    2023-08-07

    里面有图片的时候 image 的 mode aspectFill不生效怎么办呀, 生成出来的图片就是拉伸状态,我用 view + css背景图 + background-size / -position设置也不起作用

    2023-08-07
    赞同
    回复 1
    • 某人
      某人
      09-01
      我也是这个问题,请问你解决了吗
      09-01
      回复
  • 六只肥羊
    六只肥羊
    2023-07-24

    请问大佬怎么实现的字体加粗?

    2023-07-24
    赞同
    回复
  • Salvation
    Salvation
    2023-07-05

    楼主,我想问下是不是所有我想绘制canvas的所有Wxml都必须填同一个类名,我想绘制一个View下的所有内容,可不可以只加View的类名,View下节点是否需要加类名

    2023-07-05
    赞同
    回复
  • 蔬菜
    蔬菜
    2023-02-25

    请问支持 clip-path: polygon(0 0, 100% 0, 72% 100%, 0 100%);

    属性吗

    2023-02-25
    赞同
    回复
  • 奋斗的博意志
    奋斗的博意志
    2022-12-01

    生成的图片有点模糊,怎么解决啊,调整destzoom这个值的大小了,但是感觉还是会模糊,还有就是文档上说的这个值越大,图片越大,是图片的宽高越大,还是图片的质量越大呢

    2022-12-01
    赞同
    回复
  • 讌興
    讌興
    2022-11-15

    drawCanvas(){

    let that = this

    let query = uni.createSelectorQuery().in(this);

    query.select('#answer-canvas').fields({ //answer-canvas要绘制的canvas的id

    size: true,

    scrollOffset: true

    }, data => {

    let width = data.width;

    let height = data.height;

    console.log(width, height);

    setTimeout(() => {

    that.draw()

    }, 1500);

       }).exec()

    },

    draw(){

    let that = this      

    //创建wxml2canvas对象

    let drawImage = new Wxml2Canvas({

      element: 'canvas4', // canvas节点的id,

      obj: that, // 在组件中使用时,需要传入当前组件的this

      width: 340,

      height: 400,

      background: '#f0f0f0', // 默认背景色 设置背景色

      progress(percent) { // 绘制进度

    // console.log(percent);

      },

      finish(url) {

    console.log("创建的图片", url);

    that.imageUrl = url

    console.log(that.imageUrl)

      },

      error(res) {

    console.log(res);

    // uni.hideLoading()

    // 画失败的原因

      }

    }, this);

    let data = {

      //直接获取wxml数据

      list: [{

      type: 'wxml',

      class: '.share__canvas4 .draw_canvas',  // answer_canvas这边为要绘制的wxml元素跟元素类名, answer_draw_canvas要绘制的元素的类名(所有要绘制的元素都要添加该类名)

      limit: '.share__canvas4', // 这边为要绘制的wxml元素跟元素类名,最外面的元素

      x: 0,

      y: 0

    } ]

    }

      //传入数据,画制canvas图片

    drawImage.draw(data, this);

    }

    URL生成是http://tmp/Ymh1LmIZtDCPd599fd8302528560b38e6ff4eb4cf409.png

    展现是空白的

    2022-11-15
    赞同
    回复 2
    • 讌興
      讌興
      2022-11-15
      2022-11-15
      回复
    • 长安
      长安
      2023-07-20回复讌興
      情况一模一样,请问怎么解决的
      2023-07-20
      回复
  • 思源000
    思源000
    2022-08-22

    类名有伪类的canvas渲染不上去伪类怎么操作,有大佬知道吗


    2022-08-22
    赞同
    回复
  • 伊澄
    伊澄
    2022-08-10

    2022-08-10
    赞同
    回复 2
    • 伊澄
      伊澄
      2022-08-10
      就是这样,文字会换行一个字
      2022-08-10
      回复
    • 伊澄
      伊澄
      2022-08-10回复伊澄
      问题已经解决,需要设置宽度,text的需要加display:inline-block;
      2022-08-10
      回复
  • 伊澄
    伊澄
    2022-08-10

    font


    2022-08-10
    赞同
    回复
  • 伊澄
    伊澄
    2022-08-10

    ios12/13绘制的图片文字会换行一个字,楼主知道为啥么?

    2022-08-10
    赞同
    回复

正在加载...

登录 后发表内容