【翻译】Figma是如何实现多人协作的

2021-09-07

Figma是一款在线的UI设计软件,相比较传统的如sketch、axure等设计软件,它的优势是在云端,支持协同编辑,能完成设计的全流程,这让它实现了弯道超车。对于任何一家做SaaS软件的公司,Figma都有很好的借鉴意义。今天主要讨论的是其协同功能。为什么讨论这个?自然是因为内部调研,因为我们也有类似的场景,虽然不是同一行业,但都有协同编辑或展示的需求。这是一个无人探索的深水区,本文拟翻译一篇Figma技术博客中关于协同的关键文章,管中窥豹一番。

原文:https://www.figma.com/blog/how-figmas-multiplayer-technology-works/


Figma多人协作技术原理

作者:Evan Wallace,Figma CTO

发表时间:2019年10月16日

如果你想进一步了解我们接下来的产品方向,请查看 不仅是多人协作:在Figma中共同构建社区,在那里我们分享了一些令人激动的产品计划。以下是正文。

当我们在四年前第一次启动多人协作功能,我们就决定开发自己的解决方案。在此之前,没有别的设计软件提供这样的特性。并且,我们不想使用operational transforms算法(也叫OTs算法),尽管这是一种标准的多人协作算法,在诸如谷歌文档等app上得到流行。作为一家创业公司,我们重视快速实装功能的能力,对我们的场景来说,OTs算法显得太复杂了,是不必要的。因此我们构建了一个定制化的多人协作系统,它更简洁,并更易实现。

当时,我们不确定开发这个功能是不是一个正确的产品决策。没有人要求一个多人协同设计工具——甚至正相反,一些人讨厌这个主意。设计师们担心实时的协作编辑会导致“艺术总监悬停“(被监控)以及“委员会式设计”(创意被压缩)的灾难。

但最终,我们不得不这样做。因为作为一款云端设计软件,不提供协作能力是不对的。它能去除导出、同步或通过email发送文件副本的繁琐步骤,并允许更多人参与设计工作(比如文案和开发)。只需一个项目链接,任何人都可以查看项目当前的状态,而不会中断工作人员。

我们赌对了,现如今,多人协作已然成为了所有云端生产力工具的标配,而不仅仅是设计。不过尽管我们每天都在用这些具有协同编辑能力的工具,关于它们且公开的案例研究却并不多。

我们觉得是时候分享一下Figma是如何做到的,以期望帮到其他人。对于那些喜欢了解计算机理论如何在实践中应用的人来说,这应该会是一篇有趣的文章。本文将涵盖很多内容,不过每个部分都是建立在前一部分的基础上的。到最后,你应该会对整个系统有所了解。

背景:Figma的基础架构、OTs算法等

在讨论Figma多人协作的协议前,有必要了解一些关于协同系统是如何建立的背景。Figma使用C/S架构,客户端就是网页,通过WebSocket与一个服务器集群通信;服务端目前会为每个协同编辑的文档启动一个进程,协同编辑中的每个用户都会关联到这个文档。如果你感兴趣,这篇文章讲述了我们如何扩展协同服务器。

当一个文档被打开,客户端启动并下载文件的副本。从这个时间点起,任何双向的文档修改会通过WebSocket连接同步。Figma允许你在任何时间下线(退出协同状态)并能继续编辑。当你重新上线,客户端会下载一份文件最新的副本,你所有下线时进行的改动都会作用于这个最新的副本之上,然后建立WebSocket连接并同步更新。这意味着连接和重连的过程都很简单,所有协同编辑的复杂性都落在了如何处理已连接文档的同步更新上。

值得一提的是,协同编辑系统只用来同步对Figma文档的更新。我们虽然也同步一些其他数据(如评论、用户、团队、项目等),但那是存在Postgres中并用其他独立的系统同步的,而不是在我们的协同编辑系统中的。尽管两者很相似,权衡各种因素(性能、可用性、安全性),它们有着不同的实现。

