简介:面向 .NET 桌面开发者的 WinForm 流程图绘制示例,基于 C# 与 GDI+ 实现,重点解决流程图形状在画布上的动态拖动与即时刷新问题,适合需要自建工作流设计器、流程编辑工具或想深入理解 GDI+ 绘图机制的初中级开发者参考。压缩包共 39 个文件,约 176KB,以 cs 源码、resources 与 resx 资源、config 配置、exe 可执行程序及 png 效果图为主,另含 sln 解决方案与 csproj 项目文件,可直接用 Visual Studio 打开运行。资源已有 3214 人学习下载,具备一定参考基础。代码围绕 Graphics、Pen、SolidBrush 等绘图类展开,涵盖自定义形状类与 Draw 绘制方法、MouseDown/MouseMove/MouseUp 拖动事件处理、Invalidate 重绘刷新机制,以及形状与连接线的数据结构管理,并配有较详细注释,便于理解与二次扩展。读者可据此掌握 GDI+ 图形绘制与 WinForm 交互的完整实现思路,并在此基础上扩展更复杂的流程图工具。
1. 从一张能拖能连的流程图说起:这个 WinForm 项目到底能干什么
很多人第一次在 WinForm 里画流程图,都是从Panel上摆几个Button开始的,结果一拖动就闪烁、一连线就错位、一刷新整块画布重绘,最后只能推倒重来。这个基于 .NET WinForm 和 GDI+ 的流程图绘制项目,解决的正是这类问题:节点可以自由拖动,拖动过程中连线即时跟随刷新,画布不闪、不糊、不卡顿。它适合两类人:一类是需要在桌面端工控、审批、拓扑配置界面里嵌入流程编辑能力的开发者;另一类是刚接触 GDI+ 双缓冲、想找一个能跑起来、能改得动的完整示例来啃的人。整个项目不依赖第三方绘图库,纯 C# 手写绘制逻辑,拿到源码后改节点样式、改连线规则、加导出功能都有明确的落点。
2. 技术选型与整体架构:为什么是 WinForm 加 GDI+ 而不是 WPF
2.1 选型逻辑:桌面端轻量绘图的最短路径
这个项目没有用 WPF,也没有引入任何图形框架,核心原因在于部署成本和上手门槛。WinForm 在 .NET Framework 环境下几乎是零配置启动,目标机器上只要有对应运行时就能跑,不需要额外的渲染层依赖。GDI+ 作为 WinForm 自带的绘图接口,Graphics对象直接挂在控件的Paint事件上,画线、画矩形、画贝塞尔曲线都是一行调用的事。
代价也很明确:GDI+ 是即时模式绘制,没有场景图、没有命中测试框架、没有自动重绘管理。节点拖动时的刷新、连线的坐标换算、鼠标命中的判定,全部要自己写。这个项目的价值就在于把这些“自己写”的部分拆得足够清楚,让后来者不用从零推导。
从架构上看,项目大致分成四层,职责边界比较干净:
| 层次 | 职责 | 典型类型 |
|---|---|---|
| 数据模型层 | 描述节点、连线、画布的属性 | 节点类、连线类、画布状态 |
| 绘制层 | 把模型转成 GDI+ 绘制指令 | 重写OnPaint的绘制方法 |
| 交互层 | 鼠标按下、移动、抬起的命中与状态切换 | 鼠标事件处理逻辑 |
| 刷新层 | 双缓冲与局部重绘控制 | DoubleBuffered、Invalidate调用 |
这种分层不是学院派的架构设计,而是一线写 GDI+ 时被闪烁和错位逼出来的组织方式。数据模型和绘制分离之后,改节点外观不用动交互逻辑,改拖动规则不用碰绘制代码,二次开发时心里有底。
2.2 目录结构:一个能直接对照源码的骨架
项目正文没有给出完整目录树,但按这类 WinForm 绘图项目的常见组织方式,源码大致是下面这个结构。拿到压缩包后可以按这个骨架去对号入座,找不到对应文件时优先在Forms和Controls两个目录里翻:
FlowChartDemo/ ├── Forms/ │ ├── MainForm.cs 主窗体,承载画布与工具栏 │ ├── MainForm.Designer.cs 设计器生成代码 │ └── MainForm.resx 窗体资源 ├── Controls/ │ ├── FlowCanvas.cs 画布控件,重写 OnPaint 与鼠标事件 │ └── NodeShape.cs 节点绘制与命中判定 ├── Models/ │ ├── FlowNode.cs 节点数据模型 │ ├── FlowLink.cs 连线数据模型 │ └── CanvasState.cs 画布状态(选中项、拖拽偏移等) ├── Utils/ │ └── GraphicsHelper.cs 绘图辅助方法(圆角矩形、箭头等) └── Program.cs 程序入口这个结构的关键点在于FlowCanvas和NodeShape的分离。画布负责整体重绘和事件分发,节点自己负责“我长什么样、我的边界在哪、这个点算不算点在我身上”。后面讲命中测试时会看到,这个分离直接决定了拖动逻辑能不能写干净。
2.3 双缓冲:解决闪烁的第一道防线
WinForm 里 GDI+ 绘图最常见的翻车就是拖动时整片白闪。原因是每次Invalidate触发Paint,系统先用背景色刷一遍,再画内容,两次绘制之间被用户看到了。项目的做法是在画布控件构造函数里打开双缓冲:
public FlowCanvas() { // 关键:开启双缓冲,避免拖动时整片闪烁 this.DoubleBuffered = true; // 减少重绘时的背景擦除,配合双缓冲使用 this.SetStyle(ControlStyles.AllPaintingInWmPaint | ControlStyles.UserPaint | ControlStyles.OptimizedDoubleBuffer, true); this.UpdateStyles(); }DoubleBuffered = true是最省事的一行,它让控件先在内存位图上画完再整体贴到屏幕。SetStyle那三行是加强版:AllPaintingInWmPaint把擦背景的动作合并进Paint,UserPaint声明自己负责绘制,OptimizedDoubleBuffer进一步优化缓冲策略。只写第一行在简单场景够用,节点一多、连线一密,还是得把SetStyle补上。这是我在多个绘图控件里反复验证过的组合,少一行都可能在高频拖动时露出破绽。
3. 核心绘制链路:一个节点从数据到屏幕的完整过程
3.1 节点模型:先定数据,再谈绘制
节点类是整个项目的地基。它至少要携带位置、尺寸、标题、选中状态这几项,位置用左上角坐标,尺寸用宽高,选中状态影响描边颜色。常见写法如下:
public class FlowNode { public string Id { get; set; } // 节点唯一标识,连线靠它找端点 public string Title { get; set; } // 节点显示文字 public Rectangle Bounds { get; set; } // 位置与尺寸,绘制和命中都用它 public bool IsSelected { get; set; } // 是否选中,决定描边样式 // 判断一个点是否落在节点内,拖动和选中都依赖它 public bool HitTest(Point p) { return Bounds.Contains(p); } }Bounds用Rectangle而不是分开存四个 int,是因为 GDI+ 的DrawRectangle、FillRectangle直接吃这个类型,命中测试也有现成的Contains。Id的存在是为了连线不直接持有节点对象引用,避免拖动时对象引用和坐标不同步的玄学问题。连线只存两个Id,绘制时再去节点集合里查端点坐标,这样节点移动后连线自然跟着走,不需要额外的同步逻辑。
3.2 绘制入口:OnPaint 里该做什么、不该做什么
画布的重绘入口是重写的OnPaint。这里的原则是:只做绘制,不做数据修改,不做耗时计算。节点坐标换算、连线端点查找这些准备工作应该在数据层完成,OnPaint里拿到的应该是可以直接画的坐标。
protected override void OnPaint(PaintEventArgs e) { base.OnPaint(e); Graphics g = e.Graphics; // 抗锯齿,圆角和斜线不出现锯齿边 g.SmoothingMode = SmoothingMode.AntiAlias; // 先画连线,再画节点,保证节点盖住连线端点 foreach (var link in _links) { DrawLink(g, link); } foreach (var node in _nodes) { DrawNode(g, node); } } private void DrawNode(Graphics g, FlowNode node) { // 选中态用高亮描边,普通态用常规色 using (var pen = new Pen(node.IsSelected ? Color.DodgerBlue : Color.Gray, 2)) using (var brush = new SolidBrush(Color.WhiteSmoke)) { g.FillRectangle(brush, node.Bounds); g.DrawRectangle(pen, node.Bounds); // 文字居中绘制,留出内边距 TextRenderer.DrawText(g, node.Title, this.Font, node.Bounds, Color.Black, TextFormatFlags.HorizontalCenter | TextFormatFlags.VerticalCenter); } }绘制顺序是先连线后节点,这个顺序不能反。连线如果后画,端点会压在节点边框上,视觉上像线穿过了节点。SmoothingMode.AntiAlias对圆角矩形和斜线效果明显,但会略微增加绘制开销,节点数量在几百以内可以放心开。Pen和Brush都包在using里,GDI+ 对象不释放是 WinForm 绘图里最隐蔽的内存泄漏来源,长时间拖动后界面变卡,十有八九是这里漏了。
3.3 连线绘制:端点坐标怎么算才不飘
连线的核心是端点坐标。项目里连线存的是起点节点Id和终点节点Id,绘制时实时查节点中心点:
private void DrawLink(Graphics g, FlowLink link) { var from = _nodes.FirstOrDefault(n => n.Id == link.FromId); var to = _nodes.FirstOrDefault(n => n.Id == link.ToId); if (from == null || to == null) return; // 端点缺失直接跳过,防止空引用 // 取两个节点的中心点作为连线端点 Point p1 = CenterOf(from.Bounds); Point p2 = CenterOf(to.Bounds); using (var pen = new Pen(Color.SlateGray, 1.5f)) { g.DrawLine(pen, p1, p2); DrawArrow(g, p1, p2); // 在终点画箭头 } } private Point CenterOf(Rectangle r) { return new Point(r.Left + r.Width / 2, r.Top + r.Height / 2); }用中心点而不是边框交点,是为了避免节点重叠时连线端点乱跳。代价是连线会从节点内部穿出,视觉上被节点盖住一截,但因为先画线后画节点,这一截正好被节点遮住,看起来反而干净。DrawArrow是独立的箭头绘制方法,根据p1到p2的角度算箭头两个翼的坐标,这里不展开,思路是三角函数加固定翼长。端点查不到时直接return,这是防御性写法,删除节点后残留的连线不会让程序崩。
4. 拖动即时刷新的实现:鼠标状态机与局部重绘
4.1 鼠标事件的三段式状态机
拖动逻辑的本质是一个状态机:按下时记录起点和命中的节点,移动时更新节点坐标并请求重绘,抬起时结束拖拽。三段式写法如下:
private FlowNode _draggingNode; // 当前被拖动的节点 private Point _dragOffset; // 鼠标相对节点左上角的偏移 protected override void OnMouseDown(MouseEventArgs e) { base.OnMouseDown(e); // 从后往前找,后画的节点在上层,优先命中 for (int i = _nodes.Count - 1; i >= 0; i--) { if (_nodes[i].HitTest(e.Location)) { _draggingNode = _nodes[i]; _dragOffset = new Point(e.X - _nodes[i].Bounds.Left, e.Y - _nodes[i].Bounds.Top); _nodes[i].IsSelected = true; this.Invalidate(); // 选中态变化,请求重绘 return; } } _draggingNode = null; // 点空白处,取消选中 } protected override void OnMouseMove(MouseEventArgs e) { base.OnMouseMove(e); if (_draggingNode == null) return; // 用偏移量算新位置,避免节点左上角跳到鼠标位置 int newLeft = e.X - _dragOffset.X; int newTop = e.Y - _dragOffset.Y; _draggingNode.Bounds = new Rectangle(newLeft, newTop, _draggingNode.Bounds.Width, _draggingNode.Bounds.Height); this.Invalidate(); // 坐标变了,请求重绘 } protected override void OnMouseUp(MouseEventArgs e) { base.OnMouseUp(e); _draggingNode = null; // 结束拖拽 }_dragOffset是拖动手感的关键。如果直接把节点左上角设成鼠标坐标,节点会“跳”到鼠标位置,拖起来像在甩东西。记录偏移后,鼠标始终保持在节点内的相对位置,手感才对。命中测试从后往前遍历,是因为后添加的节点绘制在上层,用户点到的应该是视觉上最上面那个。Invalidate()不带参数是整画布重绘,节点数量少时够用,节点多了要换成带Rectangle参数的局部重绘,只刷旧位置和新位置的并集。
4.2 局部重绘:节点多了以后必须做的优化
整画布Invalidate在几十个节点时无感,上百个节点高频拖动就会掉帧。优化思路是只重绘受影响区域:
// 拖动前记录旧区域,拖动后与新区域合并,只刷这一块 Rectangle oldRect = _draggingNode.Bounds; // ... 更新坐标 ... Rectangle newRect = _draggingNode.Bounds; Rectangle invalid = Rectangle.Union(oldRect, newRect); // 外扩几个像素,覆盖描边和连线端点 invalid.Inflate(4, 4); this.Invalidate(invalid);Rectangle.Union取新旧位置的并集,Inflate外扩是为了把描边宽度和连线端点也算进去,否则拖动后边缘会残留一条旧线。这个优化不是必须的,但节点数量上去之后是分水岭。我一般会在节点超过五十个时切到局部重绘,再配合双缓冲,拖动基本能保持跟手。
4.3 刷新时机的取舍:Invalidate 与 Refresh 的区别
Invalidate是标记区域无效,等消息循环空闲时合并重绘;Refresh是立即强制重绘。拖动过程中用Invalidate,因为鼠标移动事件本身很密集,合并重绘能减少实际绘制次数。只有在需要立刻看到结果的场景,比如点击按钮切换视图,才用Refresh。这个区别在拖动场景里很实际:用Refresh每个鼠标移动事件都同步画一遍,鼠标一动就卡;用Invalidate系统帮你合并,流畅度明显不同。这是 GDI+ 绘图里容易被忽略但影响手感的一个点。
5. 避坑与排查:拖动刷新场景下的五条血泪记录
5.1 现象:拖动时整片白闪,节点像在眨眼
原因:控件没开双缓冲,每次重绘先擦背景再画内容,两次绘制之间被看到。解决:构造函数里设DoubleBuffered = true,再补SetStyle的AllPaintingInWmPaint | UserPaint | OptimizedDoubleBuffer。只设DoubleBuffered在节点密集时仍可能闪,两个一起上才稳。
5.2 现象:连线端点拖完对不上,线飘在节点外面
原因:连线缓存了端点坐标,节点移动后没同步。解决:连线只存节点Id,绘制时实时查中心点,不要存Point。如果为了性能必须缓存,就在节点坐标变更处统一触发连线坐标重算,但实时查更不容易出错。
5.3 现象:拖久了界面越来越卡,内存缓慢上涨
原因:Pen、Brush、Font这些 GDI+ 对象在OnPaint里 new 出来没释放。解决:全部包using,或者提到字段里复用。OnPaint每帧都执行,漏一个对象就是每帧泄漏一个句柄,几分钟就能看出卡顿。这是 WinForm 绘图最经典的坑,没有之一。
5.4 现象:点节点没反应,或者点到了下面的节点
原因:命中测试遍历顺序和绘制顺序不一致。解决:绘制按集合顺序从前往后,命中测试从后往前,保证点到的是视觉最上层。另外HitTest用Bounds.Contains时注意坐标是控件坐标还是屏幕坐标,混用会导致偏移。
5.5 现象:拖动时节点边缘残留旧线,像拖影
原因:局部重绘的区域没覆盖旧位置,或者没算描边宽度。解决:新旧区域取Union后Inflate几个像素,把描边和连线端点包进去。整画布重绘不会有这个问题,一旦切到局部重绘就必须处理。
6. 二次开发落点:从能跑到能改的几个具体切口
拿到这个项目后,最值得先动的不是界面美化,而是把节点和连线的数据模型抽出来做成可序列化。现在节点和连线都在内存里,关掉程序就没了,加一个Save和Load就能让它从演示变成能用的工具。具体做法是给FlowNode和FlowLink加[Serializable],用BinaryFormatter或JsonSerializer序列化整个_nodes和_links集合,存成文件。注意Rectangle在 JSON 里需要自定义转换,用System.Text.Json的话写个JsonConverter<Rectangle>更省事。
第二个切口是连线规则。现在连线是任意两个节点都能连,实际业务里往往有方向限制或类型限制。在DrawLink之前加一层校验,或者在创建连线的入口处判断FromId和ToId的节点类型,不合法就不让连。这个改动集中在交互层,不碰绘制代码,风险低。
第三个切口是节点样式。DrawNode里现在是白底灰边,改成圆角矩形只需要把DrawRectangle换成GraphicsPath加AddArc,再DrawPath。圆角半径做成节点属性,不同节点可以有不同外观。这一步改完,整个项目的观感会明显不一样,而且改动范围只在NodeShape里。
验证方法很直接:改完序列化后,画三个节点连两条线,保存、关闭、重开、加载,看坐标和连线是否一致;改完连线规则后,故意连一个非法方向,看是否被拦住;改完圆角后,拖动节点看圆角边缘有没有残影,有残影就说明局部重绘的Inflate不够。
我自己的习惯是,每次动绘制相关代码之前,先把节点数量调到两百个以上拖一遍,记录帧感和内存占用,改完再拖一遍对比。这个基线测试花不了几分钟,但能挡住大部分“改完看着没问题、一上量就翻车”的情况。从那以后我每次改 GDI+ 绘图逻辑都强制走一遍这个基线,希望帮到你。
本文还有配套的精品资源,点击获取