ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

C# GDI+手写WinForms流程图控件:从对象模型到命令模式

C# GDI+手写WinForms流程图控件:从对象模型到命令模式 简介这是一套面向C#初学者与WinForm进阶开发者的流程图绘制工具完整源码项目解决图形化流程建模、可视化逻辑表达与教学演示中的实际开发需求适用于课程设计、毕业设计及轻量级工作流原型开发。资源包共251个文件包含83个核心C#源码文件含主窗体、图形类、连接线管理、序列化逻辑等、33个Resources资源文件图标、光标、本地化资源、20个resx本地化配置及20个cache编译缓存结构规范、模块职责清晰压缩包仅2.75MB便于快速导入与二次扩展。已有1514人学习下载代码全程中文注释覆盖图形拖拽创建、智能节点吸附连接、双击编辑文字、属性面板实时调节填充色/位置/尺寸/层级、右键上下文菜单操作、前驱后驱关系维护、文件存取与截屏导出等全部功能点工程已含可直接运行的exe与完整sln解决方案开箱即用。 做WinForms开发这些年流程图这块的需求一直没断过。工控上位机里要画工艺流程图OA系统里要画审批流程仿真软件里要画逻辑框图。早些年我先后试过好几款第三方流程图控件不是授权费劝退就是API太封闭改不动最后干脆自己动手用C#写了一个功能完整的WinForms流程图项目绘制各种流程图形、保存、步骤记录、删除、连接断开、直线折线、属性调节全都做了代码里还带中文注释。这篇文章就把整个设计和实现过程拆开揉碎了讲清楚适合那些被流程图需求困住、想在WinForms里自研绘图组件的开发者参考。1. 从借轮子到手搓轮子WinForms里为什么值得自研一个流程图控件1.1 第三方库的坑我基本都踩过先说结论不是所有项目都要自研。如果你的需求只是画一张静态流程图给文档用Visio就能解决如果只需要在线展示AntV X6这类前端方案更合适。但如果你需要的是能拖、能连、能保存、能撤销、能对接业务数据的桌面端流程图控件WinForms生态里选择其实很少。我当时评估过几条路线方案优势痛点商业控件如GoDiagram功能全支持复杂布局授权费贵扩展业务逻辑受限于API开源控件如Netron有基础图形和连线年代久远代码风格老撑不起复杂交互WPF 自带绘图框架渲染能力强项目组不熟WPF重构成本大GDI 自研完全可控代码透明需要从零设计数据模型和命中测试说实话当时让我下决心自研的还有一个现实原因客户提了一个步骤记录的需求要求每一步操作都可以回退还要能回放。这玩意儿商业控件不直接提供属于典型的业务定制功能。自研之后就简单了我在自己的数据模型上做命令模式所有的增删改连接都能入栈。1.2 自研流程图控件的功能边界一个能用的流程图组件不是画几个框加几条线就完事了。我整理下来核心功能要做到这五件事绘制各种流程图形矩形、圆角矩形、菱形、数据框、文档框、椭圆等最好能通过继承扩展新图形。连线能力支持直线和折线连线可以选中、删除、断开拖动节点时连线自动跟随。保存与重新打开把画布上的所有节点和连线序列化到文件下次打开还能恢复到原样。步骤记录撤销、重做这是很多人容易忽略的刚需。属性调节点击某个图形后右侧能显示它的所有属性改完画布即时应答。1.3 技术选型细节技术栈锁定为C# WinForms GDI目标框架.NET Framework 4.8。为什么不用.NET 6/8因为很多工控上位机环境还停留在Windows 7/10 .NET FrameworkWinForms在.NET Core时代虽然也能跑但考虑到客户现场环境4.8是最稳妥的基线。GDI在这类场景下性能是完全够用的。流程图不是高频游戏画面绝大多数操作是鼠标松开后才重绘极限情况也就是拖动图形时每秒刷新几十帧GDI的DoubleBuffer可以轻松应付。2. 数据先行形状与连线的对象模型设计写绘图程序最容易犯的错误就是边画边想——直接在Panel的Paint事件里写死画一堆矩形和直线。这种做法Demo跑得通一旦加上拖拽、删除、保存就全乱套。我的建议是先设计数据模型把画面上的每个元素都变成对象渲染只是把对象画出来。2.1 ShapeBase所有图形的公共基类不管是矩形还是菱形它们在逻辑上都是一个占据矩形区域的图形对象。所以基类要包含public abstract class ShapeBase { // 唯一ID用于保存、连接、撤销 public Guid Id { get; set; } Guid.NewGuid(); // 图形在画布中的位置和大小 public RectangleF Bounds { get; set; } // 显示文本 public string Text { get; set; } // 样式 public Color FillColor { get; set; } Color.White; public Color BorderColor { get; set; } Color.Black; public float BorderWidth { get; set; } 2f; // 连接点图形的四个边中点 public PointF TopPoint new PointF(Bounds.X Bounds.Width / 2f, Bounds.Y); public PointF BottomPoint new PointF(Bounds.X Bounds.Width / 2f, Bounds.Bottom); public PointF LeftPoint new PointF(Bounds.X, Bounds.Y Bounds.Height / 2f); public PointF RightPoint new PointF(Bounds.Right, Bounds.Y Bounds.Height / 2f); // 子类必须实现如何构造选中区域用于着色、命中测试 public abstract GraphicsPath CreatePath(RectangleF bounds); // 绘制图形默认填充路径、画边框、画文本 public virtual void Draw(Graphics g) { using (GraphicsPath path CreatePath(Bounds)) { using (SolidBrush fillBrush new SolidBrush(FillColor)) { g.FillPath(fillBrush, path); } using (Pen borderPen new Pen(BorderColor, BorderWidth)) { g.DrawPath(borderPen, path); } DrawText(g); } } // 判断某个点是否落在图形内部 public bool Contains(PointF pt) { using (GraphicsPath path CreatePath(Bounds)) { return path.IsVisible(pt); } } }这里的CreatePath是关键。GDI里几乎所有曲线和折线多边形都能统一抽象成GraphicsPath填充、描边、命中测试都可以基于它做。这让后面扩展新图形变得极其简单只要重写CreatePath即可。2.2 派生具体图形ProcessBox、DecisionBox、DataBox有了基类再来实现各种流程图形。// 处理框圆角矩形 public class ProcessBox : ShapeBase { public float CornerRadius { get; set; } 10f; public override GraphicsPath CreatePath(RectangleF bounds) { GraphicsPath path new GraphicsPath(); float r CornerRadius; // 顺时针画圆角矩形 path.AddArc(bounds.X, bounds.Y, r * 2, r * 2, 180, 90); path.AddArc(bounds.Right - r * 2, bounds.Y, r * 2, r * 2, 270, 90); path.AddArc(bounds.Right - r * 2, bounds.Bottom - r * 2, r * 2, r * 2, 0, 90); path.AddArc(bounds.X, bounds.Bottom - r * 2, r * 2, r * 2, 90, 90); path.CloseFigure(); return path; } } // 判断框菱形 public class DecisionBox : ShapeBase { public override GraphicsPath CreatePath(RectangleF bounds) { GraphicsPath path new GraphicsPath(); float cx bounds.X bounds.Width / 2f; float cy bounds.Y bounds.Height / 2f; path.AddLine(cx, bounds.Y, bounds.Right, cy); path.AddLine(bounds.Right, cy, cx, bounds.Bottom); path.AddLine(cx, bounds.Bottom, bounds.X, cy); path.CloseFigure(); return path; } } // 数据框左边多一条竖线的那种 public class DataBox : ShapeBase { public override GraphicsPath CreatePath(RectangleF bounds) { GraphicsPath path new GraphicsPath(); path.AddRectangle(RectangleF.Inflate(bounds, -8, 0)); path.CloseFigure(); return path; } public override void Draw(Graphics g) { base.Draw(g); // 数据框特点左侧画一条竖线 using (Pen pen new Pen(BorderColor, BorderWidth)) { float x bounds.X 8; g.DrawLine(pen, x, bounds.Y, x, bounds.Bottom); } } }你可能会问图形边角上的连接点为什么直接用边中点因为后续画折线时连线默认从源图形的最近边出来四个边的中点作为锚点最简单、也最符合用户直觉。如果需求复杂可以扩展成每个形状注册N个连接点但绝大多数流程图场景用四边中点已经足够。2.3 Connector连线是独立对象不是GDI的Line很多教程把连线画成两个点之间的一条线保存时就记住端点坐标。这几乎是最差的设计一旦节点被拖动坐标就失联了。我的做法是Connector对象保存源Shape和目标Shape的Idpublic class Connector { public Guid Id { get; set; } Guid.NewGuid(); public Guid SourceShapeId { get; set; } public Guid TargetShapeId { get; set; } public string SourcePort { get; set; } // Top Bottom Left Right public string TargetPort { get; set; } public bool IsBroken { get; set; } // 是否处于断开状态 public LineType LineType { get; set; } // 直线 or 折线 }这样保存时只需要保存SourceShapeId、TargetShapeId和端口名称。加载时通过Id找到对应图形再计算具体端点坐标。3. GDI绘图各种流程图形和折线是怎样一笔一划画出来的3.1 统一控件的绘制流程我自定义了一个FlowDesignerControl : UserControl所有绘制逻辑都在OnPaint里做。绘制分三层protected override void OnPaint(PaintEventArgs e) { Graphics g e.Graphics; g.SmoothingMode System.Drawing.Drawing2D.SmoothingMode.AntiAlias; g.Clear(this.BackColor); // 第一层画网格点辅助对齐可开关 DrawGrid(g); // 第二层画连线注意连线要画在图形下层 foreach (Connector conn in m_connectors) { ConnectorRenderer.Draw(g, conn, GetShapeById(conn.SourceShapeId), GetShapeById(conn.TargetShapeId)); } // 第三层画图形本身 foreach (ShapeBase shape in m_shapes) { shape.Draw(g); } // 最后画选中状态 if (m_selectedShape ! null) { using (Pen pen new Pen(Color.DodgerBlue, 2f)) { pen.DashStyle DashStyle.Dash; g.DrawRectangle(pen, Rectangle.Round(m_selectedShape.Bounds)); } } }这里有个经验连线画在图形下层。如果连线画在图形上层连线会穿过图形中心的文字上方视觉上非常难看。放在下层配合图形不透明填充色连线就被截断在图形边缘看起来像是从边界线连出去的这才是标准Flowchart的视觉风格。3.2 直线与折线的渲染细节直线最简单直接连接两个端口点。折线需要计算路径拐点。我是这样实现的从SourceShape的出口端口先走一小段垂直或水平方向中间走一段直路最后进入TargetShape的入口端口。用端口类型决定第一步和最后一步的方向public static ListPointF BuildPolylinePoints(PointF start, string startPort, PointF end, string endPort) { ListPointF points new ListPointF { start }; // 源端口如果是Left/Right先水平走一段是Top/Bottom先垂直走一段 bool startHorizontal (startPort Left || startPort Right); bool endHorizontal (endPort Left || endPort Right); float offset 30f; if (startHorizontal) { float dir startPort Right ? 1f : -1f; points.Add(new PointF(start.X offset * dir, start.Y)); } else { float dir startPort Bottom ? 1f : -1f; points.Add(new PointF(start.X, start.Y offset * dir)); } // 中间段源垂直就水平过去源水平就垂直过去 if (startHorizontal) { points.Add(new PointF(points[^1].X, end.Y)); } else { points.Add(new PointF(end.X, points[^1].Y)); } points.Add(end); return points; }这段代码的关键逻辑是先偏移、再对齐、后进入。你可以理解为连线离开源图形时先沿着端口方向伸出一段距离避免折角紧贴图形边缘然后水平或垂直转弯最终从目标图形的端口伸进去。实测下来节点上下左右排列都能得到比较自然的折线效果。绘制时用GDI的DrawLinesusing (Pen pen new Pen(Color.Black, 2f)) { pen.StartCap LineCap.Flat; pen.EndCap LineCap.ArrowAnchor; // 末端画箭头 g.DrawLines(pen, points.ToArray()); }箭头用的是LineCap.ArrowAnchor但默认箭头较小。更精致一点可以画一个三角形在最后一条折线段的终点这个后面有需求再完善也不迟。4. 交互层实现框选、删除、拖动、连接与断开连接的完整流程4.1 鼠标事件的状态机画布交互本质是一个简单的状态机。我用一个枚举记录当前鼠标操作模式private enum MouseMode { None, DraggingShape, // 拖动节点 CreatingConnector, // 正在拉连线 DraggingConnector, // 拖动连线 RubberBand // 框选 }MouseDown时先判断命中哪个对象如果命中了连线选中连线模式为DraggingConnector。如果命中了图形选中图形记录偏移量模式为DraggingShape。如果命中在线条的端点上或者处于连接模式按钮按下状态启动CreatingConnector。否则进入框选模式。判断命中图形很简单遍历所有Shape调用Contains。判断命中连线需要算点线距离下面这段是核心public static double DistancePointToSegment(PointF p, PointF a, PointF b) { float dx b.X - a.X; float dy b.Y - a.Y; if (dx 0 dy 0) 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) / (dx * dx dy * dy); t Math.Max(0, Math.Min(1, 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)); }折线是由多段线段组成的所以命中测试就是遍历所有折线段计算点到线段的距离小于5像素就算命中。很多初学GDI的人只会在OnPaint里画线却忽略了绘图只是最后一步鼠标交互层才是真正难啃的地方。4.2 拖动节点时连线自动跟随拖动节点时只需要修改Shape的Bounds然后调用Invalidate触发重绘连线端点会从GetShapeById拿到最新的端口坐标所以不需要额外做任何事。关键点在于拖动时要用Control.MousePosition或者e.X/e.Y的差值更新坐标避免多次无效重绘。private void OnMouseMoveShape(PointF cur) { float dx cur.X - m_downPt.X; float dy cur.Y - m_downPt.Y; RectangleF old m_selectedShape.Bounds; m_selectedShape.Bounds new RectangleF(old.X dx, old.Y dy, old.Width, old.Height); m_downPt cur; Invalidate(); }有些实现里会把所有关联的Connector点也重新计算一遍其实没必要端口坐标是每次绘制时从Shape.Bounds里实时取出来的这才是对象模型设计的红利。4.3 连接与断开连接连接操作通常需要两步先在场景空白处或图形上按下鼠标拖动到目标图形上松开。这里要做的是在MouseDown时判断是否命中某个图形边缘的端口或者在拖拽过程中靠近某个图形的端口时自动吸附。断开连接我更倾向于右键菜单 删除键的组合。右键点击连接线弹出菜单里的断开连接项本质就是m_connectors.Remove(conn)。但项目中既然标题提到了连接断开还有一个特殊场景连线的一端图形的连接点被拖走时连线进入IsBroken状态。比如用户拖动某个节点到远处与它相连的连线可能变得过长、穿过其他图形这时可以在交互上提供折点断开功能——选中连线后拖动连线中间的控制点将一个折点拆成两个从而形成更复杂的绕线路径。从这个角度看连接断开不只是删除连线还包括连接点分离和断点管理。这两个功能在算法上可以简化连接点分离就是在拖动过程中实时检测拖拽的Shape与某个Connector的源或目标Id匹配如果拖开距离超过阈值就把该端点的端口类型置空仅保留另一端的连线从原点悬空延伸出来。断点管理则是把折线中间的拐点作为可拖动的控制点存进Connector的PointsOverride列表里。4.4 删除逻辑删除要分对象处理。删除图形时要找到所有以它为源或目标的Connector并一起删除否则保存文件里会出现悬空引用。public void DeleteShape(ShapeBase shape) { m_connectors.RemoveAll(c c.SourceShapeId shape.Id || c.TargetShapeId shape.Id); m_shapes.Remove(shape); Invalidate(); }这里还要注意撤销时怎么恢复被连带删除的连线。命令模式在这里体现出明显优势删除命令里记录被删的Shape和所有被连带删除的ConnectorUndo时全部恢复。5. 步骤记录与撤销/重做用命令模式把后悔药做成通用机制5.1 为什么在绘图控件里要强制用命令模式大多数绘图软件里步骤记录指的是用户每一步操作都能前进回退。如果不用命令模式而是操作前手动存一份全部对象快照做Undo时直接把快照恢复看起来省事但大数据量下内存暴涨而且无法做细粒度撤销比如撤销某一步连线而不是整个画布状态。命令模式的思路是把每个操作封装为一个对象包含Execute和Undo两个方法。当用户执行操作时先执行再压栈撤销时弹出栈顶调用Undo。5.2 命令基类与具体命令public interface ICommand { void Execute(); void Undo(); } public class AddShapeCommand : ICommand { private FlowDesignerControl _designer; private ShapeBase _shape; public AddShapeCommand(FlowDesignerControl designer, ShapeBase shape) { _designer designer; _shape shape; } public void Execute() { _designer.Shapes.Add(_shape); _designer.Invalidate(); } public void Undo() { _designer.Shapes.Remove(_shape); _designer.Invalidate(); } } public class MoveShapeCommand : ICommand { private FlowDesignerControl _designer; private ShapeBase _shape; private RectangleF _before; private RectangleF _after; public MoveShapeCommand(ShapeBase shape, RectangleF before, RectangleF after) { _shape shape; _before before; _after after; } public void Execute() { _shape.Bounds _after; } public void Undo() { _shape.Bounds _before; } // 每个命令的触发方在创建时记录 before / after }同时维护两个栈private StackICommand m_undoStack new StackICommand(); private StackICommand m_redoStack new StackICommand();执行命令后push到undoStack清空redoStack。撤销时调用Undo再把命令push到redoStack。重做则相反。5.3 哪些操作要入栈理论上所有改变画布状态的操作都要入栈。最基本的添加图形删除图形移动图形注意拖动的中间过程不入栈只有鼠标松开那一刻入栈一次添加连线删除连线修改属性属性调节面板的回车/失去焦点时入栈拖动过程中每帧都入栈是最常见的错误。我一开始也踩过这个坑拖动一个矩形到新位置Undo一次只能退回一帧用户按十几次才回到原位。正确做法是MouseUp时才把从mouseDown的Bounds到mouseUp的Bounds作为一个MoveShapeCommand压栈。5.4 步骤记录的另类用法操作回放有时候客户不只是要撤销还要步骤记录即每一步操作的日志回放。我这边顺手做了个CommandLogger在命令入栈时同时写入一个List 记录操作时间、操作类型、操作对象信息。回放时从日志重建命令序列并逐一执行。这个扩展做起来很容易因为命令模式本身就把每个操作拆干净了。6. 保存与加载跨会话恢复流程图的序列化方案6.1 为什么选JSON而不是二进制/XML流程图数据本质上是一棵对象图有图形集合、连线集合、图形属性。三种序列化方案里我用的是JSON理由也很实际二进制序列化速度快但版本升级后旧文件兼容性很差改一个字段类型直接反序列化失败。XML可读性不错但节点结构冗长而且Windows下中文编码问题偶尔会出幺蛾子。JSON在C#里配合Newtonsoft.Json或System.Text.Json序列化简单调试时随时打开文件看内容。6.2 保存数据模型设计序列化时不能直接把ShapeBase序列化因为里面有GraphicsPath这类不可序列化的对象。正确做法是定义DTOData Transfer Objectpublic class ShapeData { public string ShapeType { get; set; } // ProcessBox DecisionBox ... public Guid Id { get; set; } public float X { get; set; } public float Y { get; set; } public float Width { get; set; } public float Height { get; set; } public string Text { get; set; } public string FillColorArgb { get; set; } public string BorderColorArgb { get; set; } public float BorderWidth { get; set; } public Dictionarystring, object ShapeSpecificData { get; set; } } public class ConnectorData { public Guid Id { get; set; } public Guid SourceShapeId { get; set; } public Guid TargetShapeId { get; set; } public string SourcePort { get; set; } public string TargetPort { get; set; } public string LineType { get; set; } public ListPointF BreakPoints { get; set; } // 用户手动添加的折点 }保存流程就是把m_shapes和m_connectors转换成DTO列表然后序列化到文件。6.3 加载流程与多态恢复加载是保存的逆过程。难点在于FlowDesignerControl里持有的是ShapeBase列表但JSON写入的是各种派生类型的DTO。怎么根据ShapeType还原成对应的子类用简单工厂public static ShapeBase CreateShape(string shapeType, ShapeData data) { ShapeBase shape; switch (shapeType) { case ProcessBox: shape new ProcessBox(); break; case DecisionBox: shape new DecisionBox(); break; case DataBox: shape new DataBox(); break; default: shape new ProcessBox(); break; } shape.Id data.Id; shape.Bounds new RectangleF(data.X, data.Y, data.Width, data.Height); // ... 设置其它属性 return shape; }这里有个细节Id必须从文件恢复不能重新生成。当初保存ConnectorData里存的是SourceShapeId和TargetShapeId加载时要用这些Id去匹配之前恢复出来的ShapeBase实例所以ShapeBase的Id必须保留。6.4 保存文件时需要注意的编码与资源问题文件编码统一用UTF-8避免中文文本在不同系统上乱码。另外如果项目中引用了第三方dll、字体、图片资源不要在保存时把它们写进流程文件只保存引用路径。这样整个流程文件可以被版本控制软件管理diff起来也方便。7. 属性调节面板PropertyGrid 所见即所得的关键细节7.1 为什么不自己画属性面板WinForms自带PropertyGrid这东西简直是万能属性编辑面板。我项目里的属性调节就是用它做的节省了大量开发时间。关键技巧是把选中对象的属性通过[Category]、[DisplayName]、[Description]等特性标记好PropertyGrid会自动分组、展示中文描述。public class ShapePropertyAdapter { private ShapeBase _shape; public ShapePropertyAdapter(ShapeBase shape) { _shape shape; } [Category(布局)] [DisplayName(位置X)] [Description(图形左上角的X坐标)] public float X { get { return _shape.Bounds.X; } set { _shape.Bounds new RectangleF(value, _shape.Bounds.Y, _shape.Bounds.Width, _shape.Bounds.Height); } } [Category(布局)] [DisplayName(位置Y)] public float Y { get { return _shape.Bounds.Y; } set { _shape.Bounds new RectangleF(_shape.Bounds.X, value, _shape.Bounds.Width, _shape.Bounds.Height); } } [Category(外观)] [DisplayName(填充颜色)] public Color FillColor { get { return _shape.FillColor; } set { _shape.FillColor value; } } [Category(外观)] [DisplayName(边框颜色)] public Color BorderColor { get { return _shape.BorderColor; } set { _shape.BorderColor value; } } }在PropertyGrid上绑定选中对象private void SelectShape(ShapeBase shape) { m_selectedShape shape; propertyGrid1.SelectedObject new ShapePropertyAdapter(shape); Invalidate(); }7.2 属性变更自动刷新画布的几种姿势PropertyGrid本身只有值变更事件不会通知画布重绘。我最初写的是在PropertyGrid的PropertyValueChanged事件里调用Invalidate()。private void propertyGrid1_PropertyValueChanged(object s, PropertyValueChangedEventArgs e) { // 属性改了命令栈记录一下 if (m_selectedShape ! null) { PushCommand(new ChangePropertyCommand(m_selectedShape, e.OldValue, e.NewValue)); } Invalidate(); }但这里有个坑PropertyValueChanged的e.OldValue拿到的可能是旧值也可能是一个包含多个子属性的复杂对象对自定义类型不一定准确。想省心的做法是在属性绑定前先存一份旧状态快照事件触发时用快照和当前状态做diff生成属性修改命令。旧状态快照用浅拷贝就够了因为大多数属性是值类型。7.3 让属性面板支持枚举和颜色选择对于连线类型这种枚举属性我希望PropertyGrid显示中文而且下拉选择。可以配合TypeConverter实现public enum LineType { [Description(直线)] Straight, [Description(折线)] Polyline } public class LineTypeConverter : EnumConverter { public LineTypeConverter() : base(typeof(LineType)) { } public override object ConvertTo(ITypeDescriptorContext context, CultureInfo culture, object value, Type destType) { if (destType typeof(string)) { string name value.ToString(); FieldInfo fi value.GetType().GetField(name); DescriptionAttribute attr (DescriptionAttribute)fi.GetCustomAttribute(typeof(DescriptionAttribute)); if (attr ! null) return attr.Description; return name; } return base.ConvertTo(context, culture, value, destType); } }然后给Connector.LineType属性加上特性[TypeConverter(typeof(LineTypeConverter))] public LineType LineType { get; set; }这样属性面板里显示的就是直线和折线两个中文下拉项了。8. 实测中的坑与补丁抗锯齿、命中测试、刷新抖动这些细节8.1 保存GDI对象导致的内存泄漏初版我写得很随意Pen pen new Pen(Color.Black); g.DrawLine(pen, ...);后来用任务管理器一看内存持续上涨GDI对象数量一直涨到几百甚至上千。原因就是Pen、Brush、GraphicsPath都没有释放。修复方案是强制所有临时GDI对象都放进using块或者统一封装成工具类。这个坑在GDI绘图里特别常见因为GC最终会回收但回收时机不确定GDI对象没释放时进程可能已经不稳定了。8.2 拖动图形刷新抖动拖动图形时我一开始用的是Invalidate()整面板重绘图形多的时候会有闪烁、残影。解决方法是设置双缓冲SetStyle(ControlStyles.AllPaintingInWmPaint | ControlStyles.UserPaint | ControlStyles.OptimizedDoubleBuffer, true);如果是.NET 4.8还可以考虑在OnPaint里手动用BufferedGraphics绘制但上面三行样式基本就够了。实测从几十个图形到两百多个图形拖动都还算顺畅。8.3 命中测试的顺序MouseDown时判断命中对象的顺序很重要应该是连线优先于图形。因为连线端点和箭头会覆盖在图形附近如果先判断图形连线末端被节点遮挡时永远点不到连线。反过来连线虽然画在下层但命中测试优先判断连线这样用户才能方便地选中和删除连线。这是视觉层和交互层顺序不一致的经典场景。8.4 缩放和平移的预留扩展流程图做到后面用户一定会提出画布太大放不下想缩小一点或者用滚轮平移画布。我在项目里预留了一个ViewTransform类内部维护Scale和Offsetpublic class ViewTransform { public float Scale { get; set; } 1f; public PointF Offset { get; set; } PointF.Empty; public PointF ScreenToWorld(PointF screenPt) { return new PointF((screenPt.X - Offset.X) / Scale, (screenPt.Y - Offset.Y) / Scale); } public PointF WorldToScreen(PointF worldPt) { return new PointF(worldPt.X * Scale Offset.X, worldPt.Y * Scale Offset.Y); } }所有OnPaint绘制时都先把世界坐标转为屏幕坐标所有MouseDown/MouseMove坐标都先转为世界坐标。这样缩放平移和绘图、命中测试天然解耦。这个设计一次做好后面加功能会省很大力气。8.5 断线自动消除视觉锯齿绘制带箭头的折线时如果线条末端和图形边框紧密相连抗锯齿边缘会产生一圈白色或半透明的毛边。我的处理是在绘制箭头前先把箭头退回到图形外沿一个像素的距离或者在画图形时把边框宽度设为大于等于连线宽度让连接处被图形边框盖住。这个小细节能让整体视觉效果干净不少。9. 后续扩展思路这个流程图组件做完之后我又陆续接了三个方向的扩展在维护过程中觉得非常顺手自定义图形模板继承ShapeBase把公司的设备图标做成DrawableShape导入到画布业务上直接变成工艺流程图里的设备节点。自动布局基于拓扑排序做简单的上下层自动排列虽然比不上Graphviz强大但胜在集成在WinForms里没有额外依赖。多人协同把命令对象的Execute方法包一层网络传输A端执行命令同时发给B端B端收到命令后执行并刷新就变成了一个简易的协同设计工具。最后再分享一个小技巧所有图形和连线的对象集合我都放在FlowDesignerControl的公开属性里但数据库操作、业务逻辑全部不写在控件里而是写到外部服务类通过事件和属性双向绑定。这样控件本身保持纯UI业务框架可以换成任何架构风格。在整个开发过程中最有价值的一件事是坚持用对象模型驱动绘制和保存而不是在Paint事件里画死每一个形状。这个习惯让我添加新图形、修BUG、做撤销都轻松了很多。希望你做的时候也能把数据模型设计放在第一位这样后续的路会好走很多。本文还有配套的精品资源点击获取
返回列表