手把手教你打造一款輕量級canvas渲染引擎

袁梓民發表於2019-12-17

背景

當我們開發一個canvas應用的時候,出於效率的考量,免不了要選擇一個渲染引擎(比如PixiJS)或者更強大一點的遊戲引擎(比如Cocos Creator、Layabox)。

渲染引擎通常會有Sprite的概念,一個完整的介面會由很多的Sprite組成,如果編寫複雜一點的介面,程式碼裡面會充斥建立精靈設定精靈位置和樣式的“重複程式碼”,最終我們得到了極致的渲染效能卻犧牲了程式碼的可讀性。

遊戲引擎通常會有配套的IDE,介面通過拖拽即可生成,最終匯出場景配置檔案,這大大方便了UI開發,但是遊戲引擎一般都很龐大,有時候我們僅僅想開發個好友排行榜。

基於以上分析,如果有一款渲染引擎,既能用配置檔案的方式來表達介面,又可以做到輕量級,將會大大滿足我們開發輕量級canvas應用的場景。

本文會詳細介紹開發一款可配置化輕量級渲染引擎需要哪些事情,程式碼開源至Github:github.com/wechat-mini…

配置化分析

我們首先期望頁面可配置化,來參考下Cocos Creator的實現:對於一個場景,在IDE裡面一頓操作,最後場景配置檔案大致長下面的樣子:

  // 此處省略n個節點
  {
    "__type__": "cc.Scene",
    "_opacity": 255,
    "_color": {
      "__type__": "cc.Color",
      "r": 255,
      "g": 255,
      "b": 255,
      "a": 255
    },
    "_parent": null,
    "_children": [
      {
        "__id__": 2
      }
    ],
  },
複製程式碼

在一個JSON配置檔案裡面,同時包含了節點的層級結構樣式,引擎拿到配置檔案後遞迴生成節點樹然後渲染即可。PixiJS雖然只是個渲染引擎,但同樣可以和cocos2d一樣做一個IDE去拖拽生成UI,然後寫一個解析器,聲稱自己是PixiJS Creator?。

這個方案很好,但缺點是每個引擎有一套自己的配置規則,沒法做到通用化,而且在沒有IDE的情況下,手寫配置檔案也會顯得反人類,我們還需要更加通用一點的配置。

尋找更優方案

遊戲引擎的配置方案如果要用起來主要有兩個問題:

  1. 手寫可讀性差,特別是對於層級深的節點樹;
  2. 樣式和節點樹沒有分離,配置檔案冗餘;
  3. 配置不通用;

對於高可讀性樣式分離,我們驚訝的發現,這不就是Web開發的套路麼,編寫HTML、CSS丟給瀏覽器,介面就出來了,省時省力。

手把手教你打造一款輕量級canvas渲染引擎

如此優秀的使用姿勢,我們要尋求方案在canvas裡面實現一次!

實現分析

結果預覽

在逐步分析實現方案之前,我們先拋個最終實現,編寫XML和樣式,就可以得到結果:

let template = `
<view id="container">
  <text id="testText" class="redText" value="hello canvas"> </text>
</view>
`;

let style = {
    container: {
         width: 200,
         height: 100,
         backgroundColor: '#ffffff',
         justContent: 'center',
         alignItems: 'center',
     },
     testText: {
         color: '#ff0000',
         width: 200,
         height: 100,
         lineHeight: 100,
         fontSize: 30,
         textAlign: 'center',
     }
}
// 初始化渲染引擎
Layout.init(template, style);
// 執行真正的渲染
Layout.layout(canvasContext);
複製程式碼

手把手教你打造一款輕量級canvas渲染引擎

方案總覽

既然要參考瀏覽器的實現,我們不妨先看看瀏覽器是怎麼做的:

