ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

WinForm+GDI+流程图控件拖动与局部重绘性能优化

WinForm+GDI+流程图控件拖动与局部重绘性能优化

简介:面向 .NET 桌面开发者的 WinForm 流程图绘制示例,采用 C# 与 GDI+ 实现节点拖拽、连线绘制和即时重绘,适合制作工作流编辑、流程设计等图形界面工具,也可作为自绘控件与鼠标交互的练习素材,适合中初级开发者参考。压缩包共 39 个文件,约 176KB,含 9 个 C# 源码、界面资源、配置、工程与可执行文件等,工程结构完整,便于直接打开研究与调试。已有 3217 人学习下载。代码围绕图形对象、画笔与画刷组织绘制逻辑,结合鼠标按下、移动、抬起事件和失效重绘机制,展示流程形状、连接线、数据结构管理及注释说明,并用容器保存节点顺序与连接关系。读者可据此理解 WinForm 自绘、图形命中判断与拖动刷新流程,并扩展决策框、处理框和节点关系,搭建更完整的流程图工具,为增加缩放、保存与撤销功能打下基础。

1. 一张能拖着走的流程图:WinForm + GDI+ 控件的适用边界

接手一个老工单系统时,需求方只提了一句话:能在客户端里画审批流,节点拖一下位置就得立刻变。项目是 .NET Framework 4.x 的 WinForm,加不了 WPF,也不想为一张图买图库授权,最后落到用 GDI+ 自己画。这类 .NET 工作流程图控件在 WinForm 圈子里算是老手艺:一个继承 Panel 的画布、一份节点与连线的内存模型、一套命中测试加局部重绘,拖动即时刷新全靠 Invalidate 的矩形范围算得准不准。它适合内网审批流、生产工艺路线、组织架构示意这类几十到几百个节点、要嵌进既有桌面客户端的场景;不适合做在线协作白板。下面按我拆这套 C# 版流程图绘制代码的顺序讲:骨架怎么搭、节点怎么画、拖动为什么不能整屏刷新、以及那几个能让新手卡一整天的坑。

2. 画布骨架:从 Panel 到双缓冲 OnPaint 的最小闭环

2.1 选型:为什么是 GDI+ 手绘,不是 WPF 也不是图库

先把这个决定说清楚,因为它直接决定了后面所有代码的形态。WinForm 里的绘图只有两条路:控件叠加(每个节点一个 Label/PictureBox)和自绘(一个画布 + Graphics 画所有东西)。节点数量超过二十个,控件叠加就开始翻车——每个控件都是独立窗口句柄,拖动时 Windows 要重排 Z 序,鼠标事件在几十个控件之间跳,闪烁和事件丢失几乎无解。自绘则是一次 OnPaint 画完,节点只是内存里的一个结构体,拖动的本质是改坐标再触发重绘,路径短得多。

和 WPF 比,GDI+ 没有硬件加速、没有数据绑定、没有布局系统,但换来的是极低的启动开销和对 .NET Framework 2.0 以上全版本的兼容。很多内网机器还锁在 XP 时代的运行时上,WPF 那套依赖 DirectX 的渲染栈在这些机器上要么起不来,要么字体糊成一片。第三方图库(商业的、开源的都算)在功能上确实更全,但一个流程图控件动辄几万行,出问题时你只能等社区或厂商,而自绘的核心代码加起来不到两千行,任何一行都能自己改。

方案上手成本大数据量表现可定制性部署依赖
控件叠加最低差,20 节点以上明显卡中,受控件属性限制无
GDI+ 自绘中,要懂 Graphics好,200 节点可控高,想怎么画怎么画无
WPF Canvas中高好,但启动慢高.NET 3.0+
第三方图库低视实现低,改不动源码通常带 DLL

我一般建议:节点数在 50 以内、样式要求不高的,控件叠加也能凑合;一旦要做连线、要做拖动跟随、要做缩放,直接上自绘,别在控件叠加那条路上反复打补丁。这份 C# 版流程图绘制代码走的就是自绘路线,0 分下载的老资源,但骨架是干净的,算得上良心资源。

2.2 三层结构:文档模型、命中测试、渲染器

