Canvas2D渲染庫簡析:(二)Konva

與古老的Fabric相比,Konva的使用更爲便捷,性能更加優益,這些得益於其內部的種種設計,本次經過如下幾個方面來對其進行分析:html

  • 基礎元素及上下文的擴展
  • 圖形變換處理(變換計算及獨立的圖形控制器)
  • 光標交互處理(基於像素的目標檢測)
  • 層級渲染處理

系列:node

Konva.js

Konva的自我簡介是:一個經過擴展2d上下文,使其功能在桌面和移動端都可交互的canvas庫,包含高性能的動畫、變換、節點嵌套、事件處理、分層等等。git

Konva源自Eric的KineticJS項目,年齡比fabric要小一點,在19年初進行了部分重構,使用TypeScript進行了改寫,走上了現代化建設的道路。如今看來雖然是用ts寫了但因爲要保存API的一致性,在一些奇怪的地方能夠看到歷史的影子。github

本文所用Konva.js版本爲4.1.0web

基礎元素及上下文擴展

元素的使用及自定義

先來從一個例子來看看它的用法canvas

See the Pen konva-base-and-custom-elements by yrq110 (@yrq110) on CodePen. 數組

可使用一些內置的圖形元素,如矩形,圓形等等,也能夠自定義圖形。緩存

自定義圖形時,須要實現它的繪製方法sceneFunc,並能夠經過實現hitFunc來自定義它的碰撞檢測區域,後者是fabric中所沒有的。bash

基礎元素

Konva中設計了多種不一樣的基礎元素來管理canvas的層級與圖形,可使用這些元素構成一個可嵌套的圖層樹。app

其中:

  • 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實現

See the Pen konva-control by yrq110 (@yrq110) on CodePen.

用法是:先建立一個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進行了重寫,並得益於編輯器與代碼輔助工具,不論是閱讀源碼仍是使用都較爲方便。

因爲自身在實現業務時是寫的原生,某些部分的實現與這些框架的思路也不謀而合,不過更多的地方仍是框架們設計的好,值得借鑑的地方不少。

參考

相關文章
相關標籤/搜索