首页 / 资讯中心 / 文章详情

WinForm流程图源码拆解:GDI+拖动即时刷新与双缓冲实战

WinForm流程图源码拆解:GDI+拖动即时刷新与双缓冲实战 ★ FEATURED ARTICLE
简介这是一套基于 C# 与 GDI 实现的 WinForm 流程图绘制源码面向希望在 .NET 桌面端开发图形化流程设计工具的开发者尤其适合需要理解绘图机制与鼠标交互逻辑的中级学习者。资源包共 39 个文件约 176KB以 cs 源码、resources 资源、config 配置、resx 与 png 效果图为主另含 sln 解决方案与 csproj 工程文件可直接在 Visual Studio 中打开运行。代码围绕 Graphics、Pen、SolidBrush 等 GDI 类库展开涵盖自定义形状类、Draw 绘制方法、MouseDown/MouseMove/MouseUp 拖动事件处理以及 Invalidate 即时刷新机制并配有详细注释便于理解形状存储与重绘流程。目前已有 3212 人学习下载读者可借此掌握 WinForm 下动态拖拽与实时刷新的实现思路并在此基础上扩展出更复杂的工作流设计器。1. 从一份 WinForm 流程图源码说起GDI 拖动即时刷新到底怎么落地前阵子帮朋友看一个工控上位机项目需求很朴素在 WinForm 里画工作流程图节点能拖、连线能跟着走、拖完立刻刷新不能有残影。他找了一圈流程图绘制软件要么是 B/S 的要么是收费控件最后翻到一个 .NET WinForm GDI 的 C# 流程图源码包标题写着「0 分下载」。我拆开跑了一遍发现它正好卡在一个很实用的位置上——不是那种玩具 Demo也不是重型工作流引擎而是一套能直接嵌进 WinForm 项目里的流程图绘制骨架。它解决的核心问题就三件事用 GDI 在 Panel 或自定义控件上画节点和连线用鼠标事件驱动节点拖动拖动过程中即时刷新画布避免闪烁和残影。适合谁做 C# 上位机、工控组态、内部工具、教学演示的开发者尤其是需要在 WinForm 里快速搭一个「工作流程设计器」雏形的人。下面我按「它是什么 → 怎么用 → 坑在哪」的顺序把这份源码拆开讲清楚。2. GDI 绘图骨架从 OnPaint 到双缓冲节点和连线怎么画2.1 为什么选 GDI 而不是 WPF 或第三方控件WinForm 里画流程图常见路线有三条WPF 的 Canvas Shape、第三方商业控件、GDI 自绘。这份源码选的是 GDI理由很实际——WinForm 项目里嵌 WPF 有互操作成本商业控件要授权而 GDI 是 .NET Framework 自带的零依赖。对于节点数量在几十到几百级别的流程图GDI 的性能完全够用而且绘制逻辑全在自己手里想改节点样式、连线算法、命中检测都方便。代价是你要自己处理双缓冲、坐标变换、命中检测这些底层细节。源码里把这些都封装好了但理解它的结构后面改起来才不慌。2.2 核心绘制流程OnPaint 里到底做了什么整个绘图入口在自定义控件的OnPaint方法里。源码的结构大致是这样protected override void OnPaint(PaintEventArgs e) { // 双缓冲先画到内存位图再一次性贴到屏幕 BufferedGraphicsContext currentContext BufferedGraphicsManager.Current; BufferedGraphics myBuffer currentContext.Allocate(e.Graphics, this.ClientRectangle); Graphics g myBuffer.Graphics; g.SmoothingMode SmoothingMode.AntiAlias; // 抗锯齿连线不毛糙 g.Clear(this.BackColor); // 先画连线再画节点保证节点盖住线头 foreach (var conn in connections) { conn.Draw(g); // 内部用 DrawLine 或 DrawBezier } foreach (var node in nodes) { node.Draw(g); // 内部用 FillRectangle DrawString } myBuffer.Render(e.Graphics); // 一次性提交 myBuffer.Dispose(); }逻辑说明BufferedGraphics是 WinForm 里做双缓冲的标准做法比直接设DoubleBuffered true更可控因为它允许你在内存位图上做多次绘制操作再统一提交。SmoothingMode.AntiAlias让斜线和曲线边缘平滑代价是略微增加 CPU 开销节点多的时候可以按需关掉。参数说明Allocate的第二个参数是目标矩形一般用ClientRectangle如果你只重绘局部区域可以传更小的矩形来减少重绘量。Render之后必须Dispose否则内存会涨。2.3 节点和连线的数据结构怎么设计源码里节点和连线是分开管理的典型结构public class FlowNode { public Rectangle Bounds; // 节点矩形区域决定命中检测 public string Text; // 节点显示文字 public Color FillColor; // 填充色 public ListFlowConnection Connections; // 出边集合 } public class FlowConnection { public FlowNode From; public FlowNode To; public Point[] RoutePoints; // 折线拐点直线时为空 }逻辑说明节点用Rectangle而不是Point Size是因为 GDI 的FillRectangle、DrawRectangle、Rectangle.Contains都直接吃Rectangle命中检测一行搞定。连线持有起止节点的引用拖动节点时不需要重新查找连线直接遍历节点的Connections重绘即可。参数说明RoutePoints是为折线预留的如果只画直线可以忽略。实际项目里如果连线要避开节点就得在这里做路由算法源码里给的是最简版本。2.4 拖动即时刷新的实现鼠标事件 Invalidate拖动刷新是这份源码的重点。核心逻辑在MouseDown、MouseMove、MouseUp三个事件里private FlowNode draggingNode null; private Point dragOffset; protected override void OnMouseDown(MouseEventArgs e) { // 从后往前找后画的节点在上层优先命中 for (int i nodes.Count - 1; i 0; i--) { if (nodes[i].Bounds.Contains(e.Location)) { draggingNode nodes[i]; dragOffset new Point(e.X - nodes[i].Bounds.X, e.Y - nodes[i].Bounds.Y); break; } } } protected override void OnMouseMove(MouseEventArgs e) { if (draggingNode ! null e.Button MouseButtons.Left) { draggingNode.Bounds.X e.X - dragOffset.X; draggingNode.Bounds.Y e.Y - dragOffset.Y; this.Invalidate(); // 触发重绘OnPaint 会重画全部 } } protected override void OnMouseUp(MouseEventArgs e) { draggingNode null; }逻辑说明dragOffset记录鼠标按下点相对节点左上角的偏移这样拖动时节点不会「跳」到鼠标位置。Invalidate()标记整个控件区域为无效下一帧OnPaint重画所有节点和连线。节点数量少的时候这样最简单数量多了就要改成Invalidate(unionRect)只重绘受影响区域。参数说明e.Button MouseButtons.Left确保只有左键拖动右键可以留给上下文菜单。nodes.Count - 1倒序遍历是因为后添加的节点绘制在上层命中检测要匹配视觉层级。3. 把源码跑起来环境、编译、嵌入现有 WinForm 项目的步骤3.1 环境准备与项目结构确认这份源码是 .NET Framework 的 WinForm 项目用 VS2015 及以上版本都能打开。如果你用的是 VS2022打开时会提示重定向框架版本点确认即可。项目结构一般是一个解决方案加一个 WinForm 项目核心文件是自定义控件类比如FlowChartPanel.cs和节点/连线的模型类。常见做法是先单独编译运行源码自带的 Demo确认拖动刷新正常再把它嵌进你自己的项目。不要一上来就合并否则出问题分不清是源码本身还是你的项目环境导致的。3.2 编译与首次运行检查清单# 如果你用命令行编译需已安装 MSBuild msbuild FlowChartDemo.sln /p:ConfigurationRelease # 或者直接用 VS 打开F5 运行运行起来后按这个清单逐项确认检查项预期表现不正常的可能原因节点显示矩形 文字清晰字体未设置或 OnPaint 未调用拖动节点节点跟随鼠标无跳变dragOffset 计算错误连线跟随拖动时连线端点跟着动连线未在 OnPaint 中重绘拖动刷新无残影、无闪烁双缓冲未生效窗口缩放节点位置不跑偏坐标未做缩放适配3.3 嵌入现有项目的三种方式第一种直接把自定义控件类文件复制进你的项目改命名空间。适合你只需要绘图功能不需要 Demo 窗体。第二种把整个项目作为类库引用。适合你想保留 Demo 作为参考同时在自己的项目里用。第三种只提取节点和连线的模型类绘制逻辑自己重写。适合你的 UI 框架不是标准 WinForm 控件或者你要做 MVVM 模式。我一般用第一种因为这份源码的控件类耦合度不高复制过来改个命名空间就能用。注意检查它有没有引用项目特有的资源文件或配置有的话一并带过去。3.4 关键参数怎么调节点大小、连线粗细、刷新频率源码里这些参数通常写在节点类的构造函数或绘制方法里// 节点默认大小 public int NodeWidth 120; public int NodeHeight 60; // 连线画笔 Pen connectionPen new Pen(Color.Black, 2f); connectionPen.CustomEndCap new AdjustableArrowCap(6, 6); // 箭头大小 // 拖动时的重绘策略 this.Invalidate(); // 全量重绘简单但节点多时卡 // 优化只重绘旧位置和新位置的并集 Rectangle oldRect draggingNode.Bounds; // ... 更新位置后 Rectangle newRect draggingNode.Bounds; this.Invalidate(Rectangle.Union(oldRect, newRect));参数说明NodeWidth和NodeHeight决定命中检测区域改的时候要同步改文字绘制的位置否则文字会偏。AdjustableArrowCap的两个参数是箭头宽和高单位是像素太大在小节点上会显得突兀。Invalidate(rect)的矩形要包含连线的区域否则连线会残留这是最容易翻车的地方。4. 避坑与排查拖动残影、命中偏移、连线错位的血泪经验4.1 拖动时出现残影或闪烁现象拖动节点后旧位置还留着一个节点影子或者整个画布闪一下。原因没有用双缓冲或者Invalidate的区域没覆盖旧位置。WinForm 默认的OnPaintBackground会先用背景色刷一遍再调OnPaint两次操作之间就会闪。解决用BufferedGraphics做双缓冲并且在拖动时把旧位置和新位置的并集传给Invalidate。如果还闪检查有没有在OnPaint里调用base.OnPaint导致重复绘制。4.2 鼠标命中检测偏移现象鼠标明明点在节点上却没选中或者选中的是旁边的节点。原因命中检测用的坐标和绘制用的坐标不一致。常见于控件有滚动条、或者你在OnPaint里做了坐标平移但MouseDown里没做。解决统一坐标基准。如果控件有AutoScroll命中检测要用e.Location加上AutoScrollPosition。如果做了缩放鼠标坐标要反向变换回逻辑坐标再检测。4.3 连线端点不跟随节点现象拖动节点后连线还连在旧位置或者线头飘在节点外面。原因连线绘制时缓存了端点的绝对坐标节点移动后没有更新缓存。解决连线绘制时实时从From.Bounds和To.Bounds计算端点不要缓存。如果连线要连到节点边缘而不是中心在绘制时根据两节点相对位置算交点。4.4 节点多了之后拖动卡顿现象几十个节点时拖动还流畅上百个就开始卡。原因每次Invalidate()都全量重绘OnPaint里遍历所有节点和连线。解决拖动时只重绘受影响的区域用Invalidate(unionRect)。另外可以把节点文字用Graphics.DrawString换成缓存的位图减少文本渲染开销。如果还卡考虑把静态部分画到后台位图只重绘拖动中的节点。4.5 高 DPI 下节点和文字错位现象在 125% 或 150% 缩放的显示器上节点框和文字对不齐或者整体模糊。原因WinForm 默认的 DPI 感知是系统级GDI 绘制时坐标没按 DPI 缩放。解决在 app.manifest 里开启 PerMonitorV2 DPI 感知或者在程序入口调用Application.SetHighDpiMode(HighDpiMode.SystemAware)。绘制时用e.Graphics.DpiX / 96f作为缩放因子所有坐标和尺寸都乘这个因子。5. 进阶技巧把流程图导出成图片、加撤销重做、做节点对齐5.1 导出流程图为 PNG把画布内容导出成图片是这类工具的高频需求。思路是创建一个和画布同尺寸的Bitmap把OnPaint的绘制逻辑复用到这个位图上public Bitmap ExportToBitmap() { // 计算所有节点的包围盒留出边距 Rectangle bounds GetNodesBounds(); bounds.Inflate(20, 20); // 四周留 20 像素边距 Bitmap bmp new Bitmap(bounds.Width, bounds.Height); using (Graphics g Graphics.FromImage(bmp)) { g.SmoothingMode SmoothingMode.AntiAlias; g.Clear(Color.White); g.TranslateTransform(-bounds.X, -bounds.Y); // 平移坐标系 foreach (var conn in connections) conn.Draw(g); foreach (var node in nodes) node.Draw(g); } return bmp; }逻辑说明GetNodesBounds遍历所有节点取并集Inflate留边距避免节点贴边。TranslateTransform把坐标系平移到包围盒左上角这样绘制代码不用改。导出后直接bmp.Save(flow.png, ImageFormat.Png)即可。参数说明Inflate的值根据你的连线箭头大小调整太小箭头会被裁掉。如果背景要透明把g.Clear(Color.White)换成g.Clear(Color.Transparent)保存格式用 PNG。5.2 加一个简单的撤销重做撤销重做不需要上命令模式那么重用两个栈记录节点位置快照就行private StackDictionaryFlowNode, Rectangle undoStack new Stack...(); private StackDictionaryFlowNode, Rectangle redoStack new Stack...(); private void SaveSnapshot() { var snapshot nodes.ToDictionary(n n, n n.Bounds); undoStack.Push(snapshot); redoStack.Clear(); // 新操作后重做栈失效 } private void Undo() { if (undoStack.Count 0) return; var current nodes.ToDictionary(n n, n n.Bounds); redoStack.Push(current); var snapshot undoStack.Pop(); foreach (var kv in snapshot) kv.Key.Bounds kv.Value; Invalidate(); }逻辑说明在MouseUp时调用SaveSnapshot记录拖动后的状态。Undo时把当前状态压入重做栈再恢复上一个快照。注意快照要深拷贝Rectangle因为Rectangle是值类型ToDictionary里直接赋值就是拷贝。参数说明栈的深度可以限制比如超过 50 步就丢掉最旧的避免内存无限增长。如果节点数量很大快照可以只记录被拖动的节点而不是全部。5.3 节点对齐与吸附拖动时让节点自动对齐到网格或其他节点能显著提升流程图的可读性private const int GridSize 10; private void SnapToGrid(FlowNode node) { node.Bounds.X (int)Math.Round((double)node.Bounds.X / GridSize) * GridSize; node.Bounds.Y (int)Math.Round((double)node.Bounds.Y / GridSize) * GridSize; }逻辑说明在MouseMove更新位置后调用SnapToGrid节点就会吸附到 10 像素的网格上。如果要吸附到其他节点的边缘遍历其他节点如果距离小于阈值就对齐。参数说明GridSize太小吸附不明显太大节点移动会一跳一跳。10 到 20 像素是比较舒服的范围。吸附阈值一般设 5 到 8 像素。5.4 一个我踩过的坑有次我把这份源码嵌进一个带SplitContainer的项目结果拖动节点时整个界面都在闪。查了半天发现是SplitContainer的Panel默认开了DoubleBuffered false而我的流程图控件放在里面双缓冲被父容器破坏了。解决办法是把承载控件的Panel也设成双缓冲或者把流程图控件直接放在 Form 上。从那以后我每次嵌这类自绘控件都强制走一遍「父容器双缓冲检查 高 DPI 检查 拖动残影检查」这三步省得后面返工。希望这份拆解能帮到你源码包不大跑一遍再改比看十篇教程都管用。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?
咨询建站