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 上来。

实现这个方案,要解决两个问题:

1. 把东西画出来

2. 把交互串起来

把东西画出来

核心思想:将 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    0

0    0    0    0

0    0    0    0

0    0    0    0

1    0    0    0

0    1    0    0

0    0    1    0

0    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 的一段描述

相关描述来自: https://zhuanlan.zhihu.com/p/339275513

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万条事件量。

👉 点击这里,立即申请

  • 0
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
如何安装mschart#Region "定义变量" Const XOffset As Integer = 50, Yoffset As Integer = 20 Dim WithEvents PicCurve As PictureBox Dim xStep As Single = 5, yStep As Single Dim ValueArray As ArrayList Dim ThresHold() As Single 'HIHI、HI、LO、LOLO Dim Name As String = "参数名" Dim Unit As String = "单位" #End Region #Region "构造函数、析构函数" Public Sub New(ByVal mPictureBox As PictureBox, ByVal mThresHold() As Single, ByVal mName As String) ValueArray = New ArrayList PicCurve = mPictureBox ThresHold = mThresHold mPictureBox.BorderStyle = BorderStyle.None Name = mName End Sub Protected Overrides Sub Finalize() MyBase.Finalize() ValueArray.Clear() End Sub #End Region #Region "添加要绘制的点的信息" Public Sub AddValue(ByVal Value As Single) 'Value = ThresHold(0) * Rnd() + ThresHold(3) '测试代码 ValueArray.Add(Value) Call DrawCurve(GetGraphics(PicCurve)) End Sub Public Sub AddValues(ByVal Values() As Single, ByVal G As Graphics) For i As Integer = 0 To Values.Length - 1 ValueArray.Add(Values(i)) Next Call DrawCurve(G) End Sub #End Region #Region "绘制点之间的连线" Private Sub DrawCurve(ByVal G As Graphics, Optional ByVal mClear As Boolean = True) If ValueArray.Count > 0 Then If mClear Then G.Clear(Color.White) G.SmoothingMode = Drawing2D.SmoothingMode.HighQuality Dim mPoints(ValueArray.Count - 1) As Point, Position As Integer = XOffset For i As Integer = ValueArray.Count - 1 To 0 Step -1 Dim ThisValue As Single = CType(ValueArray(i), Single) 'If Position < PicCurve.Width Then Position += xStep Else Exit For Position += xStep mPoints(i) = New Point(Position, (ThresHold(0) - ThisValue) * yStep + Yoffset) Next G.DrawCurve(Pens.Blue, mPoints) mPoints = Nothing End If End Sub #End Region #Region "绘制坐标系统" Private Sub DrawReferenceFrame(ByVal G As Graphics) Dim mPoint1 As New Point, mPoint2 As New Point '定义绘图画笔 Dim MyPen As New Pen(Color.Black, 3) Dim MyStringFormat As New System.Drawing.StringFormat MyStringFormat.Alignment = StringAlignment.Center Dim mSize As New SizeF MyPen.SetLineCap(Drawing2D.LineCap.NoAnchor, Drawing2D.LineCap.ArrowAnchor, Drawing2D.DashCap.Flat) Dim mFont As Font = New Font(FontFamily.GenericSansSerif, 12.0F, FontStyle.Bold) '绘制Y轴 MyPen.Color = Color.Black mPoint1 = New Point(XOffset, PicCurve.Height) mPoint2 = New Point(XOffset, 0) G.DrawLine(MyPen, mPoint1, mPoint2) '绘制X轴 MyPen.Color = Color.LightGreen mPoint1 = New Point(XOffset, PicCurve.Height / 2) mPoint2 = New Point(PicCurve.Width, PicCurve.Height / 2) G.DrawLine(MyPen, mPoint1, mPoint2) '绘制参数名 MyStringFormat.FormatFlags = StringFormatFlags.DirectionVertical mSize = G.MeasureString(Name, mFont) mPoint1.Offset(-mSize.Height, 0) G.DrawString(Name, mFont, Brushes.Black, mPoint1, MyStringFormat) '绘制参数各门限 MyPen.DashStyle = Drawing2D.DashStyle.Dash : MyPen.Width = 2 MyPen.SetLineCap(Drawing2D.LineCap.NoAnchor, Drawing2D.LineCap.NoAnchor, Drawing2D.DashCap.Round) 'HI If ThresHold(1) <> ThresHold(0) Then MyPen.Color = Color.Yellow mPoint1 = New Point(XOffset, (ThresHold(0) - ThresHold(1)) * yStep + Yoffset) mPoint2 = New Point(PicCurve.Width, (ThresHold(0) - ThresHold(1)) * yStep + Yoffset) G.DrawLine(MyPen, mPoint1, mPoint2) mSize = G.MeasureString(ThresHold(1).ToString, mFont) mPoint1.Offset(-mSize.Width, -mSize.Height / 2) G.DrawString(ThresHold(1).ToString, mFont, Brushes.Yellow, mPoint1) End If 'LO If ThresHold(2) <> ThresHold(3) Then mPoint1 = New Point(XOffset, (ThresHold(0) - ThresHold(2)) * yStep + Yoffset) mPoint2 = New Point(PicCurve.Width, (ThresHold(0) - ThresHold(2)) * yStep + Yoffset) G.DrawLine(MyPen, mPoint1, mPoint2) mSize = G.MeasureString(ThresHold(2).ToString, mFont) mPoint1.Offset(-mSize.Width, -mSize.Height / 2) G.DrawString(ThresHold(2).ToString, mFont, Brushes.Yellow, mPoint1) End If 'HIHI MyPen.Color = Color.Red mPoint1 = New Point(XOffset, Yoffset) mPoint2 = New Point(PicCurve.Width, Yoffset) G.DrawLine(MyPen, mPoint1, mPoint2) mSize = G.MeasureString(ThresHold(0).ToString, mFont) mPoint1.Offset(-mSize.Width, -mSize.Height / 2) G.DrawString(ThresHold(0).ToString, mFont, Brushes.Red, mPoint1) 'LOLO mPoint1 = New Point(XOffset, PicCurve.Height - Yoffset) mPoint2 = New Point(PicCurve.Width, PicCurve.Height - Yoffset) G.DrawLine(MyPen, mPoint1, mPoint2) mSize = G.MeasureString(ThresHold(3).ToString, mFont) mPoint1.Offset(-mSize.Width, -mSize.Height / 2) G.DrawString(ThresHold(3).ToString, mFont, Brushes.Red, mPoint1) MyPen.Dispose() mFont.Dispose() End Sub #End Region #Region "要在其上绘制的控件事件" Private Sub PicCurve_Paint(ByVal sender As Object, ByVal e As System.Windows.Forms.PaintEventArgs) Handles PicCurve.Paint Call DrawReferenceFrame(e.Graphics) End Sub Private Sub PicCurve_Resize(ByVal sender As Object, ByVal e As System.EventArgs) Handles PicCurve.Resize On Error Resume Next xStep = 5 yStep = (PicCurve.Height - 2 * Yoffset) / (ThresHold(0) - ThresHold(3)) End Sub #End Region #Region "绘制永久图像" Function GetGraphics(ByRef pic As PictureBox) As Graphics Dim bmp As Bitmap = New Bitmap(pic.Width, pic.Height) pic.Image = bmp Dim g As System.Drawing.Graphics = Graphics.FromImage(bmp) Return g End Function #End Region #Region "保存图形" Public Sub SaveCurve(ByVal FileName As String) Dim bmp As New Bitmap(PicCurve.Width, PicCurve.Height) Dim g As Graphics = Graphics.FromImage(bmp) Call DrawReferenceFrame(g) Call DrawCurve(g, False) bmp.Save(FileName) End Sub #End Region End Class
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值