MiroFish:基于CRDT与WebSocket的实时数据协同画布
2026/9/19 7:23:39 网站建设 项目流程

MiroFish 这个名字最早出现在我们内部群的一条凌晨消息里:“要不别贴图了,让数据自己游起来。”那天晚上刚开完一场复盘会,会议室里三块大屏各显示一套指标,五个人对着三套口径吵了两个小时,最后的共识是——谁也不服谁。会后有人把三块屏的截图拼进协作白板,画满箭头和问号,白板确实很热闹,但截图里的数字是死的,改一个阈值就得重新截图、重新贴、重新对齐,第二天再看已经过期了。我当时的想法很朴素:如果白板上的图元能自己动、能自己带着数据活起来,很多会议其实能省掉一半时间。MiroFish 就是从这个念头里长出来的东西——一块支持多人同时编辑的画布,加上一条实时数据通道,把指标变成在画布里游动的“鱼群”,谁都能顺手戳一下看细节,谁都能拉一条线把两个数据池连起来。它不算大工程,核心代码不到八千行,但它解决的是那种“说不清但每次开会都难受”的问题。

这篇内容适合三类人看:一类是做内部工具、想把监控和协作揉到一起的工程师;一类是正在评估协同编辑方案、纠结 CRDT 还是 OT 的技术负责人;还有一类就是想找个周末项目练手的独立开发者,MiroFish 的技术栈不算轻,但每一层都能拆出来单独用。我不会把它包装成一个“企业级解决方案”,它就是一个小团队自己攒出来、在生产环境跑了大半年的东西,哪些地方是凑合的、哪些地方是被逼出来的,我都会写清楚。

1. 从“截图贴墙”到“画布上游动的数据”:MiroFish 到底解决什么问题

1.1 需求的原点:三块屏幕、五个人、零共识

先把场景讲清楚,否则后面所有技术选型都像是为了炫技。我们的日常是这样:一个服务集群,指标分散在三套系统里——业务埋点在时序库里,调用链在某套 APM 里,基础设施指标又在另一套平台。每套系统都有自己的看板和自己的口径,单看都挺清楚,放在一起就打架。有一次排查一个偶发的超时,我们从上午十点查到下午三点,最后发现是两边的“成功率”定义不同:一边把超时算失败,一边把超时算成功但单独打标。这件事之后我就很确定,我们缺的不是更多的看板,而是一块能把不同来源的东西摆在一起、并且允许所有人当场改、当场标的画布。

截图贴白板这个做法,本质上是用“静态快照”去承载“动态共识”,它会以肉眼可见的速度腐烂。第一天贴上去的时候大家还新鲜,第三天就没人看了,因为没人知道图上的数字是什么时候的。更麻烦的是拓扑关系:服务 A 调用服务 B,B 依赖中间件 C,这些关系在三套系统里各画了一遍,三张图长得都不一样,新人进来直接懵。MiroFish 的第一个目标就是把“图元 + 关系 + 实时数据”这三件事放在同一个坐标系里,图元本身带时间戳,关系可以被任何有权限的人修改,数据每隔固定周期刷新一次。

1.2 定位边界:它不做 BI,也不做在线文档

做内部工具最容易犯的错是贪心,什么都要,最后什么都不精。所以 MiroFish 从第一天就定了三条边界,写进了 README 的第一段。第一条,它不做 BI 查询。复杂的聚合、下钻、同环比,仍然交给专业的分析平台,MiroFish 只做“已经算好的指标”的可视化映射。第二条,它不做富文本文档。它不是又一个在线文档,图元上的文字就是标签级别,想写长篇大论请回到文档工具里去。第三条,它不做告警。告警的送达链路、去重、静默、升级策略是一整套独立体系,硬塞进来只会把画布变成一个噪声源。

提示:边界这件事必须在项目早期用文字固化下来,写在 README 最显眼的位置。我见过太多内部工具,因为边界模糊,功能列表半年翻了三倍,最后没人维护。

这些边界带来的好处非常直接:数据模型简单了。整个 MiroFish 的核心实体只有四种——画布、图元、连接线、数据源绑定。没有表格、没有表单、没有复杂权限组,权限只分三层:只读、可编辑、可管理。简单到什么程度?一个新人读完数据模型定义,半小时就能上手改代码。

1.3 名字拆解:Miro 管“在哪里看”,Fish 管“怎么看”

