SIT-board 遠程交互式白板的實現

來自上海應用技術大學的「SIT-board」團隊,在七牛雲校園黑客馬拉松中勇奪冠軍,以下是他們的參賽作品——SIT-board遠程交互白板的實現過程。

需求分析

基本繪圖功能

作爲一個在線協作白板,離線的本地化的白板是一切功能的前提。本地白板中需要包含所有白板繪圖相關的基本功能。

分頁展示

白板需要支持分頁顯示,每一頁都有其獨立標題,用戶能夠切換當前頁面,增加新頁面,刪除非當前頁面,需要保證項目至少存在一頁。

創建圖元

用戶可以在白板上創建各式各樣的圖形元素,至少需要包含直線、矩形、橢圓、文本框、自由路徑的繪製等等。

操作歷史

用戶能夠操作歷史線,實現回滾與重做功能。

工程化

白板若要真正具備實用價值,必然需要實現持久化存儲,用戶能夠保存當前白板工程文件,打開載入一個白板工程文件,另存爲白板工程文件。

操作圖元

添加的圖形元素的各個屬性需要支持再編輯,如選中直線能夠修改其線寬、顏色,選中文本框能夠修改其對齊方式, 背景,邊框等等。

每個添加的圖形都需要能夠支持移動、縮放、旋轉等變換。

每個添加的圖形還需要能夠支持修改層疊關係和刪除圖形的操作。

擴展繪圖功能

富文本展示

支持一定的展示富文本的功能,如支持 HTML 文檔和 Markdown 文檔。

圖片展示

支持插入位圖並能夠修改其填充方式。

支持插入矢量圖並能夠修改其填充方式,覆蓋顏色等操作。

插入附件

支持插入附件類型,用戶可上傳文件並生成外鏈到白板內並支持再次下載已上傳的附件。

創建與加入房間

每個人都可以一鍵快速創建一個白板,創建者稱爲該房間的主持人。

主持人進入白板後可點擊複製當前房間 ID 並分享給其他人。

其他人輸入房間 ID 即可加入該白板所在的房間,加入房間的人稱爲該房間的一個 成員。

協作與只讀模式

房間中的白板分爲協作模式和只讀模式:

只有主持人可隨時修改白板模式。

只讀模式

在只讀模式下,所有成員均無法編輯且視角和頁面必須與房間主持人保持同步跟隨。

協作模式

在協作模式下,所有成員都具有自己的獨立視角和獨立的頁面,均可實現獨立編輯。

UML 用例分析

從多人協同功能中我們可抽取出三種角色 actor,分別爲主持人,普通成員,用戶,其中主持人與普通成員均爲用戶,用戶能夠使用所有基本和擴展功能,主持人與普通成員均有自身特有的功能。

最終完整的功能性需求的 UML 用例圖可總結如下:

非功能性需求

跨平臺

白板需要實現跨平臺,目前用戶場景的設備或運行環境主要分爲以下環境:

PC 桌面端:Windows,MacOS,Linux

移動端:Android,iOS

網頁端:Web

考慮到目前本人手頭上已有的設備,暫時只優化 Windows 端與 Android 端的使用體驗,其他端如 Linux,MacOS,iOS,Web 端儘可能實現。跨平臺要求除了能夠實現基本的運行外,還需分別爲 PC 端鍵鼠和移動端觸屏進行單獨的適配以實現更好的用戶體驗,如 PC 端使用滾輪縮放視圖,移動端使用手勢縮放視圖,PC 端需要適配鼠標右鍵彈出菜單,移動端適配長按彈出菜單。

性能需求

儘量降低多人協同場景下的網絡延遲,儘量降低軟件中潛在的性能問題。

這意味着我們需要設計一些較巧妙的算法來避免相對暴力的解決方案。如使用 diff 算法實現增量同步,優化序列化反序列化開銷等手段。

可維護與可擴展性

隨着白板的功能演進,白板中的圖形元素未來必然會持續豐富,需要支持良好的可擴展性以實現更加方便地擴展白板具備的功能。

考慮到其實我們這個白板系統完全可抽取出獨立的白板 SDK 供第三方軟件進行直接接入使用,故需要儘可能的抽象並開放出白板中 公共的可定製化的接口,以便於第三方軟件可藉助白板 SDK 靈活定製和擴展白板的新功能。

故我們可以實現一套插件系統,擴展新功能時僅新增插件代碼和添加插件註冊點代碼而不是需要到處修改代碼,良好地符合了開閉原則。

開發方案選擇

出於跨平臺的考慮,目前較熱門的技術分別是 Web 開發和 Flutter 客戶端開發,考慮到團隊已掌握技術棧的熟練程度,最終選擇了 Flutter 客戶端開發。

起初,我們嘗試使用 Flutter 的 CustomPaint 這個控件基於 Canvas 進行自繪。也實現了像矩形,文本框,直線等基本圖元的繪製。後來我們發現,爲了優化用戶體驗,我們需要在 Canvas 繪製好的圖形上再自己繪製很多 ui 元素,還需要手動實現將 Canvas 的全局事件分發各個圖元交互事件,這其實已經類似自己寫了一個 GUI 框架了,感覺會相當麻煩,出於時間和精力的考慮,暫時放棄這種自己造輪子的想法。

經過調研發現,原來在 Web 領域有 Konva 和 Fabric.js 這樣的 Canvas 繪圖框架,完全能夠滿足繪圖需求。可惜 Flutter 生態裏缺乏類似框架(或許以後有功夫可以自己造一個類似框架)。

