如何绘制高性能的 K 线图

avatar
@雪球财经

目前,虽然前端社区已经有很多优秀的可视化图表库如 ECharts、AntV 等,但对于行情图这类高度定制化的业务需求,还是需要前端同学自己动手实现。很多同学对于图形可视化技术都不太了解,一旦脱离了开源库,还是会陷入迷茫,只能依靠网上相对较少的资料,慢慢摸索、一路踩坑地完成需求。本篇文章将介绍我们在雪球行情图项目中,实际应用到的 canvas 性能优化方案,希望对大家有所帮助。

图片

上证指数日 K 线图-雪盈证券

优化绘图指令

K 线是以每个分析周期的开盘价、最高价、最低价和收盘价绘制而成。以绘制日 K 线为例,首先确定开盘和收盘的价格,它们之间的部分画成矩形实体。如果收盘价格高于开盘价格,则 K 线被称为阳线,用空心的实体表示。反之则称为阴线用实心实体表示。在国内股票和期货市场 ,通常用红色表示阳线,绿色表示阴线(但涉及到欧美股票及外汇市场的投资者应该注意:在这些市场上通常用绿色代表阳线,红色代表阴线,和国内习惯刚好相反)。用较细的线将最高价和最低价分别与实体连接。最高价和实体之间的线被称为上影线,最低价和实体间的线称为下影线。

图片

蜡烛图

通过观察图形结构,我们很自然的可以将之分为两段垂直直线和一个矩形,因此可以分三段绘制。

  • 绘制从最高价到收盘价或开盘价较大值的直线(上影线);

  • 绘制收盘价与开盘价组成的空心线框/实心矩形(实体);

  • 绘制从最低价到收盘价或开盘价较大值的直线(下影线)。

这种画法看起来很清晰明了,但 canvas 是一个指令式的绘图系统,是通过改变 context 的若干状态来实现图形的渲染。比如调用 stroke 来绘制线段,线段宽度和颜色是取决之前设置的 lineWidth 和 strokeStyle。

var canvas = document.getElementById("canvas");
var ctx = canvas.getContext("2d");
ctx.lineWidth = 2;
ctx.strokeStyle = "#000";
ctx.beginPath();
ctx.moveTo(1010);
ctx.lineTo(5010);
ctx.stroke();
ctx.closePath();

我们可以看到绘制线段就需要调用很多指令,而相应的绘图指令越多,性能消耗就越大,因此我们要尽可能的优化绘图指令,避免不必要的画布状态改变。仔细观察图形我们可以发现两段直线 X 轴相同,其实可以缩减为一条直线,而空心线框也可以理解为填充背景色的实心矩形,那么我们新的方案就变成了:

  • 绘制连接最高价到最低价的黑色直线;

  • 绘制收盘价与开盘价组成的描边实心矩形。

这样我们每个蜡烛都缩减了一次画直线的过程,也比较符合 K 线的语义。

图片

另一方面我们知道 K 线有涨有跌,每根 K 线的属性都会不同,如果我们按照顺序从左往右依次画这些红绿相间的蜡烛,每次都要去修改画笔,而且需要切换绘图形状。那么我们是否可以考虑,在绘制开始前,将图形数据进行分组,每次只画相同属性的图形,把图形的渲染开销转移到计算上。我们可以尝试下面这种画法:

  • 根据红绿将数据分为两组;

  • 设置上涨图形属性,遍历上涨(收平)蜡烛,依次画出上涨的图形;

  • 设置下跌图形属性,遍历下跌蜡烛,依次画出下跌的图形。

图片

这样我们只改变了两次画笔就实现了绘图,相比原始的每次遍历都要修改大大降低了修改图形指令带来的渲染开销。当然示例中的这种画法对基本图元做了拆分,可能会影响产品交互形态,具体绘制时还是需要结合产品需求来进行绘制。通过例子我们需要了解的是,改变 context 的属性并非是完全无代价的,我们可以规划绘图 API 的调用顺序,达到减少绘图指令的效果。

合理使用缓存

图片

通过改变绘图顺序和画法来减少绘图指令提升渲染性能,但我们重绘的时候还是会重新调用绘图指令。如果我们可以将绘制过的图像缓存下来,在绘制的时候直接调用 drawImage 绘制图像是不是可以更好的提升渲染性能呢。

// 获取真实节点 canvas,获取 context
const realCanvas = document.getElementById("canvas");
const realCtx = canvas.getContext("2d");
// 创建节点 bufferCanvas,获取 context,也可以使用 OffscreenCanvas;
const cacheCanvas = document.createElement("canvas");
const cacheCtx = cacheCanvas.getContext("2d");
// 在 cacheCanvas 中绘制图形;
drawKlineShapes(cacheCtx, klineShapes[i]);
// 使用 drawImage 复制图形到真实节点内,图形被一次性绘制到页面上;
realCtx.drawImage(cacheCanvas, 00);
// 更新时只需调用 drawImage 实现重绘
requestAnimationFrame(() => {
realCtx.clearRect(00, realCanvas.width, realCanvas.height);
realCtx.drawImage(cacheCanvas, 00);
});