给项目起名字这件事,看起来不重要,其实会影响你对产品的想象。MiroFish 拆成两半:Miro 那一半指的是“画布”这个载体,强调多人在同一片空间里协作、拖拽、标注、对齐的自由度;Fish 那一半指的是数据的呈现形态,数据不是钉在表格里的数字,而是有速度、有方向、有聚集行为的活物。一群鱼在水里游,水质变了,鱼群的密度、速度、聚散就会跟着变,看的人不需要看懂每个数字,只看形状就知道出事了。

这个隐喻不是装饰,它直接决定了渲染层的设计。传统看板里,一个指标就是一个数字加一条折线;在 MiroFish 里,一个指标是一群个体,个体的速度和聚合度由指标的变化率决定,个体的颜色由健康度区间决定。举一个我们实际在用的例子:订单服务的 QPS 正常时是一群匀速游动的蓝鱼,一旦 P99 延迟突破阈值,鱼群的游动速度会突然下降并开始聚集,视觉上非常刺眼。值班的同学说,他有一次是在“余光扫到画面变慢”的时候才发现问题的,比告警短信还早了四十秒。

2. 技术选型:为什么是 CRDT + WebSocket + Canvas

2.1 协同模型:CRDT 与 OT 的取舍

协同编辑这块,绕不开两个流派:CRDT(无冲突复制数据类型)和 OT(操作变换)。我们选 CRDT,不是因为它更时髦,而是因为它对“弱中心化部署”和“离线编辑”更友好。OT 需要一个中心服务器来定序,变换函数要针对每一种操作类型单独推导,图元移动、缩放、旋转、改属性、删连线,每一种组合都要证明收敛性,我们的团队规模撑不起这种维护成本。CRDT 的代价是元数据膨胀,但换来的好处是:每个客户端可以本地立即生效,服务器只做广播和持久化,不需要理解操作语义。

对比维度CRDTOT
收敛保证数学上天然收敛,与到达顺序无关依赖服务器定序与变换函数正确性
中心依赖弱,可 P2P、可离线强,必须有权威服务端
元数据开销较大,每个字符/属性带标识较小
实现复杂度选对库后较低每种操作都要推导变换
适合场景图形编辑、离线优先、多端纯文本、长文档、强中心

我们用的是基于 Lamport 时间戳加站点 ID 的组合标识,图元粒度的合并,没有做到字符级——这是一个刻意的取舍。图形编辑里,两个人的光标很少落在同一个像素上,冲突主要集中在“同一次拖拽的最终位置”,粒度到图元就够了。字符级 CRDT 在万亿级文本场景才有明显价值,我们做的是几十个图元的画布,用不上。

2.2 传输层:为什么弃用 SSE 和轮询

第一版我用的是 SSE(Server-Sent Events),理由是简单,浏览器原生支持自动重连,服务端就是普通的 HTTP 响应流,连协议都不用自定义。跑了三天就换掉了,原因有三个。第一,SSE 是单向的,客户端要发操作还得再开一条 POST 通道,两条通道之间的顺序无法保证,实际操作里出现过“我的删除请求到了,但对方的更新广播先到”,导致图元删了又冒出来。第二,SSE 在 HTTP/1.1 下受同域连接数限制,一台机器开了六条 SSE 连接之后,同域的普通请求就开始排队,这个坑非常隐蔽。第三,SSE 的自动重连会带上 Last-Event-ID,但我们的操作流有自己的版本向量,两套序号体系叠在一起,调试起来让人崩溃。

方案方向重连语义多路复用结论
短轮询双向天然延迟高,请求量爆炸
长轮询双向需自实现天然连接频繁重建,不适合高频操作
SSE单向浏览器内置HTTP/1.1 受限需配第二条通道,弃用
WebSocket双向需自实现原生支持二进制与多路消息最终选择

换成 WebSocket 之后,重连逻辑得自己写,这是代价。但换来的是同一条连接上所有消息天然有序,操作和广播混在一条流里,版本号只有一套,排查问题时看一串日志就能把因果链理清楚。

2.3 渲染层:Canvas 脏矩形与 SVG 的边界

渲染这块我纠结了挺久。SVG 的好处是每个图元就是一个 DOM 节点,事件绑定、无障碍、CSS 动画全都免费;坏处是节点一多就卡,尤其是我们这种每秒都在动的东西,200 个图元开始掉帧,500 个就直接幻灯片。Canvas 的好处是绘制性能可控,坏处是所有交互都得自己算:命中检测、层级、文本换行、缩放坐标变换,一个都不能偷懒。