實際上,Flutter 自身就是基於 Skia2D 繪圖引擎通過自繪實現的一套 GUI 框架,一切控件的底層均歸結到基本的 skia 繪圖指令。於是我想,Flutter 本身這不就是我們要找的繪圖框架嗎?假如我們直接依靠 Flutter 自身的控件系統完成白板系統,那麼既省時省力又可以相當靈活地擁抱 Flutter 生態下的任何 ui 組件庫。

白板組件設計實現

白板容器

爲了使用 Flutter 自身的控件系統實現白板的大體框架,我們首先面臨的需求如下:

設計一個佈局容器,滿足如下需求:

  1. 無限大的,可自由拖動,縮放可見視角
  2. 某個控件位置由一個絕對座標來定位
  3. 其中的每個孩子需要有一定的尺寸約束,尺寸約束包含了最大尺寸和最小尺寸,用於實現圖元的大小控制。

實際上在 Flutter 中有一個叫做 Stack 的組件,Flutter 中的 Stack 控件可基於父容器的邊緣位置的偏移量實現定位。Flutter 中還自帶另一個組件 InteractiveViewer 可實現對某個 Widget 進行手勢縮放與拖動,若將兩者進行結合不就能實現我們的預期效果了嗎?

完成 Stack 佈局代碼如下,可以放置三個尺寸爲 (100,100) 的盒子並且座標分別爲 (0,0), (120,100),(50,50) 顏色分別爲紅色,綠色,黃色。

class MyWidget extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Container(
      child: Stack(
          children: [
              Positioned(
                  left: 0,
                  top: 0,
                  child: Container(
                      width: 100,
                      height: 100,
                      color: Colors.red,
                  ),
              ),
              Positioned(
                  left: 120,
                  top: 100,
                  child: Container(
                      width: 100,
                      height: 100,
                      color: Colors.green,
                  ),
              ),
              Positioned(
                  left: 50,
                  top: 50,
                  child: Container(
                      width: 100,
                      height: 100,
                      color: Colors.yellow,
                  ),
              ),
          ],
      ),
    );
  }
}

此時運行結果如下:

在外層再套一個 InteractiveViewer 即可實現可自由縮放平移的效果了

class MyInteractiveWidget extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Sizedbox.expand(
      child: InteractiveViewer(
        child: MyWidget(),
      );
    );
  }
}

但是此時我們會發現這裏的 Viewer 的視角其實僅限於其原始的父容器尺寸的可視範圍,並無法實現無限大的範圍,此時我們再爲 InteractiveViewer 設置一個屬性爲

boundaryMargin: const EdgeInsets.all(double.infinity),

即可實現無限大的平移縮放效果了。

爲了方便觀察,將調試模式中的控件邊框打開,從運行結果我們可以看出,整個 Stack 的大小其實還是原來的 Stack 所佔據的父容器空間的大小,並沒有發生任何改變。

若將紅色盒子的 left 和 right 分別設爲 - 50, -50,則呈現如下效果:

可以發現紅色盒子越界部分將被裁剪。

我們可以設置 Stack 組件的 clipBehavior 屬性以取消默認的裁剪行爲

clipBehavior: Clip.none,

看起來現在一切都很完美了,我們擁有了一個看起來是無限大的佈局容器,能夠進行的自由平移,縮放。

現在讓我們爲每個矩形嘗試添加事件監聽器 GestureDetector (),修改 MyWidget 代碼如下:

class MyWidget extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Stack(
      clipBehavior: Clip.none,
      children: [
        [-50.0, -50.0, 100.0, 100.0, Colors.red, '紅色'],
        [120.0, 100.0, 100.0, 100.0, Colors.green, '綠色'],
        [50.0, 50.0, 100.0, 100.0, Colors.yellow, '黃色'],
      ].map((e) {
        return Positioned(
          left: e[0] as double,
          top: e[1] as double,
          child: GestureDetector(
            onPanDown: (d) {
              print('${e[5]}被按下: ${d.localPosition}');
            },
            child: Container(
              width: e[2] as double,
              height: e[3] as double,
              color: e[4] as Color,
            ),
          ),
        );
      }).toList(),
    );
  }
}

此時我們會發現紅色越界部分始終無法響應任何觸摸事件,這不符合我們的需求。有關這個問題,我們可以在 flutter 官方倉庫中的 issues 中找到相關討論

https://github.com/flutter/flutter/issues/19445

這個問題在 Github 上有相當激烈的討論,大概原因就是如果 hitTest 不對超出邊界的點擊事件進行預判斷並裁剪,那麼會相當地耗性能。我們可以通過重構代碼的方式來避免這個越界裁剪的問題。

經過研究,我們發現了這個點擊裁剪原來是對於所有繼承於 RenderBox 抽象類的一個默認行爲。一種較爲優雅的解決方案,就是通過繼承 RenderStack 類並重寫 hitTest 刪除邊界裁剪代碼,再創建自己的 Stack 組件 繼承自 Stack 組件並重寫其中的 createRenderObject 方法爲自己的重寫的 RenderStack。

