跳到主要内容

HTML5 Canvas Konva 全部性能技巧列表

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

为什么这很重要

HTML5 Canvas 可以高效完成绘制工作,Konva 内部也有许多用于提高性能的功能。但是,随着项目复杂度增加或舞台上的图形数量增多,性能必然会受到影响。

优化目标

这里的优化主要遵循两条通用规则:

  • 尽量减少计算:所有计算都需要时间。单次计算可能只需极短时间,但你的代码、Konva、JavaScript 和更底层代码产生的数千或数百万次计算会不断累积。如果原本流畅的动画或效果出现卡顿,人眼就能察觉这种影响。

  • 尽量减少绘制:所有绘制都有性能成本,因此这条规则很重要。成本分为两类:一类是上一点介绍的绘制计算,另一类是将绘制内容从内存传输到屏幕。某些情况下,还会有中间的离屏合成或逐像素处理。因此,请尽量减少绘制。

舞台

  1. 优化舞台尺寸——按照“尽量减少绘制”的规则,请避免创建大型舞台。将大量字节从内存传输到屏幕会降低性能。此处提供了一些解决超大舞台问题的替代方法。

  2. 在移动端设置视口——缩放图像会明显降低性能。因此,请为移动端应用设置视口:<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">。这样可以避免对 Konva 输出进行不必要的缩放。

  3. 在 Retina 设备上使用 Konva.pixelRatio = 1——Konva 会自动调整像素比,从而在所有设备上清晰渲染。如果 Retina 设备的性能不佳,请设置 Konva.pixelRatio = 1,以减少 Konva 的缩放工作。在某些情况下,此设置可能影响输出,因此请确认结果质量符合要求。

图层

  1. 图层管理——在内部,每个 Konva 图层都是一个单独的 HTML5 Canvas 元素。这样可以只刷新发生变化的图层,从而避免刷新整个舞台的性能成本。但是,每个图层都会增加性能开销,因此应尽量减少图层数量。

  2. 使用 layer.listening(false)——Konva 为绘制的所有图形提供鼠标和触摸事件监听器。每个监听器都有性能成本。对于包含许多图形的图层,Konva 必须花费大量周期检查可能触发的监听器。如果图层中的所有图形都不需要响应事件,请设置 layer.listening(false) 以消除此开销。请参阅示例。图形部分也有类似技巧。

  3. 优化拖动成本——在图层上拖动图形时,每次移动事件循环都必须重绘该图层。为避免此性能成本,请在拖动时将图形移动到专用图层,并在拖动结束时将其移回原图层。请参阅示例

图形

  1. 图形缓存——Konva 在内部创建图形的图像,并在需要绘制图形时使用该图像。绘制图像可以避免按照绘制指令组合图形的开销,并能明显提高复杂图形和组的性能。

  2. 整理图形——舞台中的每个图形都会产生存在成本。要优化性能,请隐藏或从图层中移除不可见、不透明度为 0 或移出视野的对象。

  3. 使用 shape.listening(false)——与图层一样(请参阅上文第 7 点),Konva 会检查何时应为图形触发事件,这会产生性能成本。让图形停止监听事件可以降低此成本,详情请参阅禁用监听

  4. 禁用完美绘制——在某些情况下,HTML5 Canvas 的绘制结果可能与预期不同。有关示例,请参阅禁用完美绘制。Konva 使用完美绘制功能执行额外工作以修正结果,但这会产生性能成本。当图形同时具有填充、描边和不透明度时,设置 shape.perfectDrawEnabled(false) 可以避免此成本,并且不会降低输出质量。

  5. 优化描边绘制——当图形同时具有描边和阴影时,Konva 会执行一次额外的内部绘制,以获得符合预期的结果。禁用 Konva 为描边添加的阴影,可以避免此性能负担。

动画

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

内存

  1. 避免内存泄漏——Konva 会处理许多可能产生内存泄漏的情况,但你仍需正确创建和移除图形与补间。

  2. 了解一个图层的开销。 每个图层都会分配两块 canvas:一块按设备像素比创建的场景 canvas,以及一块像素比始终为 1 的命中检测 canvas。在 Retina 屏幕上,一个 1920 × 1080 的舞台大约是 33 MB 加 8 MB,也就是在绘制任何内容之前每个图层约 41 MB。这正是 Konva 在图层超过五个时发出警告的原因——到那时,仅仅是空图层就已经接近 200 MB 的 canvas 内存。

  3. 移动版 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';

// Create stage with good performance settings
const stage = new Konva.Stage({
container: 'container',
width: window.innerWidth,
height: window.innerHeight,
});

// Create layers with performance optimizations
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);

// Create a shape with caching
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, // performance optimization
});

// Cache the shape for better performance
star.cache();

// Optimize dragging performance
star.on('dragstart', () => {
star.moveTo(dragLayer);
});

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

// Create background with listening disabled
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);