简介:面向需要在 .NET 中构建流程设计工具的开发者,这份 C# WinForm 流程图绘制代码基于 GDI+ 实现,支持图形元素拖动与即时刷新,可作为工作流编辑器、流程建模或教学演示的起点。包体仅 176KB,共 39 个文件,包括 9 个 cs 源码、sln/csproj 工程配置、可执行 exe、效果图与使用说明 png、txt 等,源码结构清晰,适合按需查阅。已有 3212 人学习下载。代码封装了 FlowShape 等形状类,通过 Graphics、Pen、SolidBrush 完成绘制,利用 MouseDown、MouseMove、MouseUp 处理拖动交互,并调用 Invalidate 触发实时重绘;关键注释解释了 GDI+ 绘图机制与 WinForm 事件模型。在此基础上有充足的扩展空间,可继续添加连接线、决策框、撤销重做或缩放功能,为自定义流程图工具提供扎实的参考实现。
1. 为什么还要用 WinForms + GDI+ 手搓工作流流程图设计器
在 WPF 和 HTML5 图表库已经非常成熟的今天,用 WinForms + GDI+ 手搓的工作流流程图项目仍然在被持续下载和使用。这类项目的定位很明确:不依赖第三方控件、不引入前端技术栈、部署就是一个 exe,适合做内部流程编排工具、上位机配置向导、业务引擎的可视化调试面板。你要做的就是把一张画布、若干节点、连线和鼠标交互全部用 GDI+ 绘制出来,实现路径其实高度一致:先定数据模型,再做双缓冲画布,然后处理命中测试和拖动刷新,最后补连线路由。本文把这四步的关键代码、参数取舍和常见坑拆开讲,目标是让你在一两个小时内跑通一个最小可用的流程图设计器,并且知道哪些地方动了会翻车。
2. GDI+ 画布与数据模型:先把三类对象定义好,再谈绘制
2.1 工作流最小数据模型:节点、连线与画布上下文
写流程图设计器最忌讳一上来就画。你在一个可拖动的画布上既要维护节点位置、尺寸、类型和文案,又要维护节点之间的连接关系,还要处理缩放和平移。如果这些信息散落在控件的属性里,后期每加一个功能就痛苦一次。先定义好数据模型,再把绘制逻辑写成只读模型、把鼠标操作写成修改模型的逻辑,整体结构会清晰很多。
一个足够支撑工作流编辑的最小模型分三部分:节点、连线和画布。节点用 FlowNode,包含 Id、Bounds、类型和文本;连线用 FlowConnection,记录起点和终点节点 Id,外加一个路由点列表;画布用 FlowCanvas 统一持有节点、连线和缩放参数。代码可以是这样:
public enum FlowNodeType { Start, Task, Decision, End } public class FlowNode { public string Id { get; set; } public RectangleF Bounds { get; set; } public FlowNodeType Type { get; set; } public string Text { get; set; } public List<PointF> AnchorOffsets { get; set; } // 连接点相对节点中心的偏移 } public class FlowConnection { public string Id { get; set; } public string FromNodeId { get; set; } public string ToNodeId { get; set; } public List<PointF> RoutePoints { get; set; } public string Label { get; set; } } public class FlowCanvas { public List<FlowNode> Nodes { get; set; } public List<FlowConnection> Connections { get; set; } public float Zoom { get; set; } public PointF Offset { get; set; } }这里有几个细节值得注意。Bounds 用 RectangleF 而不是 Rectangle,是为了配合缩放后可能出现的小数坐标;如果用 int 矩形,缩小到 80% 再放大回 100% 时节点会有肉眼可见的漂移。AnchorOffsets 存的是相对坐标而不是绝对坐标,这样节点移动时连线的锚点可以自动跟随,不需要逐个更新连线数据。RoutePoints 在简单场景下可以是空列表,只有用户手动拖出折线时才需要填充;最朴素的实现里,连线的路径完全可以由两端节点位置实时推导,不一定非要存路由点。
有了模型,再想清楚一件事:屏幕上的坐标到底是什么坐标系下的坐标。我一般把模型里的坐标称为“世界坐标”,把绘制时经过缩放和平移后的坐标称为“屏幕坐标”。所有鼠标事件拿到的 Point 是屏幕坐标,第一步永远是换成世界坐标再和模型交互;所有绘制代码拿到的世界坐标也要先换成屏幕坐标再交给 GDI+。这一步统一了,后面缩放、拖动、命中测试才不会乱。统一变换的最简单做法是把两个转换函数放在画布控件里:
public PointF ScreenToWorld(PointF p) { return new PointF((p.X - Offset.X) / Zoom, (p.Y - Offset.Y) / Zoom); } public PointF WorldToScreen(PointF p) { return new PointF(p.X * Zoom + Offset.X, p.Y * Zoom + Offset.Y); }转换函数虽然简单,但要注意它必须同时被绘制层和交互层使用,不能一处手动算一处调函数。常见的翻车现场是:绘制时用了缩放,鼠标命中测试时忘了除以 Zoom,结果放大后点不到节点、缩小后误选中远处的节点——这个问题在第 5 章还会展开。
2.2 双缓冲画布:重写 OnPaint 与三行关键配置
在 WinForms 里给流程图一个干净的绘制平面,常见做法是继承 UserControl 作为画布控件,把全部绘制逻辑放进 OnPaint。这样做的理由有两个:一是 UserControl 天然支持焦点和鼠标事件,方便做键盘删除、框选等交互;二是可以彻底掌控重绘时机,配合 Invalidate 做到按需刷新而不是无脑刷新。
一个最小画布控件的骨架是这样:
public class FlowDesignerControl : UserControl { private FlowCanvas _canvas; public FlowDesignerControl() { DoubleBuffered = true; ResizeRedraw = true; BackColor = Color.White; } protected override void OnPaint(PaintEventArgs e) { var g = e.Graphics; g.SmoothingMode = SmoothingMode.AntiAlias; g.Clear(BackColor); // 先画连线,再画节点,让节点盖住连线端部 foreach (var conn in _canvas.Connections) { DrawConnection(g, conn); } foreach (var node in _canvas.Nodes) { DrawNode(g, node); } } }三个配置项里,DoubleBuffered 是抗闪烁的核心,它让 GDI+ 先在内存位图里把完整一帧画好,再一次拷贝到屏幕。ResizeRedraw 让窗口尺寸变化时自动重绘,不设置的话拖大窗口会出现白色空白块。SmoothingMode 设为 AntiAlias 让圆形、曲线和斜线边缘平滑,代价是绘制耗时大约增加三分之一,节点超过 200 个且需要每帧全量重绘时需要留意。绘制顺序也有讲究:先画连线后画节点,这样连线的端头会被节点矩形盖住,视觉上像是从节点边缘伸出来的;反过来画会让连线穿过节点,极其难看。
还有一个很多人忽略的点:TextRenderingHint。如果直接在 SmoothingMode.AntiAlias 的 Graphics 上画文字,Windows 默认字体渲染可能发虚。一般做法是给 DrawString 调用前单独设置 g.TextRenderingHint = TextRenderingHint.ClearTypeGridFit,画完文字再恢复。这事虽然不会让功能出错,但会让整个画布的文字观感差一截。你要是想给画布做界面美化,第一笔投入放在字体渲染上,比换背景色、加阴影的回报高得多。
2.3 节点绘制:一个方法画所有类型,用枚举驱动外观差异
工作流的节点类型通常只有四五种:开始、任务、判断、结束。绘制时不需要为每种类型写一个分支串,更合理的做法是把通用绘制抽成一个方法,用枚举驱动外观差异。
private void DrawNode(Graphics g, FlowNode node) { var rect = new RectangleF( node.Bounds.X * _canvas.Zoom + _canvas.Offset.X, node.Bounds.Y * _canvas.Zoom + _canvas.Offset.Y, node.Bounds.Width * _canvas.Zoom, node.Bounds.Height * _canvas.Zoom); using (var fillBrush = new SolidBrush(NodeColors.GetFill(node.Type))) using (var borderPen = new Pen(NodeColors.GetBorder(node.Type), 1.5f)) { switch (node.Type) { case FlowNodeType.Start: case FlowNodeType.End: g.FillEllipse(fillBrush, rect); g.DrawEllipse(borderPen, rect); break; case FlowNodeType.Decision: // 菱形:用 GraphicsPath 画四个顶点 using (var path = new GraphicsPath()) { path.AddPolygon(new[] { new PointF(rect.Left + rect.Width / 2, rect.Top), new PointF(rect.Right, rect.Top + rect.Height / 2), new PointF(rect.Left + rect.Width / 2, rect.Bottom), new PointF(rect.Left, rect.Top + rect.Height / 2) }); g.FillPath(fillBrush, path); g.DrawPath(borderPen, path); } break; default: g.FillRectangle(fillBrush, rect); g.DrawRectangle(borderPen, rect.X, rect.Y, rect.Width, rect.Height); break; } } // 绘制文本 using (var font = new Font("Microsoft YaHei", 9f)) using (var textBrush = new SolidBrush(Color.Black)) { var tf = new StringFormat { Alignment = StringAlignment.Center, LineAlignment = StringAlignment.Center }; g.TextRenderingHint = TextRenderingHint.ClearTypeGridFit; g.DrawString(node.Text, font, textBrush, rect, tf); } }注意 FillEllipse 和 DrawEllipse 各接收一次矩形,这个矩形是屏幕坐标的矩形,所以每次节点位置变化后重绘即可。FillPath 和 DrawPath 与 FillRectangle 不同,它们可以绘制不规则形状,菱形、圆角矩形、六边形都靠它。每个节点只用一个 GraphicsPath 的话,性能开销可以忽略,但如果你的节点带有阴影或者复杂的渐变背景,路径对象的创建和销毁频率会明显上升,下一步要考虑的是缓存节点外观而不是每次重画。
把绘制逻辑和节点类型绑定之后,加一个新节点类型只需在枚举加一项、在颜色表中加一项、在 DrawNode 里加一个分支,不会牵动其他代码。我给这类项目做扩展时的习惯是:节点模板单独用一个字典维护“类型 -> 绘制委托”,而不是堆 if-else;不过委托字典对新手不友好,小项目里 switch 就够用了。
3. 节点拖动与即时刷新:命中测试、MouseMove 刷新与连接线跟随
3.1 命中测试:按“节点优先、连线其次、空白兜底”的顺序来
拖动和选中的前提是知道鼠标点到了什么。GDI+ 没有现成的控件树可以查询,所有命中判断都要自己做。最笨也最可靠的方案是按优先级遍历:先遍历所有节点判断点是否落在节点矩形内,再遍历连线判断点到线段距离是否小于阈值,最后才认为是点击空白。
public FlowNode HitTestNode(PointF worldPos) { // 倒序遍历:让画在后面的节点(视觉上更上层)优先命中 for (int i = _canvas.Nodes.Count - 1; i >= 0; i--) { var node = _canvas.Nodes[i]; if (node.Bounds.Contains(worldPos)) return node; } return null; }注意这个函数接收的是世界坐标,所以调用前必须先 ScreenToWorld。另一个容易被忽略的细节:鼠标可能点在节点矩形上,但该位置恰好是另一个节点透明区域的角落。如果节点是菱形或圆形,矩形判定的误命中率会高一些,比如点到圆角之外但仍在矩形内的角落区域。要处理也简单,把 Bounds.Contains 改成对具体图形的路径判断——对圆形算距离,对菱形做多边形包含判断。只有当节点密集排布、用户经常误选时才需要做这层精修,一般矩形判定就够用。
连线命中判断的思路类似,不过拿的是线段到点的距离:
public FlowConnection HitTestConnection(PointF worldPos, float threshold) { foreach (var conn in _canvas.Connections) { var start = GetNodeAnchor(conn.FromNodeId, conn.ToNodeId); var end = GetNodeAnchor(conn.ToNodeId, conn.FromNodeId); // 内部实现用距离平方比较,避免每次开根号 if (DistanceToSegmentSquared(worldPos, start, end) < threshold * threshold) return conn; } return null; }threshold 一般取 5~8 个屏幕像素,换算成世界坐标要除以 Zoom。距离算法是高中几何的投影公式,但注意别在循环里反复调用 Math.Sqrt,直接比较距离平方与阈值平方即可。
3.2 拖动节点:MouseDown 记偏移、MouseMove 改坐标、MouseUp 收尾
流程图编辑里最核心的交互就是拖动节点即时刷新。实现思路不复杂:MouseDown 时记录按下的位置和节点原坐标的偏移,MouseMove 时修改节点坐标并调用 Invalidate,MouseUp 时清掉拖动状态。
private PointF _dragOffset; private FlowNode _draggingNode; protected override void OnMouseDown(MouseEventArgs e) { var worldPos = ScreenToWorld(e.Location); _draggingNode = HitTestNode(worldPos); if (_draggingNode != null) { _dragOffset = new PointF( worldPos.X - _draggingNode.Bounds.X, worldPos.Y - _draggingNode.Bounds.Y); } base.OnMouseDown(e); } protected override void OnMouseMove(MouseEventArgs e) { if (_draggingNode == null) return; var worldPos = ScreenToWorld(e.Location); _draggingNode.Bounds = new RectangleF( worldPos.X - _dragOffset.X, worldPos.Y - _dragOffset.Y, _draggingNode.Bounds.Width, _draggingNode.Bounds.Height); // 请求重绘,真正的绘制发生在下一帧 Invalidate(); base.OnMouseMove(e); } protected override void OnMouseUp(MouseEventArgs e) { _draggingNode = null; base.OnMouseUp(e); }这段代码的关键点是 _dragOffset 的用法。如果不记偏移,直接让节点中心跳到鼠标位置,拖起来会“闪一下”,因为鼠标按下时点的大概率不是节点中心。记录偏移后,拖动期间节点与鼠标的相对位置保持不变,手感就对了。
Invalidate() 没有参数时表示整个画布区域都需要重绘,代码简单,节点数量少时完全够用。节点数量上百后,每次拖动都全量重绘会明显掉帧,优化方法是只 Invalidate 节点的新旧两个矩形区域的并集。关于这个优化,第 4 章会给出具体做法。
还有一个容易忽略的交互细节:MouseMove 中修改节点坐标后,别在这一帧里立即做连线路由重算。原因是路由重算通常涉及避障或最短路径算法,比较耗时,而拖动鼠标时 MouseMove 一秒触发几十次,每次都重算会让拖动变得卡顿。常见做法是鼠标移动时只改节点位置并简单更新连线端点,鼠标松开后再做一次完整路由重建。
3.3 连线“即时跟随”:锚点偏移与两端动态计算
工作流图里的连线不是孤立存在的,它的端头总得粘在节点上,节点移动时连线要跟着走。有两种常见实现:
第一种是固定锚点。节点定义里带 AnchorOffsets,连线记录自己用的是哪个锚点索引。拖动节点时,连线的起点坐标 = 节点的世界坐标 + 对应偏移量,不涉及任何计算,完全即时。这种方式的优点是节点上可以精确控制连接点的数量和位置,适合状态机、端口类型固定的场景。
第二种是动态锚点。连线不记录锚点细节,每次重绘时根据两个节点的相对方位,从节点矩形边缘选一个点作为连接点。这里给出一个最常用的动态锚点计算:
public PointF GetNodeAnchor(FlowNode node, FlowNode neighbor) { var center = new PointF( node.Bounds.X + node.Bounds.Width / 2, node.Bounds.Y + node.Bounds.Height / 2); var nCenter = new PointF( neighbor.Bounds.X + neighbor.Bounds.Width / 2, neighbor.Bounds.Y + neighbor.Bounds.Height / 2); var dx = nCenter.X - center.X; var dy = nCenter.Y - center.Y; // 横向距离更大,锚点落在左/右边缘中点;纵向更大则落在上/下边缘中点 if (Math.Abs(dx) > Math.Abs(dy)) { return new PointF( dx > 0 ? node.Bounds.Right : node.Bounds.Left, center.Y); } else { return new PointF( center.X, dy > 0 ? node.Bounds.Bottom : node.Bounds.Top); } }逻辑说明:比较两个节点中心在 x 和 y 方向的距离差,哪个方向更远就把锚点放在对应边的中点。比如邻居节点在右侧偏下,但横向距离大于纵向距离,锚点就取右边缘中点。这样的连线看起来是从矩形边缘“伸”出去的,比从中心画一条裸线好看得多。
动态锚点的优点是实现简单,不需要维护复杂的端口数据;缺点是连线交叉时没有绕行感,且节点是菱形或圆形时锚点落在矩形边上的位置会略偏离视觉边缘。要解决这一点,把 node.Bounds 换成图形的实际包围盒,锚点计算逻辑不变,视觉上会好很多。
提示:无论是固定锚点还是动态锚点,节点移动时连线的跟随都不需要显式修改连线数据,只要重绘时根据当前节点位置重新算一遍端点即可。如果你发现拖动时连线没有跟着走,先确认是不是重绘区域没覆盖到连线,再确认锚点函数是不是在 OnPaint 里被真实调用了。
4. 工作流连线绘制:锚点计算、贝塞尔曲线与按需重绘的取舍
4.1 从节点边缘到连线的路径:直线、折线与路由点
工作流连线的路径有三种层次:直线、折线、带避障的折线。免费项目里多数是直线或简单的正交折线。直线最简单,直接用 3.3 的 GetNodeAnchor 得到两端点画一条线。折线需要“路由点”,也就是在画布上人为增加的中间拐点。维护方式是在连线上双击或拖拽时插入路由点,鼠标拖动路由点修改坐标。
这里给一个带正交拐角的常用做法——连线两端先垂直/水平出线,中间再用路由点控制:
private void DrawConnection(Graphics g, FlowConnection conn) { var fromNode = FindNode(conn.FromNodeId); var toNode = FindNode(conn.ToNodeId); if (fromNode == null || toNode == null) return; var start = WorldToScreen(GetNodeAnchor(fromNode, toNode)); var end = WorldToScreen(GetNodeAnchor(toNode, fromNode)); using (var pen = new Pen(Color.SteelBlue, 1.5f)) { if (conn.RoutePoints.Count == 0) { g.DrawLine(pen, start, end); DrawArrow(g, pen, start, end); return; } // 世界坐标的路由点要逐个转成屏幕坐标 var points = new List<PointF> { start }; points.AddRange(conn.RoutePoints.Select(p => WorldToScreen(new PointF(p.X, p.Y)))); points.Add(end); g.DrawLines(pen, points.ToArray()); DrawArrow(g, pen, points[points.Count - 2], end); } }DrawLines 一次调用画出多段折线,比逐段 DrawLine 省去多次状态切换。箭头单独画在最后一段的末尾,避免整条线都画箭头。注意这里有个坐标陷阱:RoutePoints 存的是世界坐标,所以要用 WorldToScreen 转成屏幕坐标再交给 GDI+,否则缩放一变化,路由点就跟节点脱节。
4.2 用贝塞尔曲线让连线好看一点:控制点选在锚点延伸方向
折线虽然功能上没问题,但视觉上棱角分明,尤其在节点密集的情况下,折线拐角容易和别的节点重叠。稍微提升观感的做法是把连线画成三次贝塞尔曲线,控制点沿锚点方向向外取固定距离。
private void DrawConnectionSmooth(Graphics g, FlowConnection conn) { var fromNode = FindNode(conn.FromNodeId); var toNode = FindNode(conn.ToNodeId); if (fromNode == null || toNode == null) return; var start = WorldToScreen(GetNodeAnchor(fromNode, toNode)); var end = WorldToScreen(GetNodeAnchor(toNode, fromNode)); // 控制点沿锚点方向外延 40 像素 float bend = 40f; var c1 = new PointF(start.X + bend, start.Y); var c2 = new PointF(end.X - bend, end.Y); using (var pen = new Pen(Color.SteelBlue, 1.5f)) { g.DrawBezier(pen, start, c1, c2, end); } DrawArrow(g, start, end); }控制点距离 bend 一般取 30~50 像素,过小曲线接近直线,过大曲线会弯出夸张的弧度。bend 应该和缩放联动:缩放为 0.8 时仍取 40 屏幕像素,视觉弯度会随缩放变化,但代码简单;要求严格的话,把 bend 乘以 Zoom 取世界坐标值。DrawBezier 的四个点依次是起点、控制点1、控制点2、终点,控制点选在锚点延伸方向外,曲线看起来就像是从节点里“流”出来的。
箭头方向也要跟着曲线走:取曲线末端的方向向量。用 GraphicsPath 可以方便地得到曲线在某点的切线方向,但更简单的做法是用 end 与 c2 的差值算方向角。
private void DrawArrow(Graphics g, PointF start, PointF end) { float angle = (float)(Math.Atan2(end.Y - start.Y, end.X - start.X) * 180 / Math.PI); float arrowSize = 8f; using (var path = new GraphicsPath()) { path.AddPolygon(new[] { end, new PointF( end.X - arrowSize * (float)Math.Cos((angle - 15) * Math.PI / 180), end.Y - arrowSize * (float)Math.Sin((angle - 15) * Math.PI / 180)), new PointF( end.X - arrowSize * (float)Math.Cos((angle + 15) * Math.PI / 180), end.Y - arrowSize * (float)Math.Sin((angle + 15) * Math.PI / 180)) }); g.FillPath(Brushes.SteelBlue, path); } }这段把箭头做成一个三角形,顶点在终点,两翼向后方展开 15 度。箭头大小 8 像素在 100% 缩放时合适,缩小时会偏大,可以乘上 Zoom 修正。
4.3 拖动时的按需重绘:从整画布刷新到局部失效区域
前面一直在提 Invalidate() 是整控件重绘,节点上百后性能扛不住。改成局部失效很简单,Invalidate(Rectangle) 只重绘指定区域。拖动节点时,节点移动前后各占一个矩形区域,这两块的并集就是需要重绘的最小区域。
private RectangleF _lastNodeBounds; private RectangleF ToScreenRect(RectangleF worldRect) { return new RectangleF( worldRect.X * _canvas.Zoom + _canvas.Offset.X, worldRect.Y * _canvas.Zoom + _canvas.Offset.Y, worldRect.Width * _canvas.Zoom, worldRect.Height * _canvas.Zoom); } protected override void OnMouseMove(MouseEventArgs e) { if (_draggingNode == null) return; var worldPos = ScreenToWorld(e.Location); var newBounds = new RectangleF( worldPos.X - _dragOffset.X, worldPos.Y - _dragOffset.Y, _draggingNode.Bounds.Width, _draggingNode.Bounds.Height); // 计算屏幕坐标下的新旧区域并集 var oldScreen = ToScreenRect(_lastNodeBounds); var newScreen = ToScreenRect(newBounds); var union = Rectangle.Union( Rectangle.Round(oldScreen), Rectangle.Round(newScreen)); // 向外扩 2 像素,抵消抗锯齿造成的边缘残留 union.Inflate(2, 2); _draggingNode.Bounds = newBounds; _lastNodeBounds = newBounds; Invalidate(union); base.OnMouseMove(e); }Invalidate(union) 只重绘并集区域,能明显降低绘制压力。注意两个细节:一是必须把旧矩形也纳入并集,否则拖动后原来位置会残留节点残影;二是抗锯齿会让图形边缘多出半透明像素,重绘区域向外扩 2 个像素,否则快速拖动时边缘会有细线状的残影残留。
这个优化对连线多的情况同样有效,因为连线端点随节点移动,变化区域就在节点附近。路由重算如果只在 MouseUp 时做一次,拖动时连线的走向可能暂时不好看,但换来的是流畅度。对工作流编辑这种交互强度,流畅度优先,重算延后是可以接受的。
5. GDI+ 绘图避坑:五个能把免费项目劝退的高频问题
5.1 拖动就闪屏:DoubleBuffered 没生效,或重绘区域与清除区域不一致
现象:鼠标拖动节点,画布整个区域闪烁,节点越多闪烁越明显;鼠标停住后画面恢复正常,一拖动又开始闪。
原因:最常见的是窗口没有真正开启双缓冲。UserControl 的 DoubleBuffered 默认值是 false,如果项目里用的是普通 Panel 而不是 UserControl,且构造器里没有设置 DoubleBuffered,GDI+ 每次重绘都先把背景整个擦成白色,再画新内容。擦背景和画内容不同步,就会在屏幕中间出现一帧空白——这就是闪烁感。另一个隐蔽原因是有些代码在鼠标事件里调用 Refresh() 而不是 Invalidate()。Refresh 是同步重绘,会立即触发 OnPaint,在拖动这种高频事件里会把数次重绘挤在同一帧,导致表现更差。
解决:把所有绘图控件统一成 UserControl 的派生类,并在构造器中设置 DoubleBuffered = true。也可以不用继承,在控件外部把属性置为 true。绘制逻辑里统一用 Invalidate() 请求重绘,尽量不要用 Refresh() 或 Update()。如果使用了自定义背景渐变,确认 OnPaint 中 Clear 用的颜色与控件 BackColor 一致,否则重绘时背景颜色跳变,视觉上类似闪屏。
5.2 运行十几分钟后 GDI 对象数逼近上限:Pen 和 Brush 没有释放
现象:程序刚启动一切正常,连续操作十几分钟后,拖动节点明显变卡,图形开始出现缺笔、断线,最后整块画不出来。任务管理器里 GDI 对象数一路涨到 8000~10000。
原因:OnPaint 每次执行时都 new 了 Pen、Brush、Font、GraphicsPath,用完没有调用 Dispose。GDI 句柄不像托管对象那样由 GC 及时回收,它在进程里累积。高频重绘加多节点场景下,一小时涨几千句柄很正常,逼近 Windows 默认上限后,GDI+ 绘图 API 开始静默失败。
解决:所有短生命周期 GDI 对象都放进 using 或手动 Dispose。第 2、3、4 章的示例已经这么写。检查现有代码时,搜索 OnPaint、DrawNode、DrawConnection 里的 new Pen、new SolidBrush、new Font,逐个加上释放。性能敏感并且不变的 Pen 可以定义成 static readonly,进程内只创一次,不存在泄漏。
| GDI+ 对象 | 生命周期建议 | 常见误用 |
|---|---|---|
| Pen / SolidBrush / Font | 方法内 using;高频且不变的用 static readonly | 每次重绘 new 不释放 |
| GraphicsPath | 用完立即 Dispose | 在字段中累积不释放 |
| Bitmap / Image | 缓存复用或 using | 异步场景加载后不释放 |
5.3 放大到 150% 后点不中节点:命中测试没走同一套坐标变换
现象:画布支持缩放后,放大 150% 时点击节点经常选不中,或者空白处点击选中了远处的节点;缩小到 50% 时,点到节点附近也会误触。
原因:鼠标事件拿到的 e.Location 是屏幕坐标,节点 Bounds 是模型里的世界坐标。绘制层做了 WorldToScreen,交互层却直接比较屏幕坐标与世界坐标,两套坐标系没对齐。放大时误差被放大,缩小时误差也等比出现。
解决:在 OnMouseDown、OnMouseMove、OnMouseUp 第一行执行 var worldPos = ScreenToWorld(e.Location)。后续所有命中判断、坐标运算都基于 worldPos。谨记第 2 章的两个转换函数必须成对出现,绘制层和交互层共用同一实现。如果你在代码里看到if (node.Bounds.Contains(e.Location)),这行几乎一定需要改成if (node.Bounds.Contains(worldPos))。
5.4 拖动后旧位置残留半透明影子:局部失效没覆盖旧区域,或 Clear 颜色不一致
现象:节点从 A 点拖到 B 点,A 点位置残留一个淡淡的影子,过几秒消失;快速连续拖动时残留尤其明显。
原因:优化过的代码往往会调用 Invalidate(newRect) 只刷新新位置。旧位置没有被纳入重绘区,于是旧图形一直留在屏幕上。抗锯齿边缘是半透明像素,向外扩 2 像素的习惯很多人没有保留,边缘就留下了细细的“胡须”。
解决:重绘区域取旧矩形和新矩形的并集,再 Inflate(2,2),最后调用 Invalidate(union)。如果不确定问题是不是 Clear 引起的,做一个快速验证:在 MouseUp 后调用一次全量 Invalidate(),看残影是否立刻消失。若能消失,说明就是局部失效区域算得不对;若仍然在,再去检查 Clear 与 BackColor 是否一致。
5.5 高分屏下连线和鼠标对不上:DPI 缩放没有同步
现象:笔记本接 4K 外接屏,或者把 exe 从 100% 缩放的机器拷到 150% 缩放的机器上运行,画布整体模糊,鼠标点击位置和节点对不上,连线端点也有偏移。
原因:WinForms 默认按系统 DPI 缩放窗口,但 GDI+ 绘制的坐标和鼠标事件坐标在不同 DPI 感知模式下换算不一致。常见的是项目没声明 PerMonitorV2,窗口从一台机器被系统缩放,内部坐标却按原 DPI 计算。
解决:在入口处启用 PerMonitorV2。.NET 6 及以上可以直接调 Application.SetHighDpiMode(HighDpiMode.PerMonitorV2);.NET Framework 4.7 以上需要在 app.manifest 里声明 dpiAware 并在 app.config 里设置。如果仍然错位,监听画布控件的 DpiChanged 事件,重新按比例调整 Zoom 并 Invalidate。这个坑在下载型项目里很普遍,因为作者通常只在自己的 100% 缩放环境里验证过。
6. 从“能拖”到“好用”:撤销栈、框选与 Shift 连线三个小改动
图上能拖、能连线、能缩放之后,一个流程图编辑器只是“能用”。要真正放进工具型软件里,还有三个成本不高、收益明显的小功能值得加,它们的实现都不依赖第三方库。
第一个是撤销栈。不需要完整的命令模式,维护两个快照列表就能覆盖大部分场景:每次 MouseUp 时把当前画布的节点位置快照 Push 进撤销栈,撤销时弹出上一帧并恢复。快照是 List<FlowNode> 的深拷贝,节点上百时一次拷贝也就几百 KB,内存压力不大。注意要在拖动开始之前压栈,而不是拖动过程每次 MouseMove 都压栈,否则撤销一步会回退好几个像素。
第二个是框选。MouseDown 在空白处按下,MouseMove 画一个选择矩形,MouseUp 把所有与矩形相交的节点设为选中。实现核心还是 Invalidate 局部重绘和坐标变换,选择矩形用 ControlPaint.DrawReversibleFrame 可以省掉手动画虚线矩形的麻烦,但我更推荐直接在 OnPaint 里画自己的选择框,因为可逆框和 GDI+ 画布混用容易出现残影。
第三个也是我觉得最实用的:按住 Shift 画连线。在 MouseDown 命中到节点时如果按住 Shift,则进入连线创建模式,拖动到另一个节点上松开,自动生成一条 FlowConnection。这个交互在 Visio 和各类在线流程图中都有,用户不需要先学习“连线工具”这个概念。
我自己的习惯是拿到这类下载项目后,先把第 5 章的五个问题排查一遍,尤其是 GDI 句柄泄漏和坐标变换这两处——它们不影响演示,但直接影响可靠性和生产可用。修完这两处,再把撤销和 Shift 连线加上,一个免费下载的示例项目就能变成自己工具链里稳定的一块。希望这些经验帮到你。
本文还有配套的精品资源,点击获取