简介:面向从事桌面软件开发、需要制作流程图或工作流设计工具的编程人员,这是一套在.NET环境下使用C#语言与GDI+绘图接口编写的WinForm示例工程。示例完整演示了从创建图形元素、鼠标拖动调整位置到界面即时刷新的全部过程,同时加入自定义形状类、线条连接、重绘刷新与列表管理等内容,让读者能直观掌握在Windows窗体中自绘图形和处理用户交互的常用方法。代码组织清晰,关键部分有注释,适合作为学习GDI+绘图或搭建轻量级流程编辑器的起点。压缩包内含39个文件,以9个C#源代码文件为主体,并配套解决方案与项目文件、界面资源文件、配置文件、可执行程序以及示意图和说明文档,整体压缩后大小约176KB,结构简洁,便于按需查阅。已有3211人学习使用。借助源码注释与效果图,可以快速理解形状绘制、拖动刷新和数据存储的实现思路;在此基础上,还可继续扩展决策框、多分支连线、属性编辑等功能,满足工作流引擎、拓扑图编辑器等更多应用场景。
1. 用 WinForm + GDI+ 画流程图:一个能拖动即时刷新的 C# 绘制方案
做 .NET 上位机或者 WinForm 工具类项目的人,多半都遇到过这种需求:要在界面上展示一个设备流程、算法流程图或者业务流转关系,第三方控件要么收费、要么拖进来一堆用不上的依赖,自己用 GDI+ 从头画又担心性能和交互做不好。这份资源给的是一个纯 C# WinForm 实现的流程图绘制工程,核心卖点就是两件事:节点和连线都是自己画的,拖节点的时候画面即时刷新、不闪烁,而且整套源码 0 分就能下下来直接编译运行。
适合谁用?正在做 WinForm 项目案例、需要给上位机界面加一个流程展示模块,或者想在自定义控件里补上图形交互能力的开发者和学生。它不依赖第三方绘图库,所有绘制逻辑都在 GDI+ 里完成,看完源码能搞清楚从数据模型到鼠标命中检测的完整链路,对理解 WinForm 自定义控件的绘制体系也很有帮助。
2. 绘制引擎搭建:为什么必须开双缓冲、类结构怎么分
2.1 自定义控件的绘制入口:OnPaint 与绘制样式设置
GDI+ 绘制流程图的第一步不是画图,而是选对绘制入口。资源里的主画布是一个继承自Control或UserControl的自定义类,所有绘制代码集中在OnPaint方法里。WinForm 的消息循环会在控件需要重绘时自动调用这个入口,我们只往里写绘制逻辑、不主动去调Paint事件,这个结构最关键的好处是刷新路径统一。
先看建项目时最容易被忽略的控件样式设置,这段通常放在画布类的构造函数里:
public class FlowCanvas : Control { public FlowCanvas() { SetStyle(ControlStyles.AllPaintingInWmPaint | ControlStyles.OptimizedDoubleBuffer | ControlStyles.UserPaint | ControlStyles.ResizeRedraw, true); UpdateStyles(); BackColor = Color.FromArgb(250, 250, 250); } }AllPaintingInWmPaint告诉系统所有绘制消息都走OnPaint,不要先擦背景再画图,避免背景擦除造成的白闪;OptimizedDoubleBuffer启动 WinForm 内置的双缓冲;UserPaint表示控件自己处理绘制而不是用系统默认;ResizeRedraw让控件尺寸变化时自动触发重绘,否则拉伸窗口后画布内容跟不上。这四个组合起来,是 GDI+ 交互式画布不闪烁的基础。
提示:如果你是拿 Panel 当画布,记得把
DoubleBuffered属性设为 true,或者用上面的 SetStyle 组合。
2.2 数据模型拆分:Node 和 Connection 各管什么
流程图控件最容易翻车的设计,是绘制逻辑和业务数据混在一起。画一个矩形时直接把坐标存进一个 List,画一条线时再存一条,结果拖动节点时想要找到所有连到它的线,只能全列表遍历。资源里的做法是把数据模型分成Node和Connection两个类,各管各的职责。
下面是 Node 类的核心字段,节点自身只关心位置、尺寸、文字和形状类型:
public class Node { public int Id { get; set; } public string Text { get; set; } public RectangleF Bounds { get; set; } // 节点在画布上的矩形区域 public NodeShape Shape { get; set; } // 矩形 / 圆角矩形 / 椭圆 public Color FillColor { get; set; } public Color BorderColor { get; set; } public float BorderWidth { get; set; } public PointF Center { get { return new PointF( Bounds.X + Bounds.Width / 2f, Bounds.Y + Bounds.Height / 2f ); } } }Bounds用的不是Rectangle而是RectangleF,因为 GDI+ 绘制方法(DrawRectangle、FillEllipse)接收的边界参数是浮点坐标,用整数Rectangle会在缩放或精细排版时产生像素偏移。Center是计算属性,直接由 Bounds 推导,连线端点要锚到节点中心时就用它。
Connection 类存的不是两端坐标,而是两个节点的引用或 Id:
public class Connection { public int Id { get; set; } public int FromNodeId { get; set; } public int ToNodeId { get; set; } public string Label { get; set; } // 连线上的文字说明 public Color LineColor { get; set; } public float LineWidth { get; set; } public bool IsBezier { get; set; } // 直线连接线 or 贝塞尔曲线 }这样设计的价值在拖动场景里立刻体现出来:用户拖节点时,只需要根据FromNodeId和ToNodeId找到受影响的那几条 Connection,重算端点坐标即可,不用维护一份冗余的坐标副本。新手常犯的错误是让 Connection 保存FromPoint和ToPoint字段,节点移动了连线坐标却不同步,最后只能在全重绘里临时凑数据。
2.3 缓存位图的取舍:到底要不要提前渲染
GDI+ 绘制流程图的第二道选择题是:每次 OnPaint 都现场画所有节点和连线,还是先渲染到一张离屏位图再一次性贴到画布上。对于节点数在几十到几百、没有缩放功能的基础需求,直接现场画就行,WinForm 的双缓冲已经能消除闪烁;但如果画面里元素特别多(上千个节点),每次重绘都要走完所有绘制指令,哪怕是局部刷新也会卡顿。
资源里的做法是双缓冲交给控件样式处理,没有额外做缓存位图。这个取舍在性能和代码复杂度之间取了平衡——双缓冲已经把绘制结果先画到内存画布上再贴到屏幕,本质上就是一次位图合成,再自己维护一张位图等于做了两次缓冲,收益有限反而增加内存。真实项目中我一般会把「是否需要离线缓存」的开关设计成可配置项,节点数超过 500 时才切成后台位图重绘,日常使用走直接绘制即可。
protected override void OnPaint(PaintEventArgs e) { Graphics g = e.Graphics; g.SmoothingMode = System.Drawing.Drawing2D.SmoothingMode.AntiAlias; g.TextRenderingHint = System.Drawing.Text.TextRenderingHint.ClearTypeGridFit; foreach (var conn in _connections) { DrawConnection(g, conn); } foreach (var node in _nodes) { DrawNode(g, node); } }绘制顺序有讲究:先画连线、再画节点。如果反过来,连线会在节点上层穿过,文字会被线盖住。SmoothingMode.AntiAlias打开图形抗锯齿,TextRenderingHint.ClearTypeGridFit让文字在 LCD 屏幕上更清晰——这两个属性是 GDI+ 画面观感差别的来源,很多人画的流程图边缘锯齿严重,就是没开 SmoothingMode。
3. 核心绘制实现:节点、连线和命中检测一起做
3.1 节点绘制:矩形、椭圆加上圆角矩形
节点绘制的核心是DrawNode方法。根据Node.Shape枚举值决定用哪个 GDI+ 绘制路径,形状不同但流程一致:先填充内部颜色,再画边框。圆角矩形需要借助GraphicsPath,GDI+ 没有现成的 FillRoundedRectangle,需要手动构造路径。
public void DrawNode(Graphics g, Node node) { using (SolidBrush fillBrush = new SolidBrush(node.FillColor)) { switch (node.Shape) { case NodeShape.Rectangle: g.FillRectangle(fillBrush, node.Bounds); break; case NodeShape.Ellipse: g.FillEllipse(fillBrush, node.Bounds); break; case NodeShape.RoundedRect: using (GraphicsPath path = CreateRoundedRectPath(node.Bounds, 8)) { g.FillPath(fillBrush, path); } break; } } using (Pen borderPen = new Pen(node.BorderColor, node.BorderWidth)) { borderPen.Alignment = PenAlignment.Inset; switch (node.Shape) { case NodeShape.Rectangle: g.DrawRectangle(borderPen, node.Bounds.X, node.Bounds.Y, node.Bounds.Width, node.Bounds.Height); break; case NodeShape.Ellipse: g.DrawEllipse(borderPen, node.Bounds); break; case NodeShape.RoundedRect: using (GraphicsPath path = CreateRoundedRectPath(node.Bounds, 8)) { g.DrawPath(borderPen, path); } break; } } DrawNodeText(g, node); }代码里两个细节值得注意。PenAlignment.Inset让边框向矩形内侧延伸,如果保持默认的 Center,边框会以边界线为中心向内外各画一半,导致实际占用的区域比 Bounds 大一圈,命中检测时容易在边界处误判。另一个是每个 Pen 和 Brush 都用 using 包裹,GDI+ 对象是托管代码包装的本机资源,不 Dispose 的话 GC 无法立刻回收原生句柄,拖动几百次之后内存会明显上涨。
圆角路径构造方法单独拎出来,它接收矩形和圆角半径,用四条弧线加四条直线拼出一个封闭路径:
private GraphicsPath CreateRoundedRectPath(RectangleF rect, float radius) { GraphicsPath path = new GraphicsPath(); float d = radius * 2f; path.AddArc(rect.X, rect.Y, d, d, 180, 90); path.AddArc(rect.Right - d, rect.Y, d, d, 270, 90); path.AddArc(rect.Right - d, rect.Bottom - d, d, d, 0, 90); path.AddArc(rect.X, rect.Bottom - d, d, d, 90, 90); path.CloseFigure(); return path; }AddArc的五个参数依次是弧线外接矩形的位置、尺寸、起始角度和扫过的角度。从 180 度起画顺时针 90 度正好是左上角的圆角,四个圆角闭合后就是完整的圆角矩形。注意路径没有提前用Dispose包裹,因为它作为返回值交给调用方的using释放,这里只负责创建路径对象。
节点文字居中绘制用的是TextRenderer而不是Graphics.DrawString,原因后面避坑章节会展开,先看实现:
private void DrawNodeText(Graphics g, Node node) { TextRenderer.DrawText( g, node.Text, _nodeFont, Rectangle.Round(node.Bounds), node.TextColor, TextFormatFlags.HorizontalCenter | TextFormatFlags.VerticalCenter | TextFormatFlags.EndEllipsis ); }TextRenderer.DrawText的参数是 Graphics、字符串、字体、目标矩形、颜色和对齐方式。EndEllipsis表示文字过长时用省略号截断而不撑破节点边界,对固定宽度节点来说很实用。一次传递多个TextFormatFlags通过按位或组合,这是 WinForm 里常见的标志枚举用法。
3.2 连线绘制:直线、贝塞尔与锚点方向计算
连线绘制比节点复杂的地方在端点坐标不是直接存的,而是从两个节点的 Center 实时算出来的。画一条线之前先拿到两个节点:
public void DrawConnection(Graphics g, Connection conn, Dictionary<int, Node> nodeMap) { Node fromNode = nodeMap[conn.FromNodeId]; Node toNode = nodeMap[conn.ToNodeId]; PointF start = GetConnectionAnchorPoint(fromNode, toNode); PointF end = GetConnectionAnchorPoint(toNode, fromNode); using (Pen linePen = new Pen(conn.LineColor, conn.LineWidth)) { if (conn.IsBezier) { PointF c1 = new PointF( start.X + (end.X - start.X) * 0.5f, start.Y ); PointF c2 = new PointF( start.X + (end.X - start.X) * 0.5f, end.Y ); g.DrawBezier(linePen, start, c1, c2, end); } else { g.DrawLine(linePen, start, end); } } }贝塞尔曲线的两个控制点c1和c2取水平方向的中点,纵向分别对齐起点和终点,这样画出来是一条平滑的 S 形曲线,适合表达有方向性的流转关系。控制点位置的选取直接决定曲线的弯曲形态:两个控制点水平距离越近,曲线越陡;越远,曲线越平缓。拖节点时连线的端点移动了,控制点也随之移动,整条线会像橡皮筋一样平滑变形,这是「拖动即时刷新」在视觉上最有体感的部分。
GetConnectionAnchorPoint算的不是节点中心,而是中心连线和节点边界的交点,这样连线从节点边缘引出而不是从中心穿过,配合箭头更接近成熟流程图工具的视觉:
private PointF GetConnectionAnchorPoint(Node fromNode, Node toNode) { PointF fromCenter = fromNode.Center; PointF toCenter = toNode.Center; float dx = toCenter.X - fromCenter.X; float dy = toCenter.Y - fromCenter.Y; float length = Math.Max(1e-6f, Math.Sqrt(dx * dx + dy * dy)); float halfWidth = fromNode.Bounds.Width / 2f; float halfHeight = fromNode.Bounds.Height / 2f; float t = Math.Min( halfWidth / Math.Abs(dx) * length, halfHeight / Math.Abs(dy) * length ) / length; t = Math.Min(1f, t); return new PointF( fromCenter.X + dx * t, fromCenter.Y + dy * t ); }这段计算的原理是:从中心出发,沿方向向量走多远会碰到节点边缘。t取横向和纵向两个方向的临界值的较小者,保证返回的点正好落在矩形边界的靠内一侧。当两个节点水平对齐导致 dy 接近 0 时,Math.Abs(dy)会变成 0 造成除零异常,所以先用Math.Max垫高长度值避免浮点除零。这个边界条件不处理的话,程序运行到水平对齐的节点时会直接抛异常。
3.3 命中检测:点到线段距离的几何判断
用户拖节点之前,程序必须先回答一个问题:鼠标落在哪个节点上?GDI+ 没有自带命中测试,矩形节点可以直接用Bounds.Contains(mousePoint)判断,但连线命中和椭圆节点命中需要自己写几何算法。连线命中通常用点到线段距离小于阈值(比如 5~8 像素)来判断:
public Connection HitTestConnection(PointF point, float threshold) { foreach (var conn in _connections) { Node fromNode = _nodeMap[conn.FromNodeId]; Node toNode = _nodeMap[conn.ToNodeId]; PointF p1 = fromNode.Center; PointF p2 = toNode.Center; double dist = DistancePointToSegment(point, p1, p2); if (dist <= threshold) { return conn; } } return null; }DistancePointToSegment是点到线段距离的标准算法,它处理的核心问题是如何计算点在直线投影上的位置。直接用点到直线距离公式算,点在线的延长线上时也会被判为命中,这是新手最容易踩的坑。
private double DistancePointToSegment(PointF p, PointF a, PointF b) { float dx = b.X - a.X; float dy = b.Y - a.Y; float lenSq = dx * dx + dy * dy; if (lenSq == 0f) { return Math.Sqrt((p.X - a.X) * (p.X - a.X) + (p.Y - a.Y) * (p.Y - a.Y)); } float t = ((p.X - a.X) * dx + (p.Y - a.Y) * dy) / lenSq; t = Math.Max(0f, Math.Min(1f, t)); float projX = a.X + t * dx; float projY = a.Y + t * dy; return Math.Sqrt((p.X - projX) * (p.X - projX) + (p.Y - projY) * (p.Y - projY)); }算法分三步:先把线段方向向量算出来,然后用点积求投影比例 t,再把 t 夹在 0 到 1 之间保证投影点不会跑到线段外面,最后算点到投影点的距离。当鼠标点在连线两端的延长线上时,由于 t 被限制在区间内,投影点就是线段端点,距离不会误判为 0。阈值 threshold 默认 6 像素,比这更小的值(比如 2 像素)会让用户感觉连线很难点中,大点的值(比如 10 像素)在节点密集时又容易误触相邻连线。这个参数做成常量放类顶部,后续按实际界面密度调即可。
4. 拖动即时刷新:鼠标状态机与局部刷新策略
4.1 鼠标三步状态机:MouseDown、MouseMove、MouseUp
拖动即时刷新的第一层逻辑是鼠标状态机。资源把交互过程拆成三个阶段:MouseDown做命中检测并记录起点;MouseMove计算位移并更新节点位置;MouseUp清理拖拽状态。它们两个一组的核心是:移动过程中改变节点 Bounds 里的坐标,然后触发重绘。看实际的 MouseDown 实现:
protected override void OnMouseDown(MouseEventArgs e) { base.OnMouseDown(e); Node hitNode = HitTestNode(e.Location); if (hitNode != null) { _draggingNode = hitNode; _dragStart = e.Location; _nodeStartBounds = hitNode.Bounds; Cursor = Cursors.SizeAll; } else { Connection hitConn = HitTestConnection(e.Location, 6f); if (hitConn != null) { _selectedConnection = hitConn; Invalidate(); } } }HitTestNode遍历所有节点,从上层节点开始判断Bounds.Contains(e.Location)。注意遍历顺序很关键:如果两个节点有重叠区域,后画的节点在视觉上应该优先被选中,所以列表尾部(后加入)的节点先判。命中检测不通过就去命中连线,都没有就是空白区域点击,空白点击通常附带一个清空选中状态的操作。
MouseMove 里的逻辑看起来简单,但有个必须处理的细节——记录节点原始位置再计算偏移,而不是直接把当前鼠标位置赋给节点:
protected override void OnMouseMove(MouseEventArgs e) { base.OnMouseMove(e); if (_draggingNode == null) { return; } float offsetX = e.X - _dragStart.X; float offsetY = e.Y - _dragStart.Y; RectangleF newBounds = _nodeStartBounds; newBounds.X = _nodeStartBounds.X + offsetX; newBounds.Y = _nodeStartBounds.Y + offsetY; _draggingNode.Bounds = newBounds; Invalidate(); }用原始起点算偏移的意义在于:鼠标移动过程中即使出现抖动或者中间丢帧,节点位置也始终以按下瞬间的原始坐标为基准,不会出现累积漂移。如果每次都拿当前节点位置加鼠标增量,一次移动里因为界面上其他元素抢焦点导致的微小错位就会被不断放大。
MouseUp 收尾时清掉拖拽引用并恢复默认光标,这个不多说,但注意一定要把_draggingNode置空。漏了这一步,鼠标松开后控件会认为还在拖拽,下次移动到界面其他区域时节点也跟着跑。
4.2 连线端点跟随重算:这是即时刷新的真正关键
如果拖动节点时只是移动矩形而不重算相连线段的端点,画面会出现什么?节点走了,线的一端还留在原点。要做到「即时刷新」,关键不是刷新动作本身,而是刷新前必须重新计算所有关联连线的端点坐标。
因为 Connection 不存坐标、只存节点 Id,所以重算动作很轻量——遍历连向该节点的所有连线,重新取两端节点锚点即可。这个操作放在 MouseMove 里紧跟节点位置更新之后:
private void RecalculateConnectedConnections(Node movedNode) { foreach (var conn in _connections) { if (conn.FromNodeId == movedNode.Id || conn.ToNodeId == movedNode.Id) { conn.Dirty = true; } } }这里为了减少遍历成本,我给 Connection 加了个Dirty标记,OnPaint 里只重绘标记为脏的连线。当然如果节点数量不大,直接在 MouseMove 里调用Invalidate()走全量重绘也不是不行,Dirty机制更多是展示一种设计思路:把「视觉刷新」和「数据重算」解耦。
实际绘制时提供两种模式:全量重绘和增量重绘。增量重绘在 OnPaint 里先检查Dirty集合,只重新画受影响的节点和连线。对于节点数超过 200 的流程图,增量重绘能把每帧绘制耗时从几十毫秒降到个位数,拖动手感从「勉强能用」变成「跟手」。
protected override void OnPaint(PaintEventArgs e) { Graphics g = e.Graphics; g.SmoothingMode = SmoothingMode.AntiAlias; List<Connection> dirtyConns = _connections .Where(c => c.Dirty) .ToList(); if (dirtyConns.Count > 0) { foreach (var conn in dirtyConns) { DrawConnection(g, conn, _nodeMap); } } else { foreach (var conn in _connections) { DrawConnection(g, conn, _nodeMap); } } }这是典型的「全量兜底、增量提速」策略。每次 MouseMove 把受影响的连线标记为脏,重绘完成后统一清除脏标记。需要注意脏标记必须在所有节点位置更新完、准备调用 Invalidate 之前由外部显式计算,不能在 OnPaint 内部去遍历检查,否则会重复计算。
4.3 刷新策略:全量 Invalidate 与局部 Invalidate 的边界
拖动节点的过程中,真正触发重绘的信号是Invalidate()。很多人以为 Invalidate 会立刻重绘,实际上它只是把窗口区域标记为「需要更新」,真正的绘制要等当前消息循环处理完、控件收到 WM_PAINT 消息后才执行。这意味着 OnMouseMove 里连续调用 Invalidate,最终只会触发一次 OnPaint——这正好符合我们的需求:鼠标在屏幕上跑过十几个事件,画面只要在最后刷新一次即可,丢掉中间帧才是流畅的原因。
全量 Invalidate 是接所有重绘请求的最简单方式,区域是整个画布:
Invalidate(); // 整个画布重绘当节点数少于 200 且单个节点尺寸不大时,全量重绘的耗时通常在 10 毫秒以内,完全够用。但节点多、连线密的场景需要改成局部刷新,只让受影响的矩形区域重绘:
RectangleF oldBounds = _nodeStartBounds; RectangleF newBounds = _draggingNode.Bounds; RectangleF union = RectangleF.Union(oldBounds, newBounds); union.Inflate(20f, 20f); Invalidate(Rectangle.Round(union)); Invalidate(Rectangle.Round(GetConnectionDirtyBounds(_draggingNode)));Invalidate(Rectangle)只重绘该矩形内的内容,系统会把矩形和当前脏区域合并后一次性发送 WM_PAINT。这里把节点旧位置、新位置、以及所有相连连线的包围盒一起取并集,再整体向外扩 20 像素给连线的加宽描边留余量。局部刷新的前提是节点的 Bounds 在 Invalidate 之前已经更新成新位置,否则这次重绘使用的还是旧坐标。
提示:局部刷新在窗口被其他窗口遮挡、从最小化还原的场景下不可靠,系统会丢区域信息,此时应回退到全量 Invalidate。所以实践中通常只在拖动过程中使用局部刷新,拖拽结束后强制一次全量绘制清残留。
5. 避坑记录:GDI+ 对象泄漏、闪烁与坐标变换翻车现场
5.1 拖动一次内存涨几 MB:Pen 和 Brush 没释放
现象:用资源里的代码跑起来,拖动节点几十次后任务管理器内存持续上涨,GC 强制回收也不回落。用 windbg 或者 dotMemory 看,Microsoft.GDI+类的对象堆积大量Pen、SolidBrush实例。
原因:GDI+ 的Pen、Brush、Font、GraphicsPath虽然是托管对象,内部包装的是原生 GDI+ 句柄。Dispose 不及时的话,托管对象进入垃圾回收栈后句柄才被释放,而句柄数量有限,高频操作(比如 MouseMove 里每帧都新建几个 Pen)会让原生资源先耗尽,表现为内存只增不减。
解决:所有临时创建的绘制对象用using包裹,或者显式Dispose。放到OnPaint外定义的 Pen、Brush 作为成员变量,控件 Dispose 时统一释放。这是 GDI+ 绘制程序的铁律——「谁创建,谁释放」,没有例外。
using(Pen pen = new Pen(Color.Red, 2f)) { g.DrawLine(pen, start, end); }5.2 拖动卡顿、画面重影:双缓冲没开全
现象:拖动节点时画面闪烁,拖动过程中隐约看到上一帧的残影。代码里已经设置了DoubleBuffered = true,但只有局部不闪,节点快速拖动时整个画布仍然发白。
原因:只开了控件层面的双缓冲,但OnPaint里直接操作PaintEventArgs.Graphics时,这个 Graphics 已经绑定了屏幕 DC。开启OptimizedDoubleBuffer后 WinForm 会创建缓冲画布,但如果你在 OnPaint 之外又调用了Refresh()或者Update()(强制同步重绘),绕过了消息循环的合并机制,双缓冲就失去了效果。
解决:拖动过程中只用Invalidate(),不要用Refresh()。Refresh()是 Invalidate 加 Update 的组合,它立即强制重绘,每一帧都强制同步绘制,双缓冲形同虚设。Close 父窗体时如果调用了Refresh()再 Dispose,还会触发二次绘制导致 GDI+ 句柄泄漏。
5.3 缩放之后鼠标点不中节点:命中检测还在用屏幕坐标
现象:给画布加了缩放功能(比如用ScaleTransform(2f, 2f)放大两倍)后,拖动节点时鼠标光标总是在目标旁边偏移,点击连线也很难命中,但视觉上节点位置正常。
原因:OnPaint里调用了g.ScaleTransform(),Graphics 内的所有绘制坐标自动乘以缩放系数。但MouseDown接收到的e.Location是屏幕坐标(也就是控件物理像素坐标),没有经过缩放变换。鼠标点屏幕上的 (100, 100),放大两倍后对应的逻辑坐标是 (50, 50),用未变换的坐标去Bounds.Contains()判断,自然找不到目标节点。
解决:给命中检测统一加坐标反变换。推荐把坐标变换封装成一个方法,画布操作前后都走它:
private PointF ScreenToCanvas(PointF screenPoint) { return new PointF( (screenPoint.X - _translateX) / _scaleFactor, (screenPoint.Y - _translateY) / _scaleFactor ); }或者用 GDI+ 的 Matrix 对象在 Paint 和命中检测里共用同一个变换实例:
private Matrix _canvasTransform = new Matrix(); private PointF ScreenToCanvas(PointF point) { PointF[] pts = new PointF[] { point }; _canvasTransform.Invert(); _canvasTransform.TransformPoints(pts); _canvasTransform.Invert(); return pts[0]; }关键是命中检测、节点移动计算、连接线端点计算都必须基于同一套坐标系。最常见的问题恰恰是绘制用变换、命中不用,结果不同坐标系的数值混在一起算偏移,拖到后期节点位置和鼠标位置产生肉眼可见的偏差。
5.4 节点文字发虚发糊:DrawString 与 TextRenderer 的字体差异
现象:节点文字在 96 DPI 下看起来正常,换到 120 DPI 的高分屏上字明显发虚,边缘有毛刺,且文字位置和节点矩形中心偏移几个像素。
原因:Graphics.DrawString使用 GDI+ 的字体渲染管线,在非 96 DPI 下不做字体像素对齐,且StringFormat的默认对齐以图形的坐标系为准;而TextRenderer.DrawText走的是 GDI 渲染,和 WinForm 原生控件文字渲染一致,所有控件文字不会发虚,但坐标换算存在细微差异。混用两者会让文字和同一位置的图形产生几像素的偏移。
解决:流程图的节点文字统一用TextRenderer.DrawText,图形绘制用 GDI+,两者混用没问题,但不要DrawString和DrawText混用。如果要用DrawString,Graphics.TextRenderingHint必须设置为TextRenderingHint.AntiAlias而不是默认的SystemDefault,否则高分屏下一定会发虚。字体尺寸用Font直接构造时,注意单位默认是 GDI+ 的点(Point)而不是像素,用new Font("微软雅黑", 9f, GraphicsUnit.Pixel)才可以避免 DPI 转换造成的大小误差。
5.5 窗口拉伸后画布留黑边:Resize 时没重建缓存
现象:画布控件随窗口拉伸放大后,右侧和下侧出现大片灰白色或黑色区域,之前的内容还在左上角。如果项目里做了显式的缓存位图,拉伸后位图尺寸跟不上控件新尺寸。
原因:设置了ResizeRedraw则 Resize 时自动重绘,但 OnPaint 里如果偏移了绘制原点(比如通过 TranslateTransform 实现了滚动),新扩展区域没有被绘制覆盖,残留黑色画布底色。资源里的基础实现没有这个问题,但如果按 2.3 节做了离屏位图缓存,必须同步处理缓存位图的尺寸。
解决:重写OnResize,在里面重建缓存位图,再把新尺寸的矩形区域标记为需要重绘:
protected override void OnResize(EventArgs e) { base.OnResize(e); if (Width > 0 && Height > 0) { _backBuffer?.Dispose(); _backBuffer = new Bitmap(Width, Height); _backBufferGraphics = Graphics.FromImage(_backBuffer); } Invalidate(); }Graphics.FromImage创建的 Graphics 对象用后也要 Dispose,否则 Bitmap 无法被其他 Graphics 操作。位图重建时如果原内容需要保留,要把旧位图内容 DrawImage 到新位图上,否则拉伸后旧内容全部丢失。实际经验是:没有滚动和缩放需求就别做缓存位图,白多一道管理负担。
还有一个隐藏坑:Graphics.FromImage只能操作位图类型的 Graphics,不能对空 Graphics 调用某些绘制方法,否则抛ArgumentException。资源里如果用到了BufferedGraphicsContext,需要确保Dispose顺序是先释放 Graphics 再释放 Bitmap,反过来会抛ObjectDisposedException。
6. 进阶一档:把拖动刷新升级成可缩放的流程图编辑器
基础版本能拖能刷新,但要真正用在复杂的业务流程绘制(比如用户管理模块流程图、BPMN 网关图、上位机设备链路图)里,还需要补上缩放和平移能力。这一节给出一个实用的升级路径。
缩放的本质是改变绘制坐标系,最干净的做法是用矩阵统一变换。定义一个_canvasTransform矩阵,绘制前 Apply,命中检测前 Inverse:
private Matrix _canvasTransform = new Matrix(); private float _scaleFactor = 1f; private PointF _panOffset = PointF.Empty; private void ApplyCanvasTransform(Graphics g) { g.ResetTransform(); g.ScaleTransform(_scaleFactor, _scaleFactor); g.TranslateTransform(_panOffset.X, _panOffset.Y); }绘制前调用ApplyCanvasTransform(g),所有绘制代码整体缩放平移。命中检测和坐标换算通过 5.3 节的ScreenToCanvas反向变换。缩放用鼠标滚轮,以鼠标位置为缩放中心,这样缩放时目标点不会跑偏:
protected override void OnMouseWheel(MouseEventArgs e) { float zoomFactor = e.Delta > 0 ? 1.1f : 0.9f; _scaleFactor = Math.Max(0.2f, Math.Min(3f, _scaleFactor * zoomFactor)); PointF canvasBefore = ScreenToCanvas(e.Location); // 缩放变换 _canvasTransform.Reset(); _canvasTransform.ScaleTransform(_scaleFactor, _scaleFactor); _canvasTransform.TranslateTransform(_panOffset.X, _panOffset.Y); PointF canvasAfter = ScreenToCanvas(e.Location); _panOffset.X += canvasAfter.X - canvasBefore.X; _panOffset.Y += canvasAfter.Y - canvasBefore.Y; Invalidate(); }缩放中心对准鼠标的逻辑是:先算出鼠标在缩放前对应的逻辑坐标,执行缩放,再把鼠标位置对应的逻辑坐标和缩放前的差值加到平移偏移量里。这样用户滚轮聚焦点(鼠标下的那个节点)在缩放过程中始终留在鼠标光标下,手感自然。
拖动画布平移用鼠标中键。MouseDown判断e.Button == MouseButtons.Middle时记录起点,MouseMove里把移动量累加进_panOffset。这套操作和节点拖动并行运行,需要在中键按下时把_draggingNode清掉,避免两个动作抢同一个鼠标事件。
框选也是流程图编辑器的刚需。按住左键从空白区域拖出矩形选框,松手后选中所有与选框相交或完全位于选框内的节点。判断相交用RectangleF.IntersectsWith,完全包含用RectangleF.Contains,两个场景按需求二选一。框选时要把屏幕坐标的矩形反变换到画布坐标,否则缩放状态下的选框和实际选中区域错位——这一点和 5.3 踩坑同理。
最后是一个实践技巧:刷新策略不要写成固定的全量或局部,做成动态切换。把上面实现整合进一个CanvasRenderMode枚举,拖动和缩放用局部刷新,框选结束时强制全量重绘。节点数少于 100 时,全量重绘的性能已经完全够用,不要为了优化而优化,保持代码简单。
当初我被「拖动即时刷新」这个问题卡了两天,翻车在Refresh()和Invalidate()的差别上,后来把绘制逻辑理清楚、统一走Invalidate()之后,整个画布的流畅度立刻上了一个档次。从那以后我每次做 GDI+ 画布,都强制自己走一遍这条验证路径:先确认双缓冲,再确认命中检测坐标系,最后验证内存释放,这三个环节全部正常,画面才敢交付。这份资源的好处是把这条路径完整走了一遍,你拿到手直接跑起来看效果,再对照源码改自己的业务场景,会省很多从头试错的时间。希望帮到你。
本文还有配套的精品资源,点击获取