10

Canvas2D渲染库简析:(二)Konva | ¥ЯႭ1I0

 4 years ago
source link: https://yrq110.me/post/front-end/dive-into-2d-canvas-framework-ii-konva/?
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

与古老的Fabric相比,Konva的使用更为便捷,性能更加优益,这些得益于其内部的种种设计,本次通过以下几个方面来对其进行分析:

  • 基础元素及上下文的扩展
  • 图形变换处理(变换计算及独立的图形控制器)
  • 光标交互处理(基于像素的目标检测)
  • 层级渲染处理

系列文章

Konva.js

Konva的自我简介是:一个通过扩展2d上下文,使其功能在桌面和移动端均可交互的canvas库,包含高性能的动画、变换、节点嵌套、事件处理、分层等等。

Konva源自Eric的KineticJS项目,年龄比fabric要小一点,在19年初进行了部分重构,使用TypeScript进行了改写,走上了现代化建设的道路。现在看来虽然是用ts写了但由于要保存API的一致性,在一些奇怪的地方可以看到历史的影子。

本文所用Konva.js版本为4.1.0

基础元素及上下文扩展

元素的使用及自定义

先来从一个例子来看看它的用法

可以使用一些内置的图形元素,如矩形,圆形等等,也可以自定义图形。

自定义图形时,需要实现它的绘制方法sceneFunc,并可以通过实现hitFunc来自定义它的碰撞检测区域,后者是fabric中所没有的。

Konva中设计了多种不同的基础元素来管理canvas的层级与图形,可以使用这些元素构成一个可嵌套的图层树。

  • Stage中包含多个绘图层Layer
  • Layer中可以添加ShapeGroup元素
  • Shape为最细粒度的元素,即具体的图形对象
  • Group为容器元素,用于管理多个Shape或其他Group
  • 每个Layer在内部包含两个<canvas>元素,场景层(scene graph)与交互层(hit graph)
    • 场景层包含绘制的图形,即实际看到的图形
    • 交互层用于高性能的交互事件检测
  • 以上元素的基类均为Node

一颗Konva图形树的结构如下:

Stage
├── Layer
|   ├── Group
|       └── Shape
|   └── Group
|       ├── Shape
|       └── Group
|           └── Shape
└── Layer
    └── Shape

上下文扩展

可以使用canvas的2d上下文来操作包含样式、变换和的剪裁等属性的状态栈。Konva在上下文对象上做了一些封装,包括API的兼容性与参数处理、指定场景的属性设置等等。

API的处理:

// 直接使用
moveTo(a0, a1) {
  this._context.moveTo(a0, a1);
}
// 参数简单检查
createImageData(a0, a1) {
  var a = arguments;
  if (a.length === 2) {
    return this._context.createImageData(a0, a1);
  } else if (a.length === 1) {
    return this._context.createImageData(a0);
  }
}
// 兼容性处理
setLineDash(a0) {
  // works for Chrome and IE11
  if (this._context.setLineDash) {
    this._context.setLineDash(a0);
  } else if ('mozDash' in this._context) {
    // verified that this works in firefox
    (this._context['mozDash']) = a0;
  } else if ('webkitLineDash' in this._context) {
    // does not currently work for Safari
    (this._context['webkitLineDash']) = a0;
  }
  // no support for IE9 and IE10
}

为了SceneCanvas和HitCanvas准备特殊的Context:SceneContextHitContext

两者是绑定于Layer中SceneCanvas和HitCanvas的Context对象,继承自Context,实现了各自的_fill()_stroke()方法。如HitContext:

export class HitContext extends Context {
  _fill(shape) {
    this.save();
    this.setAttr('fillStyle', shape.colorKey);
    shape._fillFuncHit(this);
    this.restore();
  }
  _stroke(shape) {
    if (shape.hasHitStroke()) {
      this._applyLineCap(shape);
      var hitStrokeWidth = shape.hitStrokeWidth();
      var strokeWidth =
        hitStrokeWidth === 'auto' ? shape.strokeWidth() : hitStrokeWidth;
      this.setAttr('lineWidth', strokeWidth);
      this.setAttr('strokeStyle', shape.colorKey);
      shape._strokeFuncHit(this);
      if (!strokeScaleEnabled) {
        this.restore();
      }
    }
  }
}

在Canvas类中的扩展及Layer中的使用:

