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