1. Diagram 画板到底要解决什么问题
WPF 里做画板这件事,我前后折腾了挺长时间。前面几篇把自由绘制、图元编辑、属性面板这些基础件都搭起来了,到了这一篇要面对的完全是另一种东西——Diagram 画板,也就是节点连线图。它和我们常说的普通画板最大的区别在于:普通画板关心的是"我在哪画了一条线、涂了一个色块",Diagram 画板关心的是"这两个方块之间有没有关系、关系从哪个端口出发、走到哪个端口结束"。前者是像素的集合,后者是语义的结构。
而标题里"可编程画板"这四个字,才是这套东西真正的价值点。它意味着画板不只是给鼠标用的,还能被一段脚本、一份配置、甚至一个外部系统驱动:你写几行描述,画板上自动长出节点和连线;反过来,用户在画板上拖一下、连一根线,这份描述也要跟着变。这个双向通道打通之后,画板就从"画图工具"变成了"可视化编辑器",能挂到流程编排、状态机设计、拓扑配置、教学演示这些场景上。
这篇内容适合两类人看。一类是已经用 WPF 做过基础绘图、想往结构化图形方向走的中级开发者;另一类是手头有个"让用户自己连节点"的需求,正在纠结要不要上第三方图库的人。我会把架构选型、节点端口实现、连线路由、脚本层设计、序列化与撤销重做、性能优化这整条链路拆开讲,代码都是能直接落地的那种,不是伪代码。踩过的坑我也会一并写出来,省得你再走一遍。
2. 架构选型:为什么最后回到 Canvas + ItemsControl
2.1 视觉树分工:三层画布的叠放关系
Diagram 画板的视觉结构,我试过好几种组织方式,最后稳定下来的方案是三层叠放:最底下是网格背景层,中间是连线层,最上面是节点层。这三层各自是一个独立的容器,通过Grid叠在一起,顺序固定,互不干扰。
为什么要分开?因为它们的重绘频率完全不同。拖一个节点的时候,节点自身位置变、连线端点跟着变,但背景层一动不动;缩放画布的时候,三层一起变,但内部结构不变。如果全塞进一个 Canvas 里,任何一次操作都会让整个视觉树参与布局和渲染,节点一多就会明显掉帧。
<Grid ClipToBounds="True"> <Canvas x:Name="GridLayer" /> <!-- 背景网格、对齐辅助线 --> <Canvas x:Name="EdgeLayer" /> <!-- 连线,位于节点下方 --> <ItemsControl x:Name="NodeLayer" ItemsSource="{Binding Nodes}"> <ItemsControl.ItemsPanel> <ItemsPanelTemplate> <Canvas IsItemsHost="True" /> </ItemsPanelTemplate> </ItemsControl.ItemsPanel> <ItemsControl.ItemContainerStyle> <Style TargetType="ContentPresenter"> <Setter Property="Canvas.Left" Value="{Binding X}" /> <Setter Property="Canvas.Top" Value="{Binding Y}" /> </Style> </ItemsControl.ItemContainerStyle> </ItemsControl> <Canvas x:Name="OverlayLayer" /> <!-- 框选矩形、拖拽预览线 --> </Grid>这里有个细节值得说:节点层我用ItemsControl而不是手动往Canvas.Children里塞控件。原因是数据绑定。节点集合一变,视觉树自动跟着变,不用手写增删逻辑。Canvas.Left和Canvas.Top通过ItemContainerStyle绑到ContentPresenter上,这是ItemsControl+Canvas的标准配合方式,绑定源是数据项本身,路径直接写X、Y就行。
OverlayLayer单独放在最上层,是为了让框选矩形、连线预览这些临时视觉元素永远压在节点上面,不会被节点遮住。这个层级关系一旦定下来,后面所有的交互实现都会简单很多。
2.2 数据模型先行:Node / Port / Edge 怎么切
写 Diagram 画板最容易犯的错,是先写交互再补模型,写着写着发现模型撑不住需求,回头大改。我的做法是先把模型切干净,再动 UI。
核心就三个类型:节点(Node)、端口(Port)、连线(Edge)。节点持有自己的位置、尺寸、类型和端口集合;端口是节点边界上的一个锚点,用"所在边 + 沿边偏移"来定位;连线不持有任何坐标,只持有"从哪个节点的哪个端口连到哪个节点的哪个端口"。最后这一条非常关键——连线的几何形状永远是算出来的,不是存下来的。
public class DiagramNode : INotifyPropertyChanged { public string Id { get; set; } = Guid.NewGuid().ToString("N"); public string Label { get; set; } public string Kind { get; set; } // Process / Decision / Start ... public double X { get; set; } public double Y { get; set; } public double Width { get; set; } = 160; public double Height { get; set; } = 64; public ObservableCollection<DiagramPort> Ports { get; } = new(); } public class DiagramPort { public string Name { get; set; } // "in" / "out" / "true" / "false" public PortSide Side { get; set; } // Left / Top / Right / Bottom public double Offset { get; set; } // 沿边偏移,0 表示居中 } public class DiagramEdge { public string Id { get; set; } = Guid.NewGuid().ToString("N"); public string SourceNodeId { get; set; } public string SourcePort { get; set; } public string TargetNodeId { get; set; } public string TargetPort { get; set; } public EdgeStyle Style { get; set; } = EdgeStyle.Orthogonal; public string Label { get; set; } }Offset用相对值而不是绝对坐标,好处是节点缩放时端口位置自动跟着走,不用额外同步。Id用 GUID 而不是自增索引,是为了序列化和跨会话引用不出错——这一点在保存后再打开的场景里特别重要。
2.3 为什么不用现成的图形控件库
市面上确实有能直接画节点连线的控件库,但我最后没用。不是因为它们不好,而是这个项目对"可编程"的要求太具体:脚本语法要自定义、执行过程要能单步打断点、每次图形变更要回写脚本、属性面板要按节点类型动态生成。这些需求套在通用控件上,往往要写大量适配层,最后比自己写还累。
自己写也有代价:连线路由、命中测试、框选、缩放这些都得自己实现。但这些恰恰是这篇文章要讲的部分,而且实现下来总量并不大——核心逻辑大概两千行左右就能跑得很稳。我的判断标准很简单:如果需求里"自定义交互"的比例超过三成,就自己写;低于三成,直接用库。
3. 节点与端口的实操实现
3.1 用 Thumb 还是自己处理鼠标事件
拖动节点这一步,WPF 给了两条路:Thumb控件,或者自己在节点模板上处理MouseLeftButtonDown/MouseMove/MouseLeftButtonUp。
我先试的是Thumb,因为它自带拖动语义,代码量少:
<Thumb DragDelta="OnNodeDragDelta" DragStarted="OnDragStarted" DragCompleted="OnDragCompleted"> <Thumb.Template> <ControlTemplate TargetType="Thumb"> <Border Background="{TemplateBinding Background}" CornerRadius="6" BorderThickness="1" BorderBrush="#3A3F4B"> <ContentPresenter /> </Border> </ControlTemplate> </Thumb.Template> </Thumb>用下来发现两个问题。第一,Thumb的DragDelta给的是相对于上一次事件的增量,累加的时候如果中途有精度损失或者被其他逻辑打断,位置会漂。第二,Thumb会吃掉鼠标事件,导致节点内部的按钮、端口点击都失效,得靠Preview系列事件绕回来。
后来我改成了自己处理,思路是"记录起点 + 计算总位移":
private Point _dragStartMouse; private Point _dragStartNode; private bool _dragging; private void OnNodeMouseLeftButtonDown(object sender, MouseButtonEventArgs e) { if (e.OriginalSource is DependencyObject d && FindAncestor<Thumb>(d) != null) return; // 端口优先 var node = (DiagramNode)((FrameworkElement)sender).DataContext; _dragStartMouse = e.GetPosition(NodeLayer); _dragStartNode = new Point(node.X, node.Y); _dragging = true; ((UIElement)sender).CaptureMouse(); e.Handled = true; } private void OnNodeMouseMove(object sender, MouseEventArgs e) { if (!_dragging) return; var node = (DiagramNode)((FrameworkElement)sender).DataContext; var cur = e.GetPosition(NodeLayer); var dx = cur.X - _dragStartMouse.X; var dy = cur.Y - _dragStartMouse.Y; node.X = Snap(_dragStartNode.X + dx); node.Y = Snap(_dragStartNode.Y + dy); }这个写法最大的好处是不会漂,因为每次都是从头算绝对位移。Snap是对齐吸附,后面再说。
提示:拖拽期间一定要
CaptureMouse(),并且在MouseLeftButtonUp和LostMouseCapture两处都做收尾。只处理其中一个,遇到 Alt+Tab 切窗口的情况会出现"鼠标松了但还在拖"的鬼畜状态。
3.2 端口命中测试的几个坑
端口在视觉上通常是个 8~10 像素的小圆点。直接拿这个圆点做命中,用户会骂人——太小了,根本点不准。
解决办法是在端口位置放一个透明的放大热区。我用的尺寸是 16×16,视觉圆点仍然是 10,热区比视觉大一圈,人眼看不出来但手感明显变好。
<Grid Width="16" Height="16" Background="Transparent" MouseLeftButtonDown="OnPortMouseDown" MouseEnter="OnPortMouseEnter" MouseLeave="OnPortMouseLeave"> <Ellipse Width="10" Height="10" Fill="#5B8DEF" /> </Grid>第二个坑是缩放之后的坐标系。画板支持缩放的时候,鼠标位置拿到的是控件坐标,要转成画板逻辑坐标才能和节点位置比较。转换用MatrixTransform的逆矩阵:
private Point ToDiagramPoint(Point screenPoint) { var m = NodeLayer.RenderTransform.Value; if (!m.HasInverse) return screenPoint; m.Invert(); return m.Transform(screenPoint); }第三坑更隐蔽:画板缩放到 0.5 倍时,16 像素热区在屏幕上只剩 8 像素,又变难点了。我的处理是热区尺寸按缩放比例反向补偿,缩放越小热区越大:
var scale = GetCurrentScale(); portHotspot.Width = 16 / Math.Max(scale, 0.4); portHotspot.Height = 16 / Math.Max(scale, 0.4);这个做法不够优雅,但实测体验提升非常明显,尤其是在触摸屏或者高分屏上。
3.3 拖拽吸附与对齐辅助线
节点拖动如果不吸附,位置永远是 137.42 这种数字,排出来的图看着就乱。吸附分两种:网格吸附和对象对齐吸附。
网格吸附简单,取整到最近的网格倍数:
private const double GridSize = 8; private static double Snap(double v) => Math.Round(v / GridSize) * GridSize;对象对齐吸附稍微复杂:拖动当前节点时,拿它的左、中、右、上、中、下六条参考线,去和其他节点的对应线比较,差值小于阈值(我用的是 6 像素逻辑单位)就吸过去,同时在上层画一条虚线提示。
private double? FindAlignTarget(double candidate, IEnumerable<double> others, double threshold) { double? best = null; double bestDelta = threshold; foreach (var o in others) { var d = Math.Abs(o - candidate); if (d < bestDelta) { bestDelta = d; best = o; } } return best; }辅助线画在OverlayLayer上,用一条Line加虚线StrokeDashArray,拖拽结束就清掉。
注意:吸附的判断顺序要先"对象对齐"再"网格吸附"。反过来的话,网格会先把位置拉到 8 的倍数上,对象对齐的阈值很容易被吃掉,导致吸附手感发死。
4. 连线:路由、重绘与性能
4.1 三种路由策略的取舍
连线长什么样,取决于路由策略。我实现了三种,可以按连线单独切换:
| 策略 | 视觉效果 | 实现复杂度 | 适用场景 |
|---|---|---|---|
| 直线 Straight | 两点一线 | 极低 | 节点稀疏、关系简单 |
| 正交 Orthogonal | 折线,只走横竖 | 中 | 流程图、拓扑图,最常用 |
| 贝塞尔 Bezier | 平滑曲线 | 中 | 关系图、思维导图,视觉更柔和 |
正交路由的实现,我用的是"出桩 + 折 + 入桩"的三段式,不做 A* 寻路。理由是:A* 虽然能避障,但对流程图这种节点规整排布的场景,收益不大,反而容易出现路径绕远、跳来跳去的观感问题。
private static IEnumerable<Point> BuildOrthogonal(Point s, PortSide ss, Point t, PortSide ts, double stub = 24) { var p1 = s + Dir(ss) * stub; var p2 = t + Dir(ts) * stub; var pts = new List<Point> { s, p1 }; // 根据出口方向决定中间折点 if (Dir(ss).X != 0) // 从左右出 { var midX = (p1.X + p2.X) / 2; if (Dir(ts).X != 0) { pts.Add(new Point(midX, p1.Y)); pts.Add(new Point(midX, p2.Y)); } else { pts.Add(new Point(p2.X, p1.Y)); } } else // 从上下出 { var midY = (p1.Y + p2.Y) / 2; if (Dir(ts).Y != 0) { pts.Add(new Point(p1.X, midY)); pts.Add(new Point(p2.X, midY)); } else { pts.Add(new Point(p1.X, p2.Y)); } } pts.Add(p2); pts.Add(t); return pts; } private static Vector Dir(PortSide side) => side switch { PortSide.Left => new Vector(-1, 0), PortSide.Right => new Vector( 1, 0), PortSide.Top => new Vector( 0,-1), PortSide.Bottom => new Vector( 0, 1), _ => new Vector(1, 0) };stub是出桩长度,24 这个值我调了几次,最后定在 24 是因为它在 100% 缩放下看着不挤,缩放到 50% 时也不会糊成一团。节点特别小的时候可以按节点尺寸的 15% 动态算。
贝塞尔的话,控制点沿出口方向延伸,长度取两点距离的 0.4 倍左右,形状会比较自然:
private static PathGeometry BuildBezier(Point s, PortSide ss, Point t, PortSide ts) { var dist = Math.Max((t - s).Length * 0.4, 40); var c1 = s + Dir(ss) * dist; var c2 = t + Dir(ts) * dist; var fig = new PathFigure { StartPoint = s, IsClosed = false }; fig.Segments.Add(new BezierSegment(c1, c2, t, true)); return new PathGeometry(new[] { fig }); }4.2 连线重绘的触发链路
连线的端点是算出来的,所以节点一动,相关连线必须重算。这里的触发链路设计得好不好,直接决定拖动是否跟手。
最朴素的写法是:在节点的PropertyChanged里直接遍历所有连线,重新生成PathGeometry。节点一多,拖动就会卡成 PPT,因为每一帧都要重建几十上百个几何对象。
我的处理分两步。第一步是只重算受影响的连线,用一个Dictionary<string, List<DiagramEdge>>按节点 Id 建索引,节点位置一变,只处理挂在这个节点上的那几条。第二步是合并重绘请求,同一次Dispatcher帧内多次变更,只重算一次:
private readonly HashSet<DiagramEdge> _dirtyEdges = new(); private bool _flushScheduled; public void MarkEdgeDirty(DiagramEdge e) { _dirtyEdges.Add(e); if (_flushScheduled) return; _flushScheduled = true; Dispatcher.BeginInvoke(DispatcherPriority.Background, new Action(FlushEdges)); } private void FlushEdges() { _flushScheduled = false; foreach (var e in _dirtyEdges) UpdateEdgeVisual(e); _dirtyEdges.Clear(); }DispatcherPriority.Background这个优先级很关键。用Render或者Normal都会和输入事件抢,拖起来会有粘滞感;用Background则是等输入处理完了再算,画面会稍微晚一帧,但手感顺滑得多。
4.3 大规模节点的渲染优化
当节点数超过两三百,Canvas的短板就出来了——它不做虚拟化,所有ContentPresenter都是真实存在的视觉元素,布局和渲染开销线性上涨。
我做了两层优化。第一层是视口裁剪:只让落在可视区域(加一圈缓冲)内的节点保持可见,其余设为Collapsed。Collapsed和Hidden不一样,Collapsed不参与渲染,省得更多。
private void ApplyViewportCulling(Rect viewport) { var padded = viewport; padded.Inflate(200, 200); foreach (var n in Nodes) n.IsVisible = padded.IntersectsWith(new Rect(n.X, n.Y, n.Width, n.Height)); }绑定到模板里就是一句Visibility="{Binding IsVisible, Converter={StaticResource BoolToVis}}"。
第二层是连线几何缓存。同一条连线如果起点终点都没变,不重建PathGeometry,直接复用。判断用四个坐标的快照比较,比对象引用比较更可靠。
心得:视口裁剪的触发要挂在缩放/平移的结束事件上,而不是过程中。拖动和缩放过程中频繁遍历全部节点,等于把省下来的性能又还回去了。
5. 把画板变成可编程的:脚本层设计
5.1 脚本语法怎么定
"可编程"落到具体实现上,就是画板能读懂一段文本,并且这段文本改动能反映到图上。语法设计我走了极简路线,不搞完整表达式和函数,只保留声明式和连接式两种语句:
node A at (100, 100) size (160, 64) kind=process label="接收请求" node B at (420, 100) size (160, 64) kind=decision label="校验通过?" node C at (420, 260) size (160, 64) kind=process label="返回错误" connect A.out -> B.in style=orthogonal label="" connect B.true -> C.in style=orthogonal label="否"每行一条语句,node声明节点,connect声明连线。位置、尺寸、类型、标签、样式全部内联成键值对。这个语法看起来土,但有三个实际好处:解析器一百多行就能写完;人能读;机器生成的也规整。真要做复杂逻辑,可以在语句前加#注释行,把语义放在注释里由外部系统解释。
解析器用正则 + 状态机混合,逐行Split出关键字,再按键值对填充模型。这里不贴完整代码了,核心就是别用大炮打蚊子——这种行式文法上 ANTLR 反而增加维护负担。
5.2 解释器与模型的双向同步
脚本到模型是单向的,模型到脚本才是难点。用户在画板上拖动一个节点,脚本里at (100, 100)那一段得跟着变,否则下一次执行脚本,用户的手工调整全没了。
我的做法是模型持有源信息,变更时局部回写。每个DiagramNode上挂一个SourceLine字段,节点移动结束时,只重写这一行:
private void OnNodeMoved(DiagramNode node) { if (node.SourceLine < 0) return; var line = $"node {node.Id} at ({node.X:0},{node.Y:0}) size ({node.Width:0},{node.Height:0}) " + $"kind={node.Kind} label=\"{Escape(node.Label)}\""; _scriptLines[node.SourceLine] = line; }新建的节点没有源行,就追加到脚本末尾,并记录行号。删除节点时先把行标记为待删,全部操作结束后统一压缩行号——一行一行删会让后面所有节点的SourceLine全部错位,这个坑我踩过。
注意:回写要加一个
_suppressSync标志。否则脚本重写会触发文本变更事件,文本变更又触发重新解析,重新解析再触发节点更新,来回震荡,CPU 直接拉满。
5.3 序列化与版本兼容
保存格式用的是 JSON,字段名保持和脚本一致,这样两边的概念不会打架:
{ "version": "1.2", "nodes": [ { "id": "A", "x": 100, "y": 100, "width": 160, "height": 64, "kind": "process", "label": "接收请求", "ports": [ { "name": "in", "side": "Left", "offset": 0 }, { "name": "out","side": "Right","offset": 0 } ] } ], "edges": [ { "sourceNodeId": "A", "sourcePort": "out", "targetNodeId": "B", "targetPort": "in", "style": "Orthogonal", "label": "" } ] }version字段是必须的。我第一版没加,后来加端口的时候老文件读进来全线报错。现在的处理是读出时先看版本,低于当前版本就走一段补丁逻辑,缺的字段给默认值:
public static DiagramDocument Load(string json) { var doc = JsonSerializer.Deserialize<DiagramDocument>(json, Options); if (doc.Version is null or "1.0") { foreach (var n in doc.Nodes) { if (n.Ports.Count == 0) { n.Ports.Add(new PortDto { Name = "in", Side = "Left", Offset = 0 }); n.Ports.Add(new PortDto { Name = "out", Side = "Right", Offset = 0 }); } } doc.Version = "1.2"; } return doc; }序列化的时候尽量用不可变 DTO,别直接序列化带INotifyPropertyChanged的视图模型,否则事件字段、父引用这些东西可能会被卷进去,文件体积翻倍不说,反序列化还容易炸。
6. 撤销重做与常见问题排查
6.1 命令模式还是快照
撤销重做这块,我两种都实现过,最后是按操作类型分开用。
命令模式适合高频、细粒度的操作,比如拖动节点、改标签、加一条连线。每个操作实现Do/Undo,进栈出栈。
public interface IUndoableCommand { string Name { get; } void Do(); void Undo(); } public class MoveNodeCommand : IUndoableCommand { private readonly DiagramNode _node; private readonly Point _from, _to; public string Name => "移动节点"; public MoveNodeCommand(DiagramNode node, Point from, Point to) { _node = node; _from = from; _to = to; } public void Do() => (_node.X, _node.Y) = (_to.X, _to.Y); public void Undo() => (_node.X, _node.Y) = (_from.X, _from.Y); }快照模式适合批量、破坏性大的操作,比如"导入脚本""清空画板""批量自动布局"。这类操作拆成细粒度命令非常麻烦,直接把整个文档序列化成字符串存一份,撤销的时候整体读回来,简单可靠。
代价是内存。一个两百节点的文档,JSON 大概几十 KB,存五十步就是几 MB,能接受。节点上千的话就要限制快照数量,或者只对变化的部分做增量快照。
心得:拖动这类操作绝对不要每移动一像素就压一次栈。正确做法是
MouseLeftButtonDown记起点,MouseLeftButtonUp时才把整个拖动过程压成一条命令。否则用户按一次 Ctrl+Z 只退了一像素,会疯。
6.2 问题速查表
下面这张表是我和同事在联调阶段攒出来的,基本覆盖了这类画板八成以上的怪现象:
| 现象 | 常见原因 | 处理方式 |
|---|---|---|
| 拖节点时连线明显滞后 | 每帧重建 PathGeometry,没做合并 | 用 Dispatcher.Background 节流 |
| 端口点不中 | 命中区域等于视觉尺寸 | 加透明热区,按缩放反向补偿 |
| 缩放后拖拽位置偏移 | 鼠标坐标没做逆矩阵变换 | 统一走 ToDiagramPoint 转换 |
| 连线穿过节点中间 | 正交路由没做避障 | 调整出桩方向,或改用贝塞尔 |
| 撤销后选中框错乱 | 快照未包含选中集 | 选中态独立于文档,撤销时清空 |
| 大文档打开卡顿 | 一次性创建全部视觉元素 | 视口裁剪 + 延迟创建 |
| 脚本改动后节点跳动 | 双向同步形成回环 | 加 _suppressSync 抑制标志 |
| 框选选中了不该选的 | 用边界相交而非完全包含 | 明确语义,框选通常用相交 |
| 连线端点悬空 | 端口删除后连线未清理 | 删端口时级联删连线 |
| 高分屏下线条发虚 | 用了像素对齐但缩放非整数 | 设 SnapsToDevicePixels,或用 GuidelineSet |
6.3 几条踩出来的经验
第一条,端点计算一定要抽成静态纯函数。我一开始把GetPortPoint写成节点类的方法,里面读了一堆状态,结果单元测试没法写,改路由算法的时候提心吊胆。后来改成static Point GetPortPoint(DiagramNode node, DiagramPort port),输入输出干干净净,测试覆盖起来非常轻松。
第二条,框选的语义要提前定死。是"碰到就算选中"还是"完全包含才算选中"?两种都有人用,但必须选一个并且写进文档。我见过一个工具一半操作用相交一半用包含,用户根本猜不到下次框选会选中什么。
第三条,别在MouseMove里做任何 LINQ 查询。拖拽是每帧都跑的热路径,一行Nodes.Where(...).FirstOrDefault()在几百个节点时就能看出卡顿。需要查找的东西提前建字典,或者用for循环。
第四条,端口名称要允许重复但不能重复在同一个节点上。in/out这种通用名字,在不同节点上重名是正常的;但同一个节点上出现两个out,连线的时候就不知道连哪个了。加载时做一次校验,冲突直接报错而不是静默取第一个。
第五条,缩放范围留够余量。我最初把缩放限制在 0.5 到 2.0,结果用户排大图的时候根本不够用。现在放到 0.1 到 4.0,同时把线宽改成"按缩放反向补偿",缩到 0.1 的时候线还是能看见。
7. 一点个人体会
这套 Diagram 画板做完到现在,在几个内部工具里跑了挺长时间。回过头看,真正难的不是某个算法,而是边界的设计:哪些东西该存在模型里、哪些该算出来;哪些操作走命令、哪些走快照;脚本和图形谁是真源、谁是投影。这些决定一旦定错,后面每加一个功能都要付出双倍代价。
如果让我重做一遍,我会把"端口位置计算"和"连线路由"这两个纯函数模块提前抽出来做成独立类库,先写测试再写 UI。这个顺序换过来,调试成本能省一大半。另外脚本层我会更早引入,哪怕语法只有三行,因为一旦有了脚本,"这个功能该不该做"的判断标准会清晰很多——能被脚本描述出来的,就是核心功能;描述不出来的,多半是锦上添花。
最后分享一个小技巧:给画板加一个隐藏的"开发者视图",按 F12 把节点 Id、端口名、连线 Id 全部标在图上。联调阶段所有人都在问"这条线到底连的是哪个端口",有了这个视图,这类问题基本消失。