Skip to main content

HTML5 Canvas 全部 Konva 性能优化技巧列表

不想把时间花在处理性能问题上?申请性能评审

为什么这很重要

HTML5 canvas 在其功能范围内效率很高,Konva 内部也有许多旨在提供良好性能的功能。然而,当你的项目开始变得复杂,或者舞台上有大量形状时,性能必然会受到一定影响。

优化目标

这里的优化聚焦于两个基本原则:

  • 尽可能少计算:所有计算都需要时间完成。每一次计算可能只消耗极短时间,但成千上万甚至数百万次的计算——无论是你的代码、Konva、JavaScript,还是底层的图层——累计起来,如果那个超流畅的动画或效果出现卡顿,就能被肉眼察觉。

  • 尽可能少绘制:这是关键,因为所有绘制都有性能成本。成本可以分为两类——首先是绘制所需计算时间(上述提到的计算部分),其次是将绘制内容从内存移动到屏幕所需时间。某些情况下,还可能涉及中间的离屏合成或每像素处理。因此,原则是尽可能少绘制。

舞台(Stage)

  1. 优化舞台大小 — 遵循“尽可能少绘制”原则,避免创建过大的舞台,因为从内存传输大量字节至屏幕会产生性能负担。这里有一些针对超大舞台问题的技巧 参考此处

  2. 在移动设备上设置视口 — 缩放图像会带来重大性能打击,因此在移动端应用中,设置视口标签:<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">,以避免 Konva 输出被不必要地缩放。

  3. 在视网膜设备上使用 Konva.pixelRatio = 1 — Konva 会自动调整像素比例以保证在各种设备上绘制锐利图像。但如果视网膜设备性能不佳,可以设置 Konva.pixelRatio = 1 来减少 Konva 的缩放开销。此设置在某些情况下可能影响输出效果,请确保最终画质符合你的需求。

图层(Layers)

  1. 图层管理 — 在底层,每个 Konva 图层相当于一个独立的 HTML5 canvas 元素,这带来了实用的能力,比如只刷新变更的图层以避免重绘整个舞台所消耗的性能。但强力带来责任,每个图层都会增加额外的性能开销,因此应将图层数量保持在较低水平。

  2. 使用 layer.listening(false) — Konva 会在所有绘制的形状上监听鼠标和触摸事件,但每个监听都会消耗性能。图层中形状数量多时,Konva 需要花费大量时间来检查哪些监听器可能被触发。如果图层上的形状都不需要响应事件,可以通过 layer.listening(false) 关闭该层的事件监听,减轻负担。示例见 演示。形状部分也有类似建议。

  3. 优化拖动性能 — 拖动形状时,相关图层需在每个拖动事件周期重绘,为避免此性能成本,可拖动时将形状移动至专用图层,拖动结束再将其移回原图层。示例见 演示

形状(Shapes)

  1. 形状缓存 — Konva 内部会创建形状的图像缓存,并在需要时直接绘制该缓存。绘制图像相较于从绘制指令重组形状来说开销更小,能显著提升复杂形状或组的性能。

  2. 保持形状整洁 — 舞台上的每个形状存在都会消耗性能。为优化性能,应隐藏或从图层中移除那些已不可见(opacity=0)或超出视野的对象。

  3. 使用 shape.listening(false) — 同图层(见上文第 7 点),Konva 会监听形状事件,这存在性能成本。告知形状不监听事件可减少此开销,详见 关闭监听

  4. 关闭完美绘制 — 有时 HTML5 canvas 绘制结果可能不符合预期,详见示例 关闭完美绘制。Konva 通过完美绘制功能做了额外工作以修正,但这带来性能成本。设置 shape.perfectDrawEnabled(false) 可避免此成本,且在形状有填充、描边和不透明度的情况下不会降低质量。

  5. 优化描边绘制 — 为了实现预期绘制效果,当形状同时拥有描边和阴影时,Konva 会做额外的内部绘制。通过关闭 Konva 为描边添加的阴影,可以避免这一性能开销。

动画(Animations)

  1. 优化动画 — 避免为无视觉变化的动画步骤执行不必要的重绘。