最后的方案是混合:静态的标注层、连线层用 SVG,动态的鱼群层用 Canvas,两层用同一个坐标系,缩放平移由同一套变换矩阵驱动。这个方案的关键在于两个层的重绘节奏要分开:SVG 层只在结构变化时重绘,Canvas 层按 60 帧跑。为了不把整个 Canvas 重画一遍,我们用了脏矩形:只重绘鱼群上一帧和这一帧覆盖到的区域,实测在 2000 个图元的情况下,单帧绘制时间从 14ms 降到 3ms 左右。

2.4 服务端与存储:Go、Redis Streams、时序库

服务端选 Go,理由是 WebSocket 的长连接场景下,goroutine 的调度模型比线程轻太多,单机撑五万连接时内存占用还在可控范围。广播环节用一个 hub 模式:每个画布一个 hub,hub 内部维护连接集合和一个带缓冲的 channel,写入慢的连接会被踢掉,避免慢消费者拖垮整个 hub。这个“踢掉慢消费者”的策略我们踩过坑,后面会讲。

存储分三块。画布的文档状态(图元、连线、属性)存在关系型数据库里,用 JSON 字段存 CRDT 的合并结果,定期做一次快照压缩。操作日志走 Redis Streams,保留 72 小时,用来做重连补齐。指标数据本身不进 MiroFish,只存数据源的连接配置和最近一次取到的值,真正的原始数据留在时序库里,靠定时拉取或者订阅推送。

3. 核心链路拆解:一条数据从采集到“游动”起来

3.1 接入层:三种数据源的协议归一

我们实际接了三种源:HTTP 拉取的 JSON 接口、MQTT 订阅的主题、以及时序库的查询接口。三种源的差异很大,拉取是周期性的,订阅是事件驱动的,查询是按时间窗口的。为了不让下游感知差异,接入层定义了一个统一的中间结构,核心字段只有五个:数据源标识、指标键、时间戳、数值、标签集合。所有适配器只负责把各自的原始数据翻译成这个结构,翻译完就往内部的一个 channel 里丢。

{ "source_id": "order-svc-prod", "metric_key": "order.qps", "ts": 1735689600000, "value": 1284.5, "tags": { "env": "prod", "region": "cn-east" } }

这里有个容易忽略的细节:时间戳的语义。拉取型数据源返回的时间戳,到底是“采集时间”还是“数据生成时间”?我们一开始没区分,结果在做鱼群动画插值时出现了负延迟——鱼往前游了一下又倒回来。后来统一规定,所有接入的数据必须带两个时间戳:ts是数据产生的时刻,ingest_ts是进入 MiroFish 的时刻,动画插值只用ts,监控接入延迟只看两者之差。

3.2 增量同步协议:opId、版本向量与因果序

客户端的每一个操作打成一个 op,结构尽量扁平,方便序列化和调试。

{ "op_id": "a3f1@site-2", "canvas_id": "c_9f2", "type": "element.move", "target": "el_77", "payload": { "x": 320, "y": 180 }, "deps": { "site-1": 41, "site-2": 17, "site-3": 9 } }

deps就是提交时的版本向量,表示“我基于这些站点的哪些版本产生了这个操作”。服务端收到 op 之后,先检查依赖是否满足,能满足就直接广播并更新自己的版本;不能满足就放进一个待定队列,等缺的 op 到了再一起处理。本地客户端收到远端 op 时,同样做一次依赖检查,缺依赖就暂存,避免“基于旧状态的操作”被错误地应用到新状态上。

合并函数是整个 CRDT 的心脏,逻辑不复杂,但必须非常小心。

// 简化后的合并逻辑:位置按时间戳大者胜,属性按字段独立合并 func MergeElement(local, remote Element) Element { if remote.Lamport > local.Lamport || (remote.Lamport == local.Lamport && remote.SiteID > local.SiteID) { local.Pos = remote.Pos local.Lamport = remote.Lamport local.SiteID = remote.SiteID } // 属性字段独立合并,避免"整体覆盖"导致别人改的颜色被我的移动冲掉 for k, v := range remote.Props { if local.PropClock[k] < remote.PropClock[k] { local.Props[k] = v local.PropClock[k] = remote.PropClock[k] } } return local }

这段代码里最值得说的不是胜负规则,而是属性独立合并。第一版我们是整体覆盖的,结果出现了一个很典型的现象:A 把图元挪到左边,B 同时把图元改成红色,合并之后要么位置回退要么颜色丢失。拆成字段级时钟之后,这类冲突才真正消失。

3.3 鱼群渲染:把指标映射成个体的行为参数

鱼群不是一个装饰动画,它是数据到视觉的映射函数。我们定义了四个行为参数:游动速度、聚集度、颜色通道、个体抖动幅度。每个参数都有明确的输入和映射曲线。

行为参数输入指标映射方式视觉含义
游动速度指标变化率(一阶差分)线性截断映射到 0.2x 到 2.0x变化越快,鱼游得越急
聚集度指标离散度(同组多个实例的标准差)反比映射,标准差越大越松散实例越不均衡,队形越乱
颜色通道健康度区间分段映射:正常蓝、注意黄、异常红一眼看出水位
抖动幅度采样间隔内的极差归一化后映射到 0 到 6 像素抖动大说明数据不稳

映射曲线用分段线性而不是 sigmoid,原因是可解释性。值班同学需要知道“鱼游到多快算异常”,如果用平滑曲线,阈值附近的变化太模糊,反而不好判断。分段线性虽然折角生硬,但每一段都有明确含义,调参时改的是拐点坐标,团队里非算法背景的同学也能改。

function mapSpeed(deltaRate) { const segs = [[0, 0.2], [0.05, 0.6], [0.2, 1.2], [0.5, 2.0]]; if (deltaRate <= segs[0][0]) return segs[0][1]; for (let i = 1; i < segs.length; i++) { const [x0, y0] = segs[i - 1], [x1, y1] = segs[i]; if (deltaRate <= x1) { const t = (deltaRate - x0) / (x1 - x0); return y0 + t * (y1 - y0); } } return segs[segs.length - 1][1]; }

3.4 断线重连与状态收敛

重连不是简单地把 WebSocket 重新连上就完事,真正麻烦的是状态收敛。我们的做法分三步:连接建立时先发一个sync.request,带上本地版本向量;服务端对比自己的版本,算出客户端缺失的操作区间,从 Redis Streams 里读出来按序下发;客户端应用完这批操作后,再发一次sync.ack,服务端才认为它进入正常广播队列。这中间如果操作量太大,会退化成一次全量快照下发,阈值设的是 5000 条操作或者 2MB 数据。

有个细节值得说:收敛期间要屏蔽本地编辑的广播,但允许本地编辑生效。否则用户会看到自己的操作被“拉回去”再“推出来”,体感很差。我们专门做了一层乐观锁,收敛期间本地操作只入本地队列,标记为 pending,等收敛完成再统一提交。

4. 实测数据与压测现场:200 个并发编辑者时发生了什么

4.1 压测方案与观测指标

压测没搞得很复杂,用的是一个自己写的脚本,模拟三种角色:纯浏览者(只收不发)、轻度编辑者(每分钟 1 到 3 次操作)、重度编辑者(每秒 2 到 5 次操作)。比例按 7:2:1 配,尽量贴近真实分布。观测指标有四个:操作广播的 P99 延迟、客户端帧率、服务端单连接平均内存、以及重连后的收敛耗时。

场景连接数图元数广播 P99客户端帧率收敛耗时
小画布3030042ms58fps0.3s
中画布120120078ms52fps0.9s
大画布2005000156ms41fps2.7s
极端场景2005000 + 每秒 500 指标更新340ms28fps4.1s

极端场景那一行是必然要付出代价的,5000 个图元加上每秒 500 次指标刷新,帧率掉到 28fps 已经算是能接受。真正的教训不在这张表里,而在压测过程中暴露出来的几个问题。

4.2 内存泄漏:被忽略的离屏 canvas 与事件监听

第一次压测跑了两小时,服务端内存曲线一路缓慢上扬,没有任何回落。我一开始怀疑是连接泄漏,用 pprof 抓了一轮堆快照,发现连接数很稳定,泄漏点在别处。最后定位到两个地方:一是每个画布一个离屏 canvas 用作缓存,但画布关闭时只清了引用没调用释放逻辑,导致底层像素缓冲没被回收;二是窗口 resize 事件的监听函数绑在画布上,画布销毁时没有解绑,每开关一次画布就多一个闭包持有整棵场景树。

这两个问题的共同点是:它们都不会在短时间压测中暴露,只有长时间跑才看得见。所以我把压测时长从两小时改成了十二小时,并且加了一条硬规则——任何创建资源的代码,必须同时写一个释放路径,并且这个释放路径要能被测试覆盖。

