简介这是一套基于C#与GDI实现的WinForm流程图绘制源码面向希望在.NET桌面环境中开发图形化流程设计工具的开发者尤其适合需要理解绘图机制与鼠标交互逻辑的中级学习者。资源包共39个文件以cs源码、resources资源、config配置、resx与png效果图为主另含sln解决方案与csproj工程文件压缩包约176KB结构紧凑便于直接编译运行。源码围绕Graphics、Pen、SolidBrush等GDI类展开涵盖自定义形状类、Draw绘制方法、MouseDown/MouseMove/MouseUp拖动事件处理以及Invalidate即时刷新机制并配有详细注释方便读者理解形状存储与重绘流程。目前已有3213人学习下载可作为自定义工作流或流程设计工具的参考基础通过修改扩展即可搭建更复杂的流程图应用。1. 拖一下就重绘这个 WinForm 流程图控件到底能干什么前阵子接了个内部工具的需求要在 WinForm 里画一张可拖拽的流程节点图节点能连线、能拖动、拖完立刻刷新不能有残影。我第一反应是找现成的流程图控件结果要么是 Web 端的 JS 库要么是收费的商业组件能直接塞进 C# WinForm 项目里、还带源码的真不多。后来翻到这份 .NET 工作流程图 WinForm gdi 流程图 C# 版流程图绘制资源0 分下载拿下来跑了一遍发现它正好卡在这个需求点上纯 GDI 手绘不依赖第三方图形库节点拖动即时刷新源码全开放。它适合谁一类是需要在 WinForm 里做流程编排、工单流转、审批链路可视化的开发者另一类是想搞懂 GDI 双缓冲绘图、鼠标命中检测、拖拽重绘这套机制的人。这项目不是那种封装得密不透风的黑匣子核心逻辑都摊在代码里改起来心里有底。下面我按「它是什么 → 怎么跑起来 → 核心怎么实现 → 怎么改」的顺序拆一遍。2. 技术选型与目录结构为什么是 GDI 而不是 WPF2.1 选型逻辑WinForm GDI 的取舍先说清楚这个项目为什么用 WinForm 而不是 WPF。WPF 做矢量图形确实更优雅Canvas、Path、Transform 一套下来很舒服但现实里很多存量项目就是 WinForm 的尤其是工业上位机、内部管理工具这类迁移成本高。这时候要在 WinForm 里画图GDI 就是最直接的选择——System.Drawing命名空间自带不用引 NuGet 包Graphics对象直接往控件上画。GDI 的代价是它偏底层双缓冲、坐标变换、命中检测都得自己写。这个项目正好把这几个点都覆盖了所以它更像一份「GDI 绘图实战模板」而不是一个开箱即用的成品控件。理解这一点很重要你拿到的不是拖拽式设计器而是一套可改造的绘图骨架。技术栈上它依赖的是 .NET Framework 的 WinFormsSystem.Windows.Forms加System.Drawing语言是 C#。没有用到任何第三方绘图库也没有引入 Newtonsoft 之类的序列化包属于「零外部依赖」那一类扔进任何 WinForm 工程都能编译。2.2 目录结构文件不多但分工清楚这类绘图 Demo 的目录通常不会太复杂我拿到手后整理了一下大致是这么几块FlowChartDemo/ ├── Forms/ │ └── MainForm.cs 主窗体承载绘图画布与工具栏 ├── Models/ │ ├── FlowNode.cs 节点数据模型位置、尺寸、文本、类型 │ └── FlowLink.cs 连线数据模型起点节点、终点节点、锚点 ├── Rendering/ │ ├── CanvasRenderer.cs 负责所有绘制逻辑 │ └── HitTester.cs 鼠标命中检测 ├── Interaction/ │ └── DragController.cs 拖拽状态机 └── Program.cs 入口这个划分不是原项目强制的但按这个思路拆后面改起来会顺很多。核心就三件事数据模型节点和连线长什么样、渲染器怎么画到屏幕上、交互控制器鼠标怎么操作数据。把这三者分开是这类项目能不能二次开发的关键——很多人写崩就是因为把绘制和鼠标事件全塞进MainForm里几百行之后自己都看不懂。提示如果你拿到的源码是单文件堆在 Form 里的建议先按上面这个结构拆一遍再动手改不然后面加功能会非常痛苦。2.3 数据模型节点和连线怎么定义先看节点模型这是整个绘图的基础public class FlowNode { public string Id { get; set; } // 节点唯一标识连线靠它找节点 public Rectangle Bounds { get; set; } // 节点在画布上的矩形区域 public string Text { get; set; } // 节点显示文本 public NodeType Type { get; set; } // 节点类型开始/处理/判断/结束 public bool IsSelected { get; set; } // 是否被选中用于高亮 // 判断某个点是否落在节点内命中检测的核心 public bool Contains(Point p) { return Bounds.Contains(p); } } public enum NodeType { Start, Process, Decision, End }Bounds用Rectangle而不是分开存 X、Y、Width、Height是因为 GDI 的Graphics.FillRectangle、DrawRectangle直接吃Rectangle省得来回拼。Contains方法把命中检测收进模型里鼠标点击时直接node.Contains(mousePoint)就行逻辑内聚。连线模型稍微绕一点因为它要跟着节点动public class FlowLink { public FlowNode From { get; set; } // 起点节点引用 public FlowNode To { get; set; } // 终点节点引用 public Point FromAnchor { get; set; } // 起点锚点相对节点 public Point ToAnchor { get; set; } // 终点锚点 // 每次绘制时根据节点当前位置重新算连线端点 public (Point start, Point end) GetEndpoints() { var start new Point( From.Bounds.Left FromAnchor.X, From.Bounds.Top FromAnchor.Y); var end new Point( To.Bounds.Left ToAnchor.X, To.Bounds.Top ToAnchor.Y); return (start, end); } }关键点在GetEndpoints连线不存绝对坐标而是存「相对节点的锚点偏移」绘制时实时算。这样节点一拖动连线端点自动跟着变不需要额外同步逻辑。这是流程图类项目里最省事的一种做法也是拖动即时刷新能成立的前提。3. 跑起来从编译到拖动第一个节点3.1 环境准备与编译这项目是 .NET Framework 的 WinForm 工程环境要求不高项目要求操作系统Windows 7 及以上运行时.NET Framework 4.0 或更高开发工具Visual Studio 2015 及以上依赖无第三方库仅 System.Drawing / System.Windows.Forms用 VS 打开.sln或.csproj直接 F5 就能跑。如果拿到的是散装.cs文件新建一个 WinForm 项目把文件拖进去注意把MainForm设为启动窗体。常见翻车点是目标框架选成了 .NET Core / .NET 5WinForm 在旧版 .NET Core 上支持不完整建议直接选 .NET Framework 4.7.2 这类稳定版本。3.2 双缓冲拖动不闪的前提跑起来第一件事你会注意到拖动节点时画面不闪。这不是玄学是双缓冲在起作用。WinForm 默认绘图是直接往屏幕上画拖动时频繁重绘就会闪。解决办法是在窗体或画布控件上开双缓冲public class CanvasPanel : Panel { public CanvasPanel() { // 开启双缓冲消除拖动闪烁 this.DoubleBuffered true; // 重绘时先擦除背景再画避免残影 this.SetStyle(ControlStyles.AllPaintingInWmPaint | ControlStyles.UserPaint | ControlStyles.OptimizedDoubleBuffer, true); this.UpdateStyles(); } }DoubleBuffered true是最省事的一行但有时候不够配合SetStyle里的三个标志更稳AllPaintingInWmPaint让所有绘制都走OnPaintUserPaint表示自己负责绘制OptimizedDoubleBuffer启用优化过的双缓冲。这三个一起开拖动时的闪烁基本就没了。注意如果你把绘图逻辑写在MainForm上而不是独立Panel记得在 Form 构造函数里也开DoubleBuffered否则一样会闪。3.3 重绘触发Invalidate 的用法拖动时怎么让画面刷新核心就一个方法Invalidate()。它标记控件需要重绘系统会在合适的时机调OnPaint。很多人以为要手动调Refresh()其实Invalidate更轻量Refresh是强制立即重绘频繁调用反而卡。private void OnMouseMove(object sender, MouseEventArgs e) { if (_draggingNode ! null e.Button MouseButtons.Left) { // 更新节点位置 _draggingNode.Bounds new Rectangle( e.X - _dragOffset.X, e.Y - _dragOffset.Y, _draggingNode.Bounds.Width, _draggingNode.Bounds.Height); // 只标记重绘不强制立即刷新 this.Invalidate(); } }_dragOffset是按下鼠标时记录的「鼠标点相对节点左上角的偏移」拖动时用它保证节点不会跳到鼠标正下方。Invalidate()不带参数是重绘整个控件如果节点多、画布大可以传Rectangle只重绘脏区性能会好一些。这个项目节点数量不大全量重绘够用。4. 核心实现拆解绘制、命中检测与拖拽状态机4.1 绘制流程OnPaint 里到底画了什么所有绘制都集中在OnPaint里顺序很重要——先画连线再画节点否则连线会盖住节点。整体结构是这样protected override void OnPaint(PaintEventArgs e) { base.OnPaint(e); var g e.Graphics; // 抗锯齿让线条和文字更平滑 g.SmoothingMode SmoothingMode.AntiAlias; g.TextRenderingHint TextRenderingHint.ClearTypeGridFit; // 1. 先画所有连线在节点下层 foreach (var link in _links) { var (start, end) link.GetEndpoints(); using (var pen new Pen(Color.Gray, 2)) { g.DrawLine(pen, start, end); } DrawArrow(g, start, end); // 画箭头 } // 2. 再画所有节点在上层 foreach (var node in _nodes) { DrawNode(g, node); } } private void DrawNode(Graphics g, FlowNode node) { // 选中状态用不同颜色高亮 var fillColor node.IsSelected ? Color.LightSkyBlue : Color.White; using (var brush new SolidBrush(fillColor)) using (var pen new Pen(Color.SteelBlue, 2)) { g.FillRectangle(brush, node.Bounds); g.DrawRectangle(pen, node.Bounds); } // 居中绘制文本 using (var sf new StringFormat { Alignment StringAlignment.Center, LineAlignment StringAlignment.Center }) using (var font new Font(微软雅黑, 10)) { g.DrawString(node.Text, font, Brushes.Black, node.Bounds, sf); } }几个细节值得说SmoothingMode.AntiAlias开抗锯齿线条不会锯齿状StringFormat的Alignment和LineAlignment都设成Center文本才会在节点里居中using包住Pen、Brush、Font这些 GDI 对象是因为它们持有非托管资源不释放会内存泄漏——这是 GDI 绘图最常见的坑之一。4.2 命中检测鼠标点到了哪个节点鼠标按下时得先判断点到了哪个节点。因为节点可能重叠通常从后往前遍历后画的在上层private FlowNode HitTest(Point p) { // 从最后一个节点往前找保证上层节点优先命中 for (int i _nodes.Count - 1; i 0; i--) { if (_nodes[i].Contains(p)) return _nodes[i]; } return null; }Contains就是前面模型里的Bounds.Contains(p)。这里有个容易忽略的点如果节点是圆角或异形Rectangle.Contains就不准了得换成GraphicsPath.IsVisible。这个项目用的是矩形节点所以直接矩形判断够用。命中检测的准确度直接决定拖拽手感点偏一点就选不中用户会觉得很别扭。4.3 拖拽状态机MouseDown / Move / Up 三件套拖拽本质是个状态机三个事件各管一段private FlowNode _draggingNode; private Point _dragOffset; private void OnMouseDown(object sender, MouseEventArgs e) { if (e.Button ! MouseButtons.Left) return; var hit HitTest(e.Location); if (hit ! null) { _draggingNode hit; // 记录鼠标相对节点左上角的偏移 _dragOffset new Point(e.X - hit.Bounds.Left, e.Y - hit.Bounds.Top); // 更新选中状态 foreach (var n in _nodes) n.IsSelected (n hit); this.Invalidate(); } } private void OnMouseUp(object sender, MouseEventArgs e) { // 松开鼠标结束拖拽 _draggingNode null; }MouseDown里做三件事命中检测、记录偏移、更新选中态。MouseMove里更新位置并Invalidate前面 3.3 已给。MouseUp里把_draggingNode置空状态机回到空闲。这套逻辑简单但完整加连线、加框选都是在这个骨架上扩展。提示MouseMove事件触发非常频繁里面别做重活。像重新计算所有连线端点这种交给OnPaint在绘制时算别在MouseMove里算。5. 避坑与常见问题拖动残影、连线错位、内存泄漏5.1 拖动有残影节点拖过留痕现象拖动节点时旧位置留下一个淡淡的影子越拖越多。原因没开双缓冲或者OnPaint里没先清背景。GDI 默认不会自动擦除上一帧。解决确认DoubleBuffered true且SetStyle里带了AllPaintingInWmPaint。如果还不行在OnPaint开头手动g.Clear(BackColor)但开了双缓冲后一般不需要。5.2 连线端点不跟着节点走现象节点拖走了连线还连在原来的位置或者连线端点飘在节点外面。原因连线存了绝对坐标没跟着节点更新或者锚点偏移算错了。解决按 2.3 的做法连线只存相对锚点绘制时用GetEndpoints实时算。检查锚点偏移是不是相对节点左上角别混用了绝对坐标。5.3 拖久了内存一直涨现象反复拖动几分钟任务管理器里进程内存持续上升不回落。原因Pen、Brush、Font、GraphicsPath这些 GDI 对象没释放。它们持有非托管句柄GC 回收不及时。解决所有 GDI 对象都用using包起来或者手动Dispose()。尤其注意在OnPaint里 new 出来的对象每次重绘都会创建一批不释放很快就爆。5.4 节点多了之后拖动卡顿现象节点超过几十个拖动明显掉帧。原因每次Invalidate()都全量重绘且OnPaint里反复创建Pen、Font。解决把Pen、Font这类不变的资源提到字段里复用别在OnPaint里 newInvalidate传脏区矩形只重绘受影响区域节点特别多时考虑分图层或虚拟化。5.5 高 DPI 屏幕上文字模糊、坐标偏移现象在缩放 125% 或 150% 的屏幕上节点文字发虚鼠标点击位置和节点对不上。原因WinForm 默认 DPI 感知没开系统做了位图拉伸。解决在app.manifest里声明 DPI 感知或在Program.cs里调Application.SetHighDpiMode.NET Core 及以上。老版本 .NET Framework 可以在窗体构造函数里根据Graphics.DpiX手动缩放坐标。6. 二次开发加连线交互与序列化保存拿到这套骨架后最常被要求加的两个功能是「手动连线」和「保存/加载流程图」。这两个正好能检验你对前面数据模型和状态机的理解。先说手动连线。思路是在拖拽状态机里加一个「连线模式」按住某个键比如 Shift在节点上按下时不进入拖拽而是进入连线状态记录起点节点鼠标移动时画一条临时线跟着鼠标在另一个节点上松开时创建FlowLink并加入集合。private FlowNode _linkStartNode; // 连线起点 private Point _linkCurrentPoint; // 连线终点跟随鼠标 private void OnMouseDown(object sender, MouseEventArgs e) { var hit HitTest(e.Location); if (hit ! null ModifierKeys Keys.Shift) { // Shift 左键进入连线模式 _linkStartNode hit; _linkCurrentPoint e.Location; return; } // ... 原有拖拽逻辑 } private void OnMouseMove(object sender, MouseEventArgs e) { if (_linkStartNode ! null) { _linkCurrentPoint e.Location; this.Invalidate(); // 重绘临时连线 return; } // ... 原有拖拽逻辑 } private void OnMouseUp(object sender, MouseEventArgs e) { if (_linkStartNode ! null) { var target HitTest(e.Location); if (target ! null target ! _linkStartNode) { _links.Add(new FlowLink { From _linkStartNode, To target, FromAnchor new Point(_linkStartNode.Bounds.Width / 2, _linkStartNode.Bounds.Height), ToAnchor new Point(target.Bounds.Width / 2, 0) }); } _linkStartNode null; this.Invalidate(); return; } // ... 原有逻辑 }临时连线在OnPaint里判断_linkStartNode ! null时画一条虚线即可。锚点这里我用了「起点底部中点、终点顶部中点」这是流程图最常见的连线方式你也可以按节点相对位置动态选锚点让连线更自然。再说序列化。因为FlowNode和FlowLink之间有引用关系直接 JSON 序列化会循环引用。常见做法是序列化时把连线拆成FromId/ToId加载时再按 Id 重建引用public class LinkDto { public string FromId { get; set; } public string ToId { get; set; } } // 保存节点直接序列化连线转成 Id 对 var dto new { Nodes _nodes, Links _links.Select(l new LinkDto { FromId l.From.Id, ToId l.To.Id }).ToList() }; File.WriteAllText(flow.json, JsonConvert.SerializeObject(dto));加载时先建节点字典Dictionarystring, FlowNode再遍历LinkDto把From/To接回去。这套「存 Id、加载重建」的模式凡是带引用关系的图形数据都适用值得记下来。验证改造是否成功我一般会走一遍固定流程拖三个节点 → 连两条线 → 保存 → 关掉重开 → 加载看节点位置、连线关系、选中态是否都对。有一次我偷懒没测加载结果锚点偏移存的是绝对值重开后连线全飘了返工半小时。从那以后我每次改完数据模型都强制走一遍「保存-重开-加载」这条链路不管改动多小。希望这套拆解能帮到你拿到源码先跑通再按自己的业务往里加东西。本文还有配套的精品资源点击获取