大家好,我是前端西瓜哥。
我一直对图形编辑器如何做多人协同编辑很感兴趣,最近读了 Figma 前 CTO Evan Wallace 的文章《How Figma’s multiplayer technology works》,很有收获,于是写了这篇笔记。
我建议读者直接阅读原文,里面还有动图。
https://madebyevan.com/figma/how-figmas-multiplayer-technology-works/
参考 CRDT
协同编辑,需要用到数据一致性算法,目前成熟的算法有 OT 和 CRDT。
Figma 没用 OT,太复杂,尤其是当离线数据本地缓存了很久才提交时,会进行复杂的 OT 算法计算,产生组合爆炸问题。
CRDT,也有一定复杂度,而且是去中心的,Figma 还是需要一个中心服务实现鉴权功能。
OT 和 CRDT 更多是针对富文本编辑的,而 Figma 是设计工具,作者认为没有必要引入这些复杂的东西,这样会让项目难以维护。
Figma 最终选择借鉴 CRDT 的思想,自己实现一套协同系统。
这里我比较赞同,我永远认为 “不要过早扩展”,能简单就不要复杂。
因为一些后期不一定会用到的功能,强行做了更复杂的抽象和扩展,导致功能开发的心智负担过重,当发现这些后期功能不需要,并且要扩展另一个方向的一套功能时,原本抽象的设计变得毫无意义,且一切都积重难返,最后的结果只能是屎上雕花了。
冲突处理
Figma 的设计文件的数据是一棵图形树,图形之间可能会有父子关系,比如一个 group 下有一个 rectangle,形成多层的树结构。协同编辑操作的对象就是这么一棵树。
Figma 协同操作的最小原子是 对象的属性。
修改同一个对象的不同属性没有冲突问题。
多个用户同时修改同一个对象的相同属性时,最晚提交到服务端的值会覆盖其他用户的值,包括文本内容。
假设一个属性的值是 B,一个用户修改为 AB,另一个用户修改为 BC,最终同步后,他们不会得到 ABC,只会是 AB ,或者 BC,看谁最晚提交。
这个其实在大多协同表格应用也是类似的,单元格的内容也是最后提交者优胜,只有富文本文档才要求得到 ABC。
处理闪烁现象
首先要明确 Figma 协同编辑的基本要求:
- 可以本地立即修改,而不是提交后再更新,这是为了有丝滑的用户体验,同时也能支持离线编辑能力;
- 使用中心服务,而不是去中性化(说你呢 CRDT),Figma 的服务端会维护图形树,作为最终的权威,并负责修正用户提交的数据。
当多个用户同时修改同一个对象属性时,服务端返回的有冲突的属性值如果立即给对象应用上,可能会有 “闪烁” 现象。
是这么一个场景,在同一时间,用户 A 将图形改成红色(本地改成红色然后提交到服务器),用户 B 改成黄色,用户 B 比用户 A 更早提交到服务器。
对于用户 A,他会先看到颜色从红色变成黄色,黄色再变成红色,这种不期望的 “闪烁” 现象。
解决方式是,用户 A 提交将颜色改成红色的操作,要等待服务端确认。在等待服务端确认期间,如果收到其他用户修改同一个属性的操作(用户 B 改成黄色),会把这个改动 丢弃。
之后用户 A 收到服务端的确认消息后,如果此时有个用户 C 修改图形为紫色的操作同步过来,就会走正常的流程,将图形改成紫色。
创建与删除
创建类似前面的做法,也是最后写入者优胜。(没理解)
对于删除操作,Figma 服务器不会保存被删除的数据,这么做是为了防止文档大小持续增长。
被删除的数据由进行删除操作的客户端负责,该客户端可通过 undo(撤销)恢复。
系统需要保证 id 的一致性。
做法是给每个客户端分配一个唯一 id,将其作为新创建对象 id 的一部分。这样两个客户端就不会生成相同的对象 id 了。(这有点像雪花算法)
更改对象的父元素
修改对象的位置是 Figma 系统中最复杂的部分。
其复杂度来自移动一个对象到另一个父节点操作。需要做到:
- 该移动操作不和该对象的其他无关属性冲突;
- 并发的两个操作不会导致一个对象同时在多个父元素下。
很多做法是 “删除+重新创建” 表示对象的移动,但这会导致 id 的改变,对 Figma 并不合适。
Figma 最后选择给对象加一个属性,指向它的父节点。这样 id 得以保持不变,多个用户同时进行操作只是在改这个属性,也有效避免了副本的出现。
副本指的是,两个用户同时分别把一个图形放到不同的父节点上,如果用的是修改 children 数组的方式,就会导致两个父节点都挂着同一个图形的引用。
然后还有一个 “环” 的问题,假设 B 和 C 是兄弟节点,一个用户将 B 放到 C 下,另一个用户把 C 放到 B 下,就会产生一个环。
解决方法是,最先改变父子关系,会作为最终状态。假设用户 1 将 C 放到 B 下的操作先到服务器,服务器会应用它。此时服务器收到用户 2 把 B 放到 C 下的同步信息,服务器会将其驳回,带上真正的父节点 id。
在驳回前,用户 2 其实收到了用户 1 的操作,客户端此时会将 A 和 B 临时形成环,然后移出图形树,接着驳回的信息回来,客户端就能确定父节点,然后恢复到图形树中。
该方法并不是非常好,因为图形消失了一段时间,但方案比较简单,且这种场景非常罕见,Figma 不打算用更复杂的方案。
顺序一致性
如果多个用户同时修改一个节点下的兄弟节点的位置,如何保证它们的最终顺序是一致的?
Figma 使用了 “Fractional Indexing”(小数索引) 技术。
兄弟节点会分配一个大于等于 0,小于 1 的小数索引值。
插入新的节点,会取于它相邻的两个节点的索引值的中间位置,比如要在索引为 0.3 和 0.4 的中间插入新节点,这个节点的索引值会标记为 0.35。
如果出现索引值相同的情况,服务端会进行纠正,把更晚提交的新节点的索引往后移动一点。
实现撤销(undo)
单机的 undo,是将状态会恢复到上一个时间点,如果不加以改变,换成多人协同,就会导致当前用户的操作在其他用户撤销时被覆盖。
Figma 团队总结了一个重要的准则:撤销后复制了一些东西,然后重做到当前位置,文档不应该被改变。
Figma 的做法是 改历史记录。
Figma 会在用户撤销的时候修改重做历史,以及在重做的时候修改撤销历史。
用户 A 和用户 B 都打开一张图纸,其中一个图形原来是红色。用户 A 将其更换为蓝色,同步,此时双方都看到图形是蓝色。
此时用户 B 又将图形改成黄色,同步,此时双方都是黄色的。。
用户 A 进行撤销操作,撤销为红色(因为撤销栈记录的是红变蓝),此时重做栈的命令对象跑到重做栈,本来应该是蓝变红,但是 最新的文档状态是黄色,所以这里强行把替换为黄变红。
这样历史记忆就被篡改了,可以保证重做后能回到最新状态。
对于用户 B,则不需要修改,因为他的历史记录是就是红变黄(黄是最终状态)。
要点
最后是作者的一些心得:
- CRDT 的文献很有参考价值,即使你不打算做非中心化协同;
- 可视化编辑器的协同编辑并没有想象中难做;
- 在开做之前先调研并实现原型是非常有价值的。
结尾
文章看下来,大概有一些图形编辑器如何做协同编辑的概念了,以后有机会实践一下。
其中一点我是非常赞同的,就是方案能简单就不要复杂,我不是很喜欢一些高度抽象的东西,代码是写给人看的,只是顺便让机器执行而已。