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

相關描述來自: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萬條事件量。

👉 點擊這裏,立即申請

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章