小程序canvas2d组件,支持功能:拖拽改变位置、放大缩小、增/删元素、动画预览;

3,084 阅读9分钟

前言 公司是做广告屏的,这里我要实现的是节目制作功能,且可以预览发布到屏幕上的效果,目前小程序只有canvas2d是持续更新的,市面上也没有任何canvas2d的组件可以使用,功能早就开发完毕了,正好最近有空,写个文章用来记录这个组件的实现过程吧,内容太多只能记录大概过程。

实现效果:

01.gif

具体需求:

  • canvas尺寸动态设置(与用户的广告屏一样大)
  • 区域内绘制图片
  • 区域内绘制文字(换行、自定义字体、文字属性)
  • 区域内绘制表盘
  • 区域内绘制视频
  • 实现交互:拖拽移动、放大、增减绘制元素
  • 编辑的节目,投放效果预览

需要解决的问题:

  • 如何实现增减绘制内容
  • 如何解决频繁清理后重绘的闪烁问题
  • 如何使用手指触摸实现图形元素的移动、变形
  • 如何解决canvas2d标签尺寸过大,小程序闪退/崩溃问题
  • 如何实现动画效果(算法)

实现步骤:

组件规划:需要增减图形元素、主动控制绘制,有多种需要绘制的图形元素,所以需要一个对象来管控需要绘制的元素,并且在元素信息改变后通知元素重新绘制,就有点像发布订阅这个设计模式似的,再结合组件的单向数据流,整个流程就为:封装一个canvas组件名为canvasWxdraw,组件需要的尺寸数据、绘制数据,组件内采用发布订阅设计模式,用一个对象管理所有需要绘制的元素,对象具有增减元素的能力、存储重要信息、通知绘制,如果组件内修改节目了数据,会在恰当时机传出(依然要保证单向数据流,将节目数据和绘制数据拆分,使用两套数据)。
WxDraw 构造函数:发布者,由此构造函数的实例管控绘制
Shape 构造函数: 订阅者,有此构造函数的实例管控图形元素
  Text 构造函数: 实现了区域内文字元素的具体绘制文字换行、选中、变形等
  Image 构造函数: 实现了区域内图片元素的具体绘制、选中、变形
  Video 构造函数: 实现了视频元素的具体绘制、选中、变形
  TimePanel 构造函数: 实现了时间元素的具体绘制、选中、变形
  ClockPanel 构造函数: 实现了表盘元素的具体绘制、选中、变形
 * 主要 引入对象
 * @param {number} w 画布宽度
 * @param {number} h 画布高度
 * @param {NodesRef} topCanvasNode  上层canvas标签的node
 * @param {RenderingContext} topCtx 上层canvas标签,用于显示图片的canvas 画笔
 * @param {NodesRef} bottomCanvasNode 下面的canvas的node
 * @param {RenderingContext} bottomCtx 下面的canvas 的画笔
 */
function WxDraw( w, h, topCanvasNode, topCtx, bottomCanvasNode, bottomCtx) {
  this.store = new Store();
  this.w = w;
  this.h = h;
  this.isDrawing = false;
  this.topCanvasNode = topCanvasNode; //上层动态canvasNode
  this.topCtx = topCtx; //上层动态canvas绘制上下文
  this.bottomCanvasNode = bottomCanvasNode; //底层静态canvasNode
  this.bottomCtx = bottomCtx; //底层静态canvas绘制上下文
}
WxDraw.prototype = {
  add: function (item, index) {
    item._updateLayer(this.store.getLength()); //新增图形元素,设置图形的层级,后来居上原则
    this.store.add(item, index);
    this.draw();
  },
  draw: function () {
  },
  previewDraw: function (componentThis) {
  },
  cancelPreview: function () {
    this.topCanvasNode.cancelAnimationFrame(this.animationId);
  },
  longpressDetect: function longpressDetect(e) {},
  touchstartDetect: function touchstartDetect(e) {
    // 判断激活元素
  },
  touchendDetect: function(e) {},
    // 关闭所以元素激活状态
  },
  clear: function () {
  },
  canvasToTempFilePath: function (bottomCanvasNode) {
  }
};