但需要我们注意的是,如果产品需求要不停地创建和销毁图形,还是要慎重使用离屏渲染,因为离屏渲染其实也是执行了绘图过程,会占用浏览器资源。使用缓存的好处不仅仅可以减少绘图指令优化渲染,在绘制 K 线的时候还有一个关键的应用是利用 drawImage 的裁剪功能实现图形的拖拽加载、缩放等功能。

// 设置 cacheCanvas 的大小,如果有拖拽和缩放,应该比 realCanvas 大。
cacheCanvas.width = 10000;
cacheCanvas.height = 10000;
// 初始切片,这里以取中心区域为例
var x = cacheCanvas.width / 2 - realCanvas.width / 2;
var y = cacheCanvas.height / 2 - realCanvas.height / 2;
var w = realCanvas.width;
var h = realCanvas.height;
// 初始绘图,裁剪出 cacheCanvas 图形中间部分图形
context.drawImage(cacheCanvas, x, y, w, h);
// 监听 onmousedown,onmousemove,onmouseup 事件实现拖拽
// onmousedown 记录鼠标按下的地址 mouseP(mx, my),且设置移动 flag=true
// onmousemove 当移动 flag 为 true 时,获取 newMouseP(newX, newY),计算出 drawImage 需要的参数
// onmouseup 将移动 flag 置为 false
// 计算坐标,裁剪出移动之后的图形
realCtx.drawImage(cacheCanvas, x + (newX - mX), y + (newY - mY), w, h, 00, w, h);
// 监听 onmousewheel,通过 deltaY 计算缩放系数 s,实现缩放
realCtx.drawImage(bufferCanvas, x, y, w / s, h / s, 00, w, h);

除此之外,我们还可以运用缓存来实现图形的拾取,因为离屏的图形是隐藏的,我们可以根据图形索引转换出一个十六进制色值作为图形颜色来进行绘图,点击操作时通过 getImageData API 获取选中图形的色值,再转换成图形索引从而获取到选中的图形。

// 绘制显示的图形
drawShapes(shapesArray);
// 绘制隐藏的图形
drawCacheShapes(shapesArray);
// 获取缓存画布的数据
var cacheImageData = cacheContext.getImageData(00, width, height);
// 监听点击事件,获取色值对应图形索引
canvas.onClick = function(ev) {
 var point = getPoint(ev.clientX, ev.ClientY);
 var color = getCacheColor(point);
 var index = colorToNumber(color);
 var shape = shapesArray[index];
}

这种拾取方法实现起来比较简单,而且选取精确。但因为绘制了两遍图形,渲染开销会比较大。而且 getImageData 的性能和画布大小也有关系,所以在较为复杂的画布中就很少使用这种方法。

分层渲染

在大部分场景中,我们绘制出静态图形之后,还需要频繁地更新和重绘来响应用户的操作,但是对于大部分图形来说,他们其实是不变的,我们可能只有在数据更新后才需要重绘一次,绝对没有必要每次移动鼠标就重绘一次所有图形。那这种情形我们就可能要用到分层渲染。

canvas 实现分层渲染实现起来就很简单。我们可以根据画布渲染频率创建多个 canvas,将不变的元素绘制在 background-layer 中,变化的元素绘制在 ui-layer 中。绘制 K 线的时候就可以坐标轴轴线绘制在 background-layer, K 线绘制在 main-layer,而交互产生的图形就可以绘制在 ui-layer 上。

<div id="stage">
 <canvas id="ui-layer" width="480" height="320"></canvas>
 <canvas id="main-layer" width="480" height="320"></canvas>
 <canvas id="background-layer" width="480" height="320"></canvas>
</div>
 canvas { position: absolute; }
 #ui-layer { z-index3 }`
 #main-layer { z-index2 }
 #background-layer { z-index1 }

比如我们实现鼠标悬浮 K 线展示 K 线具体信息,并高亮选中 K 线这一交互的时候,我们无需改变 background 和 main-layer,只需要在 ui-layer 上响应鼠标事件绘制出十字线和高亮的那一根 K 线,鼠标移动过程中也只需擦除和重绘 UI 层图形。

分层渲染主要用来解决静态图形重新渲染的问题,但对一些动静混合的复杂场景来说,分层就不太好实现了,我们就需要动态划分重绘区域来实现局部重绘。

小结

canvas 绘图功能很强大,当我们要绘制的图形不太大时,元素不太多的时候大多触摸不到绘制瓶颈,但仍需我们对绘图过程保持谨慎,对 K 线图这类稍有复杂度的图形,不同的绘图顺序都会对渲染性能造成很大影响。对于分分钟几百万上下的股民来说,行情图的卡顿就很影响用户体验,这次分享主要讲述了通过优化绘图指令来提升渲染性能,通过分层绘制和缓存绘制来优化图形的重绘,更多的性能优化指南可以参考 MDN canvas 的优化,也欢迎大家通过访问雪球 web 端或者下载雪盈 PC 端来体验雪球的行情功能。

还有一件事

雪球业务正在突飞猛进的发展,工程师团队期待牛人的加入。如果你对「做中国人首选的在线财富管理平台」感兴趣,希望你能一起来添砖加瓦,点击「阅读原文」查看热招职位,就等你了。

热招岗位:大前端架构师、Android/iOS/FE 工程师、推荐算法工程师、Java 开发工程师。