画布就是一切(一)— 画布编程的基本模式

768 阅读10分钟

画布编程的基本模式

画布基本介绍

我开发过基于QT的客户端程序、基于C# WinForm客户端,开发过Java后端服务,此外,前端VUE和React我也开发过不少。对应我所开发过的东西,比起一行一行冰冷的代码,我更加迷恋哪些能够直观的,可视化的东西。还记得以前在开发C#的时候,接触过一个的C# WinForm库NetronGraphLib,这个库能够让我们轻松的构建属于自己的流程图绘制软件,让我们能够以拖拉拽的方式来构建图(下图就是NetronGraphLib库的官方示例应用Cobalt):

010-NetronGraphLibShow

当年看到这个库的时候,极大的震撼了作为开发菜鸟(现在也是= - =)的我。同时,这个库开源免费,他还有一个轻量级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);
}

显示的效果如下:

020-winfrom-draw

以下的代码就是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>

实现的效果如下(黑色边框是为了便于看到画布的边界加上的):

030-html-draw

为了方便后续的实现,以及适应目前的Web前端化,我们使用html 5 的canvas来进行代码编写、演示。

画布编程的基本模式

为了讲解画布编程的基本模式,接下来我们将以鼠标悬浮矩形,矩形边框变色场景为例来进行讲解。对于一个矩形,默认的情况下显示黑色边框,当鼠标悬浮在矩形上的时候,矩形的边框能够显示为红色,就像下图一样:

050-rect-hover-show

那么如何实现这个功能呢?

要回答这个问题,我们首先要明白一组基本概念:输入(input)更新(update)渲染(render),而这几个操作,都会围绕**状态(status)**进行:

  1. 输入会触发更新
  2. 更新会修改状态
  3. 渲染读取最新的状态进行图像映射

事实上,渲染输入、更新是解耦的,它们之间只会通过状态来建立关联:

040-input-update-render

状态整理与提炼

将上述的概念应用到悬浮变色这个场景,我们首先需要整理并提炼有哪些状态。

整理状态最直接的方式,就是看所实现的效果需要哪些UI元素。悬浮变色的场景下,需要的东西很简单:

  1. 矩形位置
  2. 矩形大小
  3. 矩形边框颜色

整理完成以后,我们还需要进行提炼。有的读者可能会说,上述整理的东西已经足够了,还需要提炼什么呢?事实上提炼的过程是通用化的过程,是划清状态与渲染界限的过程。对于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中的位置,如下图所示:

060-position-1

只要满足如下的条件,我们就认为鼠标在矩形内,于是就会发生状态的更新:

(x <= xInCanvas && xInCanvas <= x + width) 
&& 
(y <= yInCanvas && yInCanvas <= y + height)

找到输入点

更新是如何触发的呢?我们现在知道,矩形的位置与大小是已有的值。那么鼠标在canvas中的x、y怎么获得呢?事实上,我们可以给canvas添加鼠标移动事件(mousemove),从移动事件中获取鼠标位置。当事件被触发时,我们可以获取鼠标相对于 viewport(什么是viewport?)的坐标(event.clientXevent.clientY,这两个值并不是直接就是鼠标在canvas中的位置)。 同时,我们可以通过 canvas.getBoundingClientRect() 来获取 canvas 相对于 viewport 的坐标(top, left),这样我们就可以计算出鼠标在 canvas 中的坐标。

注意:下图的canvas.left可能产生误导,canvas没有left,是通过调用canvas的getBoundingClientRect,获取一个boundingClientRect,再获取这个rect的left。

070-position-2

为了后续的代码编写,我们准备一个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,在控制台就能看到坐标输出:

080-show-mouse-position

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。关于这两个方法含义以及使用方式,请参考:

完成方法封装以后,我们需要该方法的调用点,一个最直接的方式就是在鼠标移动事件处理的内部进行:

// 监听鼠标移动
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 => {
	// ...
});

效果如下:

090-first-hover-show

渲染的时机

细心的读者发现了这个演示中的问题:将鼠标从canvas的外部移动进入,在初始的情况下,canvas中并没有矩形显示,只有在鼠标移动进入canvas以后才显示。原因也很容易解释:在触发mousemove事件后,渲染(drawRect调用)才开始。

要解决上述问题,我们需要明确一点:**一般情况下,图像渲染应该和任何的输入事件独立开来,输入事件应只作用于更新。**也就是说,上面的(drawRect)调用,不应该和mousemove事件相关联,而是应该在一套独立的循环中去做:

100-render-cycle

那么,在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更加宽的线条:

110-dim-line

这个问题产生的原因读者可以自行网上搜索。这里直接给出解决方案就是,在线宽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);
  // ...
}

修改之后,效果如下:

总结

画布编程的模式:

130-pattern-arch

悬浮变色代码

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