如何使用手指触摸实现图形元素的移动、变形

  1. touchStart事件中判断得到是否选中元素,并且判断出选中了元素自身还是选中元素的四个角,注意点:应当从最后绘制的元素中开始判断,从上往下判断,性能高且处理层叠问题
  2. 手指触摸的坐标相对于鼠标是不精准的,所以这四个角需要做模糊处理,(就是在四个角上面放大一个范围出来)
  3. 需要注意不能超出canvas的范围,放大缩小需要注意选中的角在放大缩小过程中是会动态改变的(例如当前选中左上角,手指像右小角滑动缩小元素,在这个过程中左上角随着手指动,右下角保持不动,当左上角超过当前的右下角时,此时选中的就应该是右下角 这个过程需要理解

解决频繁清理后重绘的闪烁问题

在使用手指交互移动元素、变形元素时,需要先清理掉之前的绘制内容,重新绘制,理论上来说清理的很快,绘制的很快就比较难以察觉到闪烁,但是用户的机型性能较差、绘制内容需要计算导致绘制时间很长,就肯定能察觉到闪烁

  1. 刚开始想到节流,让绘制过程少一点呗,但是这样绘制过程就会不流畅,会从当前位置一下到较远位置;
  2. 使用两个canvas,两个canvas层叠在一起,交互事件由上层canvas传出,在touchStart时间中将选中的图形元素就绘制在上册canvas上,将其他图形元素绘制在下层canvas元素上,这样,在移动、变形过程中既不会闪烁,重复绘制的内容也只有当前选中的图形元素,简直完美。

如何解决canvas2d标签尺寸过大问题

实测发现小程序中canvas节点超过1200~1500px这个大小,小程序会卡死、闪退,这种场景较少,反馈在微信开发者社区,官方也没有跟进、反馈、修复。

  1. 既然canvas节点超过这个范围就会出错,那么当需要较大canvas时,如需要2000*2000px尺寸的canvas时,创建四个canvas,拼接出想要的效果,在最上层使用一个view,用来触发对应事件,但是需要动态创建dom,小程序不支持动态创建dom对象;
  2. (不知道大家有没有做过渲染长列表的这种场景,我们项目没有长列表的场景,所幸我之前实现过长列表,其实显示较少数量的内容在视口中,在滚动过程中,刚开始时随着滚动一起滚动,当滚动一个条内容高度时,将dom换掉,内容继续显示在视口中,从而达到这种视觉效果);言归正传,当我们想要创建一个20002000px的canvas时,我只需要创建手机屏幕宽度的canvas如375375px的canvas,当想要看到当前视口内容右侧的内容时,只需要将坐标偏移即可,注意优化点:01视口外的canvas内容可以不绘制;02可以使用第三个canvas,先缩小canvas绘制内容,然后保留像素内容,在偏移坐标时,使用第三个节点上的像素信息来偏移,结束后将最新内容替换上去

如何实现动画效果(算法)

  • 这个需求是公司的特殊需求,用来模拟投放到大屏幕上的效果,看到的同行有兴趣可以看看;编辑的节目在存储时写了一套结构来抽象节目的信息,跟虚拟dom似的
  • 举个例子一个节目中只有一个图片分区Image实例,图片分区中可以存在多个图片,其中有每个图片有停留时间和特技时间,图片特技为向左移入;
  • 那么实际大屏幕展示过程:在开始时进入第一张图片的特技时间图片从右逐渐移入到区域内,特技时间结束图片完整展示,接着到了停留时间:内容保持不动,这个停留时间时为了让用户有充足的时间浏览,然后又到了另一张图片的特技时间,如果这个分区已经绘制过一边了,但是其他分区还没有绘制过一边,那么这个分区就循环绘制下去,直到每个分区都至少播放了一遍,预览结束; 实现的思路是根据时间来计算当前元素应该绘制的内容和上一个元素的绘制内容,项目中我实现了40多个特技,简单给大家展示两个特技的实现:
// 向左移入
ledads.effectHandlers["5"] = {
  /**
   * 绘制特技,如果内部没有特别的绘制操作,直接返回变换条件
   * @param shape
   * @param effInfo
   * @returns {*}变换条件
   */
  render: function (shape, effInfo) {
    if (effInfo.time >= effInfo.timeLen) {
      effInfo.time = effInfo.timeLen;
      return
    }
    // 时间比例
    let rate = effInfo.time / effInfo.timeLen;
    // 已经移动的量
    let movedX = Math.round(rate * shape.w);
    // 当前位置
    return [{
      image: shape.backImage,
      sw: shape.w - movedX,
      dx: shape.x - movedX
    }, {
      image: shape.frontImage,
      sx: shape.x + shape.w - movedX,
      sw: movedX,
      dx: shape.x + shape.w - movedX
    }];
  }
};

// 马赛克:随机显示部分碎片内容,逐渐增多显示更多碎片内容
ledads.effectHandlers["35"] = {
  /**
   * 绘制特技,如果内部没有特别的绘制操作,直接返回变换条件
   * @param shape
   * @param effInfo
   * @returns {*}变换条件
   */
  render: function (shape, effInfo) {
    // 如果当前时间为位置0,则完全不见,故不再绘制
    if (effInfo.time <= 0) {
      effInfo.time = 0;
      return
    }
    // 时间比例
    let rate = effInfo.time / effInfo.timeLen;
    // 将区域拆分成20个碎片
    const d = 20;
    let cols = parseInt(shape.w / d);
    let rows = parseInt(shape.h / d);
    if (cols * d < shape.w) {
      cols++;
    }
    if (rows * d < shape.h) {
      rows++;
    }
    // 将分区计算成马赛克的碎片
    if (shape.shownBlocks.ts.length === 0) {
      let tmp_ts = [];
      for (let i = 0; i < rows; i++) {
        for (let j = 0; j < cols; j++) {
          tmp_ts.push({
            image: shape.frontImage,
            sx: j * d + shape.x,
            sw: d,
            sy: i * d + shape.y,
            sh: d
          });
        }
      }
      // 将碎片打散,逐渐绘制碎片的内容,达到马赛克效果
      shape.shownBlocks.ts = randomArr(tmp_ts);
    }
    let idx = parseInt(rate * cols * rows);
    let ts = [{
      image: shape.backImage
    }];
    for (let i = 0; i < idx; i++) {
      ts.push(ledads.extend(shape.shownBlocks.ts[i], {
        image: shape.frontImage
      }));
    }
    return ts;
  }
};

总结:刚开始的时候信心不足,一顿操作下来,实现起来倒并不难,都是学过的东西,这里要提醒自己和大家要多看源码、设计模式、算法,在实现过程中也没少踩坑,而且小程序canvas 2d的坑也确实多,不仅api的写法要非常严谨,限制也非常多。

这篇文章只记录了大概的结构和思路,后续有空还会更新每个图形具体的实现和一些注意点:

  • 小程序中canvas绘制内容是同步的,图片下载是异步的,如何优雅和快速的实现图片绘制?
  • 小程序中如何实现文本换行、分页、自定义字体?
  • 我的webpack 5学习之路