画布就是一切(一)— 画布编程的基本模式
画布基本介绍
我开发过基于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# 对一个空白的窗体绘制一个红色矩形:
/// <summary>
/// 窗体绘制事件,由WinForm窗体消息事件框架调用
/// </summary>
private void Form1_Paint(object sender, PaintEventArgs e)
{
// 绘制事件中获取图形画布对象
Graphics g = e.Graphics;
// 调用API在当前窗体的 x = 10, y = 10 位置绘制一个
// width = 200, height = 150 的矩形
g.DrawRectangle(new Pen(Color.Red), 10, 10, 200, 150);
}
显示的效果如下:
以下的代码就是HTML5 Canvas 上获取Context对象,利用Context对象的API来绘制一个矩形:
<body>
<canvas id="myCanvas"
style="border: 1px solid black;"
width="200"
height="200" />
<script>
// 获取画布的上下文
let ctx =
document.getElementById('myCanvas').getContext('2d');
// 设置绘制的画笔颜色
ctx.strokeStyle = '#FF0000';
// 描边一个矩形
ctx.strokeRect(10, 10, 100, 80);
</script>
</body>
实现的效果如下(黑色边框是为了便于看到画布的边界加上的):
为了方便后续的实现,以及适应目前的Web前端化,我们使用html 5 的canvas来进行代码编写、演示。
画布编程的基本模式
为了讲解画布编程的基本模式,接下来我们将以鼠标悬浮矩形,矩形边框变色场景为例来进行讲解。对于一个矩形,默认的情况下显示黑色边框,当鼠标悬浮在矩形上的时候,矩形的边框能够显示为红色,就像下图一样:
那么如何实现这个功能呢?
要回答这个问题,我们首先要明白一组基本概念:输入(input)—更新(update)—渲染(render),而这几个操作,都会围绕状态(status)进行:
- 输入会触发更新
- 更新会修改状态
- 渲染读取最新的状态进行图像映射
事实上,渲染和输入、更新是解耦的,它们之间只会通过状态来建立关联:
状态整理与提炼
将上述的概念应用到悬浮变色这个场景,我们首先需要整理并提炼有哪些状态。
整理状态最直接的方式,就是看所实现的效果需要哪些UI元素。悬浮变色的场景下,需要的东西很简单:
- 矩形位置
- 矩形大小
- 矩形边框颜色
整理完成以后,我们还需要进行提炼。有的读者可能会说,上述整理的东西已经足够了,还需要提炼什么呢?事实上提炼的过程是通用化的过程,是划清状态与渲染界限的过程。对于1、2来说,无需过多讨论,它们是核心渲染基础,再简单的图像渲染,都离不开position和size这两个核心的元素。
但对于矩形边框颜色是不是状态,则需要探讨。在我看来,应该属于渲染的范畴,不属于状态的范畴。为什么这么来理解呢?因为颜色变化的根本原因是鼠标悬浮,鼠标是否悬浮在矩形上,是矩形的固有属性,在正常的情况下,鼠标和矩形发生交互,必然有是否悬浮这一情形;但是悬浮的颜色却不是固有属性,在这个场景中,指定了悬浮的颜色是红色,但是换一个场景,可能又需要蓝色。“流水线的颜色,铁打悬浮”。
经过上述的讨论,我们得到这个画布的状态:一个包含位置与大小,以及标识是否被鼠标悬浮的标志。在JS中,代码如下:
let rect = {
x: 10,
y: 10,
width: 80,
height: 60,
hovered: false
}
输入与更新
找到更新点
完成对状态的整理提炼后,我们需要知道哪些部分是对状态的更新操作。在这个场景中,只要鼠标坐标在矩形区域内,那么我们就会修改矩形的hover为true,否则为false。用伪代码进行描述:
if(鼠标在矩形区域内) {
rect.hover = true; // 更新状态
} else {
rect.hover = false; // 更新状态
}
也就是说,我们接下来需要需要考虑“鼠标在矩形区域内”这个条件成立与否。在canvas中,我们需要知道如下的几个数据:矩形的位置、矩形的大小以及鼠标在canvas中的位置,如下图所示:
只要满足如下的条件,我们就认为鼠标在矩形内,于是就会发生状态的更新:
(x <= xInCanvas && xInCanvas <= x + width)
&&
(y <= yInCanvas && yInCanvas <= y + height)
找到输入点
更新是如何触发的呢?我们现在知道,矩形的位置与大小是已有的值。那么鼠标在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:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Hover Example</title>
</head>
<body>
<canvas id="myCanvas"
style="border: 1px solid black"
width="450"
height="200"></canvas>
<!-- 同级目录下的index.js -->
<script src="index.js"></script>
</body>
</html>
同级目录下的index.js:
// 同级目录的index.js
let canvasEle = document.querySelector('#myCanvas');
canvasEle.addEventListener('mousemove', ev => {
// 移动事件对象,从中解构clientX和clientY
let {clientX, clientY} = ev;
// 解构canvas的boundingClientRect中的left和top
let {left, top} = canvasEle.getBoundingClientRect();
// 计算得到鼠标在canvas上的坐标
let mousePositionInCanvas = {
x: clientX - left,
y: clientY - top
}
console.log(mousePositionInCanvas);
})
用浏览器打开index.html
,在控制台就能看到坐标输出:
PS:实际上在对canvas有不同的缩放、CSS样式的加持下,坐标的计算会更加复杂,本文只是简单的获取鼠标在canvas中的坐标,不做过多的讨论,想要深入了解可以看这篇大佬的文章:获取鼠标在 canvas 中的位置 - 一根破棍子 - 博客园 (cnblogs.com)。
整合输入以及状态更新
综合上述的讨论,我们整合目前的信息,有如下的JS代码:
// 定义状态
let rect = {
x: 10,
y: 10,
width: 80,
height: 60,
hover: false
}
// 获取canvas元素
let canvasEle = document.querySelector('#myCanvas');
// 监听鼠标移动
canvasEle.addEventListener('mousemove', ev => {
// 移动事件对象,从中解构clientX和clientY
let {clientX, clientY} = ev;
// 解构canvas的boundingClientRect中的left和top
let {left, top} = canvasEle.getBoundingClientRect();
// 计算得到鼠标在canvas上的坐标
let mousePositionInCanvas = {
x: clientX - left,
y: clientY - top
}
// console.log(mousePositionInCanvas);
// 判断条件进行更新
let inRect =
(rect.x <= mousePositionInCanvas.x && mousePositionInCanvas.x <= rect.x + rect.width)
&& (rect.y <= mousePositionInCanvas.y && mousePositionInCanvas.y <= rect.y + rect.height)
console.log('mouse in rect: ' + inRect);
rect.hover = inRect; // 状态修改
})
渲染
在上一节,我们已经实现了这样的效果:鼠标不断在canvas上进行移动,移动的过程中,鼠标在矩形外部移动的时候,控制台会不断的输出文本:mouse in rect: false
,而当鼠标一旦进入了矩形内部,控制台则会输出:mouse in rect: true
。那么如何将rect的布尔属性hover,转换为我们能够看到的UI图像呢?通过canvas的CanvasRenderingContext2D类实例的相关API来进行绘制即可:
// canvasEle来源见上面的代码
// 从Canvas元素上获取CanvasRenderingContext2D类实例
let ctx = canvasEle.getContext('2d');
// 设置画笔颜色:黑色
ctx.strokeStyle = '#000';
// 矩形所在位置画一个黑色框的矩形
ctx.strokeRect(rect.x, rect.y, rect.width, rect.height);
对于strokeStyle,根据我们的需求,我们需要判断rect的hover属性来决定实际的颜色是红色还是黑色:
// ctx.strokeStyle = '#000'; 改写为:
ctx.strokeStyle = rect.hover ? '#F00' : '#000';
为了后续调用的方便,我们将绘制操作封装为一个方法:
/**
* 画布渲染矩形的工具函数
* @param ctx
* @param rect
*/
function drawRect(ctx, rect) {
// 暂存当前ctx的状态
ctx.save();
// 设置画笔颜色:黑色
ctx.strokeStyle = rect.hover ? '#F00' : '#000';
// 矩形所在位置画一个黑色框的矩形
ctx.strokeRect(rect.x, rect.y, rect.width, rect.height);
// 恢复ctx的状态
ctx.restore();
}
在这个方法中,ctx调用了save和restore。关于这两个方法含义以及使用方式,请参考:
- CanvasRenderingContext2D.save() - Web API 接口参考 | MDN (mozilla.org)
- CanvasRenderingContext2D.restore() - Web API 接口参考 | MDN (mozilla.org)
完成方法封装以后,我们需要该方法的调用点,一个最直接的方式就是在鼠标移动事件处理的内部进行:
// 监听鼠标移动
canvasEle.addEventListener('mousemove', ev => {
// 状态更新的代码
// ......
// 触发移动时,就进行渲染
drawRect(ctx, rect);
});
编写好代码以后,目前的index.js的整体内容如下:
// 定义状态
let rect = {
// ...
};
// 获取canvas元素
let canvasEle = document.querySelector('#myCanvas');
// 从Canvas元素上获取context
let ctx = canvasEle.getContext('2d');
/**
* 画布渲染矩形的工具函数
*/
function drawRect(ctx, rect) {
// ...
}
// 监听鼠标移动
canvasEle.addEventListener('mousemove', ev => {
// ...
});
效果如下:
渲染的时机
细心的读者发现了这个演示中的问题:将鼠标从canvas的外部移动进入,在初始的情况下,canvas中并没有矩形显示,只有在鼠标移动进入canvas以后才显示。原因也很容易解释:在触发mousemove事件后,渲染(drawRect调用)才开始。
要解决上述问题,我们需要明确一点:一般情况下,图像渲染应该和任何的输入事件独立开来,输入事件应只作用于更新。也就是说,上面的(drawRect)调用,不应该和mousemove事件相关联,而是应该在一套独立的循环中去做:
那么,在JS中,我们可以有哪些循环调用方法的方式来完成我们图像的渲染呢?在我的认知中,主要有以下几种:
while类循环,包括for等循环控制语句类
while(true) {
render();
}
弊端:极易造成CPU高占用的卡死问题
setInterval
let interval = 1000 / 60; // 每1秒大约60次
setInterval(() => {
render();
}, interval);
弊端:当render()的调用超过interval间隔的时候,会发生调用丢失的问题;此外,无论canvas是否需要渲染,都会进行调用渲染。
setTimeout
let interval = 1000 / 60;
function doRendert() {
setTimeout(() => {
doRender(); // 递归调用
}, interval)
}
弊端:同上,无论canvas是否需要渲染,都会调用,造成资源浪费。
requestAnimationFrame
关于这个API的基本使用以及原理,请参考这篇大神的详解:你知道的requestAnimationFrame - 掘金 (juejin.cn)。
简单来讲,requestAnimationFrame(callbackFunc),这个API调用的时候,只是告诉浏览器,我在请求一个操作,这个操作是在动画帧渲染发生的时候进行的,至于什么时候发生的动画帧渲染交由浏览器底层完成,但通常,这个值是60FPS。所以,我们的代码如下:
(function doRender() {
requestAnimationFrame(() => {
drawRect(ctx, rect);
doRender(); // 递归
})
})();
必要的画布清空
目前为止这份代码还有一个问题:我们一直在不断循环调用drawRect方法在指定位置绘制矩形,但是我们从来没有清空过画布,也就是说我们不断在一个位置画着矩形。在本例中,这问题凸显的效果看出不出,但是试想如果我们在输入更新的时候,修改了矩形的x或y值,就会发现画布上会有多个矩形图像了(因为上一个位置的矩形已经被“画”在画布上了)。所以,我们需要在开始进行图像绘制的时候,进行清空:
(function doRender() {
requestAnimationFrame(() => {
// 先清空画布
ctx.clearRect(0, 0, canvasEle.width, canvasEle.height);
// 绘制矩形
drawRect(ctx, rect);
// 递归调用
doRender(); // 递归
})
})();
1px线条模糊
目前为止这份代码还还有一个问题:默认的情况下,我们的线条宽度为1px。但实际上,我们画布上的显示的确实一个模糊的看起来比1px更加宽的线条:
这个问题产生的原因读者可以自行网上搜索。这里直接给出解决方案就是,在线宽1px的情况下,线条的坐标需要向左或者向右移动0.5像素,所以对于之前的drawRect中,绘制的时候将x和y进行0.5像素移动:
function drawRect(ctx, rect) {
// ...
// 矩形所在位置画一个黑色框的矩形,移位0.5像素
ctx.strokeRect(rect.x - 0.5, rect.y - 0.5, rect.width, rect.height);
// ...
}
修改之后,效果如下:
总结
画布编程的模式:
悬浮变色代码
index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Hover Example</title>
</head>
<body>
<canvas id="myCanvas"
style="border: 1px solid black"
width="450"
height="200"></canvas>
<script src="index.js"></script>
</body>
</html>
index.js
// 定义状态
let rect = {
x: 10,
y: 10,
width: 80,
height: 60,
hover: false
};
// 获取canvas元素
let canvasEle = document.querySelector('#myCanvas');
// 从Canvas元素上获取context
let ctx = canvasEle.getContext('2d');
/**
* 画布渲染矩形的工具函数
* @param ctx
* @param rect
*/
function drawRect(ctx, rect) {
// 暂存当前ctx的状态
ctx.save();
// 设置画笔颜色:黑色
ctx.strokeStyle = rect.hover ? '#F00' : '#000';
// 矩形所在位置画一个黑色框的矩形
ctx.strokeRect(rect.x - 0.5, rect.y - 0.5, rect.width, rect.height);
// 恢复ctx的状态
ctx.restore();
}
// 监听鼠标移动
canvasEle.addEventListener('mousemove', ev => {
// 移动事件对象,从中解构clientX和clientY
let {clientX, clientY} = ev;
// 解构canvas的boundingClientRect中的left和top
let {left, top} = canvasEle.getBoundingClientRect();
// 计算得到鼠标在canvas上的坐标
let mousePositionInCanvas = {
x: clientX - left,
y: clientY - top
};
// console.log(mousePositionInCanvas);
// 判断条件进行更新
let inRect =
(rect.x <= mousePositionInCanvas.x && mousePositionInCanvas.x <= rect.x + rect.width)
&& (rect.y <= mousePositionInCanvas.y && mousePositionInCanvas.y <= rect.y + rect.height);
console.log('mouse in rect: ' + inRect);
rect.hover = inRect;
});
(function doRender() {
requestAnimationFrame(() => {
// 先清空画布
ctx.clearRect(0, 0, canvasEle.width, canvasEle.height);
// 绘制矩形
drawRect(ctx, rect);
// 递归调用
doRender(); // 递归
})
})();
GitHub
w4ngzhen/canvas-is-everything (github.com)
01_hover