乐趣区

关于前端:SITboard-远程交互式白板的实现

来自上海利用技术大学的「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 本身的控件零碎实现白板的大体框架,咱们首先面临的需要如下:设计一个布局容器,满足如下需要:无限大的,可自在拖动,缩放可见视角某个控件地位由一个相对坐标来定位其中的每个孩子须要有肯定的尺寸束缚,尺寸束缚蕴含了最大尺寸和最小尺寸,用于实现图元的大小管制。实际上在 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/fl…

这个问题在 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/… 该组件齐全可拆散为一个独立的 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]
}
SpecialModelDataSpecialModelData 类型是一个泛指类型,不同类型的 Model 具备不同的 data 类型,其寄存了图形元素本身的外部特有的属性。RectModelDataRectModelData 类型为矩形元素的特有数据,依据需要剖析,存在文本框这种图形元素,故咱们能够间接将文本框和矩形组件合并为一种图形元素。故咱们可形象出如下的矩形 / 文本框的数据结构: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

}
}
FreeStyleModelDataFreeStyleModelData 为自在画板插件的数据类型定义,思考到需要剖析中可能绘制自在曲线,故设计该图形元素为自在绘制的画板。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 的定义与实现。ModelModel 类型定义了某个白板中的模型,其数据类型定义如下:Model {
// 模型 id
id: int
// 模型类型
type: string
// 模型数据,由模型类型决定不同数据类型
data: <SpecialModelData>
// 模型公共属性
common: CommonModelData
}
BoardViewModelBoardViewModel 定义了一个白板的视图模型,一个白板可看做由若干个模型的汇合及视角数据所形成。视角数据可由一个 4×4 矩阵所示意。为什么是 4×4 矩阵?在 Flutter 中所有 ui 元素均可定义在三维空间中的某个立体,这样咱们便能够不便地对某个 ui 元素进行更丰盛的三维变换了,例如咱们能够实现三维空间中绕 x,y,z 轴的旋转,能够实现 x, y, z 轴上的平移等变换。3×3 矩阵实际上只可能形容任意三维空间图形的线性变换,如缩放,旋转,错切等。4×4 矩阵实际上能够形容任意三维空间下图形的仿射变换,可能在线性变换的根底上外加实现平移变换。白板数据结构定义如下:BoardViewModel {
// 视口变换矩阵, 为 4 ×4 矩阵
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 算法可通过比拟计算失去某个对象在不同状态之间的差别,还可将这种差别利用到前一个状态上来计算得出后一个状态。咱们将该差别记做一个补丁 patchDiff 算法的根本运算规定如下:初始状态: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—>State1State0 = {},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 操作流程如下:从 S1 中弹出栈顶的 Patch3 计算出 – Patch3-Patch3 利用到以后状态后失去 State2 = State3 + (-Patch3)将 Patch3 退出栈 S2 此时又能够 undo 又能够 redo,此时的状态为 State2,指标状态为 State3,咱们的 redo 操作流程如下:从 S2 中弹出栈顶的 Patch3Patch3 利用到以后状态后失去 State3 = State2 + Patch3 将 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 算法实现分布式场景下的同步呢?基于话题的公布订阅通信模型本我的项目最后设计探讨时候,咱们发现其实这个多人协同的场景实际上就是一种分布式状态同步的场景,首先咱们须要解决各个节点之间的通信问题:每个用户都是作为一个分布式节点去接管核心服务器上的白板状态数据变更每个分布式节点也可将变更上传至服务器并发送到其余各个分布式节点上 BaseMessage 首先设计各个节点之间通信的根本音讯类型。各个节点具备一个惟一的 uuid 字符串节点要退出的房间也具备一个惟一的 uuid 字符串。定义一个 BaseMessage 音讯类型,节点之间的所有通信的音讯包必须为 BaseMessage 音讯:BaseMessage {

ts: DateTime
topic: String
publisher: String
sendTo: String
data: any

}
设计每个节点在某个房间均具备如下行为:每个节点都可能 send 一个 BaseMessage 对象到另一个 uuid 为 sendTo 的节点上。每个节点都可能 register 一个回调函数去接管其余节点传送过去的 BaseMessage 对象。每个节点都可能 broadcast 一个 BaseMessage 对象到所有的其余节点上。于是咱们发现这些节点通信的行为实际上齐全就能够应用基于话题的公布订阅的机制来实现。向某个房间的某节点 send 一个音讯实际上可公布话题 ${roomId}/node/${otherNodeId}/${topic}向某个房间中公布播送音讯可公布话题 ${roomId}/broadcast/${topic}房间中的每个节点必须订阅以 ${roomId}/node/${userNodeId}/ 结尾的所有话题房间中的每个节点必须订阅以 ${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 | 在线列表与个性化信息在理论应用中,咱们还会遇到以下的场景需要:查看以后房间在线的用户数与用户列表分别以后主持人的是谁每个用户能批改本身昵称等个性化信息为此咱们设计了一个非凡的播送音讯叫做 report 播送音讯:所有退出该房间的用户均须要依照肯定的工夫距离循环播送 ${roomId}/broadcast/report 音讯所有退出该房间的用户均订阅 ${roomId}/broadcast/report 音讯,此时咱们能够:获取到 BaseMessage 中的 publisher,data,ts 字段,data 字段可设为个性化信息,这里咱们应用字符串类型示意用户自定义的昵称 username 更新 Map <DateTime, String> _onlineUserIdMap,即_onlineUserIdMap [message.ts] = message.publisher,这里的 key 为最近一次的 report 音讯的工夫更新 Map <String, String> _onlineUsernameMap 数据,即_onlineUsernameMap [message.publisher] = message.data,这里的 key 为发布者的 uuid 过滤_onlineUserIdMap 得出满足束缚 以后工夫 – 最近一次 report 工夫 < 指定超时工夫的所有键值对,其中的 values 就示意以后在线的用户的 uuid 所形成的列表依据用户的 uuid 再次查问_onlineUsernameMap 即可查问到用户的自定义昵称等个性化信息分布式同步同步分为两类角色,第一类为 Owner,第二类为 MemberOwner 为会议主持人,其领有的 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/vide… 应用阐明 https://github.com/SIT-board/… 我的项目截图白板主界面

白板设置

本地白板如图展现了矩形 / 文本框插件,椭圆插件,图片插件,自在画板插件,PlantUML 插件,Markdown 插件,子画板插件的渲染,其中子画板插件为画板自身,相似于网页中的 iframe 标签元素,且比例为竖屏时主动适配挪动端 ui。

多人协同

仓库地址 Github 组织地址 https://github.com/SIT-board 我的项目仓库地址 https://github.com/SIT-board/…

退出移动版