
简介C# Winform开发者可参考的轻量级流程图绘制工具源码基于.NET Framework 2.0实现类似Visio的基础拖拽绘图功能适合需要快速掌握GDI绘图、鼠标交互及对象序列化的初级或中级开发者。资源包共41个文件压缩后仅261KB核心包含10个cs源码文件、5个resx资源文件、5个png图标及多个窗体设计器代码另有可运行的exe、dll和项目配置文件便于直接打开调试或二次扩展。已有4636人学习使用可用于课堂练习或小型工具开发。项目完整覆盖了图形对象建模、工具箱拖放、形状移动/旋转、动态连线、XML/JSON存取及属性编辑等关键环节尤其通过UcFlowNode用户控件和三个窗体设计文件展示了模块化组织方式有助于理解Winform中自定义控件与绘图缓存的配合。通过研读源码可以快速理清事件驱动绘图流程节省从零搭建轮子的时间。1. 项目定位与整体思路拆解1.1 为什么用Winform做流程图而不是WPF或Web先说一下我自己的判断这个项目里的类似Viso并不是要复刻Visio那种企业级图表引擎而是要在一个桌面工具内部快速实现一个能拖拽节点、能连线、能保存文件的轻量级流程编辑能力。这种需求在工控配置工具、数据同步任务编排、轻量级业务建模工具里非常常见。选Winform而不是WPF理由很实在第一Winform的原生控件成熟稳定部署简单客户机器上不用装额外运行时第二GDI绘制2D图形虽然性能不如WPF的渲染管线但流程图这种节点数量通常不超过几百个的场景完全够用第三很多老项目都是Winform体系的直接嵌入一个自绘控件成本最低。网上那个热词工控wpf为何替代不了winform其实也说到了点子上——工控行业里Winform积累了大量的第三方控件和成熟方案迁移成本太高而且客户现场机器配置普遍不高Winform的轻量级反而是优势。1.2 技术方案选型自绘控件是核心流程图编辑器最常见的三种实现路径方案做法优点缺点第三方控件如NodeEditor、GoDiagram直接引用现成库功能全开发快收费或阉割扩展受限自定义控件自绘GDI自己写Canvas与节点渲染完全可控无依赖交互细节要自己处理嵌入Web控件如BPMN.jsWebView加载前端流程图库流程图效果漂亮BPMN标准支持好需要前端基础Winform集成messy这个项目我选择第三种里的自绘控件路线因为需求就是简单二字。Visio最核心的能力无非三件事拖拽创建节点、节点之间拉线、拖拽调整布局。这三个能力用Winform的GDI完全可以覆盖而且代码量可控。核心设计是FlowNode流程节点数据模型名称、类型、坐标、尺寸、属性字典FlowConnection连线数据模型起点节点、终点节点、连线文本FlowCanvas自定义控件负责渲染、命中测试、鼠标交互、序列化这个结构把数据和表现分离了后面加右键菜单、属性面板、自动布局都会很轻松。1.3 要解决的核心问题清单动手写代码之前我把需求拆成了几个必答题节点如何绘制得好看圆角矩形、阴影、颜色按类型区分节点如何拖拽移动连线如何自适应跟随如何从节点边缘拖出一条线连到另一个节点如何选中节点、批量移动、删除如何把流程图保存成文件下次打开能还原如何让画布支持缩放和滚动这套清单其实就是整个开发过程的骨架接下来每个环节我都会给出能直接用的代码实现。2. 核心数据结构与渲染引擎实现2.1 节点和连线数据模型首先定义FlowNode。这里有个关键设计点节点坐标用RectangleF存放其中X、Y表示节点左上角位置Width和Height表示尺寸。这样后面做命中测试、绘制、连线锚点计算都统一用RectangleF这个几何结构省去大量坐标换算。public class FlowNode { public string Id { get; set; } Guid.NewGuid().ToString(N); public string Name { get; set; } 节点; public FlowNodeType Type { get; set; } FlowNodeType.Process; public RectangleF Bounds { get; set; } new RectangleF(0, 0, 140, 50); public Dictionarystring, string Properties { get; set; } new(); public bool IsSelected { get; set; } } public enum FlowNodeType { Start, // 开始-椭圆 Process, // 处理-矩形 Decision, // 判断-菱形 End // 结束-圆角矩形 }FlowConnection保存连接关系注意这里保存的是锚点连接而不是坐标连接。意思是连线只记录起点节点ID和终点节点ID具体的连线路径由渲染层自动计算。这样节点移动时连线自动跟着走不用手动同步。public class FlowConnection { public string Id { get; set; } Guid.NewGuid().ToString(N); public string StartNodeId { get; set; } public string EndNodeId { get; set; } public string Label { get; set; } ; public PointF StartPoint { get; set; } // 临时拖拽时用的起点 public PointF EndPoint { get; set; } // 临时拖拽时用的终点 }2.2 画布控件的双缓冲与坐标体系FlowCanvas继承自Control核心要处理两件事双缓冲渲染和鼠标交互。双缓冲是必须的否则拖动节点时画面会疯狂闪烁。Winform下最简单的开启方式是在构造函数里设置public class FlowCanvas : Control { private ListFlowNode _nodes new(); private ListFlowConnection _connections new(); private float _scale 1.0f; private PointF _offset PointF.Empty; private FlowNode _selectedNode; private bool _isDragging; private PointF _dragStart; private FlowConnection _tempConnection; public FlowCanvas() { SetStyle(ControlStyles.AllPaintingInWmPaint | ControlStyles.OptimizedDoubleBuffer | ControlStyles.UserPaint | ControlStyles.ResizeRedraw, true); BackColor Color.White; } }OptimizedDoubleBuffer这个样式是Winform自绘控件防闪烁的标配新手经常忽略这个然后发现拖动节点时整个控件像在不停地重绘体验非常差。坐标体系上我加了_scale缩放系数和_offset平移偏移量这样支持鼠标滚轮缩放和拖动画布平移。所有绘制逻辑统一先经过一个坐标变换private PointF ScreenToCanvas(Point p) { return new PointF((p.X - _offset.X) / _scale, (p.Y - _offset.Y) / _scale); }底层原理其实很简单画布内部永远使用逻辑坐标系也就是节点存储的坐标屏幕上显示时通过Graphics.TranslateTransform和ScaleTransform做矩阵变换。这个设计让节点、连线的数据结构完全不用感知屏幕缩放复杂度和出Bug的概率都大大降低。2.3 节点绘制区分节点类型绘制节点时需要按FlowNodeType渲染不同的形状。这一步要特别注意GDI的状态保存和恢复。protected override void OnPaint(PaintEventArgs e) { base.OnPaint(e); Graphics g e.Graphics; g.SmoothingMode System.Drawing.Drawing2D.SmoothingMode.AntiAlias; // 应用坐标变换 g.TranslateTransform(_offset.X, _offset.Y); g.ScaleTransform(_scale, _scale); // 先画连线 foreach (var conn in _connections) { DrawConnection(g, conn); } // 再画节点保证节点在连线上层 foreach (var node in _nodes) { DrawNode(g, node); } } private void DrawNode(Graphics g, FlowNode node) { using var path GetNodePath(node); using var fillBrush new SolidBrush(GetNodeColor(node.Type)); using var pen new Pen(node.IsSelected ? Color.OrangeRed : Color.SteelBlue, node.IsSelected ? 3f : 1.5f); g.FillPath(fillBrush, path); g.DrawPath(pen, path); // 绘制文本 using var sf new StringFormat { Alignment StringAlignment.Center, LineAlignment StringAlignment.Center }; using var font new Font(Microsoft YaHei, 9f); g.DrawString(node.Name, font, Brushes.Black, node.Bounds, sf); }GetNodePath根据不同节点类型返回不同的GraphicsPath。矩形直接用AddRectangle菱形用四个顶点构造多边形开始和结束节点用椭圆。这段代码里有几个细节值得提填充色不要用纯色用Color.FromArgb(245, 248, 255)这种柔和一点的色值整体观感好很多选中状态下边框换成橙红色、加粗到3f反馈很明显所有GDI对象Brush、Pen、Font、GraphicsPath用完一定要释放用using包裹是最稳妥的2.4 连线绘制贝塞尔曲线与箭头连线我选择了三次贝塞尔曲线Cubic Bezier而不是直线。原因有两个一是曲线看起来更接近专业流程图工具的效果二是多个节点交叉连接时曲线可以有效区分不同连线。曲线取控制点的逻辑是起点终点水平距离较远时控制点沿水平方向X轴展开垂直距离较远时沿垂直方向展开。private void DrawConnection(Graphics g, FlowConnection conn) { PointF start GetConnectionAnchor(conn.StartNodeId, conn.EndNodeId, false); PointF end GetConnectionAnchor(conn.EndNodeId, conn.StartNodeId, true); using var pen new Pen(Color.FromArgb(120, 120, 120), 1.5f); float dx Math.Abs(end.X - start.X); float dy Math.Abs(end.Y - start.Y); float offset Math.Max(20, Math.Min(dx, dy) * 0.5f); PointF c1, c2; if (dx dy) { c1 new PointF(start.X offset, start.Y); c2 new PointF(end.X - offset, end.Y); } else { c1 new PointF(start.X, start.Y offset); c2 new PointF(end.X, end.Y - offset); } g.DrawBezier(pen, start, c1, c2, end); DrawArrow(g, start, end); }GetConnectionAnchor计算的是节点边界上的锚点坐标。简单做法是取两个节点中心的连线与目标节点边界矩形求交点。这样连线是从节点边缘长出来的而不是从中心穿过观感完全不一样。箭头用一个小三角形绘制方向就是终点切线的方向。3. 交互实现拖拽、连线、缩放与选择3.1 鼠标命中测试与节点拖拽命中测试是交互的基础。核心思路是鼠标按下时逆序遍历节点列表后画的在上面判断鼠标点是否落在node.Bounds内同时要注意缩放——屏幕坐标必须先转成画布坐标再判断。protected override void OnMouseDown(MouseEventArgs e) { base.OnMouseDown(e); PointF canvasPoint ScreenToCanvas(e.Location); // 先判断是否点中节点 FlowNode hitNode HitTestNode(canvasPoint); if (hitNode ! null) { _selectedNode hitNode; _isDragging true; _dragStart canvasPoint; hitNode.IsSelected true; Invalidate(); return; } // 没有命中节点开始拖拽画布 _isPanning true; _panStart e.Location; } private FlowNode HitTestNode(PointF canvasPoint) { for (int i _nodes.Count - 1; i 0; i--) { if (_nodes[i].Bounds.Contains(canvasPoint)) { return _nodes[i]; } } return null; }拖拽逻辑发生在OnMouseMove里。这里有个容易踩坑的地方应该记录上一次鼠标位置根据位移增量移动节点而不是把节点坐标直接赋值为鼠标位置。否则鼠标开始拖拽时会先跳一下。protected override void OnMouseMove(MouseEventArgs e) { base.OnMouseMove(e); PointF canvasPoint ScreenToCanvas(e.Location); if (_isDragging _selectedNode ! null) { float dx canvasPoint.X - _dragStart.X; float dy canvasPoint.Y - _dragStart.Y; _selectedNode.Bounds new RectangleF( _selectedNode.Bounds.X dx, _selectedNode.Bounds.Y dy, _selectedNode.Bounds.Width, _selectedNode.Bounds.Height); _dragStart canvasPoint; Invalidate(); } }3.2 连线交互临时连线的动态绘制连线是流程图编辑器里最有代表性的交互。我实现的方案是在节点边缘附近按下鼠标并拖拽时如果起点落在某个节点上就进入连线模式此时显示一条随鼠标移动动态变化的临时连线。松开鼠标时判断是否落在另一个节点上是则创建正式连线否则丢弃。protected override void OnMouseDown(MouseEventArgs e) { // ... 前面代码省略 // 鼠标按下的位置如果非常接近节点边界且按下的是右键则进入连线模式 FlowNode edgeNode HitTestNodeEdge(canvasPoint); if (e.Button MouseButtons.Right edgeNode ! null) { _tempConnection new FlowConnection { StartNodeId edgeNode.Id, StartPoint canvasPoint, EndPoint canvasPoint }; } } protected override void OnMouseMove(MouseEventArgs e) { // ... 前面代码省略 if (_tempConnection ! null) { _tempConnection.EndPoint ScreenToCanvas(e.Location); Invalidate(); } } protected override void OnMouseUp(MouseEventArgs e) { base.OnMouseUp(e); PointF canvasPoint ScreenToCanvas(e.Location); if (_tempConnection ! null) { FlowNode targetNode HitTestNode(canvasPoint); if (targetNode ! null targetNode.Id ! _tempConnection.StartNodeId) { _tempConnection.EndNodeId targetNode.Id; _connections.Add(_tempConnection); } _tempConnection null; Invalidate(); } }这里我特意把连线模式设计成右键拖拽而不是左键因为左键拖拽通常用来移动节点如果还要区分点击中心移动和点击边缘连线对用户来说操作负担比较大。右键拖拽连线在Node-RED、Arduino等工具里也是常见交互比较自然。3.3 滚轮缩放与画布平移缩放用滚轮实现核心逻辑是让鼠标所在位置在缩放前后保持不动也就是缩放锚定到鼠标位置protected override void OnMouseWheel(MouseEventArgs e) { base.OnMouseWheel(e); float oldScale _scale; float newScale _scale * (e.Delta 0 ? 1.1f : 0.9f); newScale Math.Clamp(newScale, 0.2f, 3.0f); // 保持鼠标位置在缩放前后对应同一个画布坐标 PointF canvasPoint ScreenToCanvas(e.Location); _scale newScale; _offset.X e.X - canvasPoint.X * _scale; _offset.Y e.Y - canvasPoint.Y * _scale; Invalidate(); }_offset的计算公式是屏幕坐标 画布坐标 * 缩放 偏移反推偏移就得到上面的公式。这个式子建议直接背下来因为几乎所有2D编辑器包括地图、图纸、流程图的缩放锚定都是这个模型。画布平移则是在OnMouseDown未命中节点时进入平移模式OnMouseMove里把偏移量累加上去。3.4 属性面板和删除操作流程图光有图不行还得能编辑节点信息。我用Winform原生的PropertyGrid来编辑节点属性一行代码就能把节点对象绑定到面板上propertyGrid1.SelectedObject selectedNode;但这里有个坑FlowNode的几个属性是直接字段或普通属性默认情况下PropertyGrid显示的不友好而且不支持中文显示。解决方案是给属性加上[Category]和[DisplayName]特性public class FlowNode { [Category(基本信息), DisplayName(节点名称)] public string Name { get; set; } [Category(基本信息), DisplayName(节点类型)] public FlowNodeType Type { get; set; } [Category(布局), DisplayName(位置X)] public float X { get Bounds.X; set Bounds new RectangleF(value, Bounds.Y, Bounds.Width, Bounds.Height); } }这样做还有个额外好处当用户在PropertyGrid里修改了值继续选中其他节点再切回来属性会同步刷新。不过要记得在选中节点变化时调用propertyGrid1.SelectedObject null; propertyGrid1.SelectedObject selectedNode;强制刷新否则有时候属性面板不更新。删除操作用Delete键触发遍历选中节点集合移除节点及其关联的所有连线。4. 序列化保存与加载4.1 为什么用JSON而不是XML流程图数据持久化我选了JSON格式。虽然Winform生态更多是XML但JSON有两个明显优势一是体积小、可读性好二是System.Text.Json序列化一个包含嵌套对象和字典的列表非常方便零配置就能工作。保存文件扩展名自定义为.fldFlow Diagram但内容就是UTF-8编码的JSON。public class FlowDocument { public ListFlowNode Nodes { get; set; } new(); public ListFlowConnection Connections { get; set; } new(); public float Scale { get; set; } 1.0f; public PointF Offset { get; set; } PointF.Empty; } public void SaveToFile(string path) { var doc new FlowDocument { Nodes _nodes, Connections _connections, Scale _scale, Offset _offset }; string json JsonSerializer.Serialize(doc, new JsonSerializerOptions { WriteIndented true }); File.WriteAllText(path, json, Encoding.UTF8); } public void LoadFromFile(string path) { string json File.ReadAllText(path, Encoding.UTF8); var doc JsonSerializer.DeserializeFlowDocument(json); _nodes doc.Nodes; _connections doc.Connections; _scale doc.Scale; _offset doc.Offset; Invalidate(); }保存时附带Scale和Offset这个细节很容易被忽略。如果不保存视口状态用户下次打开文件发现布局全乱因为缩放比例变了体验会非常差。保存视口状态之后文件恢复的就是用户最后一次编辑时的视图。4.2 增删节点与撤销机制的扩展思路基础的增删操作代码很简单但我在做的时候就想到了后续的撤销需求所以从一开始就把增删节点、增删连线、移动节点都封装成了结构化的Command模式。伪代码类似public interface ICommand { void Execute(); void Undo(); }MoveNodeCommand记录节点Id和移动前后的位移差执行和撤销都通过偏移量恢复。这样后续要接真正意义上的撤销/重做栈就不用重构代码了。5. 常见问题与排查技巧实录5.1 画面闪烁的根源与双缓冲陷阱自绘控件最常见的坑就是闪烁。我刚开始写的时候没加双缓冲拖动节点时整个画面疯狂闪眼睛都花了。解决办法是SetStyle里加OptimizedDoubleBuffer但如果你的控件里还有子控件比如放了一个TextBox在画布上子控件区域的闪烁是另一回事需要调WS_EX_COMPOSITED窗口样式优化。注意如果开启了双缓冲还闪烁检查是否有地方直接调用了Visible false再Visible true这种操作会强制重绘即使是双缓冲也会闪一下。正确做法是只修改数据然后调用Invalidate()让系统统一重绘。5.2 鼠标坐标偏移问题做了缩放之后最容易出的Bug就是坐标偏移看起来鼠标点在了某个节点上但命中测试怎么都点不中。原因几乎都是忘了把屏幕坐标除以缩放系数。统一封装一个ScreenToCanvas方法所有鼠标事件入口都调用它这个坑就避免了。另一个容易忽视的点是如果FlowCanvas外层还有Panel且设置了AutoScrolltruee.Location拿到的是控件内相对坐标不含滚动偏移此时需要用AutoScrollPosition补偿。5.3 连线的命中测试与删除选中删除两条连线重叠的节点时要注意先找到选中的连线再删除。连线本身不是矩形命中测试不能用Bounds.Contains得计算点到贝塞尔曲线的最小距离。简化做法是用折线逼近曲线private FlowConnection HitTestConnection(PointF p, float threshold 5f) { foreach (var conn in _connections) { PointF start GetNodeCenter(conn.StartNodeId); PointF end GetNodeCenter(conn.EndNodeId); float dist DistanceToSegment(p, start, end); if (dist threshold) return conn; } return null; }实际用下来直线拟合的命中判定对简单流程图完全够用因为节点间距不复杂时贝塞尔曲线的形状接近直线。如果后续要做复杂连线命中可以每20个像素取一个采样点判断鼠标到采样点集合的最小距离。5.4 PropertyGrid 只能查看不能修改网上有个热词就是winform的propertygrid只能查看不能修改怎么现实这个问题其实有两个截然不同的原因现象原因解决办法属性灰色不可编辑对象没有公开set器检查属性的set访问器是否为public改完没效果改动没触发Invalidate()监听PropertyValueChanged事件强制重绘画布private void PropertyGrid_PropertyValueChanged(object s, PropertyValueChangedEventArgs e) { Invalidate(); }5.5 高DPI缩放导致界面模糊现在很多笔记本都是150%缩放Winform默认在高DPI下会出现字体模糊。在Program.cs的入口加上[STAThread] static void Main() { Application.EnableVisualStyles(); Application.SetCompatibleTextRenderingDefault(false); Application.SetHighDpiMode(HighDpiMode.PerMonitorV2); Application.Run(new MainForm()); }PerMonitorV2模式在Windows 10及以上表现很好但要注意如果你的自绘控件里用了Graphics.DrawString字体大小不会随DPI自动缩放需要手动从DeviceDpi计算缩放因子。实测下来节点的字体大小固定在9f在100%缩放下没问题但在1920x1080的150%缩放屏幕上会显得偏小建议读取CreateGraphics().DpiX动态计算字体大小。6. 界面美化与后续扩展6.1 使工具栏和节点面板更有生产力只做一个画布还不够一个好用的流程图工具至少需要一个侧边工具栏可以快速拖出预设节点。我的做法是左侧放一个ToolStrip或自定义面板上面放几个按钮对应不同类型的节点点击后进入放置模式在画布上点击即可创建节点。private void BtnAddStart_Click(object sender, EventArgs e) { _canvas.PendingNodeType FlowNodeType.Start; _canvas.Cursor Cursors.Cross; }画布的OnMouseClick里检测PendingNodeType是否非空非空则创建一个节点然后清空状态。6.2 把网格线和吸附做进去流程图工具没有网格线和吸附功能画出来的图会歪七扭八。网格线绘制只需在OnPaint里根据缩放级别画一些浅灰色线吸附则是在创建或移动节点时把坐标取整到网格步长比如10像素private static int SnapToGrid(float value, int gridSize 10) { return (int)(Math.Round(value / gridSize) * gridSize); }吸附配合网格线用户画出来的图会整齐非常多。6.3 后续功能扩展方向这个项目做到这里已经是一个能用的轻量级Visio。如果再往下扩展优先级最高的是撤销/重做栈基于Command模式前面已经预留多选拉框选择遍历节点判断Bounds与选择框相交连线删除连线命中测试已有节点对齐操作左对齐、居中对齐、等间距分布曲线编辑拖动控制点调整连线弧度导出为PNG图片Control.DrawToBitmap一行就能实现我个人在实际开发中的体会是这类编辑器项目数据模型设计决定了80%的代码质量。只要一开始把FlowNode和FlowConnection的职责划分清楚、坐标系统一处理后面加入各种交互都是顺势而为不会出现推翻重来的情况。如果你打算在项目里集成一个简单的流程设计功能按这个思路从零手写是成本最低、可控性最高的路线。本文还有配套的精品资源点击获取