内存(Memory)

  1. 避免内存泄漏 — Konva 已尽可能处理各种内存泄漏情况,但在创建形状和补间动画时,以及管理它们销毁时,你仍需要注意避免泄漏。

  2. 了解图层的开销。 每个图层都会分配两个 canvas:一个是使用设备像素比的场景 canvas,另一个是比例始终为 1 的命中 canvas。在视网膜屏幕上的 1920 × 1080 舞台中,这大约是 33 MB 加上 8 MB,也就是在绘制任何内容之前每个图层约占 41 MB。这就是 Konva 会在图层数量超过五个时发出警告的原因——此时仅空图层就接近 200 MB 的 canvas 内存。

  3. Mobile Safari 有严格的上限。 超过上限后,你会看到 Total canvas memory use exceeds the maximum limit,在某些设备上报告为 256 MB,在其他设备上则为 384 MB,并且 canvas 会变为空白,而不是逐步降低质量。按影响程度排序,可采取的措施是:减少图层、缩小舞台,以及设置 Konva.pixelRatio = 1——在 2× 屏幕上,这会将场景 canvas 的内存占用减少到四分之一。缓存也会计入同一内存预算,因此当节点不再可见时,请使用 node.clearCache() 释放缓存。

超大型场景

  1. 剔除屏幕外的内容。 无论节点是否位于舞台内部,Konva 都会绘制图层上的每个节点。对于远大于视口的场景,请隐藏位于视口外的节点——visible(false) 会同时跳过绘制和命中检测,其开销远低于移除节点后再重新添加。
function cull() {
const view = {
x: -stage.x() / stage.scaleX(),
y: -stage.y() / stage.scaleY(),
width: stage.width() / stage.scaleX(),
height: stage.height() / stage.scaleY(),
};

layer.children.forEach((node) => {
node.visible(Konva.Util.haveIntersection(view, node.getClientRect()));
});
}

在视图发生变化时调用它——例如平移或缩放之后——不要在每一帧都调用。getClientRect() 并非没有开销,因此对于数万个节点,请自行维护位置索引并据此进行测试,而不是向每个节点逐一查询。

  1. 拖动时跳过命中检测。 节点被拖动时,Konva 不会执行命中检测,因此拖动操作的开销较低。如果你需要知道拖动过程中指针下方有什么内容——例如高亮放置目标——可以重新启用命中检测,并接受相应的开销:
Konva.hitOnDragEnabled = true;   // default is false
  1. 优先考虑使用一个形状,而不是多个形状。 一千个节点分别拥有各自的属性、变换和命中区域,其开销远高于使用一个自定义形状,并在单个 sceneFunc 中绘制一千个对象。但这样会失去每个对象的事件和拖动功能,因此这是一种权衡,并非无条件的性能提升——请参阅自定义形状

下面的演示展示了其中一些性能优化技巧的实际应用:

import Konva from 'konva';

// 创建具有良好性能配置的舞台
const stage = new Konva.Stage({
container: 'container',
width: window.innerWidth,
height: window.innerHeight,
});

// 创建带性能优化的图层
const backgroundLayer = new Konva.Layer({ listening: false });
const mainLayer = new Konva.Layer();
const dragLayer = new Konva.Layer();

stage.add(backgroundLayer);
stage.add(mainLayer);
stage.add(dragLayer);

// 创建一个启用缓存的形状
const star = new Konva.Star({
x: 200,
y: 200,
numPoints: 6,
innerRadius: 40,
outerRadius: 70,
fill: 'yellow',
stroke: 'black',
strokeWidth: 4,
draggable: true,
perfectDrawEnabled: false, // 性能优化
});

// 缓存形状以提升性能
star.cache();

// 优化拖动性能
star.on('dragstart', () => {
star.moveTo(dragLayer);
});

star.on('dragend', () => {
star.moveTo(mainLayer);
});

// 创建关闭监听的背景形状
const rect = new Konva.Rect({
x: 0,
y: 0,
width: stage.width(),
height: stage.height(),
fill: 'lightgray',
listening: false,
});

backgroundLayer.add(rect);
mainLayer.add(star);