简介:基于.NET Framework 2.0环境,用C#语言开发的Winform流程图设计工具,实现了类似Visio的拖放式编辑体验。压缩包内含41个文件,以C#源文件、窗体设计器、resx资源、png图标及可执行程序为主,整包仅261KB,轻量紧凑便于直接运行与代码研读。项目涵盖完整工程文件(sln/csproj)及UcFlowNode自定义节点控件,读者可从中学习图形对象管理、鼠标交互、拖放创建、连接线自动调整、XML/JSON序列化保存等关键实现思路。资源聚焦简单流程图场景,适合.NET初学者、C#桌面开发者或需要在Winform中快速搭建可视化编辑器的工程师参考。目前已有4638人学习下载,是入门Winform自定义绘图与交互设计的实用样例。
1. Winform画类似Viso的流程图:先量化工作量再动手
如果你接到一个“像 Viso 那样画流程图”的 Winform 需求,第一反应大概是找现成控件,第二反应是“不就画几个框拉几条线么”。真做起来你会发现,节点拖拽、连线跟随、缩放平移、命中测试、保存还原,每一个点都能让你改到怀疑人生。这篇笔记就是一套能直接落地跑的方案,面向 C# Winform 开发者,用自绘控件 + 简单数据模型,在不用第三方大型图表库的前提下,实现一个够用的 Viso 风格流程图编辑器。适合做审批流配置、数据流程图、业务流程图这类内嵌工具场景。完整源码包里的 Demo 按本文结构实现,跑起来之后你只需要改形状和配色,就能接进自己的项目。
2. 节点与画布模型:坐标变换才是整个控件的命脉
2.1 节点数据模型:把几何数据和绘图逻辑分开
很多 Winform 流程图教程会把所有逻辑堆在自定义控件的OnPaint里,一个类写到上千行。节点数量少时看不出问题,一旦超过三四十个,维护成本就开始失控。我的做法是把“数据模型”和“渲染逻辑”彻底分离:FlowNode只保存节点的几何属性、形状类型和文本内容,不掺任何Graphics绘图代码。这样后续做序列化、命中测试、拖拽更新,操作的都是纯数据对象。
public enum NodeShape { Rect, // 矩形,普通处理步骤 RoundRect, // 圆角矩形,开始/结束 Diamond, // 菱形,判断分支 Ellipse, // 椭圆,起止节点 Document // 文档形状,用 GraphicsPath 绘制 } public class FlowNode { public string Id { get; set; } // 节点唯一标识 public string Text { get; set; } // 节点显示文本 public RectangleF Bounds { get; set; } // 画布坐标下的位置和尺寸 public NodeShape Shape { get; set; } // 形状类型 [JsonIgnore] public Color FillColor { get; set; } = Color.White; [JsonIgnore] public Color BorderColor { get; set; } = Color.FromArgb(60, 60, 60); public FlowNode() { Id = Guid.NewGuid().ToString("N"); } }这里最关键的是Bounds用了RectangleF而不是Rectangle。因为缩放操作会产生 0.8、1.25 这样的浮点坐标,如果用Rectangle,每一次缩放都强转成 int,拖拽几十次后节点位置会肉眼可见地漂移。Id用 GUID 字符串而不是自增整数,是为了避免复制粘贴、模板合并时主键冲突。[JsonIgnore]打在颜色属性上,是为了防止 JSON 序列化时直接输出System.Drawing.Color这种平台相关类型,后面第 4 章会专门讲序列化问题。
节点模型必须自带命中测试能力,因为鼠标交互的第一步就是判断“点到了谁”。我把HitTest实现为FlowNode的方法,内部按形状类型分派:
public bool HitTest(PointF canvasPt) { switch (Shape) { case NodeShape.Rect: case NodeShape.RoundRect: case NodeShape.Ellipse: return Bounds.Contains(canvasPt); case NodeShape.Diamond: PointF left = new PointF(Bounds.Left, Bounds.Y + Bounds.Height / 2f); PointF top = new PointF(Bounds.X + Bounds.Width / 2f, Bounds.Top); PointF right = new PointF(Bounds.Right, Bounds.Y + Bounds.Height / 2f); PointF bottom = new PointF(Bounds.X + Bounds.Width / 2f, Bounds.Bottom); using (GraphicsPath path = new GraphicsPath()) { path.AddPolygon(new PointF[] { left, top, right, bottom }); return path.IsVisible(canvasPt); } case NodeShape.Document: using (GraphicsPath docPath = BuildDocumentPath(Bounds)) return docPath.IsVisible(canvasPt); default: return false; } }菱形和文档形状都通过GraphicsPath.IsVisible判断,省去了手写多边形点与射线相交算法。这段代码的性能在节点数量小于几百个时完全足够,不需要过早优化。需要特别注意的是,传入的canvasPt必须是画布坐标系下的点,而不是鼠标在屏幕上的原始坐标;这条约定从第一行代码就要定死,否则后面加缩放平移时一定会出乱子。
2.2 自绘画布控件:直接继承 Control,别用 Panel
画布控件是整个流程图编辑器的核心。我选择继承Control而不是Panel,原因是Panel自带容器布局逻辑,比如Dock时调整子控件尺寸、背景擦除顺序等,这些在自绘场景下反而碍事。直接继承Control得到一块干净的画布,所有绘制、交互都自己接管。
public class FlowCanvas : Control { public List<FlowNode> Nodes = new List<FlowNode>(); public List<FlowConnection> Connections = new List<FlowConnection>(); private float _scale = 1.0f; private PointF _origin = PointF.Empty; // 画布坐标原点在屏幕上的位置 private bool _panning = false; private Point _lastPanPoint; public FlowCanvas() { SetStyle( ControlStyles.UserPaint | ControlStyles.AllPaintingInWmPaint | ControlStyles.OptimizedDoubleBuffer | ControlStyles.ResizeRedraw | ControlStyles.Selectable, true ); } protected override void OnPaint(PaintEventArgs e) { e.Graphics.SmoothingMode = SmoothingMode.AntiAlias; e.Graphics.Clear(BackColor); PaintConnections(e.Graphics); // 先画连线 PaintNodes(e.Graphics); // 再画节点,保证节点压在线上面 } public PointF ScreenToCanvas(Point screenPt) { return new PointF( (screenPt.X - _origin.X) / _scale, (screenPt.Y - _origin.Y) / _scale ); } public PointF CanvasToScreen(PointF canvasPt) { return new PointF( canvasPt.X * _scale + _origin.X, canvasPt.Y * _scale + _origin.Y ); } }SetStyle里的标志位是整个性能问题的地基,逐个说清楚:
UserPaint:告诉系统由控件自己绘制外观,不依赖系统默认绘制。AllPaintingInWmPaint:重绘时不先擦背景,直接画。少了这一步,每一帧都会白屏闪烁一次。OptimizedDoubleBuffer:开启控件级双缓冲,绘制先写到内存缓冲区,再一次发送到屏幕。对几十个节点足够用。ResizeRedraw:控件尺寸改变时强制重绘,否则缩窗口会出现残留图形。Selectable:让控件能接收焦点和键盘事件,否则 Delete 键删除节点无法实现。
ScreenToCanvas和CanvasToScreen是全局唯一的坐标换算通道,这不仅是两个方法,更是一条纪律:所有鼠标事件入口先ScreenToCanvas,所有绘制输出先CanvasToScreen。一旦有人绕过去直接拿屏幕坐标做运算,缩放平移后立刻出 bug。
2.3 滚轮缩放:以鼠标位置为缩放中心
Viso 类工具滚轮缩放的标准行为是“鼠标指哪里,缩放中心就在哪里”。如果以画布原点缩放,鼠标在右下角放大时内容却往左上角跑,非常反直觉。实现上就三步:记录缩放前的鼠标画布坐标,更新_scale,反推新的_origin。
protected override void OnMouseWheel(MouseEventArgs e) { float newScale = _scale * (e.Delta > 0 ? 1.1f : 0.9f); newScale = Math.Clamp(newScale, 0.2f, 3.0f); PointF canvasBefore = ScreenToCanvas(e.Location); _origin.X = e.X - canvasBefore.X * newScale; _origin.Y = e.Y - canvasBefore.Y * newScale; _scale = newScale; Invalidate(); base.OnMouseWheel(e); }Math.Clamp把缩放范围限制在 0.2 到 3.0,这是我实际项目中反复试出来的指标:低于 0.2 节点已经很难用鼠标选中,高于 3.0 线宽和文字的观感明显失衡。缩放倍率选择 1.1 的固定步长而不是加多少减多少,是因为视觉上等比缩放更接近人对“放大缩小”的认知。
平移交互我用鼠标中键拖拽实现,替换 Winform 自带的滚动条,因为滚动条会引入AutoScrollPosition的坐标补偿计算,徒增复杂度。中键按下记录平移起点,移动时更新_origin,所有节点的画布坐标纹丝不动,只改变视觉偏移,计算量极小。
protected override void OnMouseDown(MouseEventArgs e) { if (e.Button == MouseButtons.Middle) { _panning = true; _lastPanPoint = e.Location; } } protected override void OnMouseMove(MouseEventArgs e) { if (_panning) { _origin.X += e.X - _lastPanPoint.X; _origin.Y += e.Y - _lastPanPoint.Y; _lastPanPoint = e.Location; Invalidate(); } }这里的关键点是_panning与后面要讲的_dragNode必须互斥。同一个鼠标操作绝不能既是平移又是拖节点,否则会出现“按住中键拖,节点也跟着跑”的玄学问题。我在OnMouseMove里先判断_panning,再判断_dragNode,顺序固定,优先级明确。
3. 连线与交互:锚点计算和 Shift 键分流拖拽
3.1 连线数据模型:用 Id 关联,别用对象引用
连线是流程图里最容易写坏的部分,也是 Viso 体验的核心:节点拖动时连线要跟随,但连线不能直接从中心连到中心,否则线会穿过大半个节点图形。我先把连线数据模型定义成基于 Id 的关联,这样序列化、反序列化都不需要重建内存指针。
public class FlowConnection { public string Id { get; set; } public string FromId { get; set; } // 起始节点 Id public string ToId { get; set; } // 结束节点 Id public bool IsCurved { get; set; } // true 用贝塞尔曲线,false 用直线 public string Label { get; set; } // 连线文本,如“Y”“N” [JsonIgnore] public PointF[] AnchorCache; // 最近一次绘制的锚点缓存,序列化时忽略 }AnchorCache的属性比较容易引起疑问:既然可以实时计算锚点,为什么还要缓存?因为命中测试连线时需要一套“画线和当前屏上一致”的锚点数据。实时计算一次当然也行,但拖动节点时每帧都要算两套锚点然后做距离判断,缓存在这里纯粹是省重复计算。缓存只放在内存里,保存到文件时需要重新计算出锚点,不能把这份数组持久化。
锚点的核心算法是:已知目标节点中心点的方向,求它与当前节点边界的交点。对矩形、菱形、椭圆都适用同一个近似公式,区别只在于长短轴参数。
public PointF GetEdgeAnchor(FlowNode node, PointF targetCenter) { RectangleF r = node.Bounds; float cx = r.X + r.Width / 2f; float cy = r.Y + r.Height / 2f; float dx = targetCenter.X - cx; float dy = targetCenter.Y - cy; if (Math.Abs(dx) < 1e-6f && Math.Abs(dy) < 1e-6f) return new PointF(cx, cy); float tx = (r.Width / 2f) / Math.Abs(dx); float ty = (r.Height / 2f) / Math.Abs(dy); float t = Math.Min(tx, ty); return new PointF(cx + dx * t, cy + dy * t); }t = Math.Min(tx, ty)的物理含义是“先碰到矩形的哪条边”。水平方向更快碰到就取tx,垂直方向更快碰到就取ty。整个函数没有if分支判断象限,简洁且不易错。目标中心和当前节点中心重叠时返回中心点,这种边界情况在拖动过程中真的会遇到,处理不好连线就会突然失踪。
3.2 绘制连线:贝塞尔曲线的控制点方向要自适应
连线绘制我提供了直线和贝塞尔曲线两种模式。直线简单但僵硬,适合数据流图;业务流程图一般用曲线,观感更接近 Viso。贝塞尔曲线最关键的是控制点方向,很多实现只做水平方向控制点,导致节点上下排列时曲线绕出离谱的大圆弧。
protected void PaintConnections(Graphics g) { foreach (FlowConnection conn in Connections) { FlowNode fromNode = FindNodeById(conn.FromId); FlowNode toNode = FindNodeById(conn.ToId); if (fromNode == null || toNode == null) continue; // 防御:节点被删后连线不崩溃 PointF p1 = fromNode.GetEdgeAnchor(CenterOf(toNode)); PointF p2 = toNode.GetEdgeAnchor(CenterOf(fromNode)); conn.AnchorCache = new PointF[] { p1, p2 }; using (Pen pen = new Pen(_connColor, 1.2f / _scale)) { if (conn.IsCurved) { PointF c1, c2; ComputeCurveControls(p1, p2, out c1, out c2); g.DrawBezier(pen, p1, c1, c2, p2); } else { g.DrawLine(pen, p1, p2); } } } } private void ComputeCurveControls(PointF p1, PointF p2, out PointF c1, out PointF c2) { float dx = p2.X - p1.X; float dy = p2.Y - p1.Y; float distance = Math.Sqrt(dx * dx + dy * dy); if (Math.Abs(dx) > Math.Abs(dy)) { // 水平占比大,控制点沿水平方向偏移 float offset = Math.Max(24f, distance * 0.4f); c1 = new PointF(p1.X + offset, p1.Y); c2 = new PointF(p2.X - offset, p2.Y); } else { // 垂直占比大,控制点沿垂直方向偏移 float offset = Math.Max(24f, distance * 0.4f); c1 = new PointF(p1.X, p1.Y + offset); c2 = new PointF(p2.X, p2.Y - offset); } }笔宽1.2f / _scale是视觉一致性设计的关键细节。画布坐标经过_scale缩放后,如果笔宽不除以_scale,缩小到 0.5 倍时线宽会变成应有的两倍,放大到 2 倍时线条粗得能遮挡节点边框。24f最小值保护让两个重叠的节点仍然有可见的弧度。我用“两端同时偏移”而不是“一端偏移”,因为这样曲线形状对称,不会朝一侧甩尾巴。
3.3 拖拽与创建连线:Shift 键分流,避免交互冲突
拖拽和创建连线是两种高频操作,如果让它们在同一个鼠标手势下共存,必然会出现误操作。我采用的方案是:普通左键拖动节点;按住 Shift 从左键从一个节点拖到另一个节点时,创建连线。这两条路径在OnMouseDown的入口处就要分好,不能等MouseMove再判断,否则拖动开始时已经乱了状态。
private FlowNode _dragNode; private PointF _dragOffset; private bool _creatingLine; protected override void OnMouseDown(MouseEventArgs e) { base.OnMouseDown(e); if (e.Button != MouseButtons.Left) return; PointF canvasPt = ScreenToCanvas(e.Location); FlowNode hitNode = HitTestNode(canvasPt); if (hitNode != null && (ModifierKeys & Keys.Shift) != 0) { // Shift + 左键:开始创建连线 _creatingLine = true; _dragNode = hitNode; // 临时借用字段,记录起点节点 } else if (hitNode != null) { // 普通左键:开始拖拽节点 _dragNode = hitNode; _dragOffset = new PointF( canvasPt.X - hitNode.Bounds.X, canvasPt.Y - hitNode.Bounds.Y ); } }MouseMove里再分两种状况:拖节点时更新Bounds后整画布重绘;创建连线时只记录当前鼠标位置作为临时终点,在OnPaint里额外画一条从暂存起点到鼠标位置的预览线。
protected override void OnMouseMove(MouseEventArgs e) { if (_creatingLine) { _dragEndPoint = ScreenToCanvas(e.Location); Invalidate(); return; } if (_dragNode != null && e.Button == MouseButtons.Left) { PointF canvasPt = ScreenToCanvas(e.Location); _dragNode.Bounds = new RectangleF( canvasPt.X - _dragOffset.X, canvasPt.Y - _dragOffset.Y, _dragNode.Bounds.Width, _dragNode.Bounds.Height ); Invalidate(); } }预览线绘制我放在PaintConnections的条件分支里:
if (_creatingLine && _dragNode != null) { using (Pen previewPen = new Pen(Color.Gray, 1.2f / _scale)) { previewPen.DashStyle = DashStyle.Dash; PointF start = _dragNode.GetEdgeAnchor(_dragEndPoint); g.DrawLine(previewPen, start, _dragEndPoint); } }虚线预览让用户看到“这条连线会从哪里接出来”,而不是从一个节点中心拉出一根无头线。鼠标松开时,OnMouseUp判断当前位置是否命中目标节点,命中则创建连接,没有命中就放弃这次操作。
protected override void OnMouseUp(MouseEventArgs e) { if (_creatingLine) { PointF canvasPt = ScreenToCanvas(e.Location); FlowNode targetNode = HitTestNode(canvasPt); if (targetNode != null && targetNode != _dragNode) { Connections.Add(new FlowConnection { Id = Guid.NewGuid().ToString("N"), FromId = _dragNode.Id, ToId = targetNode.Id, IsCurved = true }); } _creatingLine = false; } _dragNode = null; base.OnMouseUp(e); }3.4 节点增删与右键菜单:顺手解决状态栏刷新
节点添加操作我用双击画布空白处创建流程节点,右键菜单里补充“新增菱形判断”“删除”“复制”三项。复制节点时Id必须重新生成,Bounds向右下偏移 20 像素,形成视觉上的新节点,否则两节点完全重叠,选中后看不出来。
删除节点时不能只删节点,还要把关联连线一并清理:
public void DeleteNode(FlowNode node) { if (node == null) return; Connections.RemoveAll(c => c.FromId == node.Id || c.ToId == node.Id); Nodes.Remove(node); Invalidate(); }这里有个容易遗漏的细节:如果有删除动画或选中高亮逻辑,必须在删除前先从画布上取消该节点的选中状态,否则OnPaint里PaintNode(n, highlight: true)会引用一个已经从列表移除的节点,引发ArgumentOutOfRangeException。我习惯把“取消选中、清理连线、移除节点”写成一个事务性方法,不允许分散在多个事件分支里。
右键菜单我用ContextMenuStrip实现,菜单项里“删除”的Enabled属性依赖当前选中节点是否为null。另外,如果宿主窗体有状态栏控件,新增或删除节点后要触发StatusChanged事件,让主窗体更新“当前节点数:N,连线数:M”的提示。这个交互虽然不起眼,但实际演示时用户感知非常明显,属于 Winform 桌面应用该有的体验细节。
4. 避坑与常见问题:自绘流程图的五个典型翻车点
4.1 拖拽时背景疯狂闪烁
现象:鼠标拖动节点,画布上出现黑色残影、白底闪烁,严重时整个控件像在抖。
原因:Winform 控件默认先擦除背景再触发OnPaint,每一帧都经历“清屏→重绘”。节点数量少时感觉不明显,节点超过 20 个后绘制时间变长,闪烁肉眼可见。单纯设置DoubleBuffered = true不一定有效,因为某些自定义控件场景中,系统在擦除背景之后、绘制之前还有一次额外的背景填充。
解决:构造函数里用SetStyle同时设置AllPaintingInWmPaint和OptimizedDoubleBuffer。AllPaintingInWmPaint让控件不单独擦背景,直接在当前内容上重绘;OptimizedDoubleBuffer把重绘结果先写入内存位图,再一次BitBlt上屏。这两个标志位必须成对出现,缺一个都会回到闪烁状态。如果再不行,检查是否在某个地方手动调用了this.BackColor的修改,这会导致WM_ERASEBKGND消息提前触发背景擦除。
4.2 加了滚动条之后,节点点不中
现象:启用AutoScroll后,画布滚动一段距离,鼠标点击位置和节点实际位置偏移,越滚动偏得越远。
原因:MouseEventArgs.Location返回的是当前客户区坐标,但我绘制时已经通过_origin做了平移。如果同时启用AutoScroll,系统还会自动叠加一层AutoScrollPosition的偏移,导致坐标被重复计算。更隐蔽的是,AutoScrollPosition在滚动条未滚动时可能返回负值,这个负偏移很容易被忽略。
解决:要么彻底不用AutoScroll,用鼠标中键或右键拖拽平移代替;要么在Paint开始时执行e.Graphics.TranslateTransform(AutoScrollPosition.X, AutoScrollPosition.Y),并且鼠标事件换算时主动减去AutoScrollPosition:
PointF canvasPt = new PointF( (e.X - _origin.X - AutoScrollPosition.X) / _scale, (e.Y - _origin.Y - AutoScrollPosition.Y) / _scale );我强烈建议直接砍掉AutoScroll。流程图工具本质上是无限画布,滚动条的存在会让坐标系统多一个状态变量,所有换算函数都要多带一个参数,排查起来非常痛苦。右键平移配合滚轮缩放,体验上更贴近 Viso。
4.3 缩放后线宽和文字一起走了样
现象:画布放大到 2 倍,节点边框和连线变得粗壮,文字也跟着放大,整个图观感很差。
原因:绘制时坐标经过_scale缩放,但Pen宽度和Font大小不会自动跟着缩放逻辑走。我用CanvasToScreen换算坐标,画笔宽度如果不除以_scale,就会以屏幕像素固定笔画。字体同理,Font以 em 为单位,会直接参与Graphics.ScaleTransform的变换。
解决:所有Pen创建时写成pen.Width = baseWidth / _scale。字体需要一个反缩放变量:
float fontScale = 1f / _scale; using (Font f = new Font("Microsoft YaHei", 10f * fontScale)) { g.DrawString(node.Text, f, Brushes.Black, rect, format); }这个10f是设计基准字号,代表缩放倍率为 1 时的视觉大小。fontScale反缩放后,画布放大 2 倍,字号实际渲染为 5f,再乘以_scale后屏上大小正好是 10f。这样文字在屏上的物理像素尺寸始终不变,形状缩放但标签清晰,避免了“缩到 0.5 倍时文字糊成一团”的常见翻车。
4.4 贝塞尔曲线方向完全不对
现象:两个节点上下垂直排列,连线绕了一个夸张的大圆弧,甚至弧线方向画反,看起来像乱码。
原因:曲线控制点固定取水平方向,但两个节点之间的方向向量是垂直的。控制点偏移方向和连线方向不一致,曲线自然绕远路。
解决:在ComputeCurveControls里检测差值方向,水平方向距离大就用水平控制点,垂直方向距离大就用垂直控制点。这个判断看起来简单,实际影响很大。还有一种更通用的做法是沿节点中心连线方向偏移控制点:
float dx = p2.X - p1.X; float dy = p2.Y - p1.Y; float len = (float)Math.Sqrt(dx * dx + dy * dy); float nx = dx / len; float ny = dy / len; float offset = Math.Max(24f, len * 0.4f); c1 = new PointF(p1.X + nx * offset, p1.Y + ny * offset); c2 = new PointF(p2.X - nx * offset, p2.Y - ny * offset);这种方式不管节点怎么排列,曲线始终沿两个节点连线方向弯曲,得到的是平滑的 S 形或 U 形曲线。代价是节点重叠时曲线可能退化,所以Math.Max(24f, ...)这个最小偏移的保底不能去掉。
4.5 序列化后节点位置错乱
现象:保存到 JSON 后重新加载,部分节点位置偏移了几个像素,某些连线丢失。
原因:直接用Newtonsoft.Json序列化FlowNode时,Bounds是RectangleF结构,序列化后变成形如{"X":1,"Y":2,"Width":3,"Height":4}的对象,反序列化本身没问题。真正的坑在Color属性:[JsonIgnore]标记后反序列化不会恢复颜色值,于是所有节点用默认的白色背景和灰色边框。如果项目里还有DateTime、float等类型,JSON 反序列化的精度也可能造成位置漂移。
解决:为输入输出单独写 DTO(Data Transfer Object),只保留基本类型,不直接序列化主体对象:
public class FlowNodeDto { public string Id { get; set; } public string Text { get; set; } public float X { get; set; } public float Y { get; set; } public float Width { get; set; } public float Height { get; set; } public string Shape { get; set; } public string FillColorArgb { get; set; } } public static FlowNodeDto ToDto(FlowNode node) { return new FlowNodeDto { Id = node.Id, Text = node.Text, X = node.Bounds.X, Y = node.Bounds.Y, Width = node.Bounds.Width, Height = node.Bounds.Height, Shape = node.Shape.ToString(), FillColorArgb = node.FillColor.ToArgb().ToString() }; }读取时用Color.FromArgb(int.Parse(dto.FillColorArgb))还原颜色。DTO 模式虽然代码多一些,但彻底切断了平台类型与 JSON 的耦合,后续新增字段只改 DTO 和转换函数,不动序列化基础设施。保存文件时还可以顺便记录版本号,后续升级结构时不至于无法读取旧文件。
5. 双缓冲与局部刷新:60 个节点的拖拽是如何从卡顿变顺畅的
前面所有的功能做好之后,你会发现 20 个节点很流畅,但增加到 60 个节点,拖动时明显掉帧。OptimizedDoubleBuffer只能保证“不闪烁”,不能保证“绘制很快”。因为每次Invalidate()都是全画布重绘,60 个节点加 60 条连线,加上抗锯齿计算,一帧的绘制时间可能超过 30ms,拖拽时自然卡。
真正的技巧是分层绘制:把不变的东西画进一张背景缓存位图,拖拽时只重绘变化区域,然后再把背景贴回屏幕。我的实现思路是维护一个_backBuffer,节点集合或连线集合变化时重建它,拖拽期间不重建,只叠加绘制当前拖拽节点。
private Bitmap _backBuffer; private bool _backBufferDirty = true; private void RebuildBackBuffer() { if (_backBuffer == null) _backBuffer = new Bitmap(Math.Max(1, Width), Math.Max(1, Height)); using (Graphics g = Graphics.FromImage(_backBuffer)) { g.SmoothingMode = SmoothingMode.AntiAlias; g.Clear(BackColor); PaintConnections(g); PaintNodes(g); // 不包含选中态高亮 } _backBufferDirty = false; } protected override void OnPaint(PaintEventArgs e) { if (_backBufferDirty) RebuildBackBuffer(); e.Graphics.DrawImageUnscaled(_backBuffer, 0, 0); // 拖拽中的节点单独叠加绘制,画高亮边框 if (_dragNode != null) { PaintNode(e.Graphics, _dragNode, true); } }何时标记_backBufferDirty?新增节点、删除节点、修改连线、缩放、平移时都要标记。但拖拽节点时不能标,拖拽只改变节点Bounds,背景缓存里该节点的旧位置还在,所以需要先画背景,再叠加新位置节点。这有一个副作用:旧位置会残留一段像素痕迹。解决办法是在叠加前把旧位置附近区域单独排除掉,或者更简单的方法,用e.Graphics.FillRectangle先把节点新位置和旧位置的联合区域擦干净,再画背景、再叠加新节点。
我用一个折中方案:拖拽时不再贴完整背景,而是只贴被节点新旧位置覆盖过的矩形区域。这个区域通常只有几百像素大小,6 次 GDI 操作就完成,整帧耗时降到 2ms 以下。
RectangleF oldBounds = _dragNode.Bounds; // 拖拽更新 Bounds 后 RectangleF unionRect = RectangleF.Union(oldBounds, _dragNode.Bounds); RectangleF expandRect = RectangleF.Inflate(unionRect, 4f * _scale, 4f * _scale); using (Bitmap backBuffer = RebuildBackBuffer()) { e.Graphics.DrawImageUnscaledAndClipped(backBuffer, new Rectangle( (int)expandRect.X, (int)expandRect.Y, (int)expandRect.Width, (int)expandRect.Height )); } PaintNode(e.Graphics, _dragNode, true);注意RebuildBackBuffer被调用时的性能问题被我写错了,实际应用里应该只在脏标记时重建,不是每一帧都重建。重建动作本身也不该在拖拽路径里触发,否则拼背景和绘制节点变成串行操作,等于没优化。我在源码包里写的版本是用独立方法UpdateBackBufferIfDirty()保证这一点。
最后说一个验证方法:在窗体里放一个Timer或者直接用Stopwatch记录OnPaint的运行时间。我给自己定的及格线是:60 个节点、60 条连线、4ms 以内。超过这个数就检查RebuildBackBuffer是不是被频繁调用,或者PaintConnections里是否做了过度的FindNodeById查找。那次优化完我把节点加到 150 个测试,拖动依然像巧克力一样顺滑。从那以后我每次做自绘控件,第一件事就是把背景缓存策略写进设计文档,而不是等卡了再返工。希望这篇笔记能帮你少走一段弯路。
本文还有配套的精品资源,点击获取