畫布就是一切(一)— 畫布程式設計的基本模式

w4ngzhen發表於2021-11-14

畫布基本介紹

我開發過基於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

相關文章