如下代碼即爲前後的核心代碼的改動

 @override
  bool hitTest(BoxHitTestResult result, {required Offset position}) {
    // 原本的RenderBox的點擊判定的源碼需要進行box邊界裁剪
    // if (_size!.contains(position)) {
    //   if (hitTestChildren(result, position: position) || hitTestSelf(position)) {
    //     result.add(BoxHitTestEntry(this, position));
    //     return true;
    //   }
    // }

    // 修改後的代碼
    if (hitTestChildren(result, position: position) || hitTestSelf(position)) {
      result.add(BoxHitTestEntry(this, position));
      return true;
    }
    return false;
  }

項目中有關白板容器組件的實現如下在代碼路徑:

https://github.com/SIT-board/board_front/tree/master/lib/component/interactive_infinity_layout

該組件完全可分離爲一個獨立的 flutter package 供任何第三方項目所使用。

白板存儲結構設計

既然我們白板顯示的內容完全是基於 Flutter 自身的控件系統完成開發的,那麼白板中的一個個圖形元素自然就是一個個 Widget。在傳統的 Flutter App 開發中,這些 ui 控件的狀態信息要麼是由外部數據傳入一個 StatelessWidget 組件,要麼是 StatefulWidget 組件中自己維護自己的狀態變量。

考慮到由於這些白板及白板元素的狀態數據需要支持持久化操作,需要支持序列化反序列化操作,需要支持 diff 操作等等,故我們需要將需要這些操作的狀態變量分離出一個獨立的 Data 類單獨存放。

class RectModelData {
    Offset position;
    Size size;
    Color color;

    RectModelData(this.position, this.size, this.color);

    factory RectModelData.createDefault() => RectModelData(
        position: Offset(0, 0), 
        size: Size(100, 100), 
        color: Colors.blue,
    );

    factory RectModelData.fromJson(Map<String, dynamic> json) => RectModelData(
        position: ((e)=>Offset(e[0], e[1]))(json['offset']),
        size: ((e)=>Size(e[0], e[1]))(json['size']),
        color: Color(json['color']),
    );

    Map<String, dynamic> toJson() => <String, dynamic>{
        'position': [position.dx, position.dy],
        'size': [size.dx, size.dy],
        'color': color.value,
    };
}

上述代碼爲典型的 json_model 序列化反序列化代碼,由於 flutter 不支持運行時反射機制,故必須寫出上述這種代碼,可以看出這種代碼較爲繁瑣且無趣。

不過事實上我們也是能夠使用第三方的代碼生成工具去根據 json 生成上述代碼,flutter 官方也提供了一種叫做 build_runner的代碼分析與生成工具,能夠實現通過編寫第三方插件實現這種代碼的生成。

當我們編寫 diff 算法時,我們接收到其他人發來的白板數據更新信息,這種更新信息能夠精確到具體的 model 中的某一個字段,故我們還需要實現修改某個 key 對應的值這種操作。一個暴力的解決方案就是先整體 model 序列化本地存儲的數據,經過修改某個字段後再整體 model 反序列化回去。不難發現這種實現方案的時間空間開銷很明顯具有相當大的優化空間,但是由於 flutter 不支持反射,故難以實現根據字符串名修改某個字段的值和類型。

那麼是否能夠自己編寫 build_runner 代碼生成工具來通過編譯期生成代碼實現反射呢?這從理論上感覺應該可行,不過我們想到了另一種解決方案:

其實我們直接將所有狀態變量存儲在 HashMap 裏不就行了?看起來完全沒有必要定義一個單獨的數據類再實現序列化和反序列化和根據字符串修改字段等方法,直接使用 HashMap,構造 Widget 時再去讀取 HashMap 裏的值不就行了?於是我們的數據類可改造爲以下寫法:

abstract class HashMapData {
    Map map;
    HashMapData(this.map);
    String toJsonString() => jsonEncode(toJson());
}

class RectModelData extends HashMapData{
    Offset get position => ((e) => Offset(e[0], e[1]))(map['position'] ??= [0, 0]);

    // 上述一行代碼等價於下面繁瑣的代碼
    // Offset get position {
    //    var p = map['position'];
    //    if(p == null) {
    //        var p1 = [0, 0];
    //        map['position'] = p1;
    //        p = p1;
    //    }
    //    return Offset(p[0], p[1]);
    //}

    set position(Offset v) => map['position'] = [v.dx, v.dy];

    Size get size => ((e) => Size(e[0], e[1]))(map['size'] ??= [0, 0]);
    set size(Size v) => map['size'] = [v.width, v.height];

    Color get color => Color(map['color'] ??= Color.blue.value);
    set color(Color v) => map['color'] = v.value;

    RectModelData(super.map);

    factory RectModelData.createDefault() => RectModelData({});
}

實際上,這就相當於對 HashMap 進行了一層封裝抽象,基於 HashMap 抽象出該圖形元素的數據讀寫類。這就像 c 語言結構體的底層存儲是原始的二進制內存數據,但是上層的使用經過了結構化抽象。

此時我們仍然像之前一樣可以使用這個數據類,但是完全不再需要使用 build_runner 生成序列化反序列化代碼,因爲底層直接就是一個 HashMap,序列化可以直接使用底層的 map,反序列化直接構造該數據類即可,當我們需要根據字符串修改某個特定的值時,也能夠輕鬆直接修改底層的 map 中的數據。

白板數據結構設計

我們採用自底向上的分析方式對數據結構的設計進行分析。首先,我們稱一個個的圖形元素爲模型 Model。

CommonModelData

首先根據需求分析,我們的每個圖形都能夠支持移動,縮放,旋轉的變換,能夠修改圖層層疊關係,故可抽取如下公共屬性:

其中 constraints 屬性有四個分量表示了其尺寸縮放的最大與最小尺寸

CommonModelData {
  // 旋轉角
  angle: double
  // 位置,分別爲x,y座標
  position: array<double>[2]
  // 大小,分別爲width與height
  size: array<double>[2]
  // 層疊關係,越大越靠前
  index: int
  // 約束關係,由minWidth,maxWidth,minHeight,minWidth四個分量構成
  // 用於確定該模型能夠拉伸的最大與最小尺寸
  constraints: array<double>[4]
}

SpecialModelData

SpecialModelData 類型是一個泛指類型,不同類型的 Model 具有不同的 data 類型,其存放了圖形元素自身的內部特有的屬性。

RectModelData

RectModelData 類型爲矩形元素的特有數據,根據需求分析,存在文本框這種圖形元素,故我們可以直接將文本框和矩形組件合併爲一種圖形元素。

故我們可抽象出如下的矩形 / 文本框的數據結構:

RectModelData {
  // 背景顏色
  color: Color
  // 背景形狀 0表示矩形,1表示圓形
  backgroundShape: int
  // 邊框屬性
  boarder: BorderModelData {
    // 邊框顏色
    color: Color
    // 邊框的寬度
    width: double
    // 邊框的圓角半徑
    radius: double
  }
  // 矩形內部的文本屬性
  text: TextModelData {
    // 文字內容
    content: string
    // 文字顏色
    color: Color
    // 文字大小
    fontSize: double
    // 對齊方式
    // 水平對齊有三個方式分別爲左對齊,居中對齊,右對齊
    // 分別對應數字-1,0,-1
    // 垂直對齊有三個方式分別爲上對齊,居中對齊,右對齊
    // 分別對應數字-1,0,-1
    // 水平與垂直對齊對應的數字即爲最終alignment的值
    alignment: array<int>[2]
    // 是否加粗
    bold: bool
    // 是否斜體
    italic: bool
    // 是否下劃線
    underline: bool
  }
}

FreeStyleModelData

FreeStyleModelData 爲自由畫板插件的數據類型定義,考慮到需求分析中能夠繪製自由曲線,故設計該圖形元素爲自由繪製的畫板。

FreeStyleModelData {
  // 路徑id列表
  pathIdList: array<int>
  // 路徑字典
  pathMap: map<int, FreeStylePathModelData>
  // 路徑顏色
  backgroundColor: Color
  // 當前畫筆狀態屬性
  paint: Paint
}

Paint {
  // 畫筆顏色
  color: Color
  // 畫筆寬度
  stokeWidth: double
  // 抗鋸齒
  isAntiAlias: bool
}
FreeStylePathModelData {
  // 路徑id
  id: int
  // 路徑點,分別對應x,y座標
  points: array<array<double>[2]>
  // 路徑畫筆
  paint: Paint
}
  • 其他圖形元素類型

同樣還有其他圖形元素的特有的數據結構,具體可參考代碼 component/board/plugins 中 data.dart 的定義與實現。

Model

Model 類型定義了某個白板中的模型,其數據類型定義如下:

Model {
  // 模型id
  id: int
  // 模型類型
  type: string
  // 模型數據,由模型類型決定不同數據類型
  data: <SpecialModelData>
  // 模型公共屬性
  common: CommonModelData
}
  • BoardViewModel

BoardViewModel 定義了一個白板的視圖模型,一個白板可看做由若干個模型的集合及視角數據所構成。

視角數據可由一個 4x4 矩陣所表示。

爲什麼是 4x4 矩陣? 在 Flutter 中一切 ui 元素均可定義在三維空間中的某個平面,這樣我們便可以方便地對某個 ui 元素進行更豐富的三維變換了,例如我們可以實現三維空間中繞 x,y,z 軸的旋轉,可以實現 x, y, z 軸上的平移等變換。 3x3 矩陣實際上只能夠描述任意三維空間圖形的線性變換,如縮放,旋轉,錯切等。 4x4 矩陣實際上可以描述任意三維空間下圖形的仿射變換,能夠在線性變換的基礎上外加實現平移變換。

白板數據結構定義如下:

BoardViewModel {
  // 視口變換矩陣, 爲4x4矩陣
  viewerTransform: array<double>[16]
  // 模型id列表
  modelIdList: array<int>
  // 模型字典
  modelMap: map<int, Model>
}

BoardPageViewModel

根據需求分析中,白板需要支持分頁展示,且每一頁均有獨立標題,那麼 BoardPageViewModel 數據類型定義了某一頁的數據,數據定義如下:

BoardPageViewModel {
  // 頁面標題
  title: string
  // 頁面id
  pageId: int
  // 白板數據
  board: BoardViewModel
}

BoardPageSetViewModel

根據需求分析中,白板能夠實現分頁展示,能夠切換當前頁面,故需要存儲當前頁面的 id,設計數據結構如下:

BoardPageSetViewModel {
  // 頁面數
  pageIdList: array<int>
  // 頁面字典,存儲了所有頁面信息
  pageMap: map<string, BoardPageViewModel>
  // 當前頁面id
  currentPageId: int
}
  • SBP 文件

sbp 文件爲 SIT-board 的工程文件。實際上 sbp 文件就是以文本形式存放的最頂層 BoardPageSetViewModel 對象的 json 序列化格式。