手把手教你打造一款輕量級canvas渲染引擎
如上圖所示,瀏覽器從構建到渲染介面大致要經過下面幾步:

  • HTML 標記轉換成文件物件模型 (DOM);CSS 標記轉換成 CSS 物件模型 (CSSOM)
  • DOM 樹與 CSSOM 樹合併後形成渲染樹。
  • 渲染樹只包含渲染網頁所需的節點。
  • 佈局計算每個物件的精確位置和大小。
  • 最後一步是繪製,使用最終渲染樹將畫素渲染到螢幕上。

在canvas裡面要實現將HTML+CSS繪製到canvas上面,上面的步驟缺一不可。

構建佈局樹和渲染樹

上面的方案總覽又分兩大塊,第一是渲染之前的各種解析計算,第二是渲染本身以及渲染之後的後續工作,先看看渲染之前需要做的事情。

解析XML和構建CSSOM

首先是將HTML(這裡我們採用XML)字串解析成節點樹,等價於瀏覽器裡面的“HTML 標記轉換成文件物件模型 (DOM)”,在npm搜尋xml parser,可以得到很多優秀的實現,這裡我們只追求兩點:

  1. 輕量:大部分庫為了功能強大動輒幾百k,而我們只需要最核心的xml解析成JSON物件;
  2. 高效能:在遊戲裡面不可避免有長列表滾動的場景,這時候XML會很大,要儘量控制XML解析時間;

綜合以上考量,選擇了fast-xml-parser,但是仍然做了一些閹割和改造,最終模板經過解析會得到下面的JSON物件

{
    "name":"view",
    "attr":{
        "id":"container"
    },
    "children":[
        {
            "name":"text",
            "attr":{
                "id":"testText",
                "class":"redText",
                "value":"hello canvas"
            },
            "children":[

            ]
        }
    ]
}
複製程式碼

接下來是構建CSSOM,為了減少解析步驟,我們手工構建一個JSON物件,key的名字為節點的id或者class,以此和XML節點形成繫結關係:

let style = {
    container: {
         width: 200,
         height: 100
     },
}
複製程式碼

DOM 樹與 CSSOM 樹合併後形成渲染樹

DOM樹和CSSOM構建完成後,他們仍是獨立的兩部分,需要將他們構建成renderTree,由於style的key和XML的節點有關聯,這裡簡單寫個遞迴處理函式就可以實現:該函式接收兩個引數,第一個引數為經過XML解析器解析吼的節點樹,第二個引數為style物件,等價於DOM和CSSOM。

// 記錄每一個標籤應該用什麼類來處理
const constructorMap = {
    view      : View,
    text      : Text,
    image     : Image,
    scrollview: ScrollView,
}
const create = function (node, style) {
    const _constructor = constructorMap[node.name];

    let children = node.children || [];

    let attr = node.attr || {};
    const id = attr.id || '';
    // 例項化標籤需要的引數,主要為收集樣式和屬性
    const args = Object.keys(attr)
        .reduce((obj, key) => {
            const value = attr[key]
            const attribute = key;

            if (key === 'id' ) {
                obj.style = Object.assign(obj.style || {}, style[id] || {})
                return obj
            }

            if (key === 'class') {
                obj.style = value.split(/\s+/).reduce((res, oneClass) => {
                return Object.assign(res, style[oneClass])
                }, obj.style || {})

                return obj
            }
            
            if (value === 'true') {
                obj[attribute] = true
            } else if (value === 'false') {
                obj[attribute] = false
            } else {
                obj[attribute] = value
            }

            return obj;
        }, {})

    // 用於後續元素查詢
    args.idName    = id;
    args.className = attr.class || '';

    const element  = new _constructor(args)
    element.root = this;
    
    // 遞迴處理
    children.forEach(childNode => {
        const childElement = create.call(this, childNode, style);

        element.add(childElement);
    });

    return element;
}
複製程式碼

經過遞迴解析,構成了一顆節點帶有樣式的renderTree。

計算佈局樹

渲染樹搞定之後,要著手構建佈局樹了,每個節點在相互影響之後的位置和大小如何計算是一個很頭疼的問題。但仍然不慌,因為我們發現近幾年非常火的React Native、weex之類的框架必然會面臨同樣的問題:

Weex 是使用流行的 Web 開發體驗來開發高效能原生應用的框架。 React Native 使用JavaScript和React編寫原生移動應用

這些框架也需要將html和css編譯成客戶端可讀的佈局樹,能否避免重複造輪子將它們的相關模組抽象出來使用呢?起初我以為這部分會很龐大或者和框架強耦合,可喜的是這部分抽象出來僅僅只有1000來行,他就是week和react native早起的佈局引擎css-layout。這裡有一篇文章分析得非常好,直接引用至,不再贅述:《由 FlexBox 演算法強力驅動的 Weex 佈局引擎》

npm上面可以搜到css-layout,它對外暴露了computeLayout方法,只需要將上面得到的佈局樹傳給它,經過計算之後,佈局樹的每個節點都會帶上layout屬性,它包含了這個節點的位置和尺寸資訊!

// create an initial tree of nodes
var nodeTree = {
    "style": {
      "padding": 50
    },
    "children": [
      {
        "style": {
          "padding": 10,
          "alignSelf": "stretch"
        }
      }
    ]
  };
 
// compute the layout
computeLayout(nodeTree);
 
// the layout information is written back to the node tree, with
// each node now having a layout property: 
 
// JSON.stringify(nodeTree, null, 2);
{
  "style": {
    "padding": 50
  },
  "children": [
    {
      "style": {
        "padding": 10,
        "alignSelf": "stretch"
      },
      "layout": {
        "width": 20,
        "height": 20,
        "top": 50,
        "left": 50,
        "right": 50,
        "bottom": 50,
        "direction": "ltr"
      },
      "children": [],
      "lineIndex": 0
    }
  ],
  "layout": {
    "width": 120,
    "height": 120,
    "top": 0,
    "left": 0,
    "right": 0,
    "bottom": 0,
    "direction": "ltr"
  }
}
複製程式碼

這裡需要注意的是,css-layout實現的是標準的Flex佈局,如果對於CSS或者Flex佈局不是很熟悉的同學,可以參照這篇文章進行快速的入門:《Flex 佈局教程:語法篇》。再值得一提的是,作為css-layout的使用者,好的習慣是給每個節點都賦予width和height屬性?。

渲染

基礎樣式渲染

在處理渲染之前,我們先分析下在Web開發中我們重度使用的標籤:

標籤 功能
div 通常作為容器使用,容器也可以有一些樣式,比如border和背景顏色之類的
img 圖片標籤,向網頁中嵌入一幅影像,通常我們會對圖片新增borderRadius實現圓形頭像
p/span 文字標籤,用於展示段落或者行內文字

在構建節點樹的過程中,對於不同型別的節點會有不同的類去處理,上述三個標籤對應了ViewImageText類,每個類都有自己的render函式。

render函式只需要做好一件事情:根據css-layout計算得到的layout屬性和節點本身樣式相關的style屬性,通過canvas API的形式繪製到canvas上;

這件事情聽起來工作量很大,但其實也沒有這麼難,比如下面演示如何處理文字的繪製,實現文字的字型、字號、左對齊右對齊等。

 function renderText() {  
    let style = this.style || {};

    this.fontSize = style.fontSize || 12;
    this.textBaseline = 'top';
    this.font = `${style.fontWeight  || ''} ${style.fontSize || 12}px ${DEFAULT_FONT_FAMILY}`;
    this.textAlign = style.textAlign || 'left';
    this.fillStyle = style.color     || '#000';
    
    if ( style.backgroundColor ) {
        ctx.fillStyle = style.backgroundColor;
        ctx.fillRect(drawX, drawY, box.width, box.height)
    }

    ctx.fillStyle = this.fillStyle;

    if ( this.textAlign === 'center' ) {
        drawX += box.width / 2;
    } else if ( this.textAlign === 'right' ) {
        drawX += box.width;
    }

    if ( style.lineHeight ) {
        ctx.textBaseline = 'middle';
        drawY += style.lineHeight / 2;
    }
}
複製程式碼

