Aevia 是一套轻量级的分布式实时协同白板系统。
该系统在底层实现了基于命令级 无冲突复制数据类型 (CRDT) 的同步引擎。为了解决广域网环境下的网络延迟与数据乱序问题,系统引入了 Lamport 逻辑时钟 作为分布式事务的偏序依据,确保多端状态的最终一致性。在渲染层,系统采用空间包围盒(AABB 脏矩形)裁剪重绘算法,通过约束渲染管线的无效计算面积,将冲突消解与撤销重做操作的开销控制在较低维度,从而在高并发弱网场景下维持 O(N) 性能。
- 操作命令化:画布上产生的每一次操作都会被底层转化为描述性的命令(Command),作为多端同步状态的媒介,凭借命令本身的信息即可在Canvas中完全还原出对应的操作。
- 命令级矢量模型:画布交互不再依赖离散的光栅化像素覆盖,所有绘制动效均解耦并封装为包含物理与逻辑时间戳、拓扑边界(即包围盒,Bounding Box)等元数据的
Command矢量对象实体。 - CRDT 与 Lamport 全局偏序:采用单例数组维护全量命令状态。在网络分发引起时序错乱时,接收端采用二分插入排序算法,将 Lamport 逻辑时间戳升序排列,解决并发操作平局碰撞,实现数学上的绝对状态收敛。
- AABB 脏矩形局部重绘算法:针对高昂的全局 Canvas 擦除重绘开销,系统运用脏区检测模型。当发生历史态覆写(如接收到晚到达的网络命令或本地撤销操作)时,通过计算命令拓扑结构的并集,在
CanvasRenderingContext2D.clip()的受限区域内进行局部增量逆向重绘渲染,极大降低了 GPU/CPU 渲染负载。
- 实时渲染算法:实时拟合用户输入,使用贝塞尔曲线、速度及压感调优路径轨迹,使笔迹、笔锋更加自然。
- 多端光标追踪同步:通过 WebSocket 提供低延迟的对等协作端实时光标位置投射。
- 无损撤销与重做树机制:基于软删除标记位与 AABB 脏矩形检测的高效局部分布式撤销重做,严格保证互不交叉的线段渲染层级解耦。
- 全局分页调度机制:支持无限页面的动态扩容与切换,并提供全局跨页面的多端活跃监控态热力分布。
- 轻量用户校验:允许终端用户提供匿名并申请临时唯一标识符(UUID)快捷接入协作区域。
- 实时状态流:通过WebSocket实时更新用户状态、操作事件及UI变化。
- 核心框架: Vue 3 + TypeScript
- 构建流: Vite
- 状态同步组件: Pinia + Vue Router
- 视觉架构引擎: Tailwind CSS + Lucide Vue Next
- 通信:WebSocket
- 核心运行时: Node.js + Express.js
- 通信: WebSocket
- 角色定位: 采用“通道留痕模式”的设计理念,因 CRDT 逻辑完全在端侧实现解耦,该服务端主要负责多端链接维系、按房间 ID 和用户 ID 进行广播分发,以及基础会话准入控制等职责,无重型数据库负担,实现近似去中心化的运行模式。
aevia-workspace/
├── frontend/ # 前端
│ ├── src/
│ │ ├── components/ # Vue 局部状态组件
│ │ ├── views/ # 主业务视图容器
│ │ ├── stores/ # Pinia 状态层
│ │ └── App.vue # 入口文件
│ └── package.json
│
└── backend/ # 后端
├── src/
│ ├── config/ # 配置文件
│ ├── controllers/ # 房间/会话控制器
│ ├── services/ # 业务服务层
│ ├── websocket/ # Websocket 事件监听
│ └── app.js # 入口文件
└── package.json
Aevia采用前后端分离的设计模式,启动项目时,你需要分别启动项目的前端和后端服务后,才能正常使用。
需确保宿主机安装 Node.js (LTS)。
cd backend
npm install
# 服务启动后默认在 ws://localhost:4646 开放链接通道
npm run startcd frontend
npm install
# 挂载本地热更新调试
npm run dev
# 或使用Vite构建
npm run build
# 构建后可直接启动预览模式
npm run preview后端因为只是一个类似转发器的作用,主要技术点基本都在前端,所以本节中的原理解释除非特殊说明,否则默认都发生在前端。
画板在捕捉鼠标或触控笔的轨迹时,并不是每一个细微的移动事件都会被记录。由于浏览器的指针移动事件触发频率极高,如果不加限制地全量上报数据,会导致数据量暴增。因此,系统在绘制的底层函数中设置了一个距离阈值进行防抖:计算当前点与上一个已记录点的直线距离,只有当这个距离大于 2 个逻辑像素(即相对于画板的宽度,这能使不同屏幕尺寸的设备在操作时呈现的状态都大致相同)时,该点才会被判定为有效关键点并记录下来。这不仅过滤了因为手抖产生的冗余噪点,还在视觉无损的前提下极大地压缩了单条线的体积。
任何一笔完整的线条在本地和网络中都会经历三个阶段的生命周期流转:
-
起点 (start):当笔尖落下时,立刻在本地初始化一个全新的命令对象(Command),生成唯一的 ID 并打上当前的逻辑时间戳,记录起点坐标,随后立刻把这仅仅包含一个点的信息放入本地队列,并通知其他客户端笔刷落下的发源信息。
-
增量 (update):随着画笔移动,系统会不断收集达到距离阈值的“关键点”。为了兼顾实时反馈和网络压力,这些点会被收集到一个暂存的批处理队列中。当一次性收集的点数达到设定的阈值时,系统才会把这批点作为一个更新包分发出去。本地画板会直接将新点位追加到命令队列中指定的命令对象上,并在画板上向后不断延长线段;远端接收到更新包后,也能通过相同的算法处理,实时看到一条正在一点一点不断延长的墨迹。
-
归档 (stop):当画笔抬起,意味着这笔操作结稿。前端立刻计算出这条完整线段最终占据的拓扑包围盒(一个表示它占据的最大最小物理范围的长方形),带齐所有未发送的剩余点进行最后一次封包确认并向全体客户端广播。至此,一条 Command 命令才算彻底完成闭环并归档入库。
为了实现近似现实中的笔锋变化,画板在设计“绘画”功能时,对于线宽考虑了三个决定因素:基础线宽、速度和压力,基础线宽为用户在UI层面设置的画笔粗细,速度则是根据两个相邻关键点之间的距离计算出来的,而压力则是在具有压感能力的屏幕上使用PointEvent采集到的,它们彼此之间采用一个权重计算得出最终线宽:
const newWidth = clamp(lastWidth * 0.7 + targetWidth * 0.3, 1, baseSize + 2);这行代码实现了一个典型的指数平滑算法。根据当前速度和压感刚刚算出来的理想线宽(targetWidth)实际上只占了 30% 的决策权重,而上一个点遗留下来的历史线宽占据了 70% 的绝对权重。之所以要这么做,是因为用户书写时手部的力度和移动速度经常会发生大幅突变。通过这种加权混色式的平滑缓冲,能够强制弱化突变数据带来的影响,让线条在由粗变细时带有一种顺滑的阻尼过渡感,避免线宽突变。
最终绘制时,系统会采用 Canvas 的二次贝塞尔曲线画出一条平滑的曲线,而不是单纯用一段段直线连成一个锯齿状的线段。
在多人的实时网络下,经常会出现别人先画的线,因为网络卡顿导致最后才收到的情况。为了让这笔迟到的线段正确的回到它应该在的历史图层位置,我们就必须根据它进门时携带的偏序逻辑时间戳(Lamport Timestamp)进行重新排队。
系统采用的是二分查找插入算法。每次有外来的 Command 到达,就会拿它和本地全局数组做折半比对。时间戳越小说明它落笔越早,就往数组更靠前的位置插队,以此来降低查找时的内存损耗。
同时为了打破两个异地用户在极低概率下刚好于同一时刻(Lamport时间戳相同)落笔导致的平局死锁,我们引入了硬性的保底规则:遇到时间戳相同时,直接对比它们各自携带的命令 ID 的 ASCII 码值大小,谁大谁排后面。这保证了无论在谁的电脑上,大家比对出来的排队纠错结果永远是数学上完全一致的,满足了CRDT核心的交换律思想。
当一个新用户进入房间时,会收到服务器推送的全量历史记录。按理来说按照这堆数据,理论上可以完全还原画板上的内容,使一个原本空空如也的白板同步成房间内其它人看到的样子。
但是,由于网络传输和多人同时操作的复杂性,服务器发来的这堆 Command 对象的物理先后顺序并不等于它们在画板上真实的上下层级关系。因此,为了完美还原画面,系统必须进行基于偏序逻辑的一套全量重绘。
首先,系统不看服务器下发时的数组索引,也不看每个Command中携带的 Lamport 时间戳,而是以附着在每个关键点上的 Lamport 时间戳为基准,对全局命令进行了一次严格的乱序时间戳排序。
梳理好绝对的时空顺序后,系统并没有简单粗暴地按命令“一条横线一条横线”地画出来;相反,它将排序好的所有线段内的关键点彻底拆散、展开、打平成了一个包含成千上万个独立坐标点的一维时间数组。
为什么要“逐点”而不是“逐条”重绘?这是因为在协同作画中,两条线极有可能几乎同时发生重复的交错缠绕。如果按线条整体去粗暴层叠,晚几毫秒画完的那条线,会像死板的贴纸一样完整盖住早几毫秒画完的线。而如果我们把它们打散成微小的点阵,按照各个点内部独立绑定的微观 Lamport 时间戳进行终极穿插重绘,外加复刻上一个点遗留的“自然笔锋”属性,就能像真实的墨水融合一样,百分百还原出那种“你中有我,我中有你”的时空交错感。
这套机制一举解决了异步状态下的笔迹遮挡错误问题,它的核心优势在于:无论一个终端离线了多久、错过了多少次高并发的网络更新,只要它最终拿到这份数据并在本地展开重排打散,最后渲染出来的画面层级,与地球上任何一个正在协作的终端所看到的,在数学和视觉层面都必定是毫厘不差的,这也是CRDT交换律的体现。
在协同画板这类应用中,如果在绘制过程中因为两条线段发生了物理交叉而产生覆盖冲突,为了保证图层正确的遮挡关系,我们需要计算两者相交的最小脏矩形(AABB)并触发重绘。但如果每次光标移动画出一个点,都要粗暴地去和全屏几万点历史记录进行逐一比对,任何电脑的 CPU 都会瞬间卡死。
为了解决这个碰撞检测的性能黑洞,系统在本地维护了一个被称作“全局哨兵”的滑动窗口队列。
之所以叫它“哨兵”,就是因为它真的就像一个哨兵一样,时刻盯着正在落笔的当前动态。它的核心机制是:
极简暂存:哨兵队列并不会将整个画布的历史点阵都存下来,它默认仅仅缓存最近 3 个连续的 Lamport 时间戳波次。
相交嗅探:当鼠标拖拽产生了一个新的坐标点时,这个点会被立即送进哨兵队列里。系统拿着这个新点周围算出的极小包围盒矩阵,去和哨兵队列里同时存在的、由其他人画出的有限邻近点做 AABB 相交重叠检测,判断它有没有与最近新画的其它点产生交叉。
动态触发:如果一旦算出来有交集,哨兵就会立即触发事件(point-collision):“这个位置发生了交叉,赶快把这部分包围盒拿去重绘!”而随着全网时钟的不断向前推进,当累计驻留超过 3 个时间波次后,那些安全通过的老网格点就会被这套滑动窗口无情清退并归档,不会再参与相交检测,而是自然的被比它更新的点覆盖。
延迟检测:当一个时间戳远小于当前队列中其它数据的时间戳的点进入队列,系统会立刻察觉到这个异常,并判断有可能是一个点因为网络延迟或其它原因,在传输过程中逗留了过长的时间才到达,而此时由于与它时间戳相近的点早已被渲染到画布上了,因此会直接触发事件(point-collision)进行局部重绘,将这个乱序的点插入到命令队列中正确的位置,并将其所处的区域进行一次局部重绘。
这套极致轻量的设计,将原本高达 O(N²) 的碰撞遍历运算强行压制在了常数级 O(1) 的微观视阀内,即完成了精准的区域时空碰撞检测,又绝不会产生内存膨胀。
前面提到了用来兜底纠错的“全量重绘”,但如果仅仅因为某人撤回了一条小弧线,或者因为一次网络延迟导致数据乱序,就要清空屏幕把几万条全部重新画一遍,这种消耗极不划算,将导致画板性能灾难性下滑。脏区域重绘就是为此诞生的终极优化方案。 脏区重绘主要会在三种情况下触发:
第一种是 网络延迟重绘,也就是发生了上面提到的,一条晚到的线通过二分插入,自己强行挤进历史老堆里导致曾经的层级错误时。
第二种是 交叉点撞车重绘,当两条时间戳相近甚至相同的线交汇时,由于两个设备可能因为网络原因,导致两条线在各自的画板上出现了不同的覆盖情况。
第三种是特殊操作引起的重绘,部分会对画板产生破坏性影响的操作会触发局部重绘,如撤回、重做、移动、变形、窗口尺寸变化等。
一旦系统发现这两种时空倒置操作,会立刻揪出引发异动的那条线段,读取它之前定稿时记录好的拓扑包围盒坐标。拿到这个正好能圈住它的矩形后,系统会在全量重绘前利用 Canvas 上下文激活 clip() 裁剪蒙版限制。
套上这个蒙版之后,系统再放心地去调用那套笨重的打平式全量重绘算法。此时由于底层硬件的重绘隔离,除了处在那个刚刚发生异动的狭小矩形范围内、和其他残影发生交叉的线段得到了重绘以外,矩形外的笔迹其实全被蒙版屏蔽了,连一帧多余的 CPU 计算资源都没有占用,重绘完全没有影响到其它的无关区域。这相当于把全屏刷新灾难极大地压缩成了微观级别。
所以,回看一条普通的协同笔迹:从用户的指尖接触屏幕起,它便被创建并带着 Lamport 时钟并发送到服务器上(start),随后不断拼凑起达到物理阈值的点阵、并接受自然笔锋指数平滑的计算(update),最终在分离时算出自己的包围盒并定格归档(stop)。期间若遭遇网络延迟,它在接收端会被二分法精准押解至对应的历史车厢段重新排位;若它或者周边的邻居后来不幸由于撤回操作而隐藏,底层的脏区裁剪又会像手术刀一样单独掏出那一块局部面积,使所有用户看到的状态最终一致。
经多轮playwright测试**(测试脚本及报告均由 AI 生成)**,从多个角度验证了系统的性能,具体测试结果如下:
你可以通过以下方式帮助我改进这个项目:
- Fork 本仓库
- 切分开发分支
- 遵循约定的 Commit Message 协议
- 提交远程端推流
- 发起 Pull Request (PR),完成 Code Review 并完成合并。