自绘最容易写歪的地方是把数据和绘制揉在一个方法里。第一版我见过有人把节点坐标直接写在 OnPaint 的局部变量里,结果拖动时无从下手,只能整段重写。正确的拆法是三层,彼此不交叉:

第一层是文档模型,只描述“图里有什么”:节点列表、连线列表、选中集合、画布偏移与缩放。它不引用 System.Drawing 以外的任何东西,可以单独序列化。

第二层是命中测试,回答“鼠标这个点落在谁身上”:把屏幕坐标反算成画布坐标,从后往前遍历节点,用矩形或路径包含判断。这一层决定了拖动、选中、右键菜单的全部行为。

第三层是渲染器,只负责“把模型画出来”:给定一个 Graphics 和一个裁剪矩形,按 Z 序画连线、画节点、画选中框、画临时连线。渲染器不修改模型,模型变化后由外部触发重绘。

这三层分开之后,拖动就变成了一件很轻的事:MouseMove 里改节点坐标,同时算出“旧位置矩形”和“新位置矩形”,把这两个矩形的并集丢给 Invalidate。渲染器收到 Paint 后只在这个并集里重画,屏幕其余部分纹丝不动。

// 文档模型:只管数据,不碰 Graphics public class FlowDocument { public List<FlowNode> Nodes { get; } = new List<FlowNode>(); public List<FlowEdge> Edges { get; } = new List<FlowEdge>(); public HashSet<string> Selected { get; } = new HashSet<string>(); public float Zoom { get; set; } = 1.0f; public PointF Offset { get; set; } = PointF.Empty; }

Nodes的顺序就是 Z 序,后加的盖在前面,命中测试时倒序遍历即可让“最上层优先被点中”。Selected用 Id 而不是对象引用,是为了序列化时不产生循环引用;Zoom和Offset放在文档里而不是控件里,是为了保存文件后重新打开能恢复视图位置。

2.3 双缓冲与 OnPaint 的正确写法

WinForm 的闪烁问题,90% 出在两个地方:没开双缓冲,或者在 Paint 里干了不该干的事。双缓冲的正确开法不是设DoubleBuffered = true就完事——Panel 的这个属性是受保护的,直接设在某些容器上不生效。稳妥写法是在构造函数里用 SetStyle 一次性把三个样式位打开:

public class FlowCanvas : Panel { public FlowCanvas() { // 关键四件套:全部自绘 + 双缓冲 + 尺寸变化重绘 SetStyle(ControlStyles.AllPaintingInWmPaint // 屏蔽 WM_ERASEBKGND,杜绝背景刷白 | ControlStyles.UserPaint // 由我们自己处理 Paint | ControlStyles.OptimizedDoubleBuffer // 离屏缓冲,整帧交换 | ControlStyles.ResizeRedraw, true); // 尺寸变化时自动重绘 UpdateStyles(); // 让样式立即生效 BackColor = Color.WhiteSmoke; } protected override void OnPaint(PaintEventArgs e) { e.Graphics.SmoothingMode = SmoothingMode.AntiAlias; e.Graphics.PixelOffsetMode = PixelOffsetMode.Half; Renderer.Draw(e.Graphics, Document, e.ClipRectangle); // 只画裁剪区 } }

AllPaintingInWmPaint是最容易被漏掉的一位。不加它,控件仍然会响应WM_ERASEBKGND先用背景色刷一遍,再用你的内容盖上去,两次绘制之间就是白闪;加上之后背景擦除被合并进 Paint 消息,一次画完。OptimizedDoubleBuffer负责在内存里开一块和客户区等大的位图,所有绘制先落在这块位图上,最后一次性 BitBlt 到屏幕。ResizeRedraw解决的是拉窗口边框时内容不跟着重画的问题——不加的话拖动窗口边缘会看到一块块残留。

还有一个细节:e.ClipRectangle必须往下传。很多人图省事在 OnPaint 里直接遍历全部节点,觉得反正双缓冲了不闪。节点少的时候看不出问题,两百个节点、每帧都全量画的时候,CPU 直接吃满一个核。把裁剪矩形传进渲染器,让它在绘制前先做一次clip.IntersectsWith(node.Bounds)判断,这一步的收益比任何微优化都大。

3. 节点与连线:圆角矩形、菱形与锚点吸附的绘制参数

3.1 节点模型:字段怎么定,序列化才不会崩

节点模型看着简单,实际上决定了后面能不能存盘、能不能撤销、能不能兼容旧文件。最小可用字段集是 Id、形状、外包矩形、文本、填充色、边框色。这里有两个选择要提前定:坐标用 int 还是 float,外包矩形用 Rectangle 还是 RectangleF。

坐标必须用 float。用 int 的话,缩放比例不是整数时(比如 0.75),节点位置会累积取整误差,来回缩放几次就对不齐了。外包矩形用 RectangleF,配合Graphics.TranslateTransform和ScaleTransform做视图变换时不需要额外换算。形状用一个枚举而不是继承体系,是因为流程图的形状总共就五六种,用枚举加 switch 分发,序列化时不会遇到多态反序列化的麻烦。

public enum NodeShape { Rect, RoundRect, Diamond, Ellipse, Parallelogram } public class FlowNode { public string Id { get; set; } = Guid.NewGuid().ToString("N"); public NodeShape Shape { get; set; } = NodeShape.RoundRect; public RectangleF Bounds { get; set; } // 画布坐标,非屏幕坐标 public string Text { get; set; } = ""; public Color Fill { get; set; } = Color.FromArgb(232, 242, 255); public Color Stroke { get; set; } = Color.FromArgb(70, 130, 180); // 拖动过程中缓存起始位置,用于计算脏矩形 public PointF DragOrigin { get; set; } }

Bounds存的是画布坐标,不是控件坐标。这一点必须从一开始就定死,否则缩放功能加进来时,全项目每一处坐标都要重新审一遍。DragOrigin是拖动开始时记录的原位置,MouseMove 里用它和当前位置做并集,才能算出正确的脏矩形;如果只用当前 Bounds 和新位置算,会出现“旧位置残留一条边”的鬼影。

3.2 GraphicsPath:圆角矩形、菱形和文本居中的画法

GDI+ 没有内置圆角矩形,得用 GraphicsPath 手工拼。圆弧半径不能超过短边的一半,否则路径会自交,填充出来是个畸形。菱形的四个顶点按矩形四边中点取,画出来的比例正好。平行四边形要注意倾斜量不能超过宽度的一半,不然左上角会跑到右边去。

// 圆角矩形:半径自动夹紧,避免路径自交 public static GraphicsPath RoundRect(RectangleF r, float radius) { float max = Math.Min(r.Width, r.Height) / 2f; radius = Math.Max(0, Math.Min(radius, max)); var p = new GraphicsPath(); if (radius <= 0.5f) { p.AddRectangle(r); return p; } float d = radius * 2; p.AddArc(r.Left, r.Top, d, d, 180, 90); p.AddArc(r.Right - d, r.Top, d, d, 270, 90); p.AddArc(r.Right - d, r.Bottom - d, d, d, 0, 90); p.AddArc(r.Left, r.Bottom - d, d, d, 90, 90); p.CloseFigure(); return p; } // 菱形:四边中点连线 public static GraphicsPath Diamond(RectangleF r) { var p = new GraphicsPath(); p.AddPolygon(new[] { new PointF(r.Left + r.Width / 2, r.Top), new PointF(r.Right, r.Top + r.Height / 2), new PointF(r.Left + r.Width / 2, r.Bottom), new PointF(r.Left, r.Top + r.Height / 2) }); return p; }

文本居中不要用DrawString的默认对齐后自己算偏移,直接用StringFormat两个方向都设 Center,再配合RectangleF传入节点的内缩矩形(通常四边各缩 6 到 8 像素),防止文字贴边。长文本别指望自动换行能好看,超过两行的直接截断加省略号,比挤成一团强。

这里有个性能细节:GraphicsPath 是实现了 IDisposable 的,每次 OnPaint 都 new 一个再丢掉,绘制两百个节点就是两百次 GDI 对象创建销毁,句柄数会明显抖。我的做法是在节点内部缓存一个路径对象,只有当Bounds或Shape变化时才重建,绘制时直接复用。这个缓存的失效时机要卡在拖动结束,而不是拖动过程中每次都重建。

3.3 连线:锚点怎么选,折线怎么拐

连线是流程图里最容易被低估的部分。新手常写的版本是从节点中心到节点中心画一条直线,结果线头扎进节点里,视觉上一团糟。正确做法是从两个节点的边框上找锚点,锚点的选择规则是:在四条边中点里,挑距离对方节点中心最近的那个。这个规则简单、稳定,对矩形和圆角矩形都适用,菱形也基本能看。

// 在节点的四条边中点中选离 target 最近的一个 public static PointF Anchor(FlowNode n, PointF target) { var r = n.Bounds; var pts = new[] { new PointF(r.Left + r.Width / 2, r.Top), // 上 new PointF(r.Right, r.Top + r.Height / 2), // 右 new PointF(r.Left + r.Width / 2, r.Bottom), // 下 new PointF(r.Left, r.Top + r.Height / 2) // 左 }; PointF best = pts[0]; float bestD = float.MaxValue; foreach (var p in pts) { float dx = p.X - target.X, dy = p.Y - target.Y; float d = dx * dx + dy * dy; if (d < bestD) { bestD = d; best = p; } } return best; }

用平方距离比较,省掉一次开方,在 MouseMove 里高频调用时这点开销是能感觉到的。锚点算出来之后,连线分两种画法:直线和正交折线。直线适合节点少、布局随意的场景;正交折线(也叫肘形线)适合审批流,因为流程图的阅读习惯是左到右、上到下,折线能表达“跳过某一层”的语义。

折线的拐点算法常见有三种:中点拐一次、三分点拐两次、以及带障碍绕行。前两种够用了。以水平方向为主的连线,取两节点中心的 X 中位数作为拐点 X,中间插两个点(起点的 y、中点 x、终点的 y),画出来就是个 Z 形。拐角的圆角用GraphicsPath.AddArc拼,半径取 6 到 8 像素,比直角柔和很多,也不会因为节点密集时线条打架显得太乱。

连线本身还有一个隐藏成本:箭头。箭头要在终点位置按线段方向旋转。用一种笨办法是手算三角函数,用Math.Atan2拿到角度,再把三个顶点旋转平移。这个计算每帧都要做,连线多的时候值得缓存——连线两端节点都没动的时候,箭头顶点和拐点坐标可以存在连线对象里,Invalidate时直接复用。

4. 拖动即时刷新:命中测试、鼠标捕获与局部失效三件事

4.1 HitTest:倒序遍历与容差边界

命中测试写得好不好,直接决定操作手感。最基本的判断是node.Bounds.Contains(pt),但有两个问题:一是节点有旋转或非矩形时矩形判断会误命中,二是拖动时鼠标稍微滑出边框一点点,节点就“掉”了,用户会觉得控件不跟手。

对矩形、圆角矩形、菱形,用GraphicsPath.IsVisible(pt)判断,精度高而且能正确处理菱形四个角空白区域。对圆角矩形,圆角那点误差其实无所谓,用矩形判断也不会有人察觉。我一般按形状分两档:矩形和圆角矩形用Bounds.Contains,菱形和椭圆用路径判断。

遍历顺序必须是倒序。Nodes列表的顺序就是绘制顺序,后画的在上面,被点中的优先级也应该是上面那个。正序遍历会出现“点中下层节点”的诡异现象,尤其是两个节点有重叠的时候。

public FlowNode HitTest(PointF pt) { // 倒序:后绘制(视觉在上层)的优先命中 for (int i = Document.Nodes.Count - 1; i >= 0; i--) { var n = Document.Nodes[i]; // 外扩 2px 容差,贴近边缘也能选中 var r = n.Bounds; r.Inflate(2, 2); if (n.Shape == NodeShape.Diamond || n.Shape == NodeShape.Ellipse) { using (var path = n.Shape == NodeShape.Diamond ? RoundRectHelper.Diamond(r) : EllipsePath(r)) if (path.IsVisible(pt)) return n; } else if (r.Contains(pt)) return n; } return null; }

容差 2 像素这个值不是随手写的。在高 DPI 屏幕上(150% 缩放),2 个逻辑像素等于 3 个物理像素,是这个交互场景下比较舒服的容差。容差过大(比如 8 像素)会让相邻很近的两个节点抢命中,用户想点下面那个老是点到上面那个,那是另一种难受。

4.2 拖动三件套:MouseDown、MouseMove、MouseUp 的配套动作

拖动看起来只是“鼠标按住移动”,实际要做四件事:MouseDown 时记录起点和选中对象、设置鼠标捕获、MouseMove 时算位移并更新坐标、MouseUp 时释放捕获并提交一次撤销记录。少任何一件都会出问题。

MouseDown 里的第一要务是Capture = true。不设捕获,鼠标一旦移出控件客户区,MouseMove 和 MouseUp 事件就不再发给这个控件,节点会“粘”在鼠标上不动,或者松手了拖动状态还没结束。这是 WinForm 自绘控件最常见的翻车现场,后面避坑章还会细说。

MouseMove 里要做坐标换算。如果画布不做缩放平移,屏幕坐标减一下控件内边距就行;一旦加了Zoom和Offset,必须走逆变换。拖动的位移量要用“当前点减按下点”再除以缩放比例,不能直接用屏幕像素差,否则放大到 200% 时节点跑得比鼠标快一倍。

private bool _dragging; private PointF _dragStart; // 按下时的屏幕坐标(控件坐标) private FlowNode _dragNode; private RectangleF _dragDirty; // 累积脏矩形 protected override void OnMouseDown(MouseEventArgs e) { base.OnMouseDown(e); var pt = ScreenToWorld(e.Location); _dragNode = Document.HitTest(pt); if (_dragNode != null) { _dragging = true; _dragStart = pt; _dragNode.DragOrigin = _dragNode.Bounds.Location; Capture = true; // 关键:捕获鼠标 Document.Selected.Clear(); Document.Selected.Add(_dragNode.Id); Invalidate(); // 选中态变化,整屏刷一次 } } protected override void OnMouseMove(MouseEventArgs e) { base.OnMouseMove(e); if (!_dragging || _dragNode == null) return; var pt = ScreenToWorld(e.Location); float dx = pt.X - _dragStart.X; float dy = pt.Y - _dragStart.Y; _dragDirty = Union(_dragDirty, _dragNode.Bounds); // 旧位置 _dragNode.Bounds = new RectangleF( _dragNode.DragOrigin.X + dx, _dragNode.DragOrigin.Y + dy, _dragNode.Bounds.Width, _dragNode.Bounds.Height); _dragDirty = Union(_dragDirty, _dragNode.Bounds); // 新位置 Invalidate(Rectangle.Round(_dragDirty)); // 只刷脏区 } protected override void OnMouseUp(MouseEventArgs e) { base.OnMouseUp(e); if (_dragging) { _dragging = false; Capture = false; // 必须释放,否则控件被"锁" _dragDirty = RectangleF.Empty; // 此刻提交一次撤销栈,拖动过程中的中间态不记录 } _dragNode = null; }

_dragDirty是个累积量,每次 MouseMove 都把旧矩形和新矩形并进来,但要注意它是“从按下到现在”的累积,所以如果拖动路径很长,脏区会越来越大,最后接近整屏。实际项目里我会在每次 Invalidate 之后把它重置成当前节点的新旧并集,而不是无限累积;上面写成累积是为了让逻辑更直观,真实代码里在Invalidate调用后紧跟一行_dragDirty = Union(oldRect, newRect)重置。这个细节决定了两百个节点时拖动会不会掉帧。

还有一个优化点:如果同时拖动多个选中节点,脏区是所有被拖节点新旧矩形的并集。多个节点的时候并集可能覆盖整屏,这时候就不值得再算局部刷新了,直接Invalidate()反而更省。

4.3 Invalidate(rect):局部失效才是不卡的关键

Invalidate()无参调用会标记整个客户区为脏,下一次 Paint 时ClipRectangle就是全部,渲染器被迫全量重画。节点二三十个的时候看不出差别,两百个节点、连线三百条的时候,一次全量重绘在普通办公机上要 25 到 40 毫秒,鼠标移动事件每秒来一百多次,队列一堆积就是明显的拖影和迟滞。

Invalidate(Rectangle)只标记这一块,ClipRectangle就是这块矩形,渲染器里那句clip.IntersectsWith(node.Bounds)能把 95% 的节点直接跳过。实测下来,单节点拖动时每帧的绘制量只有全量的 3% 到 5%,帧时间从 30 毫秒级降到 2 到 4 毫秒,拖动就非常跟手了。

这里有个 GDI+ 的坑要提前说:局部刷新时,脏区内必须能完整重画,包括背景。如果渲染器只在脏区里画节点而不铺背景,旧位置的节点虽然被“擦掉”了标识,但上一帧的像素还在,屏幕上就会留下残影。正确顺序是先e.Graphics.FillRectangle(背景刷, clip),再画连线,再画节点。背景刷用纯色或平铺纹理都行,但必须每次画。

另外,Invalidate只是“标记”,真正触发重绘是由消息循环调度的,多个 Invalidate 会合并成一个 Paint。这个特性对拖动是好事:鼠标快速移动时事件率可能超过刷新率,合并之后不会每个 MouseMove 都画一帧。但也意味着你不能在 Invalidate 之后立刻读屏幕内容,因为画还没发生。

5. 常见问题排查:闪烁、粘鼠标、点不中的五个现场

5.1 现象:拖动时整个画布闪白,拖得越快闪得越凶

原因几乎都是没开AllPaintingInWmPaint,或者开了但被别处覆盖。有些容器控件(比如 SplitContainer 的 Panel)会重置子控件的样式,SetStyle在构造函数里调用之后又被改掉了。另一个隐蔽原因是BackColor设成Color.Transparent,透明背景会强制走父控件的绘制路径,双缓冲直接失效。

解决:构造函数里SetStyle加UpdateStyles(),并确认没有在别处重新设置DoubleBuffered或BackColor = Transparent。如果确实需要透明,改用不透明底色加绘制的“伪透明”。还有一个排查手段是把OnPaintBackground重写成空方法,彻底断掉背景擦除路径,很多时候一行就能定位问题。

5.2 现象:鼠标拖出画布边缘后节点粘在鼠标上,松手了还在动

原因是 MouseDown 里没设Capture = true,或者设了但在某条分支里被提前清掉。鼠标离开控件客户区后,系统把 MouseMove 发给鼠标底下的其他窗口,本控件收不到事件,也就收不到 MouseUp,_dragging一直是 true。

解决:MouseDown 里无条件Capture = true,MouseUp 和OnMouseCaptureChanged里都要复位_dragging和_dragNode。Capture被系统夺走的情况(比如弹出模态框、Alt+Tab 切窗口)一定会触发OnMouseCaptureChanged,这个回调是最后的安全动作。

5.3 现象:加了缩放之后,鼠标点不中节点,位置整体偏出去一截

原因是命中测试用的是屏幕坐标,而节点存的是画布坐标,中间少了逆变换。很多人只在画的时候做了TranslateTransform,忘了在鼠标事件里反着算一遍。偏移量通常和滚动条位置、控件边框宽度有关,所以表现为“越往右下偏得越多”。

解决:封装一个ScreenToWorld(Point p),把控件坐标减偏移、除以缩放、再加上滚动位置,三件事一次做对,所有鼠标入口都走这一个函数。反向的WorldToScreen用于绘制和右键菜单定位,两个函数必须严格互为逆运算,写完之后拿一个已知节点测一下:把节点中心转成屏幕再转回来,误差应该在 0.01 以内。

5.4 现象:连线不跟随节点移动,节点已经跑过去了,线还停在原位

原因是连线的绘制依赖节点当前坐标,但脏区只算了节点的矩形,没有把连线经过的区域纳入。节点移动时旧线段残留,新线段又不完整,看起来就是“线掉队了”。

解决:算脏区时把与该节点相关的所有连线的外包矩形也并进去。连线的外包矩形就是起点锚点和终点锚点组成的矩形外扩箭头长度(约 12 像素)。节点一多,简单做法是只要拖动就Invalidate()全屏,节点上千了再优化。这是个典型的“先正确再快”的场景,别一上来就死磕局部刷新。

5.5 现象:拖动几分钟后程序变卡,任务管理器里 GDI 对象数一路涨

原因是每次 OnPaint 都 new 了 Pen、Brush、GraphicsPath 却没有 Dispose。GDI+ 的 Pen 和 Brush 持有非托管句柄,虽然实现了 IDisposable,但 GC 不知道这些句柄的紧迫性,回收很晚。句柄数过万之后系统级绘图开始变慢,进程可能直接崩掉。

解决:所有短生命周期的 GDI 对象用using包起来;高频复用的对象(比如节点路径、常用颜色的 Pen)提到字段里缓存,随控件销毁时释放。有个偷懒但可靠的排查方法:在 OnPaint 开头记录GetGuiResources的返回值,拖一分钟看它是否持续上涨,涨了就一定是漏了。

6. 缩放与压测:200 个节点下把重绘压到 16ms 以内

缩放的实现方式有两条路:一是缩放图形(Graphics.ScaleTransform),二是缩放模型(节点坐标乘以缩放系数)。前者不改数据,鼠标逆变换简单,缺点是线宽和文字跟着一起放大,放大到 300% 时边框粗得像马克笔。后者要修改数据但渲染可控,线宽可以固定 1 像素,文字可以用Graphics.ScaleTransform单独处理。

我一般用混合方案:坐标和路径走Graphics.Transform,Pen.Width在设置后除以缩放比例,让线宽在屏幕上保持恒定。文字单独用Graphics.DrawString加缩放后的字号,避免糊成一片。

protected override void OnPaint(PaintEventArgs e) { var g = e.Graphics; g.SmoothingMode = SmoothingMode.AntiAlias; // 1) 铺背景(局部刷新必须做,否则留残影) using (var bg = new SolidBrush(BackColor)) g.FillRectangle(bg, e.ClipRectangle); // 2) 应用视图变换:先平移后缩放 var state = g.Save(); g.TranslateTransform(Document.Offset.X, Document.Offset.Y); g.ScaleTransform(Document.Zoom, Document.Zoom); // 3) 裁剪区也要反算回画布坐标,否则 IntersectsWith 判错 var worldClip = ScreenToWorldRect(e.ClipRectangle); Renderer.Draw(g, Document, worldClip); g.Restore(state); }

g.Save()和g.Restore()一定要成对。忘掉 Restore 的后果是这个 Graphics 的状态被污染,下一次 Paint 进来时变换是叠加上去的,表现为“越刷新越偏”。ScreenToWorldRect用逆矩阵算,不要手写公式,Matrix.Invert已经足够快,缓存一下矩阵对象就行。

压测的做法:造两百个随机分布的节点和三百条连线,开一个Stopwatch在 OnPaint 末尾计时,把最近 100 帧的平均值和 P95 打到调试输出上。办公机(四代 i5、集显)上的实测数据是:全量重绘约 28 毫秒,单节点拖动局部刷新约 3 毫秒,十节点同时拖合约 11 毫秒。16 毫秒是 60 帧的临界线,超过就会有肉眼可见的迟滞,所以真正要守住的是十节点同拖这个场景。

优化的优先级也很明确:先保局部刷新,再保路径缓存,最后才考虑把SmoothingMode从 AntiAlias 降到 HighSpeed。抗锯齿一关,斜线和圆角的毛刺立刻就能看出来,那是用观感换帧率,不到万不得已别动。

血泪经验是这么来的:早期做的一个版本没做路径缓存,每个节点每帧重建一次 GraphicsPath,两百节点拖动时 GDI 句柄数在五分钟内从 400 涨到 6000,最后控件直接白屏。查了两个下午才定位到using的位置写在了循环内部,等于每帧创建销毁四百次。从那以后我每次加绘制代码,都会在调试版里挂一个句柄计数的定时器,拖一分钟看曲线平不平,不平就当场查。希望这些能帮你在接入这套 .NET WinForm 流程图控件时少走两晚弯路。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进