【Flutter ChartSpace】通過跨端 Canvas 實現圖表庫

語言: CN / TW / HK

作者:dpfe_hupo

背景

資料平臺有個基於圖形語法的圖表庫 ChartSpace ,支援 web/h5/mini program,現在收到業務訴求,要支援到 Flutter 端。

為方便理解,稍微解釋下圖形語法的概念,已經瞭解的小夥伴可以跳過這一段。

圖形語法

圖形語法(grammar of graphics) 是通過一套語法來描述任意圖形,主要來自 Wilkinson 的《The Grammar of Graphics》,可參考文章: https:// zhuanlan.zhihu.com/p/47 550015

圖形語法與一般的圖表主要區別在於:圖形語法只要修改下語法描述,就能得到完全不同的圖形,而一般的圖表需要增加圖表型別。圖形語法可描述的圖形是近乎無限的,而圖表型別是有限的。

舉個例子(擷取自: https:// segmentfault.com/a/1190 000041004457 ):

如果我們基於圖形語法繪製了柱狀圖

將語法中的座標系換成極座標後,會變成玫瑰圖

語法中調整座標度量,並增加不同顏色後,變成了更完善的玫瑰圖

繼續調整語法引數,最終可得到餅圖

在這個例子中,如果用一般圖表(如 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 區別如下:

針對第一個問題,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 的變換矩陣預設值不一致

所以解決方案如下:

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/33 9275513

const line = new Line('container', {
  data,
  xField: 'year',
  yField: 'value',
});

line.render();

大家可以對比下 g2plot 的語法示例和 g2 的語法示例,g2 的語法在文章的圖形語法一節。

拓展更多的端/技術棧

實踐下來後,我們發現,相同的技術可以拓展至更多的技術棧,比如:iOS/Android/RN