但這件事情又沒有這麼簡單,因為有些效果你必須層層組合計算才能得出效果,比如borderRadius的實現、文字的textOverflow實現,有興趣的同學可以看看原始碼

再者還有更深的興趣,可以翻翻遊戲引擎是怎麼處理的,結果功能過於強大之後,一個Text類就有1000多行:LayaAir的Text實現?。

重排和重繪

當介面渲染完成,我們總不希望介面只是靜態的,而是可以處理一些點選事件,比如點選按鈕隱藏一部分元素,亦或是改變按鈕的顏色之類的。

在瀏覽器裡面,有對應的概念叫重排和重繪:

引自文章:《網頁效能管理詳解》

網頁生成的時候,至少會渲染一次。使用者訪問的過程中,還會不斷重新渲染。重新渲染,就需要重新生成佈局和重新繪製。前者叫做"重排"(reflow),後者叫做"重繪"(repaint)。

那麼哪些操作會觸發重排,哪些操作會觸發重繪呢?這裡有個很簡單粗暴的規則:只要涉及位置和尺寸修改的,必定要觸發重排,比如修改width和height屬性,在一個容器內做和尺寸位置無關的修改,只需要觸發區域性重繪,比如修改圖片的連結、更改文字的內容(文字的尺寸位置固定),更具體的可以檢視這個網站csstriggers.com

在我們這個渲染引擎裡,如果執行觸發重排的操作,需要將解析和渲染完整執行一遍,具體來講是修改了xml節點或者與重排相關的樣式之後,重複執行初始化和渲染的操作,重排的時間依賴節點的複雜度,主要是XML節點的複雜度。

// 該操作需要重排以實現介面重新整理
style.container.width = 300;
// 重排前的清理邏輯
Layout.clear();
// 完整的初始化和渲染流程
Layout.init(template, style);
Layout.layout(canvasContext);
複製程式碼

對於重繪的操作,暫時提供了動態修改圖片連結和文字的功能,原理也很簡單:通過Object.defineProperty,當修改佈局樹節點的屬性時,丟擲repaint事件,重繪函式就會區域性重新整理介面。

Object.defineProperty(this, "value", {
    get : function() {
        return this.valuesrc;
    },
    set : function(newValue){
        if ( newValue !== this.valuesrc) {
            this.valuesrc = newValue;
            // 丟擲重繪事件,在回撥函式裡面在canvas的區域性擦除layoutBox區域然後重新繪製文案
            this.emit('repaint');
        }
    },
    enumerable   : true,
    configurable : true
});
複製程式碼

那怎麼呼叫重繪操作呢?引擎只接收XML和style就繪製出了頁面,要想針對單個元素執行操作還需要提供查詢介面,這時候佈局樹再次排上用場。在生成renderTree的過程中,為了匹配樣式,需要通過id或者class來形成對映關係,節點也順帶保留了id和class屬性,通過遍歷節點,就可以實現查詢API:

function _getElementsById(tree, list = [], id) {
    Object.keys(tree.children).forEach(key => {
        const child = tree.children[key];

        if ( child.idName === id ) {
            list.push(child);
        }

        if ( Object.keys(child.children).length ) {
            _getElementsById(child, list, id);
        }
    });

    return list;
}
複製程式碼

此時,可以通過查詢API來實現實現重繪邏輯,該操作的耗時可以忽略不計。

let img = Layout.getElementsById('testimgid')[0];
img.src = 'newimgsrc';
複製程式碼

事件實現

查詢到節點之後,自然是希望可以繫結事件,事件的需求很簡單,可以監聽元素的觸控和點選事件以執行一些回撥邏輯,比如點選按鈕換顏色之類的。

我們先來看看瀏覽器裡面的事件捕獲和事件冒泡機制:

引自文章《JS中的事件捕獲和事件冒泡》 捕獲型事件(event capturing):事件從最不精確的物件(document 物件)開始觸發,然後到最精確(也可以在視窗級別捕獲事件,不過必須由開發人員特別指定)。 冒泡型事件:事件按照從最特定的事件目標到最不特定的事件目標(document物件)的順序觸發。

