Figma 是如何做协同编辑的?

2023-12-13 08:25:43 浏览数 (1)

大家好,我是前端西瓜哥。

我一直对图形编辑器如何做多人协同编辑很感兴趣,最近读了 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 协同编辑的基本要求:

  1. 可以本地立即修改,而不是提交后再更新,这是为了有丝滑的用户体验,同时也能支持离线编辑能力;
  2. 使用中心服务,而不是去中性化(说你呢 CRDT),Figma 的服务端会维护图形树,作为最终的权威,并负责修正用户提交的数据。

当多个用户同时修改同一个对象属性时,服务端返回的有冲突的属性值如果立即给对象应用上,可能会有 “闪烁” 现象

是这么一个场景,在同一时间,用户 A 将图形改成红色(本地改成红色然后提交到服务器),用户 B 改成黄色,用户 B 比用户 A 更早提交到服务器。

对于用户 A,他会先看到颜色从红色变成黄色,黄色再变成红色,这种不期望的 “闪烁” 现象。

解决方式是,用户 A 提交将颜色改成红色的操作,要等待服务端确认。在等待服务端确认期间,如果收到其他用户修改同一个属性的操作(用户 B 改成黄色),会把这个改动 丢弃

之后用户 A 收到服务端的确认消息后,如果此时有个用户 C 修改图形为紫色的操作同步过来,就会走正常的流程,将图形改成紫色。

创建与删除

创建类似前面的做法,也是最后写入者优胜。(没理解)

对于删除操作,Figma 服务器不会保存被删除的数据,这么做是为了防止文档大小持续增长。

被删除的数据由进行删除操作的客户端负责,该客户端可通过 undo(撤销)恢复。

系统需要保证 id 的一致性。

做法是给每个客户端分配一个唯一 id,将其作为新创建对象 id 的一部分。这样两个客户端就不会生成相同的对象 id 了。(这有点像雪花算法)

更改对象的父元素

修改对象的位置是 Figma 系统中最复杂的部分。

其复杂度来自移动一个对象到另一个父节点操作。需要做到:

  1. 该移动操作不和该对象的其他无关属性冲突;
  2. 并发的两个操作不会导致一个对象同时在多个父元素下。

很多做法是 “删除 重新创建” 表示对象的移动,但这会导致 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,则不需要修改,因为他的历史记录是就是红变黄(黄是最终状态)。

要点

最后是作者的一些心得:

  1. CRDT 的文献很有参考价值,即使你不打算做非中心化协同;
  2. 可视化编辑器的协同编辑并没有想象中难做;
  3. 在开做之前先调研并实现原型是非常有价值的。

结尾

文章看下来,大概有一些图形编辑器如何做协同编辑的概念了,以后有机会实践一下。

其中一点我是非常赞同的,就是方案能简单就不要复杂,我不是很喜欢一些高度抽象的东西,代码是写给人看的,只是顺便让机器执行而已。

我是前端西瓜哥,欢迎关注我,学习更多图形编辑知识。


相关阅读,

协同编辑中使用的 OT 算法是什么?

Yjs quill:快速实现支持协同编辑的富文本编辑器

用 Yjs React 写一个支持协同的 TODO 应用

图形编辑器:历史记录设计

0 人点赞