ARTICLE DETAIL

资讯详情

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

C# WinForms自绘流程图编辑器:从设计到实现

C# WinForms自绘流程图编辑器:从设计到实现 简介这是一套面向C# WinForm开发初学者与进阶者的流程图绘制工具项目解决图形化流程建模中常见的绘图、连接、属性控制与持久化等核心需求适用于课程设计、工作流原型开发及低代码可视化组件学习。资源包共251个文件包含83个C#源码文件含主窗体、图形类、连接逻辑与序列化模块、33个resources资源文件图标与本地化支持、20个resx本地化资源及12个PNG/GIF界面素材结构规范、分层清晰压缩后仅2.75MB轻量易部署。已有1514人学习下载项目配备完整中文注释覆盖鼠标拖拽建模、智能节点吸附连接、双击编辑文本、右键菜单操作置顶/置底/删除/截屏/底图设置、前驱后继关系维护、直线/折线样式切换及坐标尺寸颜色等属性实时调节功能并支持文件存取与画布清空。代码采用标准WinForm架构便于二次扩展与教学演示。 最近有个做上位机的朋友找我帮个小忙说要在他的WinForms程序里嵌一个流程图编辑器画流程框、判断框连线能保存工程还得能撤销重做。我一听就知道这事不能用现成的第三方控件硬套需求虽然不复杂但牵扯到自绘、交互、序列化和撤销管理一堆东西。索性把整个项目完整捋了一遍从零实现了一个C# WinForms流程图项目带中文注释矩形、菱形、圆角矩形、椭圆都能画支持直线、折线连接图形可以拖拽缩放连线可以断开属性可以实时调节还能保存和步骤记录。这篇文章就是对这个项目从设计到落地的一次完整复盘适合自己写小工具、做上位机流程配置界面、或者想在WinForms里做轻量节点编辑器的同学参考新手也能跟着思路走通。我会尽量把“为什么这么做”讲清楚而不是光贴代码。做这类自绘编辑器真正的难点不在画图而在交互反馈、数据模型和撤销恢复之间的配合下面一个个拆开说。1. 项目整体设计与架构拆解1.1 需求拆解标题里的每个词对应一个模块先把标题拆开看“绘制各种流程图形”对应图形模型与GDI渲染模块“保存”对应文档序列化模块“步骤记录”对应撤销/重做模块“删除”对应编辑命令模块“连接断开”对应连线交互模块“直线折线”对应连线类型和箭头绘制“属性调节”对应PropertyGrid联动模块。这些不是零散功能点而是一个完整流程图编辑器的标准组成。所以第一件事不是急着写DrawLine而是先把数据模型定好。我的思路是图形和连线完全分离图形只管自己的位置、大小、颜色、文字连线只记录它连接的是哪两个图形、用哪种连线方式不缓存中间拐点。这样做的好处是移动图形时不需要重算所有连线拐点只需要在Paint里根据当前连接的图形位置动态计算路径后面序列化也特别干净。1.2 为什么选WinForms自绘而不是第三方控件网上确实有很多现成的流程图控件像GoDiagram、MindFusion之类功能强但有两个问题第一是授权费用不低很多个人开发者和小项目扛不住第二是定制成本高你很难改到“完全贴合自己业务”的程度。还有一批开源库功能确实全但动辄几万行代码接进来之后想加个字段都要摸半天源码。WinForms自绘则轻量得多。用UserControl做画布GDI画所有图元整个编辑器就一个控件放哪里都行。虽然WPF的Canvas和DataTemplate做这类界面更舒服但很多工控上位机项目还停留在WinForms技术栈比如串口、TCP通信、数据采集这些老底子用WinForms部署最省事。这个项目中我把所有UI交互收口在一个FlowCanvas控件里外部不需要关心内部是怎么画的直接拖进窗体就能用。做界面美化时也可以把FlowCanvas嵌入AntdUI或者SunnyUI的窗体完全不受影响。1.3 模型层设计FlowShapeBase、FlowConnection、FlowDocument模型层我建议分成三个核心类FlowShapeBase图形基类保存Id、Text、Bounds、填充色、边框色、选中状态还定义绘制和命中测试的抽象方法。FlowConnection连线对象保存StartShapeId、EndShapeId、起始锚点索引、结束锚点索引、连线类型直线/折线。FlowDocument文档容器包含Shapes列表和Connections列表负责对外提供增删改查。为什么连线不直接持有图形的对象引用而是保存Id因为保存到文件的时候XmlSerializer无法自动处理对象引用如果序列化时把整个图形对象嵌进连线里会出现大量重复数据加载时还会遇到循环引用问题。保存Id加载时再通过Id反向查找图形是最稳妥的做法。FlowShapeBase里有一个值得强调的设计不要把图形直接暴露给PropertyGrid而是提供一个只读属性集合或者DTO映射层。否则当你选中图形时如果PropertyGrid直接编辑了Bounds的X、Y、Width、Height每次击键都会触发重绘但GraphicsPath还没有重建会出现拖影和闪烁。属性调节要分“编辑提交”和“实时预览”两种模式这一点后面详细说。2. 核心细节解析与实操要点2.1 用GDI把流程图形画出来图形绘制统一走Draw(Graphics g)方法。为了让界面不闪烁我重写了OnPaint开启双缓冲和抗锯齿public class FlowCanvas : UserControl { public FlowCanvas() { SetStyle(ControlStyles.UserPaint | ControlStyles.AllPaintingInWmPaint | ControlStyles.OptimizedDoubleBuffer, true); } protected override void OnPaint(PaintEventArgs e) { e.Graphics.SmoothingMode System.Drawing.Drawing2D.SmoothingMode.AntiAlias; base.OnPaint(e); // 先画连线再画图形这样连线不会压在图形上面 DrawConnections(e.Graphics); DrawShapes(e.Graphics); } }绘制顺序很关键先画所有连线再画所有图形。如果先画图形再画连线线会穿过图形视觉上感觉连线“浮在方块上”而且箭头会被图形盖住。流程图的连线应该始终在图形下层图形被选中时的边框高亮线则画在最顶层。2.2 圆角矩形与菱形的绘制细节矩形和椭圆直接用FillRectangle、DrawRectangle、FillEllipse、DrawEllipse很方便但圆角矩形和菱形不能直接画要用GraphicsPath。圆角矩形的实现是用四条弧线拼接成一个闭合路径public static 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; }这里有个细节radius不能超过矩形宽高的一半否则四段弧会互相重叠绘制出来的形状会很奇怪。建议在属性面板里对圆角半径做上限限制比如radius Math.Min(radius, Math.Min(rect.Width, rect.Height) / 2)。菱形用四个顶点连线就行public override void Draw(Graphics g) { PointF[] pts new PointF[] { new PointF(Bounds.Left Bounds.Width / 2, Bounds.Top), new PointF(Bounds.Right, Bounds.Top Bounds.Height / 2), new PointF(Bounds.Left Bounds.Width / 2, Bounds.Bottom), new PointF(Bounds.Left, Bounds.Top Bounds.Height / 2) }; using (GraphicsPath path new GraphicsPath()) { path.AddPolygon(pts); using (SolidBrush brush new SolidBrush(FillColor)) g.FillPath(brush, path); using (Pen pen new Pen(BorderColor, 2f)) g.DrawPath(pen, path); } }所有画刷、画笔都要用using包起来GDI对象是典型的非托管资源频繁创建又不释放跑一会儿内存占用就会异常最后界面越来越卡这是WinForms开发里最容易被忽略的问题。2.3 命中测试与拖拽选中命中测试决定你鼠标点下去时到底选中了哪个图形或哪条连线。矩形判断最简单Bounds.Contains(point)。圆角矩形和椭圆要用GraphicsPath.IsVisible(point)它内部会做真实区域的路径判断不会出现点到圆角外却算选中的情况。图形多了以后要注意命中顺序多个图形叠在一起时应该从最上层往下判断也就是遍历Shapes列表时倒序查找最后绘制的最上层图形优先命中。拖拽的逻辑是MouseDown时先做命中测试如果点到了图形记录按下时的鼠标位置和图形原始位置。MouseMove时计算位移偏移量dx、dy更新图形Bounds同时调用Invalidate()重绘。MouseUp时结束拖拽这时候才把这次移动压入撤销栈。这里有一个我踩过的坑如果MouseMove里每次都把Bounds.X e.X - lastX一旦在移动过程中重新命中到另一个图形坐标就会跳变。正确做法是始终用“按下时的起点 当前鼠标偏移”而不是累计增量。配合键盘方向键微调时也一样按一下改变1像素累积逻辑要放在命令层而不是绘制层。2.4 连线与断开的交互设计连线交互是这个项目里最需要耐心的地方。我的方案是做“锚点吸附”每个图形提供4个锚点分别在上、下、左、右四条边的中点。鼠标从某个锚点拖出连线到另一个图形附近时计算鼠标位置离哪个锚点最近只要距离小于一定阈值比如15像素就自动吸附到锚点上。连线拖动状态机可以这样实现MouseDown先判断是否点中了现有连线的命中区域如果是就选中连线允许删除。如果没有点中连线但点中了图形边缘的锚点区域就进入“创建连线”模式。在“创建连线”模式下鼠标移动时画一条临时连线终点跟随鼠标。MouseUp时判断终点是否落在某个图形锚点附近是则创建成功否则取消本次连线。断开操作更简单选中连线后按Delete删除或者在右键菜单里放一个“断开连接”命令底层都是同一个RemoveConnection方法。但要注意删除连线前必须先把它从Connections列表里移除再刷新画布否则重绘时会访问到空引用。连线的绘制要区分直线和折线。直线直接连两个锚点。折线我采用最简单的“两点垂直拐弯”方案如果目标点在起点下方就先向下走一半深度再水平走到目标点正上方最后垂直落下去。这样出来的折线是标准的三段式足够覆盖大部分流程图的连线场景private PointF[] BuildPolyline(PointF start, PointF end) { float midY start.Y (end.Y - start.Y) / 2f; return new PointF[] { start, new PointF(start.X, midY), new PointF(end.X, midY), end }; }这种固定拐弯方式虽然不算智能但胜在计算简单、结果可预期。实际使用时流程图里的图形排列通常不会太密集很少出现需要绕障碍的情况。真要做自动避障复杂度会成倍上升不是这个项目的重点。箭头绘制放在线段终点方向用两条短线拼出箭头形状。计算时要根据终点到起点的角度来确定箭头两个翅片的方向否则箭头指向会乱private void DrawArrow(Graphics g, Pen pen, PointF end, float angle) { float arrowSize 10f; PointF p1 new PointF( end.X - arrowSize * (float)Math.Cos(angle - 0.4), end.Y - arrowSize * (float)Math.Sin(angle - 0.4)); PointF p2 new PointF( end.X - arrowSize * (float)Math.Cos(angle 0.4), end.Y - arrowSize * (float)Math.Sin(angle 0.4)); g.DrawLine(pen, end, p1); g.DrawLine(pen, end, p2); }3. 实操过程与核心环节实现3.1 保存与打开用XmlSerializer处理图形和连线保存功能我用的是XmlSerializer。它能直接序列化公开属性和字段生成的文件可读性好中文支持也没问题。但前面说了序列化时不能直接序列化FlowShapeBase的派生类否则要写一堆XmlInclude维护成本很高。我的做法是定义独立的DTO类运行时和持久化时分别转换[Serializable] public class ShapeData { public ShapeType Type { get; set; } 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 BorderColor { get; set; } public string FillColor { get; set; } } [Serializable] public class ConnectionData { public string StartShapeId { get; set; } public string EndShapeId { get; set; } public int StartAnchorIndex { get; set; } public int EndAnchorIndex { get; set; } public LinkType Type { get; set; } }保存时把运行时图形列表转换成ShapeData列表连线里的对象引用换成Id加载时反向操作。颜色这类值类型在XML里不直观我统一转成#RRGGBB字符串保存加载时用ColorTranslator.FromHtml还原。实际操作中一定要捕捉InvalidOperationException因为XML格式稍微变动反序列化就会失败。我给打开文件加了一个简单的版本号字段放在根节点上以后格式升级时做兼容判断。这种“DTO 版本号”的做法比直接序列化运行时对象要稳妥得多强烈建议一直保留。3.2 步骤记录快照栈实现撤销重做撤销重做我选了快照方案而不是命令模式。原因很简单这个项目的操作对象是图形列表和连线列表几乎每个操作都会改多个字段如果用命令模式每个动作都要写单独的Execute和Undo工作量翻倍。快照方案每次在操作前把整个文档序列化成字符串压栈撤销时恢复快照代码量极少效果直观。核心代码private Stackstring undoStack new Stackstring(); private Stackstring redoStack new Stackstring(); private void PushSnapshot() { undoStack.Push(SaveToXml()); if (undoStack.Count 50) { string[] arr undoStack.ToArray(); undoStack new Stackstring(arr.Take(49).Reverse()); } redoStack.Clear(); } private void Undo() { if (undoStack.Count 0) return; redoStack.Push(SaveToXml()); LoadFromXml(undoStack.Pop()); Invalidate(); } private void Redo() { if (redoStack.Count 0) return; undoStack.Push(SaveToXml()); LoadFromXml(redoStack.Pop()); Invalidate(); }快照方案有两个注意点。第一历史栈深度要限制我压到50层就不再增加否则图形一多XML字符串会占大量内存。第二每次序列化都会产生GC压力所以不要在MouseMove里压栈只应该在MouseUp、删除操作、修改属性的提交事件等“动作完成”节点压栈。还有一个细节撤销时如果当前文档有未保存的修改可以先弹出一个保存确认再执行撤销恢复。如果直接覆盖当前文档用户误操作一下可能丢掉整个工作成果。我把这个逻辑放在窗体级Closing事件里和撤销逻辑分开处理。3.3 属性调节PropertyGrid联动属性面板我直接用PropertyGrid控件选中图形后把SelectedObject设成对应的FlowShapeBase实例就行了。它最大的好处是零成本获得分类、说明和类型校验public class FlowShapeBase { [Category(布局)] [DisplayName(横坐标)] [Description(图形左上角的X坐标)] public float X { get Bounds.X; set { Bounds.X value; } } [Category(样式)] [DisplayName(边框颜色)] [Description(图形边框的颜色)] public Color BorderColor { get; set; } }但直接绑定FlowShapeBase有个坑修改X、Y、Width、Height时每次数字键盘按一下PropertyGrid就会触发一次属性变更。如果每次变更都调用Invalidate()用户输入“123”会触发三次重绘三个值界面看起来会闪烁卡顿。我的处理方法是在非鼠标拖拽状态下属性变更直接重绘而在拖拽过程中属性面板只做只读展示等拖拽结束再刷新值。另外当用户通过属性面板修改属性后PropertyGrid有时候不会自动刷新显示内容。这时需要手动调用propertyGrid1.Refresh()。还有一个经验如果属性值是自定义类记得加[TypeConverter(typeof(ExpandableObjectConverter))]否则PropertyGrid无法展开子属性只能显示一个类名交互很差。多选图形时的属性调节我用了一个更细的方案不直接绑定单个实例而是构造一个SelectionProps对象它内部持有当前选中的所有图形修改一个属性时批处理同步到所有选中图形。这样能实现“多选批量修改字体、批量换颜色”的效果。4. 常见问题与排查技巧实录4.1 图形拖动时闪烁严重这是自绘控件最常见的毛病根源是GDI默认不会清理背景重绘时每次Graphics.Clear都会闪烁。解决方式在FlowCanvas构造函数里一次性开启双缓冲public FlowCanvas() { SetStyle(ControlStyles.UserPaint | ControlStyles.AllPaintingInWmPaint | ControlStyles.OptimizedDoubleBuffer, true); UpdateStyles(); }AllPaintingInWmPaint告诉系统在WM_PAINT里一次性完成所有绘制OptimizedDoubleBuffer则让WinForms内部用内存画布做缓冲。开了双缓冲之后如果还有轻微闪烁检查是不是在OnPaint里创建了大量Pen、Brush对象。可以用using或者类级静态字段复用默认画刷避免每次绘制都分配几百个对象。4.2 缩放后鼠标坐标对不上我加了Ctrl滚轮缩放功能Zoom是一个float类型绘制时对Graphics做ScaleTransformprivate void OnMouseWheelZoom(MouseEventArgs e) { if (Control.ModifierKeys Keys.Control) { Zoom e.Delta 0 ? 0.1f : -0.1f; Zoom Math.Max(0.2f, Math.Min(3f, Zoom)); Invalidate(); } }问题在于鼠标事件里的坐标是控件原始坐标而绘制时候用了缩放如果不做逆变换命中测试全乱套。我写了一个坐标转换方法所有鼠标坐标先转成逻辑坐标再判断private PointF ScreenToLogic(Point p) { float logicX (p.X - OffsetX) / Zoom; float logicY (p.Y - OffsetY) / Zoom; return new PointF(logicX, logicY); }OffsetX和OffsetY是画布平移的偏移量按住鼠标中键拖动时更新。这套“Zoom Offset ScreenToLogic”的模型是所有自绘编辑器的核心坐标体系建议在一开始就封装好不要到处零散地乘除否则后期加一个辅助线功能就会踩坑。4.3 折线在移动后拐点变形折线如果保存中间拐点移动图形后拐点不会跟着更新最后就会“断掉”。我在前面说了连线只保存两端的图形Id和锚点索引中间拐点不落盘。绘制时每次动态计算这样无论图形怎么移动连线都能实时跟随。但这里有个反直觉的点理论上动态计算更省心实际在密集图形场景下最简单的“中点垂直拐弯”可能会从其他图形上面穿过去。我目前的方案是在生成拐点时做一个简单检测如果起点和目标点的水平距离小于一个阈值就改成“先水平再垂直”的路径避免拐点偏移过大。更完善的做法是引入一个网格路由算法但项目里用不到那么复杂有需要再扩展。4.4 常见问题速查表问题原因解决办法图形拖动时闪烁未开启双缓冲构造函数里SetStyle开启OptimizedDoubleBuffer缩放后点不准图形鼠标坐标未做逆变换统一用ScreenToLogic转换坐标折线移动后错乱保存了固定拐点不保存拐点绘制时动态计算序列化报错“无法加载类型”派生类没有声明用DTO隔离运行时对象和持久化对象PropertyGrid修改后不刷新属性变更通知缺失手动调用Refresh或实现INotifyPropertyChanged删除图形后连线还残留没有级联删除删除图形时同步删除Connections里对应的连线撤销后画布没更新只恢复了数据没有InvalidateUndo/Redo后调用Invalidate画菱形时边缘有锯齿没开抗锯齿OnPaint里设置SmoothingMode AntiAlias最后再分享一个小技巧画布右边界和下边界加一个“自动扩展”逻辑当图形拖到边缘时扩大画布尺寸避免图形突然被裁剪掉。还有双击图形时在Bounds位置弹出一个TextBox实现文字原地编辑Enter确认、Esc取消这个小交互对流程图编辑器来说非常提升使用体验。我个人在实际项目中最大的体会是自绘编辑器最重要的不是某个画图功能多炫而是数据模型够清晰。只要模型把图形、连线、锚点、文档边界分得干干净净后面加保存、撤销、属性面板都只是往模型上挂功能的事。如果你也在做类似的东西建议先花一个晚上把模型层定好再动手画第一个矩形后面会顺很多。本文还有配套的精品资源点击获取
返回列表