用Winform + GDI+ 实现一个轻量级Visio式流程图编辑器
2026/9/3 2:55:26 网站建设 项目流程

简介: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.js)WebView加载前端流程图库流程图效果漂亮,BPMN标准支持好需要前端基础,Winform集成messy

这个项目我选择第三种里的"自绘控件"路线,因为需求就是"简单"二字。Visio最核心的能力无非三件事:拖拽创建节点、节点之间拉线、拖拽调整布局。这三个能力用Winform的GDI+完全可以覆盖,而且代码量可控。核心设计是:

  • FlowNode:流程节点数据模型(名称、类型、坐标、尺寸、属性字典)
  • FlowConnection:连线数据模型(起点节点、终点节点、连线文本)
  • FlowCanvas:自定义控件,负责渲染、命中测试、鼠标交互、序列化

这个结构把"数据"和"表现"分离了,后面加右键菜单、属性面板、自动布局都会很轻松。

1.3 要解决的核心问题清单

动手写代码之前,我把需求拆成了几个必答题:

  1. 节点如何绘制得好看(圆角矩形、阴影、颜色按类型区分)
  2. 节点如何拖拽移动,连线如何自适应跟随
  3. 如何从节点边缘拖出一条线连到另一个节点
  4. 如何选中节点、批量移动、删除
  5. 如何把流程图保存成文件,下次打开能还原
  6. 如何让画布支持缩放和滚动

这套清单其实就是整个开发过程的骨架,接下来每个环节我都会给出能直接用的代码实现。

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 Dictionary<string, 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 List<FlowNode> _nodes = new(); private List<FlowConnection> _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.TranslateTransformScaleTransform做矩阵变换。这个设计让节点、连线的数据结构完全不用感知屏幕缩放,复杂度和出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序列化一个包含嵌套对象和字典的列表非常方便,零配置就能工作。保存文件扩展名自定义为.fld(Flow Diagram),但内容就是UTF-8编码的JSON。

public class FlowDocument { public List<FlowNode> Nodes { get; set; } = new(); public List<FlowConnection> 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.Deserialize<FlowDocument>(json); _nodes = doc.Nodes; _connections = doc.Connections; _scale = doc.Scale; _offset = doc.Offset; Invalidate(); }

保存时附带ScaleOffset这个细节很容易被忽略。如果不保存视口状态,用户下次打开文件发现布局全乱(因为缩放比例变了),体验会非常差。保存视口状态之后,文件恢复的就是用户最后一次编辑时的视图。

4.2 增删节点与撤销机制的扩展思路

基础的增删操作代码很简单,但我在做的时候就想到了后续的撤销需求,所以从一开始就把增删节点、增删连线、移动节点都封装成了结构化的Command模式。伪代码类似:

public interface ICommand { void Execute(); void Undo(); }

MoveNodeCommand记录节点Id和移动前后的位移差,执行和撤销都通过偏移量恢复。这样后续要接真正意义上的撤销/重做栈,就不用重构代码了。

5. 常见问题与排查技巧实录

5.1 画面闪烁的根源与双缓冲陷阱

自绘控件最常见的坑就是闪烁。我刚开始写的时候没加双缓冲,拖动节点时整个画面疯狂闪,眼睛都花了。解决办法是SetStyle里加OptimizedDoubleBuffer,但如果你的控件里还有子控件(比如放了一个TextBox在画布上),子控件区域的闪烁是另一回事,需要调WS_EX_COMPOSITED窗口样式优化。

注意:如果开启了双缓冲还闪烁,检查是否有地方直接调用了Visible = falseVisible = true,这种操作会强制重绘,即使是双缓冲也会闪一下。正确做法是只修改数据,然后调用Invalidate()让系统统一重绘。

5.2 鼠标坐标偏移问题

做了缩放之后,最容易出的Bug就是坐标偏移:看起来鼠标点在了某个节点上,但命中测试怎么都点不中。原因几乎都是忘了把屏幕坐标除以缩放系数。统一封装一个ScreenToCanvas方法,所有鼠标事件入口都调用它,这个坑就避免了。另一个容易忽视的点是:如果FlowCanvas外层还有Panel且设置了AutoScroll=truee.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%的代码质量。只要一开始把FlowNodeFlowConnection的职责划分清楚、坐标系统一处理,后面加入各种交互都是顺势而为,不会出现"推翻重来"的情况。如果你打算在项目里集成一个简单的流程设计功能,按这个思路从零手写是成本最低、可控性最高的路线。

本文还有配套的精品资源,点击获取

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

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

立即咨询