或許可以重構成二進制方式存放的更加緊湊的文件格式,或者直接使用 BSON 庫。

JsonDiff 算法設計實現

Diff 算法可通過比較計算得到某個對象在不同狀態之間的差異,還可將這種差異應用到前一個狀態上來計算得出後一個狀態。我們將該差異記做一個補丁 patch

Diff 算法的基本運算規則如下:

初始狀態:State0 = {}

目標狀態:State1 = {e1:1, e2:2}

目標到初始的差異:Patch1 = State1 – State0 = {add: {e1:1, e2:2}}

若已知差異補丁:Patch2 = {update: {e1:2}, remove: {e2}}

計算可得目標狀態:State2 = State1 + Patch2 = {e1: 2}

以上過程可得出 UML 狀態圖如下:

UndoRedo 算法設計實現

那麼我們是如何基於 Diff 算法實現 UndoRedo 的呢?

方案一

當新增數據時,即 State0 轉換到 State1 後,我們計算出其 Patch1,

State0—>State1

State0 = {},State1 = {e1:1, e2:2}

Patch1 = State1 - State0 = {add: {e1:1, e2:2}}

我們將 Patch1 放入一個棧 S1 中。

S1: Patch1

再從 State1 轉換到 State2,計算出 Patch2 = {update: {e1:2}, remove: {e2}}

我們將 Patch2 放入棧 S1 中

S1: Patch1 Patch2

再從 State2 轉換到 State3,計算出 Patch3 = {remove: {e1} }

我們將 Patch3 放入棧 S1 中

S1: Patch1 Patch2 Patch3

此時已經存在了三個 Patch 了。

當我們需要執行 Undo 撤銷操作時,我們需要彈出棧頂的 Patch 並反向計算出 Patch 的逆,一個 Patch 的逆實際上爲其逆變換。比如 Patch1 的 add 的屬性將變爲 remove 的屬性。然後我們在 當前狀態上應用 Patch 的逆實際上就實現了 Undo 操作。

Patch1 的逆: -Patch1 = {remove: {e1:1, e2:2}}

Undo 操作: State0 = State1 + (-Patch1)

Redo 操作: State1 = State0 + Patch1

注意:爲了保證每一步的 Patch 均爲可逆的,故我們需要存放一些冗餘數據,如記錄 remove 操作仍需記錄 remove 的狀態變量的狀態值,這樣可直接計算出 remove 對應的逆操作 add 操作。

爲了實現 Redo 操作,我們 Undo 時彈出棧的的那個 Patch 也不能夠丟棄,它將進入另一個棧 S2 中用於實現 Redo 操作。

當我們當前狀態處於 State3 時,此時無法繼續 redo,但是能夠 undo。

流程

於是我們進行 Undo 撤銷操作,此時狀態爲 State3,目標狀態爲 State2,我們 undo 操作流程如下:

  1. 從 S1 中彈出棧頂的 Patch3
  2. 計算出 - Patch3
  3. -Patch3 應用到當前狀態後得到 State2 = State3 + (-Patch3)
  4. 將 Patch3 加入棧 S2

此時又可以 undo 又可以 redo,此時的狀態爲 State2,目標狀態爲 State3,我們的 redo 操作流程如下:

  1. 從 S2 中彈出棧頂的 Patch3
  2. Patch3 應用到當前狀態後得到 State3 = State2 + Patch3
  3. 將 Patch3 加入棧 S1

我們可以得出如下判定條件:

可實現 Undo:S1 不爲空

可實現 Redo:S2 不爲空

若棧 S1 和棧 S2 均不爲空,即做了若干操作過後進行撤銷到一半,若此時發生了新的變更,則 UML 狀態圖上將會出現非線性的分支 branch。那麼這種情況如何處理呢?目前採取的策略是新操作將會 清空 S2 棧。

方案二

還有第二種方式爲使用雙向鏈表來實現 Undo Redo。起初存在一個 CurrentState 指針指向鏈表的頭結點,當我們每次發生變更後新增的 Patch 將插入到 CurrentState 指針的下一條位置,並且 CurrentState 指針向後移動指向本次變更新增的 Patch。

當我們進行 Undo 操作時,我們僅需要取得 CurrentState 指針指向的 Patch,並將該 Patch 的逆應用到當前狀態,然後 CurrentState 指針後退,即可實現 Undo 操作。

當我們進行 Redo 操作時,我們需要前進 CurrentState 指針到後繼 Patch 並將其應用到當前狀態,即可實現 Redo 操作。

我們可以得出如下判定條件:

可實現 Undo:CurrentState 未指向頭結點

可實現 Redo:CurrentState 未指向尾節點

在本項目中,我們是使用了順序存儲的列表 + 索引值來實現這些操作。CurrentState 爲一個 int 值的索引。當 CurrentState == -1 時代表指向了頭結點。

Package

我們已將其算法封裝爲一個獨立的 package 可隨時被任何第三方項目所引用。

這是它的單元測試用例。

首先初始化一個空的 state,使用我們封裝的 UndoRedoManager 類包裹 state

初始狀態 State0 下,既不能撤銷又不能重做。

final state = {};
final urm = UndoRedoManager(state);
expect(urm.canUndo, isFalse);
expect(urm.canRedo, isFalse);
  • 當狀態發生改變到達 State1 時,能夠撤銷但仍不能重做。
state['e1']=1; 
state['e2']=2;
urm.store();
expect(urm.canUndo, isTrue);
expect(urm.canRedo, isFalse);

