十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

手写Winform流程图控件:GDI+绘制、交互与避坑指南

手写Winform流程图控件:GDI+绘制、交互与避坑指南 简介这是一份基于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. 方案选型为什么我最终选择手写GDI1.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 ListPointF 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 ListPointF() }; } 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里展示流程图、支持拖拽微调、能编辑文本和连接关系用我上面这套思路完全够了。如果后续真要上自动布局、多页面、撤销重做建议再去研究拓扑排序、力导向布局和命令模式但底层的节点连线数据结构就是这套思路不用推翻重来。本文还有配套的精品资源点击获取
返回列表