export class HitCanvas extends Canvas {
  hitCanvas = true;
  constructor(config: ICanvasConfig = { width: 0, height: 0 }) {
    super(config);
    this.context = new HitContext(this);
    this.setSize(config.width, config.height);
  }
}

export class Layer extends BaseLayer {
  hitCanvas = new HitCanvas({
    pixelRatio: 1
  });
}

图形变换处理

变换属性、操作与矩阵处理

与Fabric类似,也是先通过显式调用Node的变换方法或通过控制器来修改变换属性,再计算变换矩阵重新渲染。其中使用Trasnform类来管理操作与矩阵的关系。

Konva中变换属性转换为变换矩阵的过程:属性 => 变换操作 => 变换矩阵。

变换属性 => 变换操作

_getTransform(): Transform {
    var m = new Transform();
    if (x !== 0 || y !== 0) {
      m.translate(x, y);
    }
    if (rotation !== 0) {
      m.rotate(rotation);
    }
    if (scaleX !== 1 || scaleY !== 1) {
      m.scale(scaleX, scaleY);
    }
    // ...
    return m;
}

变换操作 => 变换矩阵

export class Transform {
  m: Array<number>;
  constructor(m = [1, 0, 0, 1, 0, 0]) {
    this.m = (m && m.slice()) || [1, 0, 0, 1, 0, 0];
  }
  translate(x: number, y: number) {
    this.m[4] += this.m[0] * x + this.m[2] * y;
    this.m[5] += this.m[1] * x + this.m[3] * y;
    return this;
  }
  scale(sx: number, sy: number) {
    this.m[0] *= sx;
    this.m[1] *= sx;
    this.m[2] *= sy;
    this.m[3] *= sy;
    return this;
  }
  // ...
}

图形控制器的变换处理

控制器使用独立于Node元素之外的Transformer实现

用法是:先创建一个Transformer对象,再使用**attachTo()**绑定到需要控制的Shape上。

与Fabric中的控制器相比,不仅是使用方法不同,其中的内部处理很大区别,处理过程大致如下:

首先是将控制器与节点绑定

attachTo(node) {
  this.setNode(node);
}
setNode(node) {
  // 绑定节点,清空缓存
  this._node = node;
  this._resetTransformCache();
  // 监听节点属性的变化,回调中更新控制器
  const onChange = () => {
    this._resetTransformCache();
    if (!this._transforming) {
      this.update();
    }
  };
  node.on(additionalEvents, onChange);
  node.on(TRANSFORM_CHANGE_STR, onChange);
}
update() {
  // ...
  // 更新每个控制器的位置等属性
  this.findOne('.top-left').setAttrs({
    x: -padding,
    y: -padding,
    scale: invertedScale,
    visible: resizeEnabled && enabledAnchors.indexOf('top-left') >= 0
  });
  // ...
}