當撤銷狀態 State1 時,回到了最初狀態 State0,無法繼續撤銷但能夠進行重做。

urm.undo();
expect(state['e1'], equals(null));
expect(urm.canUndo, isFalse);
expect(urm.canRedo, isTrue);
  • 當重做時,回到了 State1 狀態,此時能夠撤銷但不能重做
urm.redo();
expect(state['e1'], equals(1));
expect(urm.canUndo, isTrue);
expect(urm.canRedo, isFalse);
  • 白板數據同步方案設計實現

那麼我們是如何基於 Diff 算法實現分佈式場景下的同步呢?

基於話題的發佈訂閱通信模型

本項目最初設計討論時候,我們發現其實這個多人協同的場景實際上就是一種分佈式狀態同步的場景,首先我們需要解決各個節點之間的通信問題:

  1. 每個用戶都是作爲一個分佈式節點去接收中心服務器上的白板狀態數據變更
  2. 每個分佈式節點也可將變更上傳至服務器併發送到其他各個分佈式節點上

BaseMessage

首先設計各個節點之間通信的基本消息類型。

  1. 各個節點具備一個唯一的 uuid 字符串
  2. 節點要加入的房間也具備一個唯一的 uuid 字符串。

定義一個 BaseMessage 消息類型,節點之間的所有通信的消息包必須爲 BaseMessage 消息:

BaseMessage {
    ts: DateTime
    topic: String
    publisher: String
    sendTo: String
    data: any
}
  • 設計

每個節點在某個房間均具備如下行爲:

  1. 每個節點都能夠 send 一個 BaseMessage 對象到另一個 uuid 爲 sendTo 的節點上。
  2. 每個節點都能夠 register 一個回調函數去接收其他節點傳送過來的 BaseMessage 對象。
  3. 每個節點都能夠 broadcast 一個 BaseMessage 對象到所有的其他節點上。

於是我們發現這些節點通信的行爲實際上完全就可以使用基於話題的發佈訂閱的機制來實現。

  1. 向某個房間的某節點 send 一個消息實際上可發佈話題 ${roomId}/node/${otherNodeId}/${topic}
  2. 向某個房間中發佈廣播消息可發佈話題 ${roomId}/broadcast/${topic}
  3. 房間中的每個節點必須訂閱以 ${roomId}/node/${userNodeId}/ 開頭的所有話題
  4. 房間中的每個節點必須訂閱以 ${roomId}/broadcast/ 開頭的所有話題

此時,一個房間裏的用戶之間便能夠進行一對一通信及廣播通信了。

MQTT 通信

此時我們突然想到,MQTT 通信機制不就是這樣的一種典型的話題通信的方式麼?我們應該完全可以純前端 app 直接連接一個 MQTT 服務器完成基本的分佈式通信的目標,而無需重新基於 WebSocket 再造一遍話題通信的輪子。

而且如果前端直接使用 MQTT 作爲分佈式同步的通信方式,用戶使用起來就像是使用一些開源軟件那樣,其中提供的那些需要後端提供支持的服務可通過自己配置任意的第三方服務器而實現。如類似於 Typora,一些 VSCode 插件那樣寫 Markdown 時候可以自己在設置中配置圖牀服務器。我們的白板用戶也可以自行配置任何第三方 MQTT 服務器,圖牀服務器地址等等。

此處列舉了一些免費公共 MQTT 服務器地址,可直接在我們的白板中配置使用這些免費公共的服務器

| 名稱 | Broker 地址 | TCP | TLS | WebSocket | | ------------- | -------------------------------------------------------------------------------------------------------- | ---- | ---------- | --------- | | EMQ X | http://broker.emqx.io | 1883 | 8883 | 8083,8084 | | EMQ X(國內) | http://broker-cn.emqx.io | 1883 | 8883 | 8083,8084 | | Eclipse | http://mqtt.eclipseprojects.io | 1883 | 8883 | 80, 443 | | Mosquitto | http://test.mosquitto.org | 1883 | 8883, 8884 | 80 | | HiveMQ | http://broker.hivemq.com | 1883 | N/A | 8000 |

在線列表與個性化信息

在實際使用中,我們還會遇到以下的場景需求:

  1. 查看當前房間在線的用戶數與用戶列表
  2. 辨別當前主持人的是誰
  3. 每個用戶能修改自身暱稱等個性化信息

爲此我們設計了一個特殊的廣播消息叫做 report 廣播消息:

  1. 所有加入該房間的用戶均需要按照一定的時間間隔循環廣播 ${roomId}/broadcast/report 消息
  2. 所有加入該房間的用戶均訂閱 ${roomId}/broadcast/report 消息,此時我們可以:
    1. 獲取到 BaseMessage 中的 publisher,data,ts 字段,data 字段可設爲個性化信息,這裏我們使用字符串類型表示用戶自定義的暱稱 username
    2. 更新 Map <DateTime, String> _onlineUserIdMap,即_onlineUserIdMap [message.ts] = message.publisher,這裏的 key 爲最近一次的 report 消息的時間
    3. 更新 Map <String, String> _onlineUsernameMap 數據,即_onlineUsernameMap [message.publisher] = message.data,這裏的 key 爲發佈者的 uuid
    4. 過濾_onlineUserIdMap 得出滿足約束 當前時間 - 最近一次report時間 < 指定超時時間的所有鍵值對,其中的 values 就表示當前在線的用戶的 uuid 所構成的列表
    5. 根據用戶的 uuid 再次查詢_onlineUsernameMap 即可查詢到用戶的自定義暱稱等個性化信息

分佈式同步

同步分爲兩類角色,第一類爲 Owner,第二類爲 Member

  • Owner 爲會議主持人,其擁有的 model 爲標準的完整 model。
  • Member 爲會議成員,其擁有的 model 需要從 owner 處獲取。

當 Member 加入會議時,其需要拿到完整的白板數據,需要先發起廣播消息請求需要白板數據,若在等待時間內 Member 收到了 Owner 發來的,後續 Member 將不停地接收 Owner 的 diff 結果的 Patch 包來更新自身的 model 數據。

若在規定超時時間內 Member 未收到白板數據的響應,則判定該房間不存在。

主持人離場

整個分佈式同步的通信圖中,存在若干個中心,每個中心就是一個個的 Owner,它與各個 Member 之間進行分佈式通信。

當主持人離場或意外掉線後,該房間將被銷燬。

當然,由於我們不存在後端服務,故此處的銷燬並非顯式的銷燬 api 調用。這裏的銷燬僅僅只是一種邏輯上的概念。實際上,其餘 Member 節點的 report 的 onlineUserId 列表中發現若主持人的最新 report 時間超過了給定的超時時間,則判定爲主持人已離場,Member 可自動退出房間。

PS: 由於每個人擁有的白板數據均爲完整的白板數據,故若主持人掉線,其他成員事實上也是有能力通過投票選舉主持人等方式實現轉移主持人身份來達到繼續維持房間的效果,出於時間原因,該功能暫未實現,目前若主持人離場,會議將自動結束。

插件化方案設計實現

場景概述

我們的模型 Model 的數據類型定義如下:

enum ModelType {
    rect, freeStyle, ...
}

Model {
  // 模型id
  id: int
  // 模型類型
  type: ModelType
  // 模型數據,由模型類型決定不同數據類型
  data: dynamic
  // 模型公共屬性
  common: CommonModelData
}

我們需要渲染該模型,則可能在某個 Widget 組件中需要寫出如下代碼:

Widget buildModelWidget(Model model) {
    switch(model.type) {
        case ModelType.rect:
            return RectModelWidget(model.data as RectModelData);
        case ModelType.freeStyle:
            return FreeStyleModelWidget(model.data as FreeStyleModelData);
        default:
            throw UnimplementionError();
    }
}

我們需要設計每個不同類型模型的編輯器的 ui 界面,可能需要寫出下列代碼:

Widget buildModelEditorWidget(Model model) {
    switch(model.type) {
        case ModelType.rect:
            return RectModelEditorWidget(model.data as RectModelData);
        case ModelType.freeStyle:
            return FreeStyleModelEditorWidget(model.data as FreeStyleModelData);
        default:
            throw UnimplementionError();
    }
}

我們還需要在右鍵中的 “添加模型 “菜單顯示模型元素列表,在故需要知道該模型的文字顯示,可能需要寫出如下代碼:

String buildModelInMenuText(String modelType) {
    switch(modelType) {
        case ModelType.rect:
            return '矩形';
        case ModelType.freeStyle:
            return '自由畫板';
        default:
            throw UnimplementionError();
    }
}

問題概述

考慮到我們的需求中需要支持很多豐富的圖形元素,且未來也有可能需要擴展出更多的未知的圖形元素,每當我們擴展新圖形時,均需要修改上述的模型渲染組件,模型編輯器組件,菜單項等代碼中的 switch 分支,且這些組件還分佈在不同的代碼文件,不同類,不同函數中,這將會對擴展新圖形帶來很多麻煩,並不符合開閉原則。

抽象插件接口

於是我們就考慮將上述不同種類的模型具有不同的行爲實現抽象出來定義成一組抽象接口,形成插件化接口,使得這些不同的模型的行爲職責內聚到各自插件類中,提高了內聚性,降低了白板本身與白板插件代碼的耦合度。

abstract class ModelPluginInterface {
    String getTypeName(); // 獲取該插件的type
    String getInMenuName(); // 獲取該插件在菜單中的名稱
    // 該模型的渲染視圖構造
    Widget buildModelView(Model model, EvventBus<BoardEvent> eventBus);
    // 該模型的編輯器視圖構造
    Widget buildModelEditor(Model model, EvventBus<BoardEvent> eventBus);
    // 創建該類模型時的默認數據類
    Model buildDefault();
}

通過定義不同的實現類來實現他們自身的這些行爲。

實現插件接口

我們定義一個 Markdown 插件爲插件樣例

Data

首先定義 Markdown 圖元的數據類定義:

class MarkdownModelData extends HashMapData {
  MarkdownModelData(super.map);
  String get markdown => map['markdown'] ??= '';
  set markdown(String v) => map['markdown'] = v;
}

View

定義該 Model 的渲染組件

class MarkdownModelWidget extends StatelessWidget {
  final MarkdownModelData data;
  const MarkdownModelWidget({Key? key, required this.data}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return Markdown(data: data.markdown);
  }
}

Editor

定義該 Model 的編輯器組件