注意:前端资源的生命周期管理,最容易在“快速迭代”中被牺牲。我的经验是,只要引入了离屏画布、Web Worker、定时器、全局事件监听中的任意一个,就必须同时写一个destroy函数,哪怕当期用不上。

4.3 弱网下的“幽灵图元”

某个周二的下午,有同事反馈白板上出现了一个“幽灵图元”:位置在左上角,点不中,刷新之后就没了。日志查了半天,最后复现出来的路径是这样的:客户端在弱网环境下发出删除操作,操作已经抵达服务端并广播,但这个客户端的广播通道正好断了;重连之后,客户端拿本地版本向量去要增量,服务端的 Streams 里那条删除操作已经过期被裁剪掉,于是服务端认为“你没有缺失”,直接跳到最新状态;客户端本地的图元既没被删除,也没在服务端的快照里出现,成了一个孤儿。

修复方案有两个层面。短期方案是延长 Streams 的保留时间并提高全量快照的触发敏感度,让裁剪与全量的时间窗口错开。长期方案是在快照里加一个校验摘要,客户端应用完增量后对比摘要,不一致就强制全量。这个摘要我们用的是图元 ID 集合的哈希加每个图元的版本号异或值,成本很低,但能兜住绝大多数不一致。

4.4 一次时钟漂移导致的乱序事故

还有一次事故更隐蔽。集群里有台机器的时间同步服务挂了两天没人发现,走了大概 8 秒的偏移。这台机器上的客户端发出的 op,Lamport 时间戳看着是正常的,但指标数据里的ts是本地时钟打的,比别的机器早了 8 秒。结果就是这条数据在动画插值时永远排在前面,鱼群出现了一个奇怪的“抢跑”个体,一直在队首。

这件事之后我们做了两件事。第一,所有接入数据的ts必须以服务端接收时间为准做一次偏差校正,客户端上报的时钟偏差记录在标签里,超过阈值的直接丢弃并告警。第二,Lamport 时间戳和物理时钟彻底解耦,物理时钟只用于指标插值,绝不参与 CRDT 的合并判定。这两个东西混在一起是灾难的源头。

5. 从日志里捞出来的经验:协同画布类项目的坑位地图

5.1 协同类的三个高频坑

第一个坑是“看起来很简单的移动操作”。图元的移动在用户看来就是拖拽,但在协同场景下,一次拖拽会产生几十上百个中间位置。如果每个位置都发一次 op,带宽和合并开销都会爆;如果在拖拽结束时只发一次 op,别人就看不到你正在拖。我们的折中做法是:拖拽过程中用低频(约每 80ms)发“预览位置”,这个 op 带一个ephemeral: true标记,不参与持久化和合并判定,只在广播层存活 3 秒;拖拽结束时发一个正式 op,覆盖整个移动。这样既能看到别人在动,又不会污染文档状态。

第二个坑是“选择集冲突”。两个人选中同一个图元,A 删掉它,B 正在改它的颜色,B 的操作就落空了。处理方式是在 op 里带上前置条件,如果目标已不存在,服务端标记为 no-op 并回一个提示,客户端把编辑面板上的“已失效”状态显示出来。别小看这个提示,没有它,用户会觉得“这东西不稳定”。

第三个坑是“撤销重做”。CRDT 天生不记录“用户意图”,它只有操作。做撤销的时候不能简单地把操作反向执行,因为中间可能夹杂了别人的操作。我们的做法是维护一个本地操作栈,撤销时生成一个“逆操作”,把这个逆操作当作一个新操作发出去,让它参与正常的合并流程。这样做的代价是撤销不保证回到历史状态,但保证了全局一致性。

5.2 可视化类的两个隐性坑

隐性坑之一是“坐标系漂移”。缩放和平移的变换矩阵如果在前端各模块里各算一遍,迟早会出现鼠标点击位置和实际图元位置差几个像素的情况。我们的做法是把变换矩阵收敛到一个Viewport单例,所有坐标转换必须走它的方法,禁止任何地方自己乘矩阵。这条规则写进了代码规范,用 lint 规则检查,违反直接构建失败。

隐性坑之二是“文本测量”。Canvas 里绘制文本前必须知道宽度,而文本宽度依赖字体加载状态。字体没加载完就测量,得到的是回退字体的宽度,字体加载完之后布局就错位了。修复方式是用document.fonts.ready阻塞首帧渲染,并且把测量结果按“字体+字号+文本内容”做缓存,避免每次重绘都重复测量——文本测量是 Canvas 里最贵的操作之一,不加缓存,2000 个图元的画布每秒能多烧掉 20ms。