手把手教你打造一款輕量級canvas渲染引擎

**前提:**每個節點都存在事件監聽器on和發射器emit;每個節點都有個屬性layoutBox,它表明了元素的在canvas上的盒子模型:

layoutBox: {
    x: 0,
    y: 0,
    width: 100,
    height: 100
}
複製程式碼

canvas要實現事件處理與瀏覽器並無不同,核心在於:給定座標點,遍歷節點樹的盒子模型,找到層級最深的包圍該座標的節點。

手把手教你打造一款輕量級canvas渲染引擎

當點選事件發生在canvas上,可以拿到觸控點的x座標和y座標,該座標位於根節點的layoutBox內,當根節點仍然有子節點,對子節點進行遍歷,如果某個子節點的layoutBox仍然包含了該座標,再次重複執行以上步驟,直到包含該座標的節點再無子節點,這個過程稱之為事件捕獲

// 給定根節點樹和觸控點的位置通過遞迴即可實現事件捕獲
function getChildByPos(tree, x, y) {
    let list = Object.keys(tree.children);

    for ( let i = 0; i < list.length;i++ ) {
        const child = tree.children[list[i]];
        const box   = child.realLayoutBox;

        if (   ( box.realX <= x && x <= box.realX + box.width  )
            && ( box.realY <= y && y <= box.realY + box.height ) ) {
            if ( Object.keys(child.children).length ) {
                return getChildByPos(child, x, y);
            } else {
                return child;
            }
        }
    }

    return tree;
}
複製程式碼

層級最深的節點被找到之後,呼叫emit介面觸發該節點的ontouchstart事件,如果事先有對ontouchstart進行監聽,事件回撥得以觸發。那麼怎麼實現事件冒泡呢?在事件捕獲階段我們並沒有記錄捕獲的鏈條。這時候佈局樹的優勢又體現出來了,每個節點都儲存了自己的父節點和子節點資訊,子節點emit事件之後,同時呼叫父節點的emit介面丟擲ontouchstart事件,而父節點又繼續對它自己的父節點執行同樣的操作,直至根節點,這個過程稱之為事件冒泡

// 事件冒泡邏輯
['touchstart', 'touchmove', 'touchcancel', 'touchend', 'click'].forEach((eventName) => {
    this.on(eventName, (e, touchMsg) => {
        this.parent && this.parent.emit(eventName, e, touchMsg);
    });
});
複製程式碼

滾動列表實現

螢幕區域內,展示的內容是有限的,而瀏覽器的頁面通常都很長,可以滾動。這裡我們實現scrollview,如果標籤內子節點的總高度大於scrollview的高度,就可以實現滾動。

1.對於在容器scrollview內的所有一級子元素,計算高度之合;

function getScrollHeight() {
    let ids  = Object.keys(this.children);
    let last = this.children[ids[ids.length - 1]];

    return last.layoutBox.top + last.layoutBox.height;
}
複製程式碼

2.設定分頁大小,假設每頁的高度為2000,根據上面計算得到的ScrollHeight,就可以當前滾動列表總共需要幾頁,為他們分別建立用於展示分頁資料的canvas:

this.pageCount = Math.ceil((this.scrollHeight + this.layoutBox.absoluteY) / this.pageHeight);
複製程式碼

3.遞迴遍歷scrollview的節點樹,通過每個元素的absoluteY值判斷應該坐落在哪個分頁上,這裡需要注意的是,有些子節點會同時坐落在兩個分頁上面,在兩個分頁都需要繪製一遍,特別是圖片類這種非同步載入然後渲染的節點

