简介:这是一份基于Winform开发的轻量级流程图绘制项目,脱胎于FlowChart.Net并针对小项目场景做了精简,功能集中在节点创建、连线编辑和基础交互控制,适合C#桌面开发者快速集成或初学者研究流程图组件的实现方式。压缩包共59个文件,整体仅2.48MB,其中15个cs源码覆盖了主窗体、节点类、连线类以及节点/连线编辑窗体的核心逻辑,12个DLL主要基于MindFusion图形库提供渲染、拖拽和缩放能力,另有XML文档、资源文件和可直接运行的exe,便于边运行边对照理解。资源已有4775人学习下载,在同类轻量级流程图示例中关注度比较高。通过这套代码,既能获得一个开箱即用的流程图基础模板,也可以学习到Winform中对象拖拽、连接线坐标计算、界面交互事件等关键写法;同时项目保留了扩展点,方便在此基础上添加自定义节点类型或样式,减少从零开发的工作量。 做Winform开发的同学,多多少少都躲不开这样一个需求:在界面里画流程图。工单状态流转、审批流配置、算法流程展示、设备拓扑可视,这些场景在桌面应用里实在太常见了。我第一次遇到这个需求时,第一反应是去NuGet上翻现成的FlowChart控件,结果试了一圈发现,要么是商业授权的老控件,要么是国外开发者写的老古董库,在.NET Framework和现代C#版本下编译都费劲,UI风格更是和Winform原生控件格格不入。后来我干脆用GDI+手写了一个轻量流程图控件,前前后后花了两个周末,功能说不上花哨,但胜在完全可控、零依赖、想怎么改就怎么改,而且稳定跑在正式项目里两年多了。
这篇文章就把我手写Winform流程图控件的完整思路和关键代码分享出来,从数据结构设计到鼠标交互,从绘制优化到高分屏适配,都属于可以直接抄作业的水平。适合刚接触Winform自定义控件的读者,也适合想在现有项目里快速集成一个简单流程图功能的开发者。
1. 方案选型:为什么我最终选择手写GDI+
1.1 第三方流程图控件试用后的真实感受
先说说我试过的几条路。市面上常见的流程图控件,比如Node Editor、Northwoods GoDiagram、MindFusion,各有各的问题。GoDiagram和MindFusion功能非常强大,但价格不便宜,而且文档全是英文,光是把Demo跑起来就够折腾一阵。Node Editor这类开源项目倒是不花钱,但它的代码风格偏老,有的甚至还在用.NET Framework 2.0时代的写法,拿到现代项目里要改的地方非常多。
更关键的问题是样式定制。第三方控件通常自带一套UI风格,想要做成和公司现有Winform界面统一的样子,往往得去翻它内部的绘制逻辑,改起来非常痛苦。有一次我想把一个开源库的节点改成圆角矩形加渐变背景,结果发现它的绘制方法是写死在一个基类里的,硬继承不是不行,但后续升级一合并代码就冲突。
1.2 手写方案的三点核心优势
为什么最终选择手写?第一是可控制性。每一条绘制代码都是自己的,从节点样式到连线拐弯方式,想怎么改就怎么改,不用迁就任何框架的约束。第二是体积和依赖。一个自定义控件文件就能完成大部分功能,不引入额外DLL,部署到工控机或者客户现场的时候少很多麻烦。第三是对接业务模型方便。流程图本质上只是业务数据的一种可视化表达,手写的话数据结构可以完全贴合业务,比如每个节点直接绑定一个工单状态对象,而不是在控件内置的数据类型上再做一次映射。
当然,手写意味着自己扛下所有细节,尤其是交互部分,后面我会详细讲。但如果你需要的只是一个“够用、可控、不折腾”的流程图,手写方案绝对值得一试。
2. 数据结构先行:节点与连线怎么建模才不返工
2.1 节点和连线的类设计:数据与绘制分离
画流程图最容易犯的错误是一上来就写绘制代码,画着画着发现数据模型不够用,再回头改类结构。这里我建议先花半小时把数据结构定好,后面所有代码都会顺畅很多。
我的核心模型就两个类:Node和Connection。
public enum NodeType { Start = 0, Process = 1, Decision = 2, End = 3 } public class Node { public string Id { get; set; } public string Text { get; set; } public RectangleF Bounds { get; set; } public NodeType Type { get; set; } public bool IsSelected { get; set; } public object Tag { get; set; } // 业务数据挂在Tag上,一个万能口袋 } public class Connection { public string Id { get; set; } public string FromNodeId { get; set; } public string ToNodeId { get; set; } public string Text { get; set; } public List<PointF> RoutePoints { get; set; } // 中间的拐点,直线连接时可以只保留空列表 }有几个细节值得说明。节点里用Bounds而不是X、Y、Width、Height分开的四个属性,因为GDI+绘制时直接需要RectangleF,这样省去多次构造临时结构。RoutePoints是为了将来做自动布局或者拐线预留的,如果只是简单的直线连接,保持空列表就行。Tag挂业务数据这个习惯非常好,比如审批流里每个节点要绑定审批人,直接塞到Tag里,绘制和交互代码完全不用感知业务对象。
为什么不用现成的控件对象(比如Button、Panel)来表示节点?我试过,一开始拖动很方便,但一旦节点数量到几十个,控件数量多起来,布局和刷新性能会急剧下降,而且自定义样式也得走控件重绘,比GDI+直接画更麻烦。数据与显示分离,这是手写控件的第一原则。
2.2 命中检测:鼠标点下去时程序该先问谁
画布上可能有节点、连线、背景区域,鼠标点下去之后,程序必须快速判断“用户在点什么”。这个逻辑就是命中检测(HitTest),是整个交互的基石。
我的实现方式是分优先级判断:先判断是否点在节点上,再判断是否点在连线上,最后判断是空白区域。
public Node HitTestNode(PointF pt) { // 从后往前遍历,后画的节点在上层,优先命中 for (int i = nodes.Count - 1; i >= 0; i--) { var node = nodes[i]; if (node.Bounds.Contains(pt)) return node; } return null; } public Connection HitTestConnection(PointF pt, float tolerance = 5f) { foreach (var conn in connections) { var p1 = GetNodeCenter(conn.FromNodeId); var p2 = GetNodeCenter(conn.ToNodeId); if (DistanceToSegment(pt, p1, p2) <= tolerance) return conn; } return null; }连线命中检测里有一个点需要特别注意:计算点到线段的距离,而不是点到直线的距离。如果直接套用点到直线的公式,鼠标点在连线延长线上也会被判定为命中,实际使用时就会产生“明明没有点到连线,却选中了它”的奇怪体验。
点到线段的距离算法网上很多,这里就不贴公式了,核心思路是先求点在直线上的投影,然后判断投影是否落在线段范围内,若落在线段内则取投影距离,否则取点到两个端点的最小值。这个函数在连线上右键和双击时都会用到,值得单独封装好。
3. 绘制与交互:让流程图从“能看”变成“能用”
3.1 用GDI+画节点和连线:几个容易被忽略的细节
绘制是流程图的门面,代码本身不复杂,但细节决定了最终效果。先说节点绘制,我用的是一个统一的DrawNode方法,根据节点类型和选中状态切换样式:
private void DrawNode(Graphics g, Node node) { // 开启抗锯齿,这是让图看起来“专业”的第一要素 g.SmoothingMode = System.Drawing.Drawing2D.SmoothingMode.AntiAlias; var rect = node.Bounds; var radius = 10; // 圆角半径 // 用GraphicsPath画圆角矩形,直接DrawRectangle是直角,工业风太强 using (var path = CreateRoundedRect(rect, radius)) { // 选中状态的节点用亮色边框,未选中用灰色 Color borderColor = node.IsSelected ? Color.FromArgb(64, 128, 255) : Color.FromArgb(180, 180, 180); // 根据节点类型填充不同的背景色 Color fillColor = node.Type switch { NodeType.Start => Color.FromArgb(220, 240, 220), NodeType.Decision => Color.FromArgb(255, 235, 180), NodeType.End => Color.FromArgb(240, 220, 220), _ => Color.FromArgb(230, 242, 255) }; using (var brush = new SolidBrush(fillColor)) using (var pen = new Pen(borderColor, node.IsSelected ? 2.5f : 1.2f)) { g.FillPath(brush, path); g.DrawPath(pen, path); } } // 绘制文本,居中显示,注意处理TextAlignment TextRenderer.DrawText(g, node.Text, this.Font, Rectangle.Round(rect), Color.Black, TextFormatFlags.HorizontalCenter | TextFormatFlags.VerticalCenter | TextFormatFlags.WordBreak); }这里我踩过两个坑。第一个是圆角矩形,用Path.AddArc一个角一个角加,代码又长又容易错,后来封装了一个CreateRoundedRect辅助方法,传入矩形和圆角半径就行。第二个是文本绘制,用g.DrawString虽然默认字体是天生的,但它在某些机器上会模糊,尤其是高分屏,后来我换成TextRenderer.DrawText,清晰度和对齐都好了很多,建议所有Winform自定义绘制里的文本都统一走TextRenderer。
连线绘制要处理箭头。判断方向很简单,取两个节点的中心点连线,方向就是向量方向,然后在终点画一个小三角形箭头。计算箭头的代码:
private void DrawArrow(Graphics g, PointF from, PointF to) { var dx = to.X - from.X; var dy = to.Y - from.Y; var angle = (float)Math.Atan2(dy, dx); var arrowLen = 10f; var arrowAngle = (float)(Math.PI / 6); // 30度 var p1 = new PointF( to.X - arrowLen * (float)Math.Cos(angle - arrowAngle), to.Y - arrowLen * (float)Math.Sin(angle - arrowAngle)); var p2 = new PointF( to.X - arrowLen * (float)Math.Cos(angle + arrowAngle), to.Y - arrowLen * (float)Math.Sin(angle + arrowAngle)); using (var pen = new Pen(Color.FromArgb(120, 120, 120), 1.5f)) { g.DrawLine(pen, from, to); g.DrawLine(pen, to, p1); g.DrawLine(pen, to, p2); } }这个箭头是实心三角形的三条边,看着简单但实际够用。如果要更精致的效果,可以用FillPolygon填充三角形,但那样需要额外创建一个SolidBrush,性能略低。对于简单的流程图终端展示来说,三根线条的箭头已经足够清晰了。
3.2 鼠标状态机:拖拽、连线和选中的冲突处理
绘制搞定之后,真正的硬骨头是交互。一个流程图控件起码要支持三种操作:点击选中、拖动节点、从输出口拉一根线到另一个节点。这三种操作共用MouseDown、MouseMove、MouseUp三个事件,如果不做状态管理,代码很快就会变成一团乱麻。
我采用的方案是一个简单的鼠标状态机,枚举值有:
private enum MouseAction { None, // 无操作 Dragging, // 正在拖动节点 Connecting, // 正在画连线 MarqueSelect // 正在框选(扩展功能,可以不加) } private MouseAction currentAction = MouseAction.None;MouseDown时根据命中检测结果决定进入哪个状态:
protected override void OnMouseDown(MouseEventArgs e) { base.OnMouseDown(e); var hitNode = HitTestNode(e.Location); if (hitNode != null) { // 如果点在节点边缘的“端口”区域,则进入连线模式 if (IsInPortArea(hitNode, e.Location)) { currentAction = MouseAction.Connecting; tempConnection = new Connection { FromNodeId = hitNode.Id, ToNodeId = null, RoutePoints = new List<PointF>() }; } else { currentAction = MouseAction.Dragging; dragStart = e.Location; nodeStartBounds = hitNode.Bounds; } SelectNode(hitNode); Invalidate(); return; } // 没点到节点,看看是不是点在连线上 var hitConn = HitTestConnection(e.Location); if (hitConn != null) { SelectedConnection = hitConn; Invalidate(); return; } // 点空白处则取消选中 ClearSelection(); Invalidate(); }MouseMove里的逻辑就跟着状态走:
protected override void OnMouseMove(MouseEventArgs e) { base.OnMouseMove(e); if (currentAction == MouseAction.Dragging && dragNode != null) { var offsetX = e.X - dragStart.X; var offsetY = e.Y - dragStart.Y; dragNode.Bounds = new RectangleF( nodeStartBounds.X + offsetX, nodeStartBounds.Y + offsetY, nodeStartBounds.Width, nodeStartBounds.Height); Invalidate(); } else if (currentAction == MouseAction.Connecting && tempConnection != null) { tempRouteEnd = e.Location; Invalidate(); } }这里的关键设计是“当前动作”只在一个状态内处理逻辑,不互相干扰。比如拖动节点时MouseMove只更新节点位置,不再做命中检测,防止节点移动到另一个节点上方时发生误判。
有一个细节很多教程不会提:MouseDown之后一定要调用this.Capture = true,把鼠标捕获到控件上。否则当鼠标移出控件边界时,MouseMove和MouseUp事件都不再触发,拖动过程中就会出现节点“掉到窗口外”的情况。
4. 美化与进阶:流程图也要考虑“体面”
4.1 样式主题化与自动布局初体验
当流程图不再只是给自己看了,你就得考虑界面“体面”的问题。第一件事是不要把所有颜色写死在绘制代码里,而是抽成一个主题类,比如:
public class FlowChartTheme { public Color NodeFill { get; set; } = Color.FromArgb(230, 242, 255); public Color NodeBorder { get; set; } = Color.FromArgb(150, 180, 220); public Color SelectBorder { get; set; } = Color.FromArgb(64, 128, 255); public Color LineColor { get; set; } = Color.FromArgb(120, 120, 120); public Color DecisionFill { get; set; } = Color.FromArgb(255, 235, 180); public Color StartFill { get; set; } = Color.FromArgb(220, 240, 220); public Color EndFill { get; set; } = Color.FromArgb(240, 220, 220); }这样换肤就变得非常简单,给控件加一个Theme属性,重绘时统一读取,后续做夜间模式或客户定制都方便。
第二个让我头疼的需求是自动布局。手动画图时,节点的坐标可以随意摆放,但算法流程图一旦节点多了,全靠手动拖非常痛苦。我实现了一个简单但实用的分层布局思路:先根据节点之间的连线关系做拓扑排序,确定层级;然后同一层的节点纵向居中排布,不同层之间用固定间距隔开。
这个方案达不到Graphviz那种专业布局效果,但胜在代码量少,几十个节点线性流程足够用。如果要做复杂的自动布局,建议去研究力导向算法或层次布局算法,不过那些属于另一个量级的工作量了。
4.2 扩展性设计:双击编辑、右键菜单和属性面板联动
流程图控件光能画还不够,得能改。双击节点修改文本是最基本的需求,实现方式是双击时弹出一个内嵌的TextBox,初始位置和节点重合,LostFocus或回车时把文本写回节点。
protected override void OnDoubleClick(EventArgs e) { base.OnDoubleClick(e); var hitNode = HitTestNode(lastMousePosition); if (hitNode == null) return; var editBox = new TextBox { Bounds = Rectangle.Round(hitNode.Bounds), Text = hitNode.Text, BorderStyle = BorderStyle.FixedSingle }; editBox.LostFocus += (s, args) => FinishEdit(editBox, hitNode); editBox.KeyDown += (s, args) => { if (args.KeyCode == Keys.Enter) FinishEdit(editBox, hitNode); if (args.KeyCode == Keys.Escape) { editBox.Visible = false; this.Controls.Remove(editBox); } }; Controls.Add(editBox); editBox.Focus(); }这里有个小坑:双击节点时,如果先触发了MouseDown的拖动逻辑,节点位置会被改掉,所以双击之前要把双击点和之前的鼠标状态做隔离,我在MouseDown里判断了Clicks属性,第二次点击时不再进入拖动逻辑。
右键菜单我直接用ContextMenuStrip,动态生成菜单项。比如右键节点时显示“删除节点”“设为起始节点”,右键空白处显示“新增节点”“全选”。注意ContextMenuStrip要在MouseUp里根据命中结果动态指定,不要绑死在控件的ContextMenuStrip属性上,否则不好区分右键的对象。
属性面板联动这个需求非常典型。选中节点后,把节点信息显示到旁边的PropertyGrid里,用户调整Text、Type等属性,控件的重绘会自动生效。PropertyGrid默认是可编辑的,如果你只想让用户看不能改,可以在属性定义上加ReadOnly特性,或者设置PropertyGrid的ReadOnly属性为true。我之前实现过把业务对象挂到Tag上后,直接用PropertyGrid.SelectedObject = selectedNode.Tag,一行代码就把用户管理模块的对象属性展示出来了,体验相当顺畅。
5. 常见问题与避坑实录
5.1 高分屏模糊与界面尺寸异常
很多人写完控件在开发机上正常,一拿到笔记本或者客户的高分屏上就模糊或者尺寸错乱。这个问题的根因是DPU缩放。Winform默认是不感知DPI变化的,系统缩放从100%切到125%或150%时,控件绘制的坐标全部错位。
解决办法有几个层次。最简单的是在Program.cs入口处声明DPI感知:
[STAThread] static void Main() { if (Environment.OSVersion.Version.Major >= 6) { SetProcessDPIAware(); } Application.EnableVisualStyles(); Application.SetCompatibleTextRenderingDefault(false); Application.Run(new MainForm()); } [DllImport("user32.dll")] private static extern bool SetProcessDPIAware();这个方案在Windows 10 1803以上的系统上表现不错,注意如果系统设置里的缩放比例是自定义值(比如125%),坐标仍然可能有一丝偏移,建议在MainForm的OnLoad里添加:
this.AutoScaleMode = AutoScaleMode.Dpi;这样窗体自身的缩放策略就统一了。我遇到过的另一个现象是笔记本分辨率低导致窗口高度容纳不下所有内容,这属于布局问题,可以在窗体尺寸变化时对画布做自适应缩放,或者给控件加滚动支持。如果想偷懒,把窗体的AutoScroll属性设为true,再给画布一个建议尺寸,用户至少能滚动查看全部内容。
5.2 刷新闪烁、卡顿与线程Invoke
流程图控件最容易犯的问题就是刷新闪烁。因为整个画布是自绘的,每次鼠标移动触发Invalidate时,控件都会进行全量重绘,效率低且视觉上会闪。解决办法是启用双缓冲:
public FlowChartControl() { SetStyle(ControlStyles.AllPaintingInWmPaint | ControlStyles.UserPaint | ControlStyles.OptimizedDoubleBuffer, true); }这三行代码是Winform自定义控件的标配,加上之后闪烁问题基本消失。
另一个卡顿来源是绘制过程中创建大量Pen和Brush对象没有释放。每帧绘制时如果都new一个Pen,几百个节点就会产生上千个GDI对象,旧版本.NET的垃圾回收不及时,内存占用会越来越高。我的经验是全用using包裹,或者把颜色固定的Pen和Brush在构造函数里一次性创建并复用。
如果数据是从数据库或网络加载的,加载过程不要放在UI线程,否则界面会卡死。加载完成后的刷新必须通过Invoke切回UI线程:
Task.Run(() => { var nodes = LoadNodesFromDb(); if (InvokeRequired) { Invoke(new Action(() => { AddNodes(nodes); Invalidate(); })); } });这就是热搜词里“winform invoke”最常见的应用场景,记住凡是操作控件属性、集合、刷新,都必须回到UI线程。
5.3 删除节点后连线残留的“孤儿引用”
这是一个隐藏很深的Bug。删除节点A之后,所有连接A和其他节点的连线如果没有同步清理,画布上就会出现“一端悬空”的线,点击还能选中,非常影响体验。
我的处理方案是在Control里维护一个AllConnections的List,删除节点时遍历所有连线,找到所有包含该节点Id的引用,一律从列表中移除:
public void DeleteNode(Node node) { nodes.Remove(node); connections.RemoveAll(c => c.FromNodeId == node.Id || c.ToNodeId == node.Id); Invalidate(); }这个操作极简单,但很容易忘记。另外删除节点后如果选中集合里还持有这个节点的引用,下一次绘制时就会访问到被回收的对象,所以ClearSelection也要一起调用。为了避免这些细节漏掉,我的建议是所有对节点和连线集合的修改,都通过自定义方法(AddNode、DeleteNode、AddConnection)来做,而不是让业务代码直接操作List,这样清理逻辑只在一个地方维护。
5.4 常见问题速查表
| 现象 | 常见原因 | 处理方式 |
|---|---|---|
| 鼠标拖到控件外,节点卡住不动 | 没有捕获鼠标 | MouseDown时调用Capture = true |
| 画布刷新闪烁 | 没有开双缓冲 | SetStyle中开启OptimizedDoubleBuffer |
| 节点文字模糊 | 使用DrawString | 改用TextRenderer.DrawText |
| 单击节点被误判成双击 | 事件顺序混乱 | MouseDown里判断e.Clicks,第二次进入双击分支 |
| PropertyGrid只读 | 属性被保护 | 设置ReadOnly或加[ReadOnly(true)]特性 |
| 高DPI下尺寸错乱 | 未处理DPI感知 | 声明SetProcessDPIAware + AutoScaleMode.Dpi |
写在最后的体验
这套东西做完之后,我最深的一个体会是:画图本身不复杂,真正的工程量和复杂度全部藏在交互和数据建模里。很多人一上来就想着做一个类似Visio的流程图编辑器,结果往往死在连线碰撞检测和撤销重做这两个大坑上。如果你只是需要能在Winform里展示流程图、支持拖拽微调、能编辑文本和连接关系,用我上面这套思路完全够了。如果后续真要上自动布局、多页面、撤销重做,建议再去研究拓扑排序、力导向布局和命令模式,但底层的节点连线数据结构就是这套思路,不用推翻重来。
本文还有配套的精品资源,点击获取