在Web技术领域中,canvas是一个使用非常广泛的功能,可以支持开发者在原有的HTML能力之外,拓展矢量图形绘制能力,常用于实现矢量动画、粒子特效、图表、游戏等等场景。而canvas在HTML里面只是一个画布,本身并不具备绘图能力,需要依托JS脚本来绘制图形。
canvas是W3C(万维网联盟)标准集中的其中一项标准,该功能目前主要实现在浏览器中。对于大部分IoT终端来说,浏览器是一个太复杂的系统,包括它的性能、资源占用等都是大部分IoT终端无法承受的。而目前,越来越多IoT领域的GUI框架,开始引入前端技术,支持使用JS来写UI,那么canvas也成为一个非常棒的高级功能,包括鸿蒙的JS应用、RTT的柿饼UI以及阿里云IoT的HaaS UI都部分或完整的支持了canvas功能。
所以本文主要探讨在IoT带屏领域里面,使用quickjs引擎以及Skia图形引擎来拓展2dcanvas场景的基本方法。
QuickJS
QuickJS(官网地址:QuickJS Javascript Engine)是一个小型并且可嵌入的JS引擎,由Bellard大神(Qemu、FFmpeg的作者)于2019年推出的开源方案。相比较其他一些嵌入式的JS引擎,QuickJS在性能、标准支持等方面,都表现优异。在运行性能上,几乎可以媲美jitless的V8引擎,而启动性能又碾压之;并且QuickJS支持最新的ES2020规范,包括模块、异步等等。所以像鸿蒙JS应用,阿里云IoT的HaaS轻应用等框架,都开始拥抱并使用QuickJS作为其JS的运行引擎。所以这里也选择使用QuickJS来进行canvas2d的接口拓展。
下图是QuickJS作者放出的比较不同JS引擎的benchmark测试,分数越高代表性能越好:
引擎启动
QuickJS的运行环境主要有两个关键信息:
- JSRuntime:JS运行时,可以同时存在多个Runtime,互相独立,在Runtime维度,不支持多线程,所以QuickJS不支持跨线程调用与回调
- JSContext:JS上下文环境,每个JSContext都有自己的全局(global)对象,一个JSRuntime可以创建多个JSContext,它们之间可以通过引用方式共享JS对象(一般JS扩展,包括cfunction以及cmodule的扩展是以context为载体的)
以下为一个QuickJS实例启动的代码示例:
// 创建Runtime
JSRuntime *rt = JS_NewRuntime();
// 创建Context
JSContext *ctx = JS_NewContext(rt);
// 在这里可以添加各种cfunction和cmodule的扩展,提供给JS调用
// eval js
JSValue val = JS_Eval(ctx, buf, buf_len, filename, eval_flags);
JS_FreeValue(ctx, val); // 引用计数,需要使用者注意管理js对象引用
// 或者eval binary(quickjs支持导出字节码,加速执行)
// js_std_eval_binary,参考quickjs-libc.c
// 这里是线程loop(用于接收执行timer,Promise等pending任务,以及其他线程callback的消息)
// 在Linux系统,可以使用quickjs-libc.c里自带的js_std_loop
// 对于一些RTOS系统,可以参考后进行适配
// 退出loop
// 销毁Context
JS_FreeContext(ctx);
// 销毁Runtime
JS_FreeRuntime(rt);
C函数/属性扩展
空引擎初始化之后,只会包含在ES规范中定义的JS语法。而对于大部分的真正使用场景,都需要拓展业务场景所必须的扩展功能,所有的JS引擎都会支持这样的一些扩展能力。扩展一般用于往global对象上binding一些C函数与属性(也可以往其他对象上):
// 需binding的C函数
JSValue wrap_Foo(JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv) {
// argv为参数列表
printf("run global.foo(arg).\n");
int a = 0;
if (argc > 0) {
JS_ToInt32(ctx, &a, argv[0]);
}
// return a+1
return JS_NewInt32(ctx, a + 1);
}
// 获取当前context的global对象
JSValue global_obj = JS_GetGlobalObject(ctx);
// 往global对象上绑定env属性
JSValue environment = JS_NewObject(ctx);
// globalThis.$env.platform
JS_SetPropertyStr(ctx, environment, "platform",
JS_NewString(ctx, "AliOS Things"));
// globalThis.$env.version
JS_SetPropertyStr(ctx, environment, "version",
JS_NewString(ctx, "0.0.1"));
// globalThis.$env
JS_SetPropertyStr(ctx, global_obj, "$env",
environment);
// 往global对象上绑定c函数
// C函数原型
// typedef JSValue JSCFunction(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv);
JS_SetPropertyStr(ctx, global_obj, "foo",
JS_NewCFunction(ctx, wrap_Foo, "foo", 1));
JS_FreeValue(ctx, global_obj);
扩展好之后,在JS端使用:
// console.log需要按照c函数扩展的方法扩展
console.log("platform:", globalThis.$env.platform);
// 打印platform:AliOS Things
console.log("version:", globalThis.$env.version);
// 打印version:0.0.1
console.log("foo:", globalThis.foo(1));
// 打印foo:2
C模块(ES6 module)扩展
模块是ES6(2015)中的规范,像JerryScript、Duktape等最高支持ES5的引擎是不支持该功能的。以下为在QuickJS中扩展一个module的方法。
模块定义:
JSModuleDef
关键函数:
- JS_NewCModule:用于创建一个JSModuleDef,
函数原型:
JSModuleDef *JS_NewCModule(JSContext *ctx, const char *name_str,
JSModuleInitFunc *func);
参数列表:context,name(模块名),initFunc(模块初始化函数)
- JS_AddModuleExportList:用于添加模块export列表
- JS_AddModuleExport:用于添加模块export
类似js端的exports.xxx = xxx
可参考quickjs-libc.c中的js_init_module_std和js_init_module_os
在JS端的使用方法:
// 示例为使用std模块打开一个文件
import * as std from "std";
let f = std.open("xxx.txt");
let buf = new Uint8Array(10);
f.read(buf, 0, 10);
f.close();
扩展2dcanvas
最开始提到了,这里会使用Skia作为2dcanvas底层需要的矢量引擎,作为示例为了方便移植Skia到IoT场景(Linux和RTOS系统),这边选择了比较老的一个Skia版本,但是它的功能是能够完全支持2dcanvas所需要的能力的。
2dcanvas的具体标准内容可以监考:HTML Standard;或者也可以参考w3cschool里的内容:HTML Canvas 参考手册。包括40+接口,涵盖“颜色、样式、线条、路径、矩阵变换”等。
在浏览器里面,要使用2dcanvas的主要步骤为:1、创建canvas标签;2、创建2dcanvas上下文;3、调用api执行绘图操作。
// 创建canvas标签,然后通过domApi插入节点(也可以通过html直接创建)
var canvas = document.createElement("canvas");
// 获取2d上下文
var ctx = canvas.getContext("2d");
// 设置填充样式
ctx.fillStyle = "#FF0000";
// 填充矩形区域
ctx.fillRect(20,20,150,100);
那么在这里,因为不是在浏览器场景,所以关键需要先支持创建2dcanvas的上下文环境,我在这里通过在quickjs上扩展createCanvasContext接口来支持创建上下文。
创建2dcanvas上下文
首先先按照上述quickjs的C函数扩展方法,实现一个createCanvasContext函数,并绑定好一个C的context对象。因为Skia是C++实现的,所以这里我们也使用C++来实现。首先,先构造一个C的context对象:
// 先构建一个基础的context对象,包含需要真正做渲染的Skia画布
struct CanvasContext
{
// Skia图形引擎的画布对象
SkCanvas* canvas;
SkBitmap* bitmap;
// 用于绘制填充对象时的画笔
SkPaint fillPaint;
// 用于绘制描边对象时的画笔
SkPaint strokePaint;
// 绘制图形时使用的Path对象
SkPath path;
};
然后往quickjs中扩展createCanvasContext函数:
// 声明canvas对应的js类
static JSClassID _js_canvas_class_id;
static void _js_canvas_finalizer(JSRuntime* rt, JSValue val)
{
// 在js canvas对象被GC前,用来销毁C的CanvasContext对象
CanvasContext* context = (CanvasContext*)JS_GetOpaque(val, _js_canvas_class_id);
if (context) {
delete context->bitmap;
delete context->canvas;
delete context;
}
}
static JSClassDef _js_canvas_class = {
"Canvas",
.finalizer = _js_canvas_finalizer, // CanvasContext GC前的资源回收
};
// 需binding的C函数
JSValue createCanvasContext(JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv)
{
// 创建C的context对象
CanvasContext* context = new CanvasContext;
SkBitmap* bmp = new SkBitmap;
// 这里需要创建Skia绘制时的图像buffer,需要对接到设备屏幕
// bmp->setConfig(SkBitmap::kARGB_8888_Config, screenWidth, screenHeight);
// bmp->setPixels(fb_buf);
// TODO 这边示例先用内存buffer代替下,创建一块200x200的buffer
bmp->setConfig(SkBitmap::kARGB_8888_Config, 200, 200);
bmp->allocPixels();
// 初始化清空画布
memset(bmp->getPixels(), 0, bmp->getSize());
context->bitmap = bmp;
context->canvas = new SkCanvas(*bmp);
// 设置描边样式
context-strokePaint.setStyle(SkPaint::kStroke_Style);
// 创建canvas对应的JS对象
JSValue obj = JS_NewObjectClass(ctx, _js_canvas_class_id);
// TODO 之后需要在这里挂载2dcanvas的所有api
// 绑定canvas对象
JS_SetOpaque(obj, context);
// 返回jscanvas对象
return obj;
}
// 定义JS的canvas类
JS_NewClassID(&_js_canvas_class_id);
JS_NewClass(JS_GetRuntime(ctx), _js_canvas_class_id, &_js_canvas_class);
// 按照quickjs扩展方法扩展createCanvasContext函数
JS_SetPropertyStr(ctx, globalObject, "createCanvasContext", JS_NewCFunction(context, &createCanvasContext, "createCanvasContext", 0));
通过以上的扩展,JS端已经可以通过createCanvasContext来创建canvas上下文了。只不过目前这个上下文对象上还没有挂载任何2dcanvas的api,所以接下来,就先来尝试一下扩展填充矩形的API。
扩展第一个API
首先先来扩展一个较简单的填充矩形的API。填充矩形主要分为两个步骤:1、设置画笔样式fillStyle;2、调用fillRect接口执行绘制矩形操作。JS脚本如下:
// 创建canvas上下文,也就是上一个步骤中扩展的C函数
var ctx = createCanvasContext();
// 设置填充样式
ctx.fillStyle = "#FF0000";
// 填充矩形区域,x=20,y=20,w=100,h=100
ctx.fillRect(20,20,100,100);
所以,这里需要先扩展两个API,fillStyle和fillRect。先来增加一下fillStyle的设置,因为这是一个属性设置,而不是接口调用,这块上面quickjs扩展没提到,其实也是类似的,可以通过quickjs扩展对象的getter/setter方法;以及扩展一个fillRect函数,具体实现代码如下:
// 设置填充样式
JSValue setFillStyle(JSContext* ctx, JSValue this_val, int argc, JSValue* argv)
{
// 获取绑定的CanvasContext对象
CanvasContext* canvasCtx = static_cast< CanvasContext* >(JS_GetOpaque(this_val, _js_canvas_class_id));
if (canvasCtx == NULL) return JS_EXCEPTION;
if (argc < 1) return JS_EXCEPTION;
if (JS_IsString(argv[0])) {
const char* jcolor = JS_ToCString(ctx, argv[0]);
// 通过字符串解析为RGBA色值
// 颜色格式可能是#RGB, #RRGGBB, rgb(r,g,b), rgba(r,g,b,a)以及HSL等等
// 这里先不实现,先简单直接写死红色先吧
// 给fillPaint设置颜色
canvasCtx->fillPaint.setColor(SkColorSetARGB(0xFF, 0xFF, 0x0, 0x0));
JS_FreeCString(ctx, jcolor);
} else if (JS_IsObject(argv[0])) {
// 看W3C标准规范,fillStyle可能是CanvasGradient,包括线性渐变和径向渐变,这里先不实现
}
}
// 填充矩形
JSValue fillRect(JSContext* ctx, JSValue this_val, int argc, JSValue* argv)
{
// 获取绑定的CanvasContext对象
CanvasContext* canvasCtx = static_cast< CanvasContext* >(JS_GetOpaque(this_val,_js_canvas_class_id1));
if (canvasCtx == NULL) return JS_EXCEPTION;
if (argc < 4) return JS_EXCEPTION;
double x, y, w, h;
if (JS_ToFloat64(ctx, &x, argv[0]) == 0
&& JS_ToFloat64(ctx, &y, argv[1]) == 0
&& JS_ToFloat64(ctx, &w, argv[2]) == 0
&& JS_ToFloat64(ctx, &h, argv[3]) == 0) {
SkRect rect;
rect.set(x, y, x + w, y + h);
// 调用canvas绘制矩形
canvasCtx->canvas->drawRect(rect, canvasCtx->fillPaint);
}
return JS_UNDEFINED;
}
JSValue createCanvasContext(JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv)
{
... 省略其他代码
// 创建canvas对应的JS对象
JSValue obj = JS_NewObjectClass(ctx, _js_canvas_class_id);
// TODO 之后需要在这里挂载2dcanvas的所有api
// 设置fillStyle样式
JS_DefinePropertyGetSet(ctx, obj, JS_NewAtom(ctx, "fillStyle"), JS_UNDEFINED, JS_NewCFunction(ctx, &setFillStyle, "fillStyle", 1), 0);
// fillRect填充矩形的api
JS_SetPropertyStr(ctx, obj, "fillRect", JS_NewCFunction(ctx, &fillRect, "fillRect", 1));
...
}
这样子,支持fillRect填充矩形区域的接口就扩展完成了,然后再程序中通过quickjs运行上面的JS脚本,就可以绘制出下面的图形了
绘制复杂路径
通过上面的扩展的方式,就可以开始扩展其他更多的接口了,接下来尝试一下扩展复杂路径的绘制,这里需要扩展的接口会有好几个与Path相关的。可以在规范中看到,与Path有关的接口包含以下一些常用接口等:
- beginPath: 开始一条路径,或重置当前的路径
- closePath: 创建从当前点回到起始点的路径
- moveTo: 把路径移动到画布中的指定点,不创建线条
- lineTo: 添加一个新点,然后在画布中创建从该点到最后指定点的线条
- quadraticCurveTo: 创建二次贝塞尔曲线
- bezierCurveTo: 创建三次方贝塞尔曲线
- arcTo: 创建两切线之间的弧/曲线
- arc: 创建弧/曲线(用于创建圆形或部分圆)
- rect: 创建矩形
- fill: 填充当前绘图(路径)
- stroke: 绘制已定义的路径(描边)
通过以上的接口,就可以实现各种丰富的矢量图形。比如,可以通过arc接口绘制圆弧:
var ctx = createCanvasContext();
ctx.strokeStyle = 'red';
ctx.beginPath();
// 圆心坐标x, 圆心坐标y, 半径radius, startAngle, endAngle
ctx.arc(100, 100, 50, 0, 2 * Math.PI);
ctx.stroke();
按照示例需要,来扩展benginPath,arc以及stroke接口:
// 填充矩形
JSValue beginPath(JSContext* ctx, JSValue this_val, int argc, JSValue* argv)
{
// 获取绑定的CanvasContext对象
CanvasContext* canvasCtx = static_cast< CanvasContext* >(JS_GetOpaque(this_val,_js_canvas_class_id1));
if (canvasCtx == NULL) return JS_EXCEPTION;
// 重置Path
canvasCtx->path.reset();
return JS_UNDEFINED;
}
// 创建圆弧的接口
JSValue arc(JSContext* ctx, JSValue this_val, int argc, JSValue* argv)
{
// 获取绑定的CanvasContext对象
CanvasContext* canvasCtx = static_cast< CanvasContext* >(JS_GetOpaque(this_val,_js_canvas_class_id1));
if (canvasCtx == NULL) return JS_EXCEPTION;
if (argc < 4) return JS_EXCEPTION;
// 创建圆弧
double x, y, radius, startAngle, endAngle;
if (JS_ToFloat64(ctx, &x, argv[0]) == 0
&& JS_ToFloat64(ctx, &y, argv[1]) == 0
&& JS_ToFloat64(ctx, &radius, argv[2]) == 0
&& JS_ToFloat64(ctx, &startAngle, argv[3]) == 0
&& JS_ToFloat64(ctx, &endAngle, argv[4]) == 0) {
SkRect oval;
oval.set(x - radius, y - radius, x + radius, y + radius);
// 弧度转为角度
startAngle = startAngle * 180.f / M_PI;
double sweepAngle = endAngle * 180.f / M_PI - startAngle;
// 创建圆弧
canvasCtx->path.addArc(oval, startAngle, sweepAngle);
}
return JS_UNDEFINED;
}
// 绘制线条的接口
JSValue stroke(JSContext* ctx, JSValue this_val, int argc, JSValue* argv)
{
// 获取绑定的CanvasContext对象
CanvasContext* canvasCtx = static_cast< CanvasContext* >(JS_GetOpaque(this_val,_js_canvas_class_id1));
if (canvasCtx == NULL) return JS_EXCEPTION;
// 使用strokePaint来绘制路径线条
if (!canvasCtx->path.empty()) {
canvasCtx->canvas->drawPath(canvasCtx->path, canvasCtx->strokePaint);
}
return JS_UNDEFINED;
}
JSValue createCanvasContext(JSContext* ctx, JSValueConst this_val, int argc, JSValueConst* argv)
{
... 省略其他代码
// 创建canvas对应的JS对象
JSValue obj = JS_NewObjectClass(ctx, _js_canvas_class_id);
// TODO 之后需要在这里挂载2dcanvas的所有api
// beginPath重置路径
JS_SetPropertyStr(ctx, obj, "beginPath", JS_NewCFunction(ctx, &beginPath, "beginPath", 0));
JS_SetPropertyStr(ctx, obj, "arc", JS_NewCFunction(ctx, &arc, "arc", 0));
JS_SetPropertyStr(ctx, obj, "stroke", JS_NewCFunction(ctx, &stroke, "stroke", 0));
...
}
实现了这几个接口之后,运行上面的JS脚本,就可以绘制出以下的图形了:
更多扩展
有了以上的接口扩展经验,2dcanvas规范中的所有接口都可以使用类似的方式来扩展了,Skia引擎是能够完全支持2dcanvas标准规范的,这里就不一一展开细说了。
动画
在Web开发领域,有了2dcanvas之后,还有基于canvas实现矢量动画也是非常常用的功能;而动画的基本工作原理就是,每隔一定时间重新调用绘制图形,并且每一帧绘制的图形根据时间戳进行差值计算,使得图形具备平滑过渡的效果。
一个最简单的实现就是在JS上通过不断的调用setTimeout定时来定时绘制图形。在quickjs中,setTimeout函数有在quickjs-libc.c中有参考实现,如果是RTOS系统,可以参考来实现一下。这里就不具体展开了。
结语
本文主要内容是在IoT领域中(主要指Linux和RTOS的带屏设备上),使用quickjs和Skia来实现W3C中对于2dcanvas的规范的一个基本方法的探讨。如果在quickjs和Skia已经适配移植完成的情况下,实现起来是比较容易的,主要的思路就是借助quickjs的C函数扩展方法来将Skia的一些接口扩展到JS层。希望能对大家有所帮助。
开发者支持
如需更多技术支持,可加入钉钉开发者群,或者关注微信公众号。
更多技术与解决方案介绍,请访问HaaS官方网站https://haas.iot.aliyun.com。