简介:这份资源面向具备一定C#与XAML基础的WPF开发者,提供一套类似Visio的流程图绘制实现方案,可用于工作流设计器、流程建模工具等场景。压缩包共142个文件,约4.68MB,以66个cs源码文件与14个xaml、14个baml界面文件为核心,辅以dll、exe、pdb等编译产物,以及sln、csproj工程文件和少量gif、ppt演示素材,结构完整可直接运行调试。内容围绕图形元素、可缩放绘图板、带箭头连接线、拖放交互、数据模型与绑定、自动布局算法、撤销重做、样式模板及序列化等关键模块展开,从SelectionFrame、RelinkControl到FlowchartEditor、Node等界面组件均有对应实现,便于读者理解流程图编辑器的整体架构与事件处理思路。目前已有3114人学习下载,适合希望深入掌握WPF图形绘制与交互式编辑器开发的中高级开发者参考借鉴。
1. 从零搭一个能画流程图的 WPF 画板:需求到底卡在哪
很多人第一次接到「WPF 实现流程图绘制」这个题目,第一反应是找个 Canvas,鼠标按下就画矩形,拖一拖就完事。真动手才发现,需求根本不是「画个框」这么简单。用户要的是类似 Visio 的体验:从工具箱拖出节点、用连线把节点接起来、拖动节点时连线跟着走、能选中能删除能撤销、画完还能存成文件下次打开继续改。这几条里任何一条没处理好,整个东西就只是个玩具。
这套方案要解决的就是这件事:用 WPF 做一套可交互、可持久化、可扩展的流程图编辑器。适合两类人——正在做图形编辑器方向毕设或课设的学生,以及需要在桌面端内嵌一个轻量绘图模块的开发者。下面按需求、架构、模块实现、避坑、验收的顺序,把整套东西拆开讲清楚。
2. 总体架构设计:三层结构与四类核心对象的职责划分
WPF 画流程图,最容易翻车的地方不是画不出来,而是画出来之后改不动。根因通常是把「数据」和「界面」揉在一起——节点位置直接存在 Rectangle 的 Canvas.Left 里,连线直接画成一条 Line,等到要保存、要撤销、要批量操作时,发现根本拿不到一份干净的数据。所以架构的第一件事,是把模型层和视图层彻底分开。
2.1 三层结构:模型层、视图层、交互层
我一般把整个编辑器切成三层。模型层只关心「图里有什么」:有哪些节点、每个节点什么类型、在哪个坐标、哪些节点之间连了线。这一层是纯 C# 对象,不引用任何 WPF 控件,好处是序列化、撤销、单元测试都能独立做。视图层负责「怎么显示」:把模型对象渲染成 Canvas 上的图形,节点用带内容的 Border 或自定义控件,连线用 Path。交互层负责「用户干了什么」:鼠标按下选中、拖动移动、从端口拉出连线、框选、删除。
三层之间靠事件和绑定通信。模型变了,视图层收到通知刷新;用户在界面上操作,交互层把动作翻译成对模型的修改。这样做的直接收益是:撤销功能只需要记录模型的操作历史,跟界面无关;保存功能只需要序列化模型,不用去遍历视觉树。
2.2 四类核心对象:节点、端口、连线、画布
模型层里我定义四个核心类。节点(Node)是流程图的方块,带 Id、类型、位置、尺寸、显示文本。端口(Port)挂在节点上,分输入和输出,是连线的锚点——连线不直接连节点,而是连端口,这样节点移动时连线端点自动跟着端口走。连线(Connection)记录起点端口和终点端口,以及可选的折线路径点。画布(Diagram)是容器,持有所有节点和连线的集合,并提供增删查改和序列化。
端口这个设计是整套方案的关键。很多人图省事让连线直接连节点中心,结果节点一多,连线全糊在一起,也没法表达「从哪个输出连到哪个输入」。加上端口之后,连线端点有了明确归属,后面做自动路由、做连接校验(比如输出不能连输出)都有地方下手。
2.3 技术选型:为什么用 Canvas 而不是 Grid,用 Path 而不是 Line
容器选 Canvas,因为流程图节点是绝对定位,Canvas 的 Left/Top 附加属性最直接,命中测试也简单。Grid 适合自适应布局,但节点要自由拖动,用 Grid 反而要不停算行列,得不偿失。
连线用 Path 而不是 Line。Line 只能画直线,一旦要做带箭头的连线、要做贝塞尔曲线、要做折线,Line 就不够了。Path 的 Data 可以是任意 Geometry,直线、折线、曲线都能表达,箭头用 Path 的另一个小三角形叠加即可。下面是一个连线 Path 的最小构造示例:
// 根据起点和终点生成一条带箭头的连线 Path // start、end 是相对 Canvas 的坐标点 var geometry = new StreamGeometry(); using (var ctx = geometry.Open()) { ctx.BeginFigure(start, false, false); // 用三次贝塞尔让连线有自然的弧度,避免直线穿过节点 var ctrl1 = new Point(start.X + (end.X - start.X) / 2, start.Y); var ctrl2 = new Point(start.X + (end.X - start.X) / 2, end.Y); ctx.BezierTo(ctrl1, ctrl2, end, true, false); } var path = new Path { Data = geometry, Stroke = Brushes.SteelBlue, StrokeThickness = 2, // 箭头通过附加的三角形 Geometry 实现,这里省略 };这段代码里,BeginFigure的第二个参数 false 表示不填充,第三个参数 false 表示不闭合。BezierTo的两个控制点取在起终点水平中间位置,能让连线呈现平滑的 S 形,视觉上比直线舒服,也不容易和节点边框重叠。实际项目里控制点位置要根据端口方向动态算,这里给的是水平连线的简化版。
3. 核心模块实现:节点拖拽、连线跟随与撤销栈
架构定下来之后,真正花时间的是三个模块:节点怎么拖、连线怎么跟着节点走、操作怎么撤销。这三个做扎实,编辑器就活了。
3.1 节点拖拽:鼠标捕获与坐标换算
节点拖拽的坑在于坐标系。鼠标事件给的坐标是相对于触发事件的控件,而节点位置是相对于 Canvas。如果节点内部还有子元素,坐标会一层层偏移,直接拿 e.GetPosition(this) 很容易飘。正确做法是统一换算到 Canvas 坐标系:
// 在节点的 MouseLeftButtonDown 中开始拖拽 private Point _dragOffset; private bool _isDragging; private void Node_MouseLeftButtonDown(object sender, MouseButtonEventArgs e) { var node = (FrameworkElement)sender; var canvas = FindParentCanvas(node); // 记录鼠标相对节点左上角的偏移,避免拖动时节点跳到鼠标位置 _dragOffset = e.GetPosition(node); _isDragging = true; // 捕获鼠标,保证拖出节点范围也能收到 Move 和 Up node.CaptureMouse(); e.Handled = true; } private void Node_MouseMove(object sender, MouseEventArgs e) { if (!_isDragging) return; var node = (FrameworkElement)sender; var canvas = FindParentCanvas(node); var pos = e.GetPosition(canvas); // 用当前鼠标位置减去偏移,得到节点新的左上角坐标 Canvas.SetLeft(node, pos.X - _dragOffset.X); Canvas.SetTop(node, pos.Y - _dragOffset.Y); // 通知连线刷新端点 _diagram.UpdateConnectionsFor(node); }CaptureMouse是关键,没有它,鼠标拖快了移出节点范围,Move 事件就断了,节点会卡在半路。_dragOffset保证节点跟手,不会一按下就跳到鼠标正下方。每次移动后调用UpdateConnectionsFor,让所有连到这个节点的连线重算端点,这就是连线跟随的实现入口。
3.2 连线跟随:端口位置计算与路径重绘
连线跟随的本质是:节点位置变了,端口的世界坐标就变了,连线的起点或终点要重新算。端口位置 = 节点左上角坐标 + 端口在节点内的相对偏移。节点移动时,遍历所有连线,凡是端点挂在这个节点端口上的,都重新计算 Path 的 Data。
// 节点移动后刷新相关连线 public void UpdateConnectionsFor(FrameworkElement node) { var nodePos = new Point(Canvas.GetLeft(node), Canvas.GetTop(node)); foreach (var conn in _connections) { if (conn.SourceNode == node || conn.TargetNode == node) { // 端口相对节点的偏移在创建端口时就固定了 var start = conn.SourcePort.GetWorldPosition(nodePos); var end = conn.TargetPort.GetWorldPosition(nodePos); conn.Path.Data = BuildConnectionGeometry(start, end); } } }这里有个性能注意点:如果节点数量上百,每次 MouseMove 都全量遍历连线会卡。通常的做法是给节点维护一个「关联连线列表」,移动时只刷新这个列表里的连线,而不是遍历全部。这个优化在节点少的时候看不出差别,节点一多就是流畅和卡顿的分界线。
3.3 撤销栈:命令模式记录可逆操作
撤销功能如果一开始没设计,后期补会非常痛苦。我一般从第一天就用命令模式:每个会改变模型的操作封装成一个 Command,带 Do 和 Undo 两个方法,执行时压入撤销栈。移动节点、新增连线、删除节点,都走这套。
// 移动节点的命令,记录移动前后的坐标 public class MoveNodeCommand : ICommand { private readonly Node _node; private readonly Point _oldPos; private readonly Point _newPos; public MoveNodeCommand(Node node, Point oldPos, Point newPos) { _node = node; _oldPos = oldPos; _newPos = newPos; } public void Do() => _node.Position = _newPos; public void Undo() => _node.Position = _oldPos; }注意移动命令要在鼠标松开时才生成,而不是 MouseMove 时。拖动过程中只改界面,松手时把「从哪到哪」记成一条命令。否则一次拖动会产生几百条命令,撤销一次只退一个像素,体验直接崩。这是血泪经验,早期没注意,撤销功能基本等于废的。
4. 避坑与常见问题排查:连线错位、撤销失效与性能卡顿
这一章集中讲项目里最容易翻车的几个点,每条按现象、原因、解决来说,都是实际做的时候会撞上的。
4.1 连线端点错位,节点一动线就飘
现象是节点拖动后,连线端点没跟上,或者跟上了但偏了一段距离。原因通常是端口坐标算错,把端口相对节点的偏移当成了相对 Canvas 的坐标,或者节点有 Margin、Padding 没算进去。解决办法是统一用一套坐标换算函数,端口世界坐标 = 节点 Canvas 坐标 + 端口相对节点坐标,中间不要混入其他变换。如果节点支持缩放,还要把缩放系数乘进去。
4.2 撤销一次退太多或退不动
现象是点撤销,要么一下回到解放前,要么纹丝不动。原因多半是命令粒度不对——把连续操作合成了一条,或者命令压栈时机错了。解决是明确每个命令对应一次「用户可感知的完整操作」:一次拖动、一次连线、一次删除。压栈放在操作结束时,不在过程中。
4.3 节点上百后拖动卡顿
现象是节点少时流畅,超过一定数量后拖动明显掉帧。原因是 MouseMove 里做了全量刷新或频繁创建对象。解决有三条:只刷新关联连线不遍历全部;Path 的 Data 复用而不是每次 new;拖动过程中用简单的直线预览,松手后再渲染正式曲线。通常做到前两条,上百节点就能稳住。
4.4 保存后重新打开,连线全丢了
现象是节点能存能读,连线读出来是空的或者错位。原因是序列化时只存了节点,没存端口和连线的引用关系,或者存了引用但反序列化时对象 Id 对不上。解决是序列化时给每个节点和端口分配稳定 Id,连线存的是端口 Id 而不是对象引用,反序列化分两步:先重建所有节点和端口,再根据 Id 重建连线。
4.5 框选和单选冲突,点一下选中一片
现象是单击某个节点,结果旁边的也被选上了。原因是框选的判定区域算大了,或者鼠标按下和抬起之间移动了微小距离被误判成框选。解决是设一个拖动阈值,比如移动超过 3 像素才算框选,否则按单击处理。这个阈值别设太小,手抖是常态。
5. 验收与答辩:怎么证明这套流程图编辑器真的能用
做到这里,功能基本齐了,但答辩或交付时,光说「能画」是不够的,得有一套能当场演示、能自证的验收流程。我一般按下面这张表逐项过,每项都有明确的通过标准,演示时按顺序走一遍,评委或验收方一眼就能看出完成度。
| 验收项 | 操作步骤 | 通过标准 |
|---|---|---|
| 节点创建 | 从工具箱拖出三种不同类型节点 | 节点出现在画布,类型样式正确 |
| 节点拖拽 | 拖动节点到任意位置 | 节点跟手,松手后位置保持 |
| 连线绘制 | 从输出端口拖到另一节点输入端口 | 连线生成,端点吸附到端口 |
| 连线跟随 | 拖动已连线的节点 | 连线端点实时跟随,无错位 |
| 选中与删除 | 单击选中节点,按 Delete | 节点及其连线一并删除 |
| 撤销重做 | 连续操作后多次撤销再重做 | 状态逐步回退和恢复,无跳变 |
| 保存加载 | 保存后关闭,重新打开加载 | 节点、连线、位置完全一致 |
| 性能 | 创建 100 个节点后拖动 | 拖动无明显卡顿,帧率可接受 |
演示时有个技巧:不要一上来就展示最复杂的功能,先走「创建—连线—拖动—保存」这条主线,让验收方看到完整闭环,再补撤销、框选这些加分项。如果中途某个功能出问题,不要慌着重试,直接说明这是已知边界并给出原因,比反复试错体面得多。
答辩常被问到的几个问题也提前准备一下。比如「为什么用 Canvas 不用 Grid」,答绝对定位和命中测试更直接;「撤销怎么实现的」,答命令模式加撤销栈,粒度按用户操作;「节点多了怎么办」,答关联连线局部刷新加路径复用。这些问题答得上来,说明你是真做过,不是拼的。
从那以后我每次做图形编辑器,都强制自己先把模型层和视图层的边界画清楚再动手,哪怕多花半天,也比后期重构省一周。希望这套结构能帮到你,需要完整工程的话可以按上面的模块顺序自己搭一遍,跑通了就是你的东西。
本文还有配套的精品资源,点击获取