当咱们开发一个canvas应用的时候,出于效率的考量,免不了要选择一个渲染引擎(好比PixiJS)或者更强大一点的游戏引擎(好比Cocos Creator、Layabox)。css
渲染引擎一般会有Sprite的概念,一个完整的界面会由不少的Sprite组成,若是编写复杂一点的界面,代码里面会充斥建立精灵、设置精灵位置和样式的“重复代码”,最终咱们获得了极致的渲染性能却牺牲了代码的可读性。html
游戏引擎一般会有配套的IDE,界面经过拖拽便可生成,最终导出场景配置文件,这大大方便了UI开发,可是游戏引擎通常都很庞大,有时候咱们仅仅想开发个好友排行榜。node
基于以上分析,若是有一款渲染引擎,既能用配置文件的方式来表达界面,又能够作到轻量级,将会大大知足咱们开发轻量级canvas应用的场景。react
本文会详细介绍开发一款可配置化轻量级渲染引擎
须要哪些事情,代码开源至Github:https://github.com/wechat-miniprogram/minigame-canvas-engine。git
咱们首先指望页面可配置化,来参考下Cocos Creator的实现:对于一个场景,在IDE里面一顿操做,最后场景配置文件大体长下面的样子:github
// 此处省略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😬。web
这个方案很好,但缺点是每一个引擎有一套本身的配置规则,无法作到通用化,并且在没有IDE的状况下,手写配置文件也会显得反人类,咱们还须要更加通用一点的配置。算法
游戏引擎的配置方案若是要用起来主要有两个问题:npm
对于高可读性和样式分离,咱们惊讶的发现,这不就是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里面要实现将HTML+CSS绘制到canvas上面,上面的步骤缺一不可。
上面的方案总览又分两大块,第一是渲染以前的各类解析计算,第二是渲染自己以及渲染以后的后续工做,先看看渲染以前须要作的事情。
首先是将HTML(这里咱们采用XML)字符串解析成节点树,等价于浏览器里面的“HTML 标记转换成文档对象模型 (DOM)”,在npm搜索xml parser),能够获得不少优秀的实现,这里咱们只追求两点:
综合以上考量,选择了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构建完成后,他们还是独立的两部分,须要将他们构建成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 | 文本标签,用于展现段落或者行内文字 |
在构建节点树的过程当中,对于不一样类型的节点会有不一样的类去处理,上述三个标签对应了View
,Image
和Text
类,每一个类都有本身的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对象)的顺序触发。
前提:每一个节点都存在事件监听器on
和发射器emit
;每一个节点都有个属性layoutBox
,它代表了元素的在canvas上的盒子模型:
layoutBox: { x: 0, y: 0, width: 100, height: 100 }
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渲染引擎出具模型:
文章篇幅有限,不少细节和难点仍然无法详细描述,好比内存管理(内存管理不当很容易内存持续增涨致使应用crash)、scrollview的滚动事件实现细节、对象池使用等。有兴趣的能够看看源码:https://github.com/wechat-miniprogram/minigame-canvas-engine/tree/master/src
下图再补一个滚动好友排行列表demo:
做为一个完整的引擎,没有IDE怎么行?这里为了提升UI调试的效率(实际上不少时候游戏引擎的工做流很长,调试UI,改个文案之类的是个很麻烦的事情),提供一个简版的在线调试器,调UI是彻底够用了:https://wechat-miniprogram.github.io/minigame-canvas-engine/
最后要问,费了这么大劲搞了个渲染引擎有什么应用场景呢?固然是有的:
1.由 FlexBox 算法强力驱动的 Weex 布局引擎:https://www.jianshu.com/p/d085032d4788
2.网页性能管理详解:https://www.ruanyifeng.com/blog/2015/09/web-page-performance-in-depth.html
3.渲染性能:https://developers.google.cn/web/fundamentals/performance/rendering
4.简化绘制的复杂度、减少绘制区域:https://developers.google.com/web/fundamentals/performance/rendering/simplify-paint-complexity-and-reduce-paint-areas?hl=zh-CN