简介:这是一份基于 C# WinForm 的完整流程图绘制项目,面向需要开发图形化编辑工具的.NET开发者,提供了从工具箱创建图元到属性调节的一整套可复用方案。项目内置矩形、菱形、圆、直线、曲线等图元,支持六个操纵柄、四个自动吸附连接点、文字编辑、缩放画布、文件打开存储,且带操作记录可撤销,移动图形时已连接线条会联动,属性窗口可调整背景、填充和文字等参数,功能覆盖非常全面。资源包共94个文件,以53个cs源代码文件为核心,辅以配置文件、图片资源和可执行文件等,整体仅2.29MB,结构紧凑清晰,适合直接参考、移植或二次开发。目前已有880人学习下载,对希望掌握WinForm交互绘图、图元编辑与撤销机制的开发者来说,是一份性价比很高的实战模板。
1. C# WinForm 流程图:从工具箱拖拽到撤销重做,这套源码把设计器该有的都做全了
做上位机或者内部工具的时候,十有八九会遇到画流程图的需求。用第三方控件,贵的要命不说,改个样式还要看文档看到吐;自己从零写,画布缩放、图元拖动、连线、撤销重做,每一项都是看不见的坑。这套 C# WinForm 流程图源码属于「能直接跑、能照着改」的那一类,工具箱拖拽、文件存储打开、画布放大缩小、图元操作、可撤销、图元属性调节全都齐了。它不是那种只有一个画板画几个方块的 demo,而是把设计器该有的交互闭环做完了。适合想在自己的 WinForm 项目里嵌流程图功能、又不想被商业控件绑架的开发者,也适合拿来做二次开发的底子。
2. 画布与坐标变换:缩放平移背后那套矩阵逻辑,搞不懂后面全是坑
2.1 三层坐标系:屏幕、视口与画布
流程图设计器最大的错觉就是你以为鼠标点在哪,图元就画在哪。实际上鼠标给的是屏幕坐标,图元存的是画布坐标,中间隔着一个「视口偏移 + 缩放系数」。这套源码里,画布坐标是图元的唯一真实坐标,所有图元、连线的位置都按画布坐标存储,绘制时统一做一次变换,这样才能保证缩放后数据不丢。
我一般会维护两个核心字段:_offsetX / _offsetY表示视口左上角在屏幕上的位置,_scale表示缩放系数。绘制时用TranslateTransform平移、ScaleTransform缩放,然后所有图元用画布坐标直接画。这样做的好处是图元代码干净,不用每个图元自己关心缩放。代价是 GDI+ 的变换矩阵会把 Pen 宽度、Font 字号一起缩放,这个坑后面专门说。
鼠标交互时要做的是屏幕坐标转画布坐标,公式很简单:
// 屏幕坐标 -> 画布坐标 PointF ScreenToCanvas(Point screen) { return new PointF( (screen.X - _offsetX) / _scale, (screen.Y - _offsetY) / _scale ); } // 画布坐标 -> 屏幕坐标 Point CanvasToScreen(PointF canvas) { return new Point( (int)(canvas.X * _scale + _offsetX), (int)(canvas.Y * _scale + _offsetY) ); }这两个转换方法是整个交互的地基。拖拽、命中测试、框选、连线,所有涉及鼠标的操作都要先过ScreenToCanvas。新手最容易犯的错是拿屏幕坐标直接和图元的画布坐标比较,结果一缩放全乱套。记住一条:鼠标事件进来第一步永远是转画布坐标,转完再谈逻辑。
2.2 滚轮缩放围绕鼠标位置:公式与实现
画布缩放不能以左上角为锚点,否则鼠标盯着一个图元放大,图元反而跑了。正确做法是缩放前后,鼠标所在的画布坐标点保持不动。推导不复杂:先算出缩放前鼠标指向的画布点p,缩放后让p乘以新缩放系数、加上新偏移,等于当前鼠标屏幕坐标,反解新偏移。
private void Canvas_MouseWheel(object sender, MouseEventArgs e) { float oldScale = _scale; float newScale = oldScale * (e.Delta > 0 ? 1.1f : 0.9f); newScale = Math.Clamp(newScale, 0.2f, 5f); // 限制缩放范围,防止缩得找不回来 // 缩放前鼠标指向的画布坐标 PointF p = new PointF( (e.X - _offsetX) / oldScale, (e.Y - _offsetY) / oldScale ); // 缩放后让 p 仍对准鼠标位置,反解 offset _offsetX = e.X - p.X * newScale; _offsetY = e.Y - p.Y * newScale; _scale = newScale; Invalidate(); // 触发重绘 }e.Delta是滚轮增量,WinForm 里向上为正,每次滚一格一般取 120。1.1f / 0.9f是缩放步进,想要手感更细可以改成1.05f。Math.Clamp限制缩放范围非常关键,没有它用户能把画布缩到 0.01 倍,图元变成像素点,连选中都做不到。缩放下限我一般设 0.2,上限 5.0,这个范围对流程图编辑足够了。
2.3 命中测试必须在画布坐标下做
图元选中、拖拽、连线端点捕捉,全都依赖命中测试。常见做法是每个图元实现一个HitTest(PointF p)方法,矩形图元用Bounds.Contains(p),连线用点到线段距离。
// 矩形图元命中测试,tolerance 是容差(像素) public bool HitTest(PointF p, float tolerance) { RectangleF rect = Bounds; rect.Inflate(tolerance, tolerance); return rect.Contains(p); } // 线段命中测试:点到线段距离小于容差 public bool HitTestLink(PointF p, float tolerance) { float dist = DistanceToSegment(p, StartPoint, EndPoint); return dist <= tolerance; }命中测试的容差必须除以_scale,因为容差是屏幕像素概念,要转成画布单位再比较。比如屏幕容差 8 像素,画布容差就是8f / _scale。缩放 0.5 倍时容差是 16 个画布单位,这很合理,因为用户缩小时眼睛和鼠标的精度都下降了,不加容差根本点不中细线段。
3. 工具箱与图元交互:拖拽创建、选中移动与连线锚点
3.1 图元基类:把形状、边界、连接点统一起来
这套源码的图元不是一堆散装绘制代码,而是有一个清晰的继承结构。基类定义公共接口:绘制、命中测试、边界、连接点、属性集合。派生类只管自己的形状长什么样。
public abstract class FlowNode { public string Name { get; set; } = "图元"; public RectangleF Bounds { get; set; } public Color FillColor { get; set; } = Color.White; public Color BorderColor { get; set; } = Color.Black; // 每个图元自己负责画,基类不管形状 public abstract void Draw(Graphics g); // 默认按矩形区域命中,菱形或圆形图元可重写 public virtual bool HitTest(PointF p, float tolerance) { RectangleF rect = Bounds; rect.Inflate(tolerance, tolerance); return rect.Contains(p); } // 四个连接锚点:上下左右 public PointF GetAnchor(AnchorDirection dir) { switch (dir) { case AnchorDirection.Top: return new PointF(Bounds.Left + Bounds.Width / 2, Bounds.Top); case AnchorDirection.Bottom: return new PointF(Bounds.Left + Bounds.Width / 2, Bounds.Bottom); case AnchorDirection.Left: return new PointF(Bounds.Left, Bounds.Top + Bounds.Height / 2); case AnchorDirection.Right: return new PointF(Bounds.Right, Bounds.Top + Bounds.Height / 2); } return Bounds.Location; } }派生类只需要画形状。比如决策节点是菱形,开始节点是圆角矩形,处理节点是普通矩形。连线锚点不要自己算,统一走GetAnchor,这样连线代码才不会在每种图元里重复一遍。
3.2 工具箱拖拽创建:从 ListBox 到画布的 DragDrop
工具箱在 WinForm 里最省事的实现就是 ListBox,每一项绑定一个NodeType枚举。用户按下鼠标左键时发起拖拽,画布接收后创建图元。
// 工具箱:鼠标按下开始拖拽 private void Toolbox_MouseDown(object sender, MouseEventArgs e) { if (toolboxList.SelectedItem == null) return; NodeType type = (NodeType)toolboxList.SelectedItem; toolboxList.DoDragDrop(new NodeCreateRequest { NodeType = type }, DragDropEffects.Copy); } // 画布:接收拖拽 private void Canvas_DragEnter(object sender, DragEventArgs e) { if (e.Data.GetDataPresent(typeof(NodeCreateRequest))) e.Effect = DragDropEffects.Copy; else e.Effect = DragDropEffects.None; } private void Canvas_DragDrop(object sender, DragEventArgs e) { if (!e.Data.GetDataPresent(typeof(NodeCreateRequest))) return; NodeCreateRequest req = (NodeCreateRequest)e.Data.GetData(typeof(NodeCreateRequest)); PointF canvasPt = ScreenToCanvas(new Point(e.X, e.Y)); // 注意 e.X/e.Y 是屏幕坐标 FlowNode node = NodeFactory.Create(req.NodeType); node.Bounds = new RectangleF(canvasPt.X, canvasPt.Y, 120, 60); doc.Nodes.Add(node); Invalidate(); }DragEventArgs里的e.X / e.Y拿到的是屏幕坐标,必须经ScreenToCanvas转换,这个漏掉就是图元跑偏。还有一个小细节容易被忽视:WinForm 的DragDrop事件里e.Data.GetDataPresent判断的是数据类型,不是字符串,拖拽对象要能被同一个程序集识别,跨程序集拖拽则要用序列化后的格式。这套源码里定义了一个NodeCreateRequest小类,干净利落。
3.3 连线不存坐标存锚点:拖动图元不会散架
流程图里连线最容易做成「存两个点的坐标」,那样拖动图元时线就断了。这套源码的做法是连线只存起点图元和终点图元的引用加锚点方向,绘制时实时取GetAnchor拿坐标。
public class FlowLink { public FlowNode From { get; set; } public FlowNode To { get; set; } public AnchorDirection FromAnchor { get; set; } public AnchorDirection ToAnchor { get; set; } public void Draw(Graphics g) { PointF p1 = From.GetAnchor(FromAnchor); PointF p2 = To.GetAnchor(ToAnchor); // 贝塞尔曲线,控制点沿锚点方向外延 float offset = Math.Max(40, Math.Abs(p2.X - p1.X) / 2); PointF c1 = OffsetAnchor(p1, FromAnchor, offset); PointF c2 = OffsetAnchor(p2, ToAnchor, offset); using GraphicsPath path = new GraphicsPath(); path.AddBezier(p1, c1, c2, p2); g.DrawPath(Pens.Black, path); } }控制点外延距离offset决定连线弧度,两个图元水平排列时弧线会比较自然。连线交互的流程是:鼠标按下时检测是否点到已有图元,然后进入「连线模式」,鼠标弹起时检测是否落在目标图元上,是则创建FlowLink并加入文档。这个流程里唯一要小心的是不要把连线动作跟拖拽动作混淆,一般用鼠标按下时的距离阈值判断,移动超过 5 像素算拖拽,否则算点击。
4. 文件存储与打开:序列化方案、版本兼容与反序列化陷阱
4.1 为什么选 JSON 而不是二进制
流程图文件的存储,最常见也最稳妥的方案是 JSON,而不是二进制。原因很实际:二进制序列化(BinaryFormatter)在 .NET 里已经被标记过时,而且它的数据里带程序集全名,一改类名、改名空间、升级版本,旧文件全废。XML 能用但太啰嗦,手改文件调试不方便。JSON 可读、可 diff、可手写,出了问题还能拿出来人工检查。
序列化结构大概是这样的:
{ "version": 1, "nodes": [ { "$type": "FlowEditor.StartNode, FlowEditor", "name": "开始", "x": 120, "y": 80, "width": 120, "height": 60 } ], "links": [ { "fromId": "a1f3c9", "fromAnchor": "Bottom", "toId": "b2e8d7", "toAnchor": "Top" } ] }节点和连线分开存。节点用$type标记具体类型,连线不直接存对象引用,而是存fromId / toId,这两个 ID 是节点序列化时生成的Guid。反序列化后要用字典把 ID 映射回节点实例,重建引用关系。
4.2 Newtonsoft.Json 的 TypeNameHandling 配置
多态反序列化是 JSON 方案里最不好处理的点。FlowDocument里的List<FlowNode>是基类列表,反序列化时如果不加配置,JsonConvert根本不知道某个 JSON 对象该反序列化成ProcessNode还是DecisionNode。常见做法是用 Newtonsoft.Json 的TypeNameHandling.All,序列化时自动写$type字段:
JsonSerializerSettings settings = new JsonSerializerSettings { TypeNameHandling = TypeNameHandling.All, Formatting = Formatting.Indented, NullValueHandling = NullValueHandling.Ignore }; // 保存 string json = JsonConvert.SerializeObject(doc, settings); File.WriteAllText(path, json); // 打开 FlowDocument doc = JsonConvert.DeserializeObject<FlowDocument>(json, settings);TypeNameHandling.All会递归处理所有类型,List<FlowNode>里的每个元素都能正确还原成实际类型。但这个配置有一个隐患:如果文件被外部篡改,$type可以被改成任意类型,存在反序列化攻击风险。在自己的本地文件场景下问题不大,但如果你要把.flow.json这种文件发给别人,我建议改成自定义JsonConverter,只允许白名单里的图元类型,代价是代码多一点,换来的是文件不会被恶意利用。这也是这套源码里值得研究的一个点,读它的时候可以留意一下用的是哪种方案。
4.3 版本字段与旧文件兼容
流程图文件最大的坑是改版。这周加了一个新图元类型,上周存的旧文件打开就报错;下周把某个属性改名了,所有旧文件里的数据全部丢失。这套源码的文档结构里带了version字段,打开文件时先读版本号,再做字段兜底。
// 打开文件后做版本迁移 private void MigrateDocument(FlowDocument doc) { if (doc.Version < 2) { foreach (FlowNode node in doc.Nodes) { // 旧版本没有 Width/Height,用默认值补齐 if (node.Bounds.Width == 0) node.Bounds = new RectangleF(node.Bounds.X, node.Bounds.Y, 120, 60); } } doc.Version = 2; // 迁移完成后更新版本 }反序列化时还要配MissingMemberHandling.Ignore,JSON 里缺字段不要抛异常,让属性保持默认值。我的习惯是:每改一次图元结构,就升一版version,打开文件时判断版本号走对应的迁移逻辑。不加版本号的流程图文件,三个月后你打开自己写的程序都会发现旧文件全是坏的,更别说发给同事用了。
5. 常见问题排查:缩放失真、画布闪烁、撤销失效三个翻车点
5.1 缩放后连线粗细异常、文字模糊
现象:画布放大到 2 倍,连线变成 2 像素粗,缩小到 0.5 倍线几乎看不见;图元文字在放大时糊成一片。
原因:Graphics.ScaleTransform是作用于整个绘图管线的,Pen 的宽度、Font 的字号都会被缩放系数影响。这不是 bug,是 GDI+ 的数学行为,但流程图场景下几乎没人希望文字和线宽跟着缩放走。
解决:画图元的形状时可以接受缩放,但画文字和连线前要把缩放抵消。文字绘制前恢复字号:Font用new Font(font.FontFamily, font.Size / _scale),Pen 宽度同理:new Pen(color, penWidth / _scale)。或者更彻底一点:绘制时完全不使用ScaleTransform,所有坐标手动乘_scale再画,Pen 宽度保持恒定。这两种方案我推荐后者,因为手动乘坐标虽然代码丑一点,但文字、线宽、阴影效果全部可控,不会有奇怪的副作用。
5.2 画布闪烁严重,尤其是缩放和平移时
现象:拖动画布或者滚动缩放时,画布区域疯狂闪烁,图元多的时候几乎没法操作。
原因:WinForm 默认的控件绘制是先擦背景再画前景,两帧之间有一个空窗期,频繁Invalidate时这个空窗期被无限放大。常见的误用是在OnPaint里直接画,然后每次交互都Invalidate,没有开双缓冲。
解决:开双缓冲是标准做法。
public CanvasControl() { // 双缓冲三件套,缺一个都会闪 SetStyle(ControlStyles.AllPaintingInWmPaint, true); SetStyle(ControlStyles.OptimizedDoubleBuffer, true); SetStyle(ControlStyles.UserPaint, true); UpdateStyles(); // 减少无效重绘区域 this.ResizeRedraw = false; } protected override void OnPaintBackground(PaintEventArgs e) { // 背景交给 OnPaint 绘制,避免闪白 }OptimizedDoubleBuffer是核心,AllPaintingInWmPaint告诉系统别在 WM_PAINT 之前擦背景,UserPaint是让控件自己负责绘制。还有一个细节:拖动图元时不要每次都Invalidate()整个画布,只Invalidate(图元旧位置 ∪ 新位置)的合并矩形,图元多的时候性能差好几倍。
5.3 撤销之后图元位置不对,或者撤销一次跳两步
现象:连续拖动两个图元,点撤销,第二个图元回到原位了,第一个图元的移动也被撤销了,但只显示撤销了一次。
原因:撤销命令栈里存的是「新建的图元对象」,而不是「同一个图元实例的前后值」。常见误用是某个操作内部new了一个节点来做逻辑,命令的Undo()作用于这个临时对象,画布上新对象完全没受影响,或者反过来:Undo()里把画布上的对象替换成了命令里的旧对象,后续操作全堵在旧对象上。
解决:命令模式里,命令持有的是图元的实例引用,Undo和Redo都改这个实例的属性,而不是替换引用。
public class MoveNodeCommand : ICommand { private FlowNode _node; private RectangleF _oldBounds; private RectangleF _newBounds; public void Execute() => _node.Bounds = _newBounds; public void Undo() => _node.Bounds = _oldBounds; }这套源码的撤销重做是操作级而不是像素级的,一条命令对应一次完整的图元操作——移动、新建、删除、连线都各自封装成实现ICommand的类,入栈时Execute(),撤销时Undo()。判断撤销栈有没有问题,最快的验证方法是连续移动同一个图元三次再全部撤销,图元应该准确回到起点。这在这个源码里是可以直接跑通验证的。
5.4 打开文件后连线的 From 是 null
现象:文件保存时好好的,重新打开后连线的起点终点丢失,或者连线的From引用指向 null 导致绘制时报NullReferenceException。
原因:序列化时连线存的是对象引用,JSON 序列化后引用变成了嵌套的对象副本,反序列化后连线的From指向的节点和文档Nodes列表里的节点根本不是同一个实例。这是所有图元编辑器序列化最容易踩的坑。
解决:连线只存 ID,不存引用。序列化时节点分配Guid Id,连线存FromId / ToId,反序列化后通过字典重建引用。
// 反序列化后重建连线引用 Dictionary<Guid, FlowNode> nodeMap = doc.Nodes.ToDictionary(n => n.Id); foreach (FlowLink link in doc.Links) { link.From = nodeMap[link.FromId]; link.To = nodeMap[link.ToId]; }6. 属性调节进阶:PropertyGrid 直接绑定图元,属性改动也进撤销栈
图元属性调节最自然的做法就是用 WinForm 自带的PropertyGrid控件,把选中图元的实例赋给SelectedObject,属性和值就自动展示出来了。表面看是一行代码的事,但要做得好用有两个进阶点。
第一是显示中文属性名和分组。属性加特性标注,PropertyGrid才能按中文显示、按分类折叠:
[Category("外观"), DisplayName("填充颜色"), Description("图元的背景色")] public Color FillColor { get; set; } [TypeConverter(typeof(ExpandableObjectConverter))] [Category("布局"), DisplayName("边界矩形")] public RectangleF Bounds { get; set; }ExpandableObjectConverter可以让RectangleF这类复合类型在属性网格里展开成 X、Y、Width、Height 分别编辑,比直接把整个结构体平铺出来体验好得多。
第二是属性改动联动撤销。默认情况下PropertyGrid改值只回写图元属性,不会进撤销栈,用户改了十几个属性后想撤销完全没有后悔药。我的做法是挂PropertyValueChanged事件,把旧值和新值封装成命令推入撤销栈:
private void propGrid_PropertyValueChanged(object sender, PropertyValueChangedEventArgs e) { FlowNode node = (FlowNode)propGrid.SelectedObject; object oldValue = e.OldValue; string propName = e.ChangedItem.PropertyDescriptor.Name; var cmd = new ChangePropertyCommand(node, propName, oldValue); _undoStack.Push(cmd); }ChangePropertyCommand的Undo用反射把oldValue写回图元属性,Redo写回新值。注意属性变更后要触发画布重绘,否则颜色改了画布上没反应。从那以后我每次做流程图编辑器,都会把「属性 → 撤销 → 重绘」这三个环节放在一起设计,缺一个,交互就是断的。这套源码里已经把这套逻辑串起来了,真要集成进自己的项目,优先看ICommand的实现和PropertyGrid的绑定方式,比看绘制代码更有收获。希望帮到你。
本文还有配套的精品资源,点击获取