前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >画布就是一切(一)— 画布编程的基本模式

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

作者头像
w4ngzhen
发布2023-10-16 21:45:09
2560
发布2023-10-16 21:45:09
举报
文章被收录于专栏:编译思想

画布编程的基本模式

画布基本介绍

我开发过基于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# 对一个空白的窗体绘制一个红色矩形:

代码语言:javascript
复制
/// <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来绘制一个矩形:

代码语言:javascript
复制
<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)**进行:

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

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

状态整理与提炼

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

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

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

整理完成以后,我们还需要进行提炼。有的读者可能会说,上述整理的东西已经足够了,还需要提炼什么呢?事实上提炼的过程是通用化的过程,是划清状态与渲染界限的过程。对于1、2来说,无需过多讨论,它们是核心渲染基础,再简单的图像渲染,都离不开position和size这两个核心的元素。

但对于矩形边框颜色是不是状态,则需要探讨。在我看来,应该属于渲染的范畴,不属于状态的范畴。为什么这么来理解呢?因为颜色变化的根本原因是鼠标悬浮,鼠标是否悬浮在矩形上,是矩形的固有属性,在正常的情况下,鼠标和矩形发生交互,必然有是否悬浮这一情形;但是悬浮的颜色却不是固有属性,在这个场景中,指定了悬浮的颜色是红色,但是换一个场景,可能又需要蓝色。“流水线的颜色,铁打悬浮”。

经过上述的讨论,我们得到这个画布的状态:一个包含位置与大小,以及标识是否被鼠标悬浮的标志。在JS中,代码如下:

代码语言:javascript
复制
let rect = {
    x: 10,
    y: 10,
    width: 80,
    height: 60,
    hovered: false
}

输入与更新

找到更新点

完成对状态的整理提炼后,我们需要知道哪些部分是对状态的更新操作。在这个场景中,只要鼠标坐标在矩形区域内,那么我们就会修改矩形的hover为true,否则为false。用伪代码进行描述:

代码语言:javascript
复制
if(鼠标在矩形区域内) {
    rect.hover = true; // 更新状态
} else {
    rect.hover = false; // 更新状态
}

也就是说,我们接下来需要需要考虑“鼠标在矩形区域内”这个条件成立与否。在canvas中,我们需要知道如下的几个数据:矩形的位置、矩形的大小以及鼠标在canvas中的位置,如下图所示:

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

代码语言:javascript
复制
(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。

为了后续的代码编写,我们准备一个index.html:

代码语言:javascript
复制
<!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:

代码语言:javascript
复制
// 同级目录的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代码:

代码语言:javascript
复制
// 定义状态
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来进行绘制即可:

代码语言:javascript
复制
// canvasEle来源见上面的代码
// 从Canvas元素上获取CanvasRenderingContext2D类实例
let ctx = canvasEle.getContext('2d');
// 设置画笔颜色:黑色
ctx.strokeStyle = '#000';
// 矩形所在位置画一个黑色框的矩形
ctx.strokeRect(rect.x, rect.y, rect.width, rect.height);

对于strokeStyle,根据我们的需求,我们需要判断rect的hover属性来决定实际的颜色是红色还是黑色:

代码语言:javascript
复制
// ctx.strokeStyle = '#000'; 改写为:
ctx.strokeStyle = rect.hover ? '#F00' : '#000';

为了后续调用的方便,我们将绘制操作封装为一个方法:

代码语言:javascript
复制
/**
 * 画布渲染矩形的工具函数
 * @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。关于这两个方法含义以及使用方式,请参考:

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

代码语言:javascript
复制
// 监听鼠标移动
canvasEle.addEventListener('mousemove', ev => {
  // 状态更新的代码
  // ......
  // 触发移动时,就进行渲染
  drawRect(ctx, rect);
});

编写好代码以后,目前的index.js的整体内容如下:

代码语言:javascript
复制
// 定义状态
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等循环控制语句类

代码语言:javascript
复制
while(true) {
	render();
}

弊端:极易造成CPU高占用的卡死问题

setInterval

代码语言:javascript
复制
let interval = 1000 / 60; // 每1秒大约60次
setInterval(() => {
	render();
}, interval);

弊端:当render()的调用超过interval间隔的时候,会发生调用丢失的问题;此外,无论canvas是否需要渲染,都会进行调用渲染。

setTimeout

代码语言:javascript
复制
let interval = 1000 / 60;
function doRendert() {
	setTimeout(() => {
        doRender(); // 递归调用
    }, interval)
}

弊端:同上,无论canvas是否需要渲染,都会调用,造成资源浪费。

requestAnimationFrame

关于这个API的基本使用以及原理,请参考这篇大神的详解:你知道的requestAnimationFrame - 掘金 (juejin.cn)

简单来讲,requestAnimationFrame(callbackFunc),这个API调用的时候,只是告诉浏览器,我在请求一个操作,这个操作是在动画帧渲染发生的时候进行的,至于什么时候发生的动画帧渲染交由浏览器底层完成,但通常,这个值是60FPS。所以,我们的代码如下:

代码语言:javascript
复制
(function doRender() {
  requestAnimationFrame(() => {
    drawRect(ctx, rect);
    doRender(); // 递归
  })
})();

必要的画布清空

目前为止这份代码还有一个问题:我们一直在不断循环调用drawRect方法在指定位置绘制矩形,但是我们从来没有清空过画布,也就是说我们不断在一个位置画着矩形。在本例中,这问题凸显的效果看出不出,但是试想如果我们在输入更新的时候,修改了矩形的x或y值,就会发现画布上会有多个矩形图像了(因为上一个位置的矩形已经被“画”在画布上了)。所以,我们需要在开始进行图像绘制的时候,进行清空:

代码语言:javascript
复制
(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像素移动:

代码语言:javascript
复制
function drawRect(ctx, rect) {
  // ...
  // 矩形所在位置画一个黑色框的矩形,移位0.5像素
  ctx.strokeRect(rect.x - 0.5, rect.y - 0.5, rect.width, rect.height);
  // ...
}

修改之后,效果如下:

总结

画布编程的模式:

悬浮变色代码

index.html

代码语言:javascript
复制
<!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

代码语言:javascript
复制
// 定义状态
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

本文参与 腾讯云自媒体同步曝光计划,分享自作者个人站点/博客。
原始发表:2021-11-112,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 作者个人站点/博客 前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 画布基本介绍
  • 简单绘制
  • 画布编程的基本模式
    • 状态整理与提炼
      • 输入与更新
        • 找到更新点
        • 找到输入点
        • 整合输入以及状态更新
      • 渲染
        • 渲染的时机
        • 必要的画布清空
        • 1px线条模糊
    • 总结
      • 悬浮变色代码
        • index.html
        • index.js
        • GitHub
    领券
    问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档