5.3 部署与运维踩过的坑

部署这块有两个教训。第一个是关于慢消费者:WebSocket 连接的写入如果阻塞,会拖住整个广播循环。我们最初给每个连接一个无缓冲 channel,结果一个网络差的用户能让整个画布卡住。改成带缓冲的 channel 之后,还要处理写满的情况——我们的策略是写满就断开并让客户端重连,同时把这个用户的连接标记为“降级”,重连后先只给他发摘要,等他确认再进广播队列。

第二个是关于水平扩展。WebSocket 是有粘性的,用户在哪台机器上,他的 op 就发到哪台机器,但广播要覆盖整个画布的所有用户,所以必须有一个跨节点的广播通道。我们用的是 Redis 的发布订阅,按画布 ID 分频道。这里有个坑:Redis 发布订阅不保证投递,节点抖动期间的消息会丢。所以增量补齐必须走 Streams 而不能走 Pub/Sub,Pub/Sub 只负责“通知有新消息”,真实数据从 Streams 里拉。这两者的分工一定要分清楚。

# 关键配置片段 sync: reconnect_timeout_ms: 5000 full_snapshot_op_threshold: 5000 full_snapshot_size_threshold_mb: 2 digest_check_enabled: true broadcast: per_conn_buffer: 256 slow_consumer_policy: disconnect history_ttl_hours: 72 render: target_fps: 60 dirty_rect_enabled: true text_measure_cache_size: 4096

6. 把它跑起来并改成你自己的形状

6.1 最小可运行配置

本地跑起来只需要三样东西:一个关系型数据库、一个 Redis、以及一个能在浏览器里打开的页面。首次启动会自动建表并写入一份示例画布,包含三个图元和一条连线,外加一个模拟数据源,每两秒产生一次随机波动。想看鱼群动起来,不用接任何真实数据,示例数据源就够。

配置项一共不到三十个,我建议第一次跑的时候把render.target_fps调到 30,观察 CPU 占用,再慢慢调回 60。很多人上手就把画布塞满几千个图元,然后觉得“这玩意儿太卡”,其实是没按规模调参。图元数量在 500 以内时,脏矩形基本没收益,关掉反而更简单;超过 1000 再打开,收益才明显。

6.2 插件机制:自定义图元和自定义数据源

MiroFish 留了两个扩展点。图元类型插件用来定义新的图形和它的渲染逻辑,接口只有三个方法:drawhitTestgetBounds。数据源适配器插件用来接新类型的数据源,接口也只有三个方法:connectfetchclose。接口少是刻意的,接口一多,插件作者就会开始往里面塞业务逻辑,最后变成另一个需要维护的系统。

// 自定义图元插件示例 export default { type: 'pool', draw(ctx, el, viewport) { const p = viewport.worldToScreen(el.pos); ctx.beginPath(); ctx.arc(p.x, p.y, el.props.radius * viewport.scale, 0, Math.PI * 2); ctx.fillStyle = el.props.color; ctx.fill(); }, hitTest(el, worldPoint, viewport) { const dx = worldPoint.x - el.pos.x; const dy = worldPoint.y - el.pos.y; return Math.hypot(dx, dy) <= el.props.radius; }, getBounds(el) { const r = el.props.radius; return { x: el.pos.x - r, y: el.pos.y - r, w: r * 2, h: r * 2 }; } };

6.3 后续还能往哪扩

有三个方向我一直在想但还没做。第一个是“时间轴回放”。既然操作日志留了 72 小时,理论上可以把画布的演化过程录下来回放,这对复盘特别有用——会后不用靠回忆,直接看当时大家是怎么讨论的。第二个是“跨画布引用”,允许一个画布里嵌另一个画布的缩略视图,这在画多层架构图的时候会省很多事。第三个是移动端只读视图,值班同学在外面临时想看状态,不需要能编辑,只需要能看得清楚。

我在实际用下来最深的体会是:这类工具的价值不在功能多,而在“打开成本”低。我们现在开复盘会的第一件事就是把 MiroFish 的画布投出来,谁有想法直接上手改,改完的数据是活的,不用再截图、不再对口径。它替我们省掉的不是操作时间,是那种“先解释一遍这张图是什么时候的”的沟通成本。如果你也在做类似的内部工具,我建议先把图元和连接线这两个模型打磨到极致,其余的都可以后面再加——因为一旦模型不稳,后面每加一个功能,都是在给未来的自己挖坑。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询