不过在一开始我们不是基于这样的实现。在做出这样根本性的改变之前,重要的是能有一种快速迭代与实验的方法。这就是为什么我们首先建立了一个原型环境来测试我们的想法,而不是直接上线。这个原型是一个网页,它模拟了三个客户端连接服务器,并可视化了系统的整个状态。从而让我们可以围绕离线的客户端与带宽受限的连接设置不同的场景。

一旦我们弄清楚如何去构建协同系统,就可以直接将原型中的想法移植到正式的代码库中。我们就使用这个原型来快速地研究并评估各种协同算法与数据结构。

OTs与CRDTs算法如何影响我们对协同的实现

协同编辑技术有一个长远的历史,自Douglas Engelbart在1968年的demo开始就一直被讨论。在我们深入了解Figma协同系统如何实现之前,有必要快速地概览一番这些影响我们的传统方法,即OTs与CRDTs。

之前提到,OTs为大多数基于文本的多人协作应用提供支持,例如谷歌文档。它是最知名的技术,不过在研究它的过程中,我们很快意识到对我们想要达到的效果来说,有些矫枉过正了。对长文本文档编辑来说,这是一个很棒的方法,有着较低的内存消耗与较高的性能。但是它太复杂且难以正确实现,其可能导致状态的组合爆炸,这很难预测

在设计协同系统时,我们的主要目标是让完成这个系统不要过于复杂。简单的系统更容易推理,从而更容易实现、调试、测试及维护。由于Figma不是文本编辑器,因此我们不需要OTs的强大功能,可以摆脱一些复杂的事情。

Figma的实现灵感来源于CRDTs,全称为Conflict-free Replicated Data Types(无冲突复制的数据类型)。CRDTs是一组可以在分布式系统中不冲突使用的数据结构。所有CRDTs满足特定的数学属性,保证最终一致性。如果不再有其他更新,最终所有访问者都会看到相同一致的内容。这一约束是正确性所必需的;我们不能让编辑同一个Figma文档的两个客户端产生分歧并无法汇合。

有许多种CRDTs的类型,见这个列表。一些例子如下:

  • Grow-only set:这是一个元素的集合。其update操作只有一种类型即新增某物到集合。增加同一物第两次会视为无效,因此你可以保证最终的内容一致,只需要应用所有更新,且不需要考虑顺序。
  • Last-writer-wins register:这个一个单个元素的容器。更新操作被实现为一个新的值、时间戳与节点标识符的组合。你可以保证元素的值通过取最新一次更新的值(如果时间相同,使用标识符来判断)。

Figma没有使用严格意义上的CRDTs。CRDTs被设计用于分布式且去中心化的系统,因此没有一个唯一的中心节点来决定最终状态。这样做会产生一些不可避免的性能和内存开销。但Figma实际上是有中心的(服务器就是中心节点),因此我们可以通过去除额外的开销来简化我们的系统,从而受益于更快更精简的实现。

此外,Figma的数据结构也不仅仅是单独某种的CRDTs,而是从多种CRDTs中获取灵感并组合使用,创建出表达Figma文档的最终数据结构(下文会描述)。

即使你已经有一个客户端-服务端的体系,CRDTs也值得去研究,因此它有经过充分研究的坚实基础。理解CRDTs有助于建立关于如何正确构建系统的sense。之后,就可以像我们一样根据应用的需要放宽对CRDTs的要求。

Figma文档的结构

接下来,我们希望通过CRDTs去同步Figma文档的更新。那么Figma文档的结构是什么样的呢?

每个Figma文档都是一棵对象树,类似于HTML DOM。一个根对象表示整个文档,根对象以下是page对象,每个page对象下是该page内所有元素的列表。这棵树展示在Figma编辑器的左侧栏中。