class MarkdownModelEditor extends StatelessWidget {
  final Model model;
  final EventBus<BoardEventName> eventBus;
  const MarkdownModelEditor({
    Key? key,
    required this.model,
    required this.eventBus,
  }) : super(key: key);
  void refreshModel() => eventBus.publish(BoardEventName.refreshModel, model.id);
  void saveState() => eventBus.publish(BoardEventName.saveState);
  MarkdownModelData get modelData => MarkdownModelData(model.data);

  @override
  Widget build(BuildContext context) {
    final controller = TextEditingController();
    controller.text = modelData.markdown;
    controller.addListener(() {
      modelData.markdown = controller.text;
      refreshModel();
    });
    return TextField(
      minLines: 100,
      maxLines: null,
      controller: controller,
    );
  }
}

Entry

定義 Markdown 插件的入口類

class MarkdownModelPlugin implements BoardModelPluginInterface {
  @override
  Model buildDefaultAddModel({required int modelId, required Offset position}) {
    return Model({})
      ..id = modelId
      ..common = (CommonModelData({})..position = position)
      ..type = modelTypeName
      ..data = (MarkdownModelData({})..markdown = '# HelloWorld').map;
  }

  @override
  Widget buildModelEditor(Model model, EventBus<BoardEventName> eventBus) {
    return MarkdownModelEditor(eventBus: eventBus, model: model);
  }

  @override
  Widget buildModelView(Model model, EventBus<BoardEventName> eventBus) {
    return MarkdownModelWidget(data: MarkdownModelData(model.data));
  }

  @override
  String get inMenuName => 'Markdown文檔';

  @override
  String get modelTypeName => 'markdown';
}

註冊插件接口

那麼白板怎樣使用這些插件呢?我們需要引入一個插件容器去註冊管理這些插件,定義一個簡單的插件容器如下:

class BoardModelPluginManager {
  final Map<String, BoardModelPluginInterface> _plugins = {};

  // 構造一個插件管理器
  BoardModelPluginManager({
    List<BoardModelPluginInterface> initialPlugins = const [],
  }) {
    initialPlugins.forEach(registerPlugin);
  }

  // 註冊一個插件類
  void registerPlugin(BoardModelPluginInterface plugin) {
    String typeName = plugin.modelTypeName;
    if (_plugins.containsKey(typeName)) {
      // 同一個插件重複註冊
      if (_plugins[typeName] == plugin) return;
      // 不同插件但是類型名稱相同,拋異常
      throw Exception('Board model plugin has been registered $typeName');
    }
    _plugins[typeName] = plugin;
  }

  // 通過一個type獲取插件
  BoardModelPluginInterface getPluginByModelType(String modelType) {
    if (!_plugins.containsKey(modelType)) {
      throw Exception('Plugin name: $modelType not be registered');
    }
    return _plugins[modelType]!;
  }

  // 獲取插件名稱列表
  List<String> getPluginNameList() => _plugins.keys.toList();
}

於是我們可以創建一個插件管理器對象並傳入各個插件的定義並在構造白板對象時傳入插件管理器

BoardBodyWidget(
    eventBus: eventBus,
    boardViewModel: pageSetViewModel.currentPage.board,
    pluginManager: BoardModelPluginManager(
        initialPlugins: [
          RectModelPlugin(),
          LineModelPlugin(),
          OvalModelPlugin(),
          SvgModelPlugin(),
          PlantUMLModelPlugin(),
          ImageModelPlugin(),
          AttachmentModelPlugin(),
          FreeStyleModelPlugin(),
          HtmlModelPlugin(),
          MarkdownModelPlugin(),
          SubBoardModelPlugin(),
        ],
   ),
)

插件化設計 UML

最終插件化的設計 UML 類圖如下

當我們面臨新的圖形元素的擴展需求時,僅僅只是增加了一個插件的實現類,在 Main 中構造這些插件類並傳入 ModelPluginManager 中輕鬆實現了圖元類型的擴展,這符合了開閉原則。

插件化設計總結

我們通過抽象出公共接口來實現了一種插件化的設計,符合了開閉原則和依賴倒置原則,內聚了圖形元素的行爲職責到插件類。不過,當前我們的插件化系統僅僅只能算是一種靜態的插件化系統,並不算是一個動態插件化系統,若要實現一個動態插件化系統,我們還需要考慮插件的生命週期,插件的加載與卸載等。

項目展示

在線運行

https://sit-board.github.io/

注意:受限於時間精力,故未針對 Web 端做平臺相關的適配,可能很多功能在 Web 端無法使用,若需要完整體驗,請下載 Release 中的客戶端進行體驗。

Web 端僅作爲快速體驗爲目的,請以實際桌面端或移動端平臺爲準。

瀏覽器端右鍵或長按時可能會彈出剪切板權限提示,這是因爲軟件支持複製粘貼圖形對象到本機剪切板。

視頻 Demo 演示

https://www.bilibili.com/video/BV1Wd4y1b7rc/

使用說明

https://github.com/SIT-board/board_front/blob/master/docs/%E4%BD%BF%E7%94%A8%E8%AF%B4%E6%98%8E.md

項目截圖

白板主界面

白板設置

本地白板

如圖展示了矩形 / 文本框插件,橢圓插件,圖片插件,自由畫板插件,PlantUML 插件,Markdown 插件,子畫板插件的渲染,其中子畫板插件爲畫板本身,類似於網頁中的 iframe 標籤元素,且比例爲豎屏時自動適配移動端 ui。

多人協同

倉庫地址

Github 組織地址 https://github.com/SIT-board

項目倉庫地址 https://github.com/SIT-board/board_front

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