function renderChildren(tree) {
    const children = tree.children;
    const height   = this.pageHeight;

    Object.keys(children).forEach( id => {
        const child   = children[id];
        let originY   = child.layoutBox.originalAbsoluteY;
        let pageIndex = Math.floor(originY / height);
        let nextPage  = pageIndex + 1;

        child.layoutBox.absoluteY -= this.pageHeight * (pageIndex);

        // 對於跨界的元素,兩邊都繪製下
        if ( originY + child.layoutBox.height > height * nextPage ) {
            let tmpBox = Object.assign({}, child.layoutBox);
            tmpBox.absoluteY = originY - this.pageHeight * nextPage;

            if ( child.checkNeedRender() ) {
                this.canvasMap[nextPage].elements.push({
                    element: child, box: tmpBox
                });
            }
        }

        this.renderChildren(child);
        });
    }
複製程式碼

4.將scrollview理解成遊戲裡面的Camera,只把能拍攝到的區域展示出來,那麼所有的分頁資料從上而下拼接起來就是遊戲場景,在列表滾動過程中,只“拍攝”尺寸為scrollviewWidth*scrollViewHeight的區域,就實現了滾動效果。拍攝聽起來很高階,在這裡其實就是通過drawImage實現就好了:

// ctx為scrollview所在的canvas,canvas為分頁canvas
this.ctx.drawImage(
    canvas,
    box.absoluteX, clipY, box.width, clipH,
    box.absoluteX, renderY, box.width, clipH,
);
複製程式碼

5.當scrollview上觸發了觸控事件,會改變scrollview的top屬性值,按照步驟4不斷根據top去裁剪可視區域,就實現了滾動。

上述方案為空間換時間方案,也就是在每次重繪過程中,因為內容已經繪製到分頁canvas上了(這裡可能會比較佔空間),每次重繪,渲染時間得到了最大優化。

其他

至此,一個類瀏覽器的輕量級canvas渲染引擎出具模型:

  1. 給定XML+style物件可以渲染介面;
  2. 支援一些特定的標籤:view、text、image和scrollview;
  3. 支援查詢節點反向修改節點屬性和樣式;
  4. 支援事件繫結;

文章篇幅有限,很多細節和難點仍然沒法詳細描述,比如記憶體管理(記憶體管理不當很容易記憶體持續增漲導致應用crash)、scrollview的滾動事件實現細節、物件池使用等。有興趣的可以看看原始碼:github.com/wechat-mini… 下圖再補一個滾動好友排行列表demo:

手把手教你打造一款輕量級canvas渲染引擎

除錯及應用場景

作為一個完整的引擎,沒有IDE怎麼行?這裡為了提高UI除錯的效率(實際上很多時候遊戲引擎的工作流很長,除錯UI,改個文案之類的是個很麻煩的事情),提供一個簡版的線上偵錯程式,調UI是完全夠用了:wechat-miniprogram.github.io/minigame-ca…

手把手教你打造一款輕量級canvas渲染引擎

最後要問,費了這麼大勁搞了個渲染引擎有什麼應用場景呢?當然是有的:

  1. 跨遊戲引擎的遊戲周邊外掛:很有遊戲周邊功能比如簽到禮包、公告頁面等都是偏H5頁面的周邊系統,如果通過本渲染引擎渲染到離屏canvas,每個遊戲引擎都將離屏canvas當成普通精靈渲染即可實現跨遊戲引擎外掛;
  2. 極致的程式碼包追求:如果你對微信小遊戲有所瞭解,就會發現現階段在開放資料域要繪製UI,如果不想裸寫UI,就得再引入一份遊戲引擎,這對程式碼包體積影響是很大的,而大部分時候僅僅是想繪製個好友排行榜;
  3. 螢幕截圖:這點在普通和H5和遊戲裡面都比較常見,將一些使用者暱稱和文案之類的與背景圖合併成為截圖,這裡可以輕鬆實現。
  4. 等等等......

參考資料

1.由 FlexBox 演算法強力驅動的 Weex 佈局引擎:www.jianshu.com/p/d085032d4… 2.網頁效能管理詳解:www.ruanyifeng.com/blog/2015/0… 3.渲染效能:developers.google.cn/web/fundame… 4.簡化繪製的複雜度、減小繪製區域:developers.google.com/web/fundame…

相關文章