每个对象都有一个ID及一系列属性(Property)。一种理解的方式是把文档看作是一个两级的map:Map<ObjectID, Map<Property, Value>>,另一种方式是看做数据表,每个元组存储(ObjectID, Property, Value),这意味着在Figma中加入新的特性只需要给对象加新的属性。

Figma协同系统的实现细节

剩下的内容中,我们将讨论Figma协同编辑系统的实现细节,以及我们如何处理一些遇到的边界情况。

同步对象属性

Figma的协同服务器持续跟踪每个客户端对某对象某个属性发送的最新值,这意味着两个客户端改变同一个对象上不相关的属性并不会互相冲突,改变不同对象上的同一种属性也不会冲突。冲突只发生在当两个客户端改变同一个对象上的同一个属性值,这种情况下,服务器只需保留最后一个发送到服务器的值。这种处理方法类似CRDTs中的Last-writer-wins register,只是我们不需要时间戳,因为服务器可以定义事件的顺序。

该动画展示了两个客户端在不冲突的情况下同步更新

这样做一个重要的效果是更新在属性值粒度上是具有原子性的。某个属性的最终值始终一致是某个客户端发送的值。这就是在Figma中编辑相同文本值不能奏效的原因,想象如果文本值本来为B,有人将其更改AB,同时其他人将其更改为BC,则最终结果将是AB或BC,而绝不是ABC。这对我们来说没有问题,因为Figma是一个设计工具而不是文本编辑器,并且这种情况也不在我们优化的范围内。

协同编辑最复杂的部分在于当冲突发生时如何在客户端上处理冲突。客户端上的属性总是立即应用的,而不会去等待服务器的确认,因为我们希望Figma响应尽可能快。然而,如果这样做的同时,我们还去应用来自服务器的每个更改,会出现的情况是:当旧的已确认值暂时覆盖新的未确认值时,处理冲突变更会像“闪烁”一样。我们希望避免这种闪烁的行为。

直观地说,我们希望向用户展示我们对最终一致值的最佳预测。因为刚发送的变更是还没有被服务器确认的,但来自服务器的所有变更都是已确认过的。那么这个未被确认(返回ack)的变更就是我们的最佳预测,因为它于被发送过来的已确认的变更比起来,是最近的变更,按last-wins原则,它就是最终的值,因此我们会拒绝掉那些来自服务器的与本地未确认值冲突的变更。


该动画展示了当两个客户端发生冲突如何避免闪烁

同步对象的创建与删除

创建和删除对象都是我们协议中明确的操作。无法通过将属性写入一个未分配的ID来创建对象,删除对象时会从服务器删除其所有数据,包括它的所有属性。

Figma中创建对象的操作与CRDTs中的last-writer-wins set最接近,其判断一个对象是否在集合中也只是该对象上的一个last-writer-wins的布尔属性。与此模型的一个很大的区别是,Figma不会在服务器上存储已删除对象的任何属性,被删除对象只会存储在执行删除操作的那个客户端的撤销缓冲区中。如果该客户端想要撤销删除,它还负责重做已删除对象的所有属性。这样的策略有助于长期存在的文档在编辑过程中变得巨大。

这个系统依赖于客户端能够保证生成唯一的对象ID。而这可以通过为每个客户端分配一个唯一的客户端ID,并将客户端ID作为新创建对象ID的一部分来轻松实现。这样的话任何两个客户端都不会生成相同的对象 ID。 需要注意的是,我们无法通过让服务器为新创建的对象分配 ID 来解决此问题,因为对象创建需要能够在离线状态下进行。

同步对象树

能在一棵具有最终一致性的树中排列对象是我们的协同系统中最复杂的部分。复杂性来自如何处理Reparent(改变父级)操作(将对象从一个父级移到另一个父级下)。在设计树结构时,我们要记住两个主要目标:

  • Reparent对象不应与该对象上无关属性的变更冲突。例如有人正在更改对象的颜色,而其他人改变了该对象的父级,那么这两个操作都应该成功。
  • 对同一个对象并发的两个Reparent操作不应导致该对象在树中存在两个副本。

