Flutter ChartSpace:通过跨端 Canvas 实现图表库

基于Flutter 的图形语法库,通过跨端 Canvas ,将基于 Javascript 的图形语法库 ChartSpace 扩展至 Flutter 端
作者:字节跳动终端技术——胡珀
背景
数据平台有个基于图形语法的图表库 ChartSpace,支持 web/h5/mini program,现在收到业务诉求,要支持到 Flutter 端。
为方便理解,稍微解释下图形语法的概念,已经了解的小伙伴可以跳过这一段。
图形语法
图形语法(grammar of graphics) 是通过一套语法来描述任意图形,主要来自 Wilkinson 的《The Grammar of Graphics》,可参考文章:https://zhuanlan.zhihu.com/p/47550015
图形语法与一般的图表主要区别在于:图形语法只要修改下语法描述,就能得到完全不同的图形,而一般的图表需要增加图表类型。图形语法可描述的图形是近乎无限的,而图表类型是有限的。
举个例子(截取自:https://segmentfault.com/a/1190000041004457):
如果我们基于图形语法绘制了柱状图

将语法中的坐标系换成极坐标后,会变成玫瑰图

语法中调整坐标度量,并增加不同颜色后,变成了更完善的玫瑰图

继续调整语法参数,最终可得到饼图

在这个例子中,如果用一般图表(如 ECharts),需要至少4个图表类型,图表数据的格式也可能存在区别。但使用图形语法描述,只需要调整不同的语法参数,就能得到不同的图形。
图形语法通过调整语法参数,得到不同的图形,给数据的表达提供了更大的空间,属于更专业的图表引擎,但同样也带来了较为复杂的语法规则。
基于图形语法,前端(JS语法)常用的图表库:
- G2:蚂蚁金服基于图形语法的图表库,图形语法通过 js 语法使用
const ds = new DataSet();
const chart = new Chart({
...
});
...
const dv2 = ds.createView().source(dv1.rows);
dv2.transform({
type: 'regression',
method: 'polynomial',
fields: ['year', 'death'],
bandwidth: 0.1,
as: ['year', 'death']
});
const view2 = chart.createView();
view2.axis(false);
view2.data(dv2.rows);
...
chart.render();
- Vega:开源的图形语法框架,图形语法通过 json 配置使用
{
width : 500,
height : 200,
config : {
axis : {
grid : true,
gridColor : #dedede
}
},
...
}
- ChartSpace:字节跳动基于图形语法的图表库,图形语法通过 json 配置使用,语法与 Vega 相近
{
type : line ,
data : [],
labels : {
visible : false
},
axes : [
{
orient : left
},
{
orient : bottom
}
],
xField : x ,
yField : y
}
在跨端,跨语言的情况下,json 配置的语法拥有更好的多端一致性。后端保存一套相同的 json 配置,可以在多端绘制出相同的图形。
图表库 ChartSpace
ChartSpace 是字节数据平台基于图形语法的图表库,已支持 web/h5/mini program,现在要支持到 flutter 端。
业务上期望多端协同,同一份数据在不同端上有一致性的表现,以折线图为例:

方案
常规的方案是实现一套 flutter 版的图形语义,解析 chartspace 的语义配置,绘制成相同规格的图形。但这种方案带来的开发成本比较高,所以我们选择了另一套方案:跨端 canvas。
原理就是将 chartspace (js) 所使用的 web canvas 上绘制的内容,通过跨端技术给呈现到 flutter canvas 上来。

实现这个方案,要解决两个问题:
- 把东西画出来
- 把交互串起来
把东西画出来
核心思想:将 chartspace(js) 的 canvas 绘制指令执行从 js 转移到 flutter 执行,目标是对齐 Flutter Canvas 和 Web Canvas。
实现方式是:在 JS 中通过构造 Mock Canvas 对象,录制 canvas 指令,然后发送到 Flutter 侧,通过 Flutter Canvas 来实现这些指令。
主要工作量在于用 Flutter Canvas 实现一套 Web Canvas 的 API。

把交互串起来
用户交互的输入是 touch 事件,只需要将 Flutter PointerEvent 转换为 Web TouchEvent,输入到 chartspace 即可。
之后 chartspace 会产生新的 canvas 指令,在 Flutter Canvas 中绘制出新的内容,流程和首次渲染一样,至此交互就完整了。

效果
完成后效果如下,tooltip 的效果是手指点击后产生的。

取得的收益是:低成本实现,低成本维护,跨端一致性。
渲染性能对比:
开发期间做过很多优化,graph 渲染时间从80ms优化到50ms,我们还在持续优化,争取做到接近原生的体验。后续我们其他小伙伴会分享优化的思路和实践。
| 跨端 Canvas | 纯 Flutter | |
|---|---|---|
| graph 渲染 | 52ms | 20ms |
| tooltip 渲染 | 9ms | 0ms |
跨端 Canvas 的数据是从用户输入开始,到渲染图形结束,包含了 bridge 传输,chartspace (js) 生成 canvas 指令的时间。
纯 Flutter 是将相同的 canvas 指令变成 Flutter 代码后的执行时间。
可以看到渲染性能与纯 Flutter 模式有一定差距,但也在可接受范围内,正常图表交互时,用户很难感知到区别。
我们相信,相同的图表如果自己绘制,应该能有更好的性能,在 canvas 的指令优化 和 Web Canvas API 的实现上,还有一定的优化空间。
踩坑 & 解决方案
实践过程中,遇到了很多问题,这里选取几类有代表性的分享一下
Canvas 生命周期不同
生命周期区别如下:
| Flutter Canvas | Web Canvas |
|---|---|
| 渲染不会保存画布 | 渲染会保存画布 |
| 每次都是重新绘制 | 在上一次的基础上继续绘制 |
我们的解决方案是,保存渲染后的结果,在上一次的渲染结果上继续绘制
@override
void paint(Canvas canvas, Size size) {
final paintList = _repaint.consume();
ui.Picture picture = canvasRecorder.record(canvasId, size, _repaint.reverse, paintList);
if (picture != null) {
canvas.drawPicture(picture);
}
}
Canvas Context 不同
Context 区别如下:
| Flutter Canvas | Web Canvas |
|---|---|
| canvas.save 保存的内容:Saves a copy of the current transform and clip on the save stack. | ctx.save 保存的内容: |
| 每次 paint 都是全新的 canvas 实例 | canvas 创建后,实例不变 |
针对第一个问题,save / restore 的内容不一致,我们创建了 WebCanvas 对象以模拟 Web 上的 Canvas,手动管理 save / restore 的内容
class WebCanvas {
...
SaveStack saveStack = SaveStack();
SaveInfo get current => saveStack.current;
...
}
针对第二个问题,我们创建了 CanvasRecorder 对象,并在该对象中持有 WebCanvas 实例,与 Web 上的 Canvas 实例的生命周期保持一致
class CanvasRecorder {
...
CanvasHistory getCanvasHistory(String canvasId) {
if (!hisMap.containsKey(canvasId)) {
hisMap.putIfAbsent(canvasId, () => CanvasHistory(canvasId));
}
return hisMap[canvasId];
}
...
}
class CanvasHistory {
...
final ChartSpaceCanvas chartSpaceCanvas = ChartSpaceCanvas();
...
}
class ChartSpaceCanvas {
...
final WebCanvas webcanvas = WebCanvas();
...
}
Canvas 默认值不同
Canvas 默认值不同的地方较多,我们直接按 Web Canvas 的标准设置了默认值,没有仔细统计过差异,粗略来说有以下属性有区别:
- transform
- fillStyle
- strokeStyle
- strokeMiterLimit
- font
以 transform 为例,transform 实际维护的是一个 4 * 4 的变换矩阵(DOMMatrix 对象),web 上 setTransform 方法设置的是变换矩阵不同位置的值



Flutter 上是直接操作这个变换矩阵

但是 Web Canvas 和 Flutter Canvas 的变换矩阵默认值不一致
| Flutter Canvas | Web Canvas |
|---|---|
| 0 0 0 00 0 0 00 0 0 00 0 0 0 | 1 0 0 00 1 0 00 0 1 00 0 0 1 |
所以解决方案如下:
class Matrix4Builder {
static Matrix4 webDefault() {
final matrix4 = Matrix4.zero();
matrix4.setEntry(0, 0, 1.0);
matrix4.setEntry(1, 1, 1.0);
matrix4.setEntry(2, 2, 1.0);
matrix4.setEntry(3, 3, 1.0);
return matrix4;
}
}
Bridge 需要同步 API
我们通过 Mock CanvasRenderdingContext 对象,来达到录制 canvas 指令的目的,但是 CanvasRenderdingContext 对象上有很多方法需要同步 API,比较高频的比如 measureText。
但是常规的 Bridge 通信是

其中 Flutter 与 iOS/Android 的通信是异步的,所以这里使用 FFI 直接与 JS Runtime 通信才能保证同步

截取部分代码实现:
Pointer<Utf8> funcMeasureTextCString = Utf8.toUtf8('measureText');
var measureTextFunctionObject = jSObjectMakeFunctionWithCallback(
_globalContext,
jSStringCreateWithUTF8CString(funcMeasureTextCString),
Pointer.fromFunction(measureTextFunction));
jSObjectSetProperty(
_globalContext,
_globalObject,
jSStringCreateWithUTF8CString(funcMeasureTextCString),
measureTextFunctionObject,
jsObject.JSPropertyAttributes.kJSPropertyAttributeNone,
nullptr);
free(funcMeasureTextCString);
总结 & 展望
总结一下,我们通过跨端 Canvas 的方式,低成本实现了 Flutter ChartSpace,实践下来取得了不错的性能表现。
这也得益于 ChartSpace 本身合理的架构设计,通过 json 配置来定义图形语义,能有效屏蔽不同平台,语言的差异。
由于 ChartSpace 是基于图形语义的实现,相比定制化的图表类型,需要更大的计算量,会影响渲染性能。但现在也支持了分步渲染,在大数据和复杂的图形下,能以渐进式的效果逐步呈现完整图形,对用户体验并没有损害。
Flutter ChartSpace 暂时还没支持分步渲染,当前的方案还有很大的优化空间,我们会继续探索。
未来考虑在两个方向上继续拓展:
设计易用性更高的 API
图形语法虽然很强大,也带来了使用上的复杂度,我们可以在图形语法上包装一层 API,将常用的图形给剥离出来,降低使用成本。
比如蚂蚁集团的 g2plot 就是在 g2 基础上的封装,提供了更简洁的语法,引用 g2plot 的一段描述

const line = new Line('container', {
data,
xField: 'year',
yField: 'value',
});
line.render();
大家可以对比下 g2plot 的语法示例和 g2 的语法示例,g2 的语法在文章的图形语法一节。
拓展更多的端/技术栈
实践下来后,我们发现,相同的技术可以拓展至更多的技术栈,比如:iOS/Android/RN

开源
ChartSpace 和 Flutter ChartSpace 都是字节内部的产品。ChartSpace 在大量的数据产品,和其他业务中不断打磨,经受了不同场景的考验,包括抖音的数据分析,现在已经有开源计划了。Flutter ChartSpace 也需要在内部场景打磨后,再考虑开源。
Flutter ChartSpace 会在 ChartSpace 之后开源,预期是今年年底。
火山引擎 APMPlus 应用性能监控是火山引擎应用开发套件 MARS 下的性能监控产品。我们通过先进的数据采集与监控技术,为企业提供全链路的应用性能监控服务,助力企业提升异常问题排查与解决的效率。目前我们面向中小企业特别推出「APMPlus 应用性能监控企业助力行动」,为中小企业提供应用性能监控免费资源包。现在申请,有机会获得60天免费性能监控服务,最高可享6000万条事件量。
点击这里,立即申请
Flutter ChartSpace:通过跨端 Canvas 实现图表库的更多相关文章
- 准备开发一个基于canvas的图表库,记录一些东西(一)
开源的图表库已经有很多了,这里从头写个自己的,主要还是 提高自己js的水平,增加复杂代码组织的经验 首先写一个画图的库,供以后画图表使用.经过2天的开发,算是能拿出点东西了,虽然功能还很弱,但是有了一 ...
- 18个基于 HTML5 Canvas 开发的图表库
如今,HTML5 可谓如众星捧月一般,受到许多业内巨头的青睐.很多Web开发者也尝试着用 HTML 5 来制作各种各样的富 Web 应用.HTML 5 规范引进了很多新特性,其中之一就是 Canvas ...
- ECharts-基于Canvas,纯Javascript图表库,提供直观,生动,可交互,可个性化定制的数据可视化图表
ECharts http://ecomfe.github.com/echarts 基于Canvas,纯Javascript图表库,提供直观,生动,可交互,可个性化定制的数据可视化图表.创新的拖拽重计算 ...
- 基于html5 Canvas图表库 : ECharts
ECharts开源来自百度商业前端数据可视化团队,基于html5 Canvas,是一个纯Javascript图表库,提供直观,生动,可交互,可个性化定制的数据可视化图表.创新的拖拽重计算.数据视图.值 ...
- 【译】使用 Flutter 实现跨平台移动端开发
作者: Mike Bluestein | 原文地址:[https://www.smashingmagazine.com/2018/06/google-flutter-mobile-developm ...
- React / Vue 跨端渲染原理与实现探讨
跨端渲染是渲染层并不局限在浏览器 DOM 和移动端的原生 UI 控件,连静态文件乃至虚拟现实等环境,都可以是你的渲染层.这并不只是个美好的愿景,在今天,除了 React 社区到 .docx / .pd ...
- Chart.js – 效果精美的 HTML5 Canvas 图表库
Chart.js 是一个令人印象深刻的 JavaScript 图表库,建立在 HTML5 Canvas 基础上.目前,它支持6种图表类型(折线图,条形图,雷达图,饼图,柱状图和极地区域区).而且,这是 ...
- 重磅!滴滴跨端框架Chameleon 1.0正式发布
滴滴在 GitHub 上开源的跨端解决方案 Chameleon(简写 CML)正式发布 1.0 版本,中文名卡梅龙:中文意思变色龙,意味着就像变色龙一样能适应不同环境的企业级跨端整体解决方案,具有易用 ...
- 基于canvas实现的高性能、跨平台的股票图表库--clchart
什么是 ClChart? ClChart是一个基于canvas创建的简单.高性能和跨平台的股票数据可视化开源项目.支持PC.webApp以及React Native和Weex等平台.在React Na ...
随机推荐
- python 小兵(5)参数
我们目前为止,已经可以完成一些软件的基本功能了,那么我们来完成这样一个功能:约x 1 2 3 4 5 pint("拿出手机") print("打开陌陌") pr ...
- Luogu P1438无聊的数列
洛谷 P1438无聊的数列 题目链接 点这里! 题目描述 维护一个数列\(a_i\),支持两种操作: 给出一个长度等于 \(r-l+1\)的等差数列,首项为\(k\) 公差为\(d\) 并将它对应加到 ...
- 关于https域名下的页面iframe嵌套http页面的问题
业务场景:在一个https域名下用iframe嵌套一个http域名的页面,会直接报错的,报错信息如下: 这段话的意思是:http域名的页面是通过https域名页面加载的,在一个安全的页面不允许加载一个 ...
- 模仿系统的UIImageView
整体思路: 我们想要模仿系统的UIImageView,我们必须得要知道系统的UIView怎么用. 系统的用法是创建一个UIImageView对象,设置frame,给它传递一个UIIma ...
- js null和{}区别
{}是一个不完全空的对象,因为他的原型链上还有Object呢,而null就是完全空的对象,啥也没有,原型链也没有,所以null instanceof Object === false;[]就更不用说了 ...
- 关于setInterval方法中function的定义方法
使用window对象的setInterval方法,作为第一个参数传递的function必须在全局作用域中定义,否则会出现报错而无法执行. 具体如下: 在下面的代码中,试用jQuery方式在回调函数中使 ...
- 使用Docker安装ElasticSearch和可视化界面Kibana【图文教学】
一.前言 Elasticsearch是一个基于Lucene的搜索服务器.它提供了一个分布式多用户能力的全文搜索引擎,基于RESTful web接口.Elasticsearch是用Java语言开发的,并 ...
- 「 题解 」P2487 [SDOI2011]拦截导弹
简单题意 给定 \(n\) 个数对 \((h_i, v_i)\). 求: 最长不上升子序列的长度. 对于每个 \(i\),分别求出包含数对 \((h_i, v_i)\) 的最长上升子序列的个数和最长不 ...
- 「Python实用秘技05」在Python中妙用短路机制
本文完整示例代码及文件已上传至我的Github仓库https://github.com/CNFeffery/PythonPracticalSkills 这是我的系列文章「Python实用秘技」的第5期 ...
- 常用模块(Day25-Day28)
模块分为三种: 1.内置模块:python安装时自带的. 2.扩展模块:别人写的,需要安装之后可以直接使用,如django,tornado等. 3.自定义模块:自己写的模块. 序列化模块 序列指字符串 ...