畫布基本介紹
我開發過基於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