其次是事件监听与变换过程

  1. 初始化时在每个控制器上添加mousedown事件监听

    _createAnchor(name) {
      var anchor = new Rect({...});
      var self = this;
      anchor.on('mousedown touchstart', function(e) {
       self._handleMouseDown(e);
      });
    }
    
  2. 触发回调时添加mousemove事件监听

    _handleMouseDown(e) {
      window.addEventListener('mousemove', this._handleMouseMove);
      window.addEventListener('touchmove', this._handleMouseMove);
    }
    
  3. 计算移动的变化量,更新需要变动的控制器位置

    _handleMouseMove(e) {
      // ...
      if (this._movingAnchorName === 'bottom-center') {
        this.findOne('.bottom-right').y(anchorNode.y());
      } else if (this._movingAnchorName === 'bottom-right') {
        if (keepProportion) {
          newHypotenuse = Math.sqrt( Math.pow(this.findOne('.bottom-right').x() - padding, 2) + Math.pow(this.findOne('.bottom-right').y() - padding, 2));
          var reverseX = this.findOne('.top-left').x() > this.findOne('.bottom-right').x() ? -1 : 1;
          var reverseY = this.findOne('.top-left').y() > this.findOne('.bottom-right').y() ? -1 : 1;
          x = newHypotenuse * this.cos * reverseX;
          y = newHypotenuse * this.sin * reverseY;
          this.findOne('.bottom-right').x(x + padding);
          this.findOne('.bottom-right').y(y + padding);
        }
      } else if (this._movingAnchorName === 'rotater') {
      // ...
    }
    
  4. 通过计算变化后的控制器位置形成的区域,得到节点需要适应的变换后区域

    _handleMouseMove(e) {
      // ...
      x = absPos.x;
      y = absPos.y;
      var width = this.findOne('.bottom-right').x() - this.findOne('.top-left').x();
      var height = this.findOne('.bottom-right').y() - this.findOne('.top-left').y();
      this._fitNodeInto(
        {
          x: x + this.offsetX(),
          y: y + this.offsetY(),
          width: width,
          height: height
        },
        e
      );
    }
    
  5. 根据这个区域计算变化后的节点尺寸与位置属性

    this.getNode().setAttrs({
      scaleX: scaleX,
      scaleY: scaleY,
      x: newAttrs.x - (dx * Math.cos(rotation) + dy * Math.sin(-rotation)),
      y: newAttrs.y - (dy * Math.cos(rotation) + dx * Math.sin(rotation))
    });
    
  6. 在下一次rAF渲染中重绘

    // src/shapes/Transformer.ts
    this.getLayer().batchDraw();
    // src/BaseLayer.ts
    batchDraw() {
      if (!this._waitingForDraw) {
        this._waitingForDraw = true;
        Util.requestAnimFrame(() => {
          this.draw();
          this._waitingForDraw = false;
        });
      }
      return this;
    }
    

交互事件处理

konva中判断光标与图形的碰撞使用了基于像素的方法,并非几何判断。

目标检测的主要流程如下:

  1. Stage::_mousedown => Stage::getIntersection

    在最上层的Stage上监听鼠标事件,根据光标位置及传入的选择器从最上层的layer中查找目标图形

    for (n = end; n >= 0; n--) {
      shape = layers[n].getIntersection(pos, selector);
      if (shape) {
        return shape;
      }
    }
    
  2. Layer::getIntersection

    // 使用INTERSECTION_OFFSETS扩展光标的范围,使其易于产生相交情况
    for (i = 0; i < INTERSECTION_OFFSETS_LEN; i++) {
      intersectionOffset = INTERSECTION_OFFSETS[i];
      // 计算得到相交对象
      obj = this._getIntersection({
        x: pos.x + intersectionOffset.x * spiralSearchDistance,
        y: pos.y + intersectionOffset.y * spiralSearchDistance
      });
      shape = obj.shape;
      // 若存在图形且包含元素选择器,则向其祖先查找,如'Group',否则直接返回图形
      if (shape && selector) {
        return shape.findAncestor(selector, true);
      } else if (shape) {
        return shape;
      }
    }
    
  3. Layer::_getInersection 目标检测中最核心的部分在这里

    // 取得hitCanvas上下文中光标位置的像素值
    var p = this.hitCanvas.context.getImageData(Math.round(pos.x * ratio), Math.round(pos.y * ratio), 1, 1).data;
    // 将rga转换为hex,与shape的colorKey比较
    var colorKey = Util._rgbToHex(p[0], p[1], p[2]);
    // shapes中包含所有添加过的图形对象,每个图形用一个随机hex颜色表示它的key
    var shape = shapes['#' + colorKey];
    // 若hit graph中当前位置的颜色与某个图形的代表颜色相同,则该图形为光标命中的对象
    if (shape) { return { shape: shape }; }
    
  4. Stage::targetShape

    得到targetShape后,就会触发各种交互事件了

    this.targetShape._fireAndBubble(SOME_MOUSE_EVENT, { evt: evt, pointerId });
    

要达到通过比较hit graph上光标位置与代表图形key的像素值是否相同来判断是否命中的目的,需要事先在layer的HitCanvas上画出Shape对象的hit graph,在这一部分做了以下工作:

  • 在创建图形时,生成该图形的唯一key,即随机颜色

    // 生成唯一key
    while (true) {
      key = Util.getRandomColor();
      if (key && !(key in shapes)) { break; }
    }
    // 保存颜色,用于之后的hit graph绘制
    this.colorKey = key;
    // 将该对象保存在shapes对象中,用于目标检测时的查询
    shapes[key] = this;
    
  • 当将图形添加到layer上后,执行layer.draw()时会绘制它的SceneCanvas和HitCanvas

    // Layer::draw() => Node::draw()
    draw() {
      this.drawScene();
      this.drawHit();
      return this;
    }
    // Layer::drawHit() => Container::drawHit(), Container继承自Node,实现了抽象类drawHit()
    this._drawChildren(canvas, 'drawHit', top, false, caching, caching);
    // Container::_drawChildren()
    this.children.each(function(child) {
      // 在每一个子元素上执行drawHit(),子元素为Shape或Group类型
      child[drawMethod](canvas, top, caching, skipBuffer);
    });
    // Shape::drawHit
    drawHit(can) {
      // 获取内置或自定义Shape对象中实现的_hitFunc或_sceneFunc
      var drawFunc = this.hitFunc() || this.sceneFunc();
      context.save(); // 这里的context为HitContext对象
      layer._applyTransform(this, context, top);
      drawFunc.call(this, context, this);
      context.restore();
    }
    

此时还有一个问题,就是在绘制HitCanvas时并没有体现出使用了colorKey的颜色去绘制,其实这个fillStyle的设置操作在之前出现过,在HitContext类中:

export class HitContext extends Context {
  _fill(shape) {
    this.save();
    // 在这里设置hit graph的填充样式
    this.setAttr('fillStyle', shape.colorKey);
    shape._fillFuncHit(this); // => this.fill()
    this.restore();
  }
}

元素渲染处理

以在Stage上添加一个Layer和一个Shape为例,来看看层级渲染的处理。

在界面上显示一个图形可以用下面步骤:

  1. 创建一个Stage let stage = new Konva.Stage()
  2. 创建一个Layer let layer = new Konva.Layer()
  3. 创建一个Shape let box = new Konva.Rect()
  4. 在Layer上添加Shape layer.add(box)
  5. 在Stage上添加Layer stage.add(layer)

之后就会看到一个矩形显示在界面上。

若此时在layer上添加了新的图形: layer.add(new_box),可以看到新的图形并没有展示出来,需要在执行一次layer.draw()。如果在上面步骤的基础上修改次序,要达到同样的效果,就变成了:

  1. 创建一个Stage let stage = new Konva.Stage()
  2. 创建一个Layer let layer = new Konva.Layer()
  3. 在Stage上添加Layer stage.add(layer)
  4. 创建一个Shape let box = new Konva.Rect()
  5. 在Layer上添加Shape layer.add(box)
  6. 执行Layer的绘制 layer.draw()

Stage的add方法中,绘制了layer内容,并将layer的SceneCanvas元素插入到DOM树中

add(layer) {
  // 在父类Container中处理layer的当前父子关系等
  super.add(layer);
  // 设置当前尺寸
  layer._setCanvasSize(this.width(), this.height());
  // 绘制layer中的内容
  layer.draw();
  // 将Canvas元素插入到DOM树中
  if (Konva.isBrowser) {
    // 这里仅添加了SceneCanvas,而没有添加HitCanvas
    this.content.appendChild(layer.canvas._canvas);
  }
}

Layer并没有实现自身的add方法,默认执行Container中的add方法

add(...children: ChildType[]) {
   var child = arguments[0];
   // 1. 处理父子关系,若已有父辈,则"领养"
   if (child.getParent()) {
     child.moveTo(this);
     return this;
   }
   var _children = this.children;
   // 2. 验证child可用性,该方法为子类实现
   this._validateAdd(child);
   child.index = _children.length;
   child.parent = this;
   // 3. 保存到children数组中
   _children.push(child);
}

关于Layer的draw()方法的执行在上面目标检测的部分刚刚提到过,会依次执行children中每个child的相关绘制方法。

需要注意一点的是: 当在Stage对象上执行draw()时,会清空并重绘所有Layer的内容,这是由于Layer作为Stage的child,在执行它的drawScene方法时会根据其clearBeforeDraw属性(默认为true)来清空内容,之后再执行绘制。

// src/Layer.ts
drawScene(can, top) {
  var layer = this.getLayer(),
    canvas = can || (layer && layer.getCanvas());  
  if (this.clearBeforeDraw()) {
    canvas.getContext().clear();
  }
  Container.prototype.drawScene.call(this, canvas, top);
  return this;
}

这样应该就明白了,在layer上添加图形时并没有实际执行绘制,因此当layer包含的图形变化时需要手动执行draw()才有效果,而将layer添加到stage时,stage的内部自动执行了Layer对象的draw(),因此不需要显式的调用。

Konva的主要模块虽然也是多年前的设计,但个人觉得模块化做的较Fabric更好,不管是更灵活的层级管理还是组件自定义的方面。其次,由于使用ts进行了重写,并得益于编辑器与代码辅助工具,不管是阅读源码还是使用都较为方便。

由于自身在实现业务时是写的原生,某些部分的实现与这些框架的思路也不谋而合,不过更多的地方还是框架们设计的好,值得借鉴的地方很多。


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK