一个跨端渲染示例和思路
[TOC]
前言&背景
近期由于Fuchsia正式发布的信息也再一次让Flutter变得更加火热起来,Flutter是一个可以运行在Linux,Window,Android,Ios等多平台上的开源移动应用软件开发工具包.但是Flutter默认集成的语言是Dart,这就给我们带来了较强的上手学习成本,且需要长时间的Dart踩坑.如果能够使用Typescript和JSX就可以在各个平台进行图形化编程,我想应该会给React开发者很大方便.
同样针对IOT 时代的到来,越来越多的图形化设备的出现,如何高效可复用动态化的在低配硬件设备进行图形编程是一个可以进行探索的课题.
项目其核心设计关键就是利用JSON语言承载界面描述的DSL,再利用排版引擎解析DSL后利用skia绘制出来.demo的核心就在于如何工程化的结合这些东西。
对看到这里,相信大家可能已经看出了笔者想要做什么,就是一个及其简版的浏览器内核.
现在chrome 设计如下
image笔者在这里尝试使用Quickjs和Skia平台尝试搭建一个Iot设备的渲染Demo,其实就要将V8和Blink替换掉,让IOT图形开发者可以利用类xml这样强大的dsl语言和现代高级语言javascript就能高效的在IOT设备上尽显才华(反正吹牛不要钱...)
排版引擎的选择
IOT 图形设备的特性
- 主要展示2d为主,
- 交互多为点击为主
- 显示面积小,不支持滚动(多如,监控设备上的图形展示)
- 布局简单,页面大的更新少
- 设备配置低
笔者简单总结了一下iot图形设备的特性.
笔者选择描述界面ui的dsl为vitrual dom,本想着可以借鉴Blink如下的渲染方式
rendertree-> renderobj->renderlayer->paint->composite
(删了一大段笔者的实践过程),本文的绝大数时间都花费在了这里(断断续续大半个月,因为失败了就不贴上了,.....)
但是当笔者看了一下相关文档并去翻阅了一下Blink的render和paint的模块的代码后就顺利放弃了如上的打算,被其代码复杂性征服了...
在这里 笔者同时也借鉴了 html2canvas的相关思路和代码html2canvas 有两种模式,一种通过svg的方式.,一种通过纯canvas的方式,显然我们需要关注的后者的生成方式.
纯Canvas
- 递归取出目标模版的所有DOM节点,填充到一个
rederList
,并附加是否为顶层元素/包含内容的容器 等信息- 通过
z-index
postion
float
等css属性和元素的层级信息将rederList
排序,计算出一个canvas的renderQueue- 遍历renderQueue,将css样式转为
setFillStyle
可识别的参数,依据nodeType调用相对应canvas方法,如文本则调用fillText
,图片drawImage
,设置背景色的div调用fillRect
等- 将画好的canvas填充进页面
看到这里,我们也可以看出来可能html2canvas的作者是利用js实现blink的相关算法,例如根据z-index等css属性生成renderList列表,和RenderLayer的生成有很多想通之处.
笔者又去调研了一下Flutter的渲染管线和相关设计概念,非常适合借鉴。flutter的和blink的技术路线非常相似,且flutter的代码具有较强的可阅读性。在技术调研的过程中笔者发现了由FaceBook 推出的YogaLayout 弹性布局库,非常适合适配我们的需求,虽然只支持弹性布局,但是其生态完善和健壮性也能够满足在iot设备上布局渲染。
为什么选择QuickJs
Quickjs最近也是大火,频频出现在笔者的阅读视线中.选择QuickJs,是因为QuickJs本身就是的定位就是一款嵌入式引擎,采用 c语言编写,没有太多的外部依赖,这非常适合一些基于微内核的IOT操作系统.且Quickjs 的性能也不差,Quickjs向比较与V8来说虽然还有很多不足之处,但是在IOT场景下Quickjs的小和快笔者更愿意选择前者.
Quickjs到底多么好,笔者也就不说了,直接上bellard大佬博客上的基准测试结果.
基准测试前期编译工作(水一下字数)
由于Skia是C++ 写的,QuickJs是C写的,所以我们需要将它们编译好,在我们的主项目进行链接,笔者还是选择了C++ 作为我们的项目语言.
QuickJs 编译安装和生静态链接库
Quickjs的编译安装非常简单,具体可以参考Quickjs的项目主页.,笔者就不在这里多叙述了.
https://bellard.org/quickjs/quickjs.html#Installation
我们需要编译QuickJs项目为一个静态链接库,由于笔者的菜鸡MakeFile的水平,所以这里我们采用Cmake作为我们的预构建工具. 以下是CmakeList.txt
cmake_minimum_required(VERSION 3.15)
project(quickjs C)
file(STRINGS VERSION version)
set(quickjs_src quickjs.c libbf.c libunicode.c libregexp.c cutils.c quickjs-libc.c)
set(quickjs_def CONFIG_VERSION="${version}" _GNU_SOURCE CONFIG_BIGNUM)
add_compile_definitions(${quickjs_def})
set(CMAKE_C_STANDARD 99)
add_library(quickjs ${quickjs_src})
target_link_libraries(quickjs ${CMAKE_DL_LIBS} m )
编译后,我们可以得到一个静态链接库文件libquickjs.a
Skia的编译
Skia使用和V8相同的项目管理工具和编译系统Ninja.
首先我们需要翻墙拉一下代码,然后走一下编译流程
git clone 'https://chromium.googlesource.com/chromium/tools/depot_tools.git'
export PATH="${PWD}/depot_tools:${PATH}"
git clone https://skia.googlesource.com/skia.git
cd skia
python2 tools/git-sync-deps
## 安装需要的依赖,如果不是知名发行版,需要简单改一下脚本,mac中需要自己安装相关依赖。
tools/install_dependencies.sh
bin/gn gen out/Shared --args='is_official_build=true is_component_build=true'
ninja -C out/Shared
PS: 需要注意的是编译Skia至少需要c++17 ,也就是至少g++7的版本,如果版本过低会提示语法错误,安装好新版本的g++7版本后,由于不知道如何更改编译器选项,可以link 一下c++ 到g++7上去.
结合Skia 和QuickJS
示例项目是一个新建的cmake项目,引用Skia和QuickJs的头文件和静态库以及相关在macos上的服务框架等。
cmake_minimum_required(VERSION 3.15)
project(demo)
set(CMAKE_CXX_STANDARD 17)
set(QUICKJSDIR /Users/xxx/sources/quickjs)
set(SKIADIR /Users/xxx/sources/skia)
include_directories(${QUICKJSDIR})
include_directories(${SKIADIR})
include_directories(/usr/local/include)
link_directories(${SKIADIR}/out/Release)
link_directories(${QUICKJSDIR})
link_directories(/usr/local/lib)
find_library(CoreServices CoreServices)
find_library(CoreGraphics CoreGraphics)
find_library(CoreText CoreText)
find_library(CoreFoundation CoreFoundation)
find_library(OpenGL_LIBRARY OpenGL)
link_libraries(quickjs)
link_libraries(skia jpeg icu png webp)
link_libraries(SDL2 SDL2main pthread fontconfig freetype)
set(CMAKE_BUILD_TYPE "Release")
add_executable(demo src/cpp/main.cpp)
target_link_libraries(demo ${OpenGL_LIBRARY} ${CMAKE_DL_LIBS} ${CoreServices} ${CoreGraphics} ${CoreText} ${CoreFoundation} ${COCOA_LIBRARY} m )
工程介绍
自定义JSX 解析
我们的JSX可能长这样
<flex height={200}
width={200}
flexDirection={Yoga.FLEX_DIRECTION_ROW}
justifyContent={Yoga.JUSTIFY_SPACE_AROUND}
alignItem={Yoga.ALIGN_FLEX_START}
borderRadiusPercent={10}
>
<flex backGroundColor="000000" borderRadiusPercent={50} height={50} width={50}/>
<flex backGroundColor="000000" borderRadiusPercent={50} height={50} width={50}/>
</flex>
这里主要利用typescript的jsxFactory,自定义转化器来解析并构造我们的"Virtual Dom Tree“。
首先我们需要在tsconfig.json中指定
"jsx": "react",
"jsxFactory": "Dtf.createElement",
这样子typescript在编译我们的tsx文件时就会使用使用jsxFactory 使用类React.createElement 函数传参一样包裹住住我们的VirtualNode。
我们只需要定义好Dtf.createElement
函数即可。
类似这样
export default {
createElement<T extends keyof JSX.IntrinsicElements>(type:T,props:JSX.IntrinsicElements[T],...childrens:FlexNode[]):FlexNode{
const node = Node.create();
const flexNode=new FlexNode(node);
flexNode.setWidth(props.width || 0);
flexNode.setHeight(props.height || 0);
flexNode.setJustifyContent(props.justifyContent || Yoga.JUSTIFY_FLEX_START);
flexNode.setFlexDirection(props.flexDirection || Yoga.FLEX_DIRECTION_ROW);
//.......
childrens.forEach((element,index) => {
flexNode.insertChild(element,index);
});
return flexNode
},
FlexNode:FlexNode
}
这里我们并不需要考虑递归等问题,因为typescript会把jsx node 都包裹进来通过此函数传递进来,
在这里我们定义FlexNode 同时把相关的props和children关联上去
同时我还需要暴露出全局namespace jsx,方便用户使用我们的自定义的jsx。
declare namespace JSX{
interface IntrinsicElements {
flex:{
flexDirection?:import("yoga-layout").YogaFlexDirection;
height?: number;
width?: number;
justifyContent?:import("yoga-layout").YogaJustifyContent,
//.....
}
}
}
同时我们需要像React一样
import React from "react"
在我们的tsx中文件 引入我们的Dtf
import Dtf from "./Dtf";
最终tsc 预编译出来的就会类似这样
var node = (Dtf_1.default.createElement("flex", { height: 200, width: 200, flexDirection: yoga_layout_1.default.FLEX_DIRECTION_ROW, justifyContent: yoga_layout_1.default.JUSTIFY_SPACE_AROUND, alignItem: yoga_layout_1.default.ALIGN_FLEX_START, borderRadiusPercent: 10 },
Dtf_1.default.createElement("flex", { backGroundColor: "000000", borderRadiusPercent: 50, height: 50, width: 50 }),
Dtf_1.default.createElement("flex", { backGroundColor: "000000", borderRadiusPercent: 50, height: 50, width: 50 })));
Yoga Layout 引擎
YogaLayout 是一个弹性布局引擎,拥有非常优秀的排版速度和性能,且对开发者提供的相关接口非常简单, 我们只需要关联好设置好渲染节点的属性和关联属性,就可以全局或局部进行布局计算。
FlexNode是我们在YogaNode上多封装一层的node,可以增强对于渲染节点的表达丰富性。
这里为了完成示例DEMO,可以直接获取根节点进行全局计算。
node.getNode().calculateLayout(200,200);
这样子我们就可以获取到静态的布局的内容,我们只需要把根节点的引用传递进入绘制函数即可。
编译JS成字节码
我们使用qjsc 将我们的webpack打包文件编译成字节码。
qjsc -c -o dist/out.c dist/out.js
准备运行环境
我们在 quickjs 中全局变量注入render函数。
void AddDtfRender(JSContext *ctx) {
JSValue dtf= JS_NewObject(ctx);
JSValue dtfRender =JS_NewCFunction(ctx,js_dtf_render,"render",1);
JS_SetPropertyStr(ctx,dtf,"render",dtfRender);
JS_SetPropertyStr(ctx, JS_GetGlobalObject(ctx), "EngineDtf", dtf);
};
这样子我们就可以在js中使用EngineDtf.render() 调用到我们的内置的函数。
构建渲染树
在c++中我们需要把布局的信息构建成一个有序,完整的结构。
为此我们简单设计了如下的Layout对象
class Layout {
private:
JSContext *ctx;
JSValue *layout = NULL;
JSValue *realNode = NULL;
Layout *fistChild = NULL;
Layout *next = NULL;
CssStyle *cssStyle = NULL;
BoxContainer *container =NULL;
}
在从render中获取到根节点引用后我们就可以使用 getComputeLayout
获取到一些位置信息。
在经历过递归遍历后我们即可获取到一个完整的Layout树,携带了 位置,大小,嵌套关系和样式属性的Layout 树。
绘制渲染树
我们采用广度优先的方式进行绘制。
if(rootLayout->getNext() != NULL){
paintLayout(rootLayout->getNext(),NULL);
}
if(rootLayout->getFirstChild()!= NULL){
paintLayout(rootLayout->getFirstChild(),NULL);
}
同时我们这里针对仅支持的两个样式属性进行一些画笔上的一些设置。
CssStyle *cssStyle = rootLayout->getCssStyle();
int borderRadiusPercent =0;
if(cssStyle != NULL){
borderRadiusPercent=cssStyle->getBorderRadiusPercent();
string backgroundColor =cssStyle->getBackGroundColor();
if(checkStr(backgroundColor)){
paint->setStyle(SkPaint::kFill_Style);
paint->setColor( convertHexToColor(backgroundColor));
}
}
_canvas->drawRoundRect(rect, rootLayout->getContainer()->getWidth() *intborderRadiusPercent /100, rootLayout->getContainer()->getHeight() * intborderRadiusPercent / 100, *paint);
效果
image.png总结和展望
在本文中我们实现了一个简单的跨端渲染DEMO,浅层次的验证了一些技术调研的可行性。
但是更多的是了解到一些渲染问题的复杂性和性能提升上的问题。本文针对布局和绘制两大重要步骤仅仅进行了遍历操作,并未做任何优化操作。这些地方都是可以向优秀的开源库进行学习的。
希望本文可以给各位读者提供一个跨端渲染的思路。