许多Reparent的解决方案是删除原对象并在目标处用新的ID重新创建该对象,这不适用于我们。因为如果对象的ID变化了,并发的变更将会被丢弃。我们处理的方式是在子对象存储一个父对象的指针作为属性,来表示父子关系。这样的话子对象的ID不需要变化。相反假如我们让父对象存储子对象的指针,某种情况下对象可能会拥有多个父级。而在我们的解决方案我们不需要处理这样的情况。

但是,这样做会产生新的问题。如果没有其他约束,这些父子关系只是图的一条有向边。无法保证它们没有成环并形成一棵有效的树。一个例子是并发编辑时,其中一个客户端使 A 成为 B 的子节点,而另一个客户端使 B 成为 A 的子节点。然后 A 和 B 都是彼此的父节点,这形成了一个循环。

Figma的协同服务器会避免导致循环的父对象指针变更,因此该问题不会在服务器上发生,但仍可能在客户端发生。客户端无法拒绝来自服务器的变更,因为服务器是保证最终一致性的中心节点。因此客户端可能遇到的状态是:它既向服务器发送了将A设为B的子对象 的未确认更改,也从服务器收到了将B设成A子对象的更改。客户端的变更之后会被服务器拒绝,因为它会形成一个循环,但此时客户端还不知道。


该动画展示了Reparent冲突

Figma的解决方案是临时让这些对象成环,并将它们从树中移除,直到服务端将拒绝的ack发送到客户端,之后对象会重新设置应有的父子关系。这个解决方案并不完美,因为对象会暂时消失,但由于这是一个相当罕见的临时问题的简单解决方案,我们认为没有必要在这里尝试更复杂的东西,例如在客户端就打破这些循环。

为了构造对象树,我们还需要一种方法确定某个父级下子对象的顺序。Figma使用一种被称为“fractional indexing”(分数索引)的方法来做到这一点。总的来说,对象在其父级的子对象列表中的位置表示为一个0到1之间的分数。子对象的顺序是由它们的位置排序来确定的。通过将某个对象的位置设置为其他两个对象位置的平均值,你就可以在这两个对象之间插入它。


该动画展示了使用分数索引进行重排序

我们写了另一篇文章来详细描述这种技术。一个重要的部分是父指针和位置需要存储在一起为一个属性。因为当变更父级后,原来的位置是没有意义的。

实现撤销(undo)

在单人模式下,撤销记录是很自然能理解的。但在多人协同环境下撤销令人疑惑。如果其他人编辑了你正在编辑的对象然后撤销,会发生什么?你的编辑是否应该应用于它们之后的编辑?还有重做(redo)呢?

我们遇到了很大的苦难,直到我们确定了一个指导方针:如果你撤销了许多,复制了一些东西,然后重做到当前状态(一个常见的操作),文档不应该改变。这似乎很显然,但撤销的单人模式实现意味着“放回我之前做的”,这会导致可能覆盖掉其他人接下来的变更(如果你不小心的话)。因此在Figma中,会在撤销时修改重做历史,同样在重做时修改撤销历史。


该动画展示了对撤销重做历史的修改

大收获

我们已经介绍了很多!这是我们希望在刚开始研究时能够阅读的一篇文章。 抽象地学习 CRDT 是一回事,但要了解这些理论在实际生产系统的实践中是如何工作的则是另一回事。

我们的一些主要收获有:

  • 即使你没有创建一个去中心化系统,CRDTs也可能是能关联的。
  • 像Figma这样的UI编辑器的多人协作并不像想象中那么令人生畏。
  • 在一开始花时间进行调研与原型设计真的得到了回报。

如果你看到了这里,你现在应该有足够的信息来制作你自己的协作树数据结构。即使你的问题领域不与我们完全一样,我们也希望这篇文章能够展示CRDT研究如何成为灵感的重要来源。