画布编程的基本模式
画布基本介绍
我开发过基于QT的客户端程序、基于C# WinForm客户端,开发过Java后端服务,此外,前端VUE和React我也开发过不少。对应我所开发过的东西,比起一行一行冰冷的代码,我更加迷恋哪些能够直观的,可视化的东西。还记得以前在开发C#的时候,接触过一个的C# WinForm库NetronGraphLib,这个库能够让我们轻松的构建属于自己的流程图绘制软件,让我们能够以拖拉拽的方式来构建图(下图就是NetronGraphLib库的官方示例应用Cobalt):
当年看到这个库的时候,极大的震撼了作为开发菜鸟(现在也是= - =)的我。同时,这个库开源免费,他还有一个轻量级Light版本也是开源的。迫于对这种UI的迷恋,我从Light版入手,深入研究了它的实现原理。尽管是C#编写的一个库,但是它内在的实现原理以及思想确实很通用的,对于我来说都是有革新意义的,以至于这么多年以来,我都会时常回忆起这个库。
这个库原理并不复杂,就是通过C# GDI+来进行图像的绘制。也许读者没有开发过C#,不知道所谓的GDI+是什么。简单来讲,很多开发语言都提供所谓的画布以及绘制能力(比如html5中的canvas标签,C#中的Graphics对象等)。在画布上,你能够通过相关绘图API来绘制各种各样的图形。上图的流程图中,你所看到的矩形、线段等等,都是通过画布提供的绘制功能来实现的。
简单绘制
以下的代码就是C# 对一个空白的窗体绘制一个红色矩形:
1 |
|
显示的效果如下:
以下的代码就是HTML5 Canvas 上获取Context对象,利用Context对象的API来绘制一个矩形:
1 |
|
实现的效果如下(黑色边框是为了便于看到画布的边界加上的):
为了方便后续的实现,以及适应目前的Web前端化,我们使用html 5 的canvas来进行代码编写、演示。
画布编程的基本模式
为了讲解画布编程的基本模式,接下来我们将以鼠标悬浮矩形,矩形边框变色场景为例来进行讲解。对于一个矩形,默认的情况下显示黑色边框,当鼠标悬浮在矩形上的时候,矩形的边框能够显示为红色,就像下图一样:
那么如何实现这个功能呢?
要回答这个问题,我们首先要明白一组基本概念:输入(input)—更新(update)—渲染(render),而这几个操作,都会围绕**状态(status)**进行:
- 输入会触发更新
- 更新会修改状态
- 渲染读取最新的状态进行图像映射
事实上,渲染和输入、更新是解耦的,它们之间只会通过状态来建立关联:
状态整理与提炼
将上述的概念应用到悬浮变色这个场景,我们首先需要整理并提炼有哪些状态。
整理状态最直接的方式,就是看所实现的效果需要哪些UI元素。悬浮变色的场景下,需要的东西很简单:
- 矩形位置
- 矩形大小
- 矩形边框颜色
整理完成以后,我们还需要进行提炼。有的读者可能会说,上述整理的东西已经足够了,还需要提炼什么呢?事实上提炼的过程是通用化的过程,是划清状态与渲染界限的过程。对于1、2来说,无需过多讨论,它们是核心渲染基础,再简单的图像渲染,都离不开position和size这两个核心的元素。
但对于矩形边框颜色是不是状态,则需要探讨。在我看来,应该属于渲染的范畴,不属于状态的范畴。为什么这么来理解呢?因为颜色变化的根本原因是鼠标悬浮,鼠标是否悬浮在矩形上,是矩形的固有属性,在正常的情况下,鼠标和矩形发生交互,必然有是否悬浮这一情形;但是悬浮的颜色却不是固有属性,在这个场景中,指定了悬浮的颜色是红色,但是换一个场景,可能又需要蓝色。“流水线的颜色,铁打悬浮”。
经过上述的讨论,我们得到这个画布的状态:一个包含位置与大小,以及标识是否被鼠标悬浮的标志。在JS中,代码如下:
1 |
|
输入与更新
找到更新点
完成对状态的整理提炼后,我们需要知道哪些部分是对状态的更新操作。在这个场景中,只要鼠标坐标在矩形区域内,那么我们就会修改矩形的hover为true,否则为false。用伪代码进行描述:
1 |
|
也就是说,我们接下来需要需要考虑“鼠标在矩形区域内”这个条件成立与否。在canvas中,我们需要知道如下的几个数据:矩形的位置、矩形的大小以及鼠标在canvas中的位置,如下图所示:
只要满足如下的条件,我们就认为鼠标在矩形内,于是就会发生状态的更新:
1 |
|
找到输入点
更新是如何触发的呢?我们现在知道,矩形的位置与大小是已有的值。那么鼠标在canvas中的x、y怎么获得呢?事实上,我们可以给canvas添加鼠标移动事件(mousemove),从移动事件中获取鼠标位置。当事件被触发时,我们可以获取鼠标相对于 viewport(什么是viewport?)的坐标(event.clientX
和event.clientY
,这两个值并不是直接就是鼠标在canvas中的位置)。 同时,我们可以通过 canvas.getBoundingClientRect() 来获取 canvas 相对于 viewport 的坐标(top, left
),这样我们就可以计算出鼠标在 canvas 中的坐标。
注意:下图的canvas.left可能产生误导,canvas没有left,是通过调用canvas的getBoundingClientRect,获取一个boundingClientRect,再获取这个rect的left。
为了后续的代码编写,我们准备一个index.html:
1 |
|
同级目录下的index.js:
1 |
|
用浏览器打开index.html
,在控制台就能看到坐标输出:
PS:实际上在对canvas有不同的缩放、CSS样式的加持下,坐标的计算会更加复杂,本文只是简单的获取鼠标在canvas中的坐标,不做过多的讨论,想要深入了解可以看这篇大佬的文章:获取鼠标在 canvas 中的位置 - 一根破棍子 - 博客园 (cnblogs.com)。
整合输入以及状态更新
综合上述的讨论,我们整合目前的信息,有如下的JS代码:
1 |
|
渲染
在上一节,我们已经实现了这样的效果:鼠标不断在canvas上进行移动,移动的过程中,鼠标在矩形外部移动的时候,控制台会不断的输出文本:mouse in rect: false
,而当鼠标一旦进入了矩形内部,控制台则会输出:mouse in rect: true
。那么如何将rect的布尔属性hover,转换为我们能够看到的UI图像呢?通过canvas的CanvasRenderingContext2D类实例的相关API来进行绘制即可:
1 |
|
对于strokeStyle,根据我们的需求,我们需要判断rect的hover属性来决定实际的颜色是红色还是黑色:
1 |
|
为了后续调用的方便,我们将绘制操作封装为一个方法:
1 |
|
在这个方法中,ctx调用了save和restore。关于这两个方法含义以及使用方式,请参考:
- CanvasRenderingContext2D.save() - Web API 接口参考 | MDN (mozilla.org)
- CanvasRenderingContext2D.restore() - Web API 接口参考 | MDN (mozilla.org)
完成方法封装以后,我们需要该方法的调用点,一个最直接的方式就是在鼠标移动事件处理的内部进行:
1 |
|
编写好代码以后,目前的index.js的整体内容如下:
1 |
|
效果如下:
渲染的时机
细心的读者发现了这个演示中的问题:将鼠标从canvas的外部移动进入,在初始的情况下,canvas中并没有矩形显示,只有在鼠标移动进入canvas以后才显示。原因也很容易解释:在触发mousemove事件后,渲染(drawRect调用)才开始。
要解决上述问题,我们需要明确一点:**一般情况下,图像渲染应该和任何的输入事件独立开来,输入事件应只作用于更新。**也就是说,上面的(drawRect)调用,不应该和mousemove事件相关联,而是应该在一套独立的循环中去做:
那么,在JS中,我们可以有哪些循环调用方法的方式来完成我们图像的渲染呢?在我的认知中,主要有以下几种:
while类循环,包括for等循环控制语句类
1 |
|
弊端:极易造成CPU高占用的卡死问题
setInterval
1 |
|
弊端:当render()的调用超过interval间隔的时候,会发生调用丢失的问题;此外,无论canvas是否需要渲染,都会进行调用渲染。
setTimeout
1 |
|
弊端:同上,无论canvas是否需要渲染,都会调用,造成资源浪费。
requestAnimationFrame
关于这个API的基本使用以及原理,请参考这篇大神的详解:你知道的requestAnimationFrame - 掘金 (juejin.cn)。
简单来讲,requestAnimationFrame(callbackFunc),这个API调用的时候,只是告诉浏览器,我在请求一个操作,这个操作是在动画帧渲染发生的时候进行的,至于什么时候发生的动画帧渲染交由浏览器底层完成,但通常,这个值是60FPS。所以,我们的代码如下:
1 |
|
必要的画布清空
目前为止这份代码还有一个问题:我们一直在不断循环调用drawRect方法在指定位置绘制矩形,但是我们从来没有清空过画布,也就是说我们不断在一个位置画着矩形。在本例中,这问题凸显的效果看出不出,但是试想如果我们在输入更新的时候,修改了矩形的x或y值,就会发现画布上会有多个矩形图像了(因为上一个位置的矩形已经被“画”在画布上了)。所以,我们需要在开始进行图像绘制的时候,进行清空:
1 |
|
1px线条模糊
目前为止这份代码还还有一个问题:默认的情况下,我们的线条宽度为1px。但实际上,我们画布上的显示的确实一个模糊的看起来比1px更加宽的线条:
这个问题产生的原因读者可以自行网上搜索。这里直接给出解决方案就是,在线宽1px的情况下,线条的坐标需要向左或者向右移动0.5像素,所以对于之前的drawRect中,绘制的时候将x和y进行0.5像素移动:
1 |
|
修改之后,效果如下:
总结
画布编程的模式:
悬浮变色代码
index.html
1 |
|
index.js
1 |
|
GitHub
w4ngzhen/canvas-is-everything (github.com)
01_hover