☰
C# WinForm流程图编辑器实现:数据模型、缩放与撤销重做
2026/10/7 21:53:33 网站建设 项目流程

简介:面向C#/WinForm开发者的流程图编辑器完整工程,覆盖工具箱创建图元、文件存储与打开、画布无级缩放、图元编辑、操作步骤撤销及属性调节等常见交互,可用于工作流设计器、网络拓扑、流程绘制等场景,也适合学习自定义绘图与图形交互的中高级开发者。压缩包仅2.29MB,共94个文件,以53个.cs源文件为核心,配合界面资源、项目配置与说明文档,可直接编译运行并对照阅读。内置矩形、菱形、圆形、直线和曲线五类图元,每个图元带六个操纵柄与四个连接点,支持自动吸附连线和连线箭头;移动图元时已连接直线或曲线会同步跟随,兼具撤销记录和属性面板,可修改背景、填充、文字等。其他图形可仿照现有图元快速扩展。已有880人学习/下载,适合作为WinForm图形编辑器项目的完整范例。

1. 从一次“画布崩了”说起:为什么我把 C# WinForm 流程图拆成五件事

前阵子给产线工具做一个 C# WinForm 流程图编辑器,第一版直接把所有图元画在 PictureBox 上,结果节点一多,放大缩小的时候整个界面卡到没法动。后来我把整套需求拆成五块:图元数据与文件存储打开、画布放大缩小、工具箱拖入与图元操作、操作步骤可撤销、图元属性调节,用“数据驱动绘制”的方式重写,问题才算真正解决。这篇就以这个经验为主线,讲清楚 C# WinForm 流程图从数据模型到可撤销操作完整落地的做法。适合正在做上位机流程编排、MES 工站图、或者内部可视化工具的人直接参考。

2. 图元数据与文件存储打开:用 JSON 存“可编辑流程”,而不是一张位图

很多人做流程图工具时,先画完再把画布内容用DrawToBitmap存成图片,打开时再把图片贴回去。这个方案对“只展示”的项目够用,可一旦要支持图元操作、可撤销、属性调节,图片就没法做了:图上的节点位置、连线关系、文本内容全被拍平,点不到也改不了。所以我一般把整个画布抽象成一份FlowDocument,文件里存的是结构化数据,打开时重新绘制。

2.1 先定数据模型:节点、连线、画布三张基础表

我习惯把数据模型分成三层:节点表、连线表、画布状态。节点表里存每个图元的位置、尺寸、类型和视觉属性;连线表不直接存坐标,而是存“从哪个节点的哪个端口连到哪个节点”,这样移动节点时连线会自动跟着走;画布状态存缩放倍率和偏移量,方便下次打开还原视角。

[Serializable] public class BaseElement { public string Id { get; set; } public string Name { get; set; } public float X { get; set; } public float Y { get; set; } public float Width { get; set; } public float Height { get; set; } public string Type { get; set; } public string BackColor { get; set; } } [Serializable] public class NodeElement : BaseElement { public List<PortPoint> Ports { get; set; } } [Serializable] public class LineElement : BaseElement { public string StartNodeId { get; set; } public string EndNodeId { get; set; } public string StartPort { get; set; } public string EndPort { get; set; } } [Serializable] public class FlowDocument { public int Version { get; set; } public List<NodeElement> Nodes { get; set; } public List<LineElement> Lines { get; set; } public float Zoom { get; set; } public float OffsetX { get; set; } public float OffsetY { get; set; } }

这里的几个参数需要说明。Id我用字符串而不是 int,因为撤销时要保存历史快照,字符串 ID 在序列化、深拷贝和反序列化之后不会因为索引变化而错乱。X、Y、Width、Height用float而不用 int,是为了放大缩小之后不损失精度。StartPort、EndPort存端口名,比如"input"、"output",比存端口坐标更稳,节点移动后端口坐标重新计算就行。

2.2 文件存储与打开:Json.NET 序列化的落地写法

数据模型定了,文件存储就很自然。在 VS2015 的 WinForm 项目里,我一般通过 NuGet 引入 Newtonsoft.Json,用JsonConvert做序列化。保存时只要把FlowDocument转成 JSON,写入文件;打开时反序列化回来后刷新画布。

public void SaveToFile(string path, FlowDocument doc) { JsonSerializerSettings settings = new JsonSerializerSettings { Formatting = Formatting.Indented, NullValueHandling = NullValueHandling.Ignore }; string json = JsonConvert.SerializeObject(doc, settings); File.WriteAllText(path, json, new UTF8Encoding(false)); } public FlowDocument LoadFromFile(string path) { string json = File.ReadAllText(path, Encoding.UTF8); FlowDocument doc = JsonConvert.DeserializeObject<FlowDocument>(json); if (doc.Version != CurrentVersion) { doc = Migrate(doc); } return doc; }

Formatting.Indented让 JSON 可读,方便调试时直接打开文件看节点坐标;NullValueHandling.Ignore可以省掉大量空端口、空名称的冗余字段;new UTF8Encoding(false)表示不带 BOM,避免某些文本工具打开文件时多出不可见字符。Migrate(doc)是版本升级用的迁移方法,后面会讲。

打开文件后不要直接拿doc绘制,我通常会复制一份到当前工作文档,并清空撤销栈和重做栈。原因是“打开文件”本身不应该被撤销成“空白画布”,否则用户会觉得很困惑。

2.3 版本号与迁移:改字段后老文件不崩的后悔药

版本号这个东西,第一版经常有人嫌麻烦不加,等模型加了字段才发现老文件打不开。我的做法是:FlowDocument.Version写死为当前版本常量,每次修改数据结构时递增,然后在Migrate里写转换逻辑。

private FlowDocument Migrate(FlowDocument doc) { if (doc.Version < 2) { foreach (NodeElement node in doc.Nodes) { if (string.IsNullOrEmpty(node.Type)) { node.Type = "Task"; } } } doc.Version = CurrentVersion; return doc; }

这样老文件打开后,缺的字段会按默认值补齐,而不是直接抛异常。如果只是加一个可空字段,NullValueHandling.Ignore加反序列化默认值也能兼容,但只要有“字段含义变化”这类情况,就必须走Migrate。这是文件存储打开里最容易被忽略的坑,建议一开始就把版本常量放进去。

3. 画布放大缩小:滚轮事件、缩放矩阵和坐标换算

画布放大缩小的核心不是把控件放大,而是让绘制逻辑和鼠标坐标都走同一套变换。如果直接canvas.Width *= zoom,不仅连线和文字会糊,而且滚动条、命中测试全部要跟着重算,越做越乱。

3.1 为什么不直接改控件尺寸:用 Graphics 变换绘制

我的画布是一个自定义CanvasControl : Control,在OnPaint里用TranslateTransform和ScaleTransform完成缩放。节点和连线的坐标永远保持逻辑坐标,比如一个节点左上角是(100, 50),宽度是120,不管当前 zoom 是多少,这个数据都不变。

protected override void OnPaint(PaintEventArgs e) { e.Graphics.SmoothingMode = SmoothingMode.AntiAlias; e.Graphics.Clear(_backColor); e.Graphics.TranslateTransform(_offsetX, _offsetY); e.Graphics.ScaleTransform(_zoom, _zoom); foreach (NodeElement node in _doc.Nodes) { DrawNode(e.Graphics, node); } foreach (LineElement line in _doc.Lines) { DrawLine(e.Graphics, line); } }

这里需要注意 GDI+ 的变换顺序:先平移,后缩放。绘制点(logicX, logicY)会先乘以_zoom,再加上_offsetX/_offsetY,最终变成屏幕坐标(logicX * _zoom + _offsetX, logicY * _zoom + _offsetY)。后面所有鼠标坐标换算都要保持同一套公式,否则一点一个准。_zoom建议限制在 0.2 到 5.0,太小节点挤成一团,太大坐标精度和拾取都会出问题。

3.2 鼠标坐标换算:滚轮缩放以光标为中心

不处理坐标换算的缩放是没用的,因为用户希望在鼠标指着的那个点放大,而不是画布左上角跑掉。正确做法是:滚轮缩放前,先把鼠标屏幕坐标换算成逻辑坐标,缩放完再反推出新的偏移量,让同一个逻辑点仍然落在鼠标下方。

private void Canvas_MouseWheel(object sender, MouseEventArgs e) { float oldZoom = _zoom; float newZoom = _zoom; if (e.Delta > 0) { newZoom = _zoom * 1.25f; } else { newZoom = _zoom / 1.25f; } newZoom = Math.Max(0.2f, Math.Min(5f, newZoom)); // 换算鼠标位置对应的逻辑坐标 PointF logical = new PointF( (e.X - _offsetX) / oldZoom, (e.Y - _offsetY) / oldZoom); _zoom = newZoom; // 重新计算偏移量,让逻辑坐标仍处于鼠标位置 _offsetX = e.X - logical.X * _zoom; _offsetY = e.Y - logical.Y * _zoom; Invalidate(); } private PointF ScreenToLogical(PointF screenPoint) { return new PointF( (screenPoint.X - _offsetX) / _zoom, (screenPoint.Y - _offsetY) / _zoom); }

滚轮缩放倍率我用1.25f,滚一格放大四分之一,连续缩放的手感比较自然。e.Delta在 WinForm 里通常一次是 120 的倍数,不需要再除120当步数,直接判断正负即可。每次缩放后调用Invalidate()触发重绘,这是 WinForm 里最容易漏的一步,漏了你会发现拖动画布半天没反应。

3.3 缩放时的性能与绘制边界

图元多的时候,OnPaint里把所有节点和连线都遍历一遍并不会太慢,真正卡的是你在上面画阴影、画渐变、或者用了复杂的 Path。我一般会先做一个粗略的“可视区域裁剪”:用ScaleTransform之后的可见矩形反推出逻辑区间,然后只绘制和这个区间相交的节点。对于几百个节点的内部工具,这个优化能把重绘时间从几十毫秒降到个位数毫秒。

还有一个细节:滚动条。如果画布要支持拖动查看大图,我一般把CanvasControl.AutoScrollMinSize设置成逻辑画布大小乘以_zoom后的值。这里踩过的坑是,AutoScrollMinSize改变后会触发Scroll事件,而Scroll事件里如果再去设置AutoScrollPosition,很容易互相触发。我的做法是只在一个OnDocChanged方法里统一刷新滚动范围,Invalidate()只负责重绘,不在滚动事件里改数据。

4. 工具箱与图元操作:拖拽建图元、命中测试和移动

工具箱在这个需求里不只是放按钮,而是作为拖拽的数据源,让用户把节点拖到画布上生成新图元。图元操作则包括选择、移动、连线命中。这里最容易翻车的是工具箱和数据模型没打通:工具箱里看到的节点类型,和画布上的NodeElement.Type用的是两套字符串。

4.1 工具箱面板:把 ListView 变成拖拽数据源

我用一个普通的ListView当工具箱,每一项的Tag存节点类型字符串。这样拖拽时不需要传整个对象,只要把类型字符串传给画布,画布再按类型创建对应图元。

private void Toolbox_ItemDrag(object sender, ItemDragEventArgs e) { ListViewItem item = (ListViewItem)e.Item; DoDragDrop(item.Tag.ToString(), DragDropEffects.Copy); } private void Canvas_DragEnter(object sender, DragEventArgs e) { if (e.Data.GetDataPresent(DataFormats.Text)) { e.Effect = DragDropEffects.Copy; } } private void Canvas_DragDrop(object sender, DragEventArgs e) { if (!e.Data.GetDataPresent(DataFormats.Text)) { return; } Point clientPoint = canvas.PointToClient(new Point(e.X, e.Y)); PointF logic = ScreenToLogical(clientPoint); string nodeType = (string)e.Data.GetData(DataFormats.Text); AddNode(nodeType, logic.X, logic.Y); }

注意DragEventArgs.X和Y是屏幕坐标,必须先转成客户区坐标,再调用ScreenToLogical。如果你把屏幕坐标直接当逻辑坐标用,会发现拖进去的节点总是跑偏一个画布位置。AddNode里要做两件事:往_doc.Nodes加节点、调用PushUndoSnapshot()记录操作前的状态,否则拖入这个动作没法撤销。

4.2 图元操作:命中测试与带偏移的移动

移动图元的思路很简单:鼠标按下时找到命中的节点,记录按下位置和节点原坐标的差值;鼠标移动时把差值套回节点坐标。关键是所有计算都在逻辑坐标里做,而且按下和移动都要先ScreenToLogical。

private NodeElement HitTestNode(PointF point) { foreach (NodeElement node in _doc.Nodes) { RectangleF rect = GetNodeRect(node); if (rect.Contains(point)) { return node; } } return null; } private void Canvas_MouseDown(object sender, MouseEventArgs e) { PointF logic = ScreenToLogical(e.Location); _selectedNode = HitTestNode(logic); if (_selectedNode != null) { PushUndoSnapshot(); _dragOffset = new PointF( logic.X - _selectedNode.X, logic.Y - _selectedNode.Y); _dragging = true; } } private void Canvas_MouseMove(object sender, MouseEventArgs e) { if (!_dragging || _selectedNode == null) { return; } PointF logic = ScreenToLogical(e.Location); _selectedNode.X = logic.X - _dragOffset.X; _selectedNode.Y = logic.Y - _dragOffset.Y; Invalidate(); }

_dragOffset是命中点和节点左上角的差值,不加这个偏移,节点会跳一下,鼠标像“吸”住节点左上角,像翻车。这里PushUndoSnapshot()必须放在MouseDown里,不能放MouseMove。否则鼠标移动一次就压一次栈,撤销一次只回退一步,移动一个节点要撤销十多次。

4.3 连线与连线命中:用点到线段距离做热区

连线的操作一般有两种做法:一种是从端口拖出线到另一个端口,另一种是工具栏选“连线模式”后点两个节点。不管哪种,存到数据模型里都是LineElement。选中连线不能用鼠标点像素中心,因为线太细了,所以我用“点到线段距离小于阈值”来判断。

public bool HitTestLine(LineElement line, PointF p, float threshold) { PointF a = GetPortCenter(line.StartNodeId, line.StartPort); PointF b = GetPortCenter(line.EndNodeId, line.EndPort); float dx = b.X - a.X; float dy = b.Y - a.Y; if (dx == 0 && dy == 0) { return Distance(p, a) <= threshold; } float t = ((p.X - a.X) * dx + (p.Y - a.Y) * dy) / (dx * dx + dy * dy); t = Math.Max(0f, Math.Min(1f, t)); float projX = a.X + t * dx; float projY = a.Y + t * dy; return Distance(p, new PointF(projX, projY)) <= threshold; }

核心是参数t,它表示鼠标点在直线上的投影落在a到b之间的比例。t小于 0 时取 0,大于 1 时取 1,这样点到线段两端延长线上的情况不会误命中。threshold我一般取 6 到 8 个逻辑像素,太小手指点不到,太大连线之间会互相抢命中。

5. 操作步骤可撤销:命令栈实现与常见问题排查

可撤销是这整套功能里最容易被做成“半成品”的部分。很多实现只对移动做了撤销,对“改属性”没做,结果用户把节点颜色改了之后按 Ctrl+Z 没反应,直接骂人。我最后采用的是快照式撤销:每次操作前把整个FlowDocument深拷贝一份压栈。原理虽然粗暴,但对“功能超完整”的工具类应用最可靠。

5.1 快照式撤销 vs 命令式撤销

命令式撤销是把每个操作封装成ICommand,比如移动节点的命令保存旧坐标,撤销时把坐标写回去。好处是省内存、指令明确,坏处是每增加一种图元操作,就要多写一个命令类,尤其属性调节、连线、删除这类操作组合起来非常繁琐。

快照式撤销则简单很多:任何操作前,把当前文档完整序列化一份存进栈。撤销时把栈顶文档拿出来替换当前文档。内存占用确实高一点,但现代机器跑内部流程图工具完全没有压力。我在项目里把撤销栈深度限制为 50 步,并且对单个文档节点数超过 500 的情况才考虑命令式。做工具类软件,稳定可预期的撤销行为比节省几十 MB 内存更重要。

5.2 快照栈的代码实现:深拷贝靠 JSON 做兜底

我选择用同一套 Json.NET 设置对文档序列化再做一次反序列化,得到全新对象。这样不会因为引用类型共享导致修改一个节点时,快照里的节点也跟着变。写代码前要先想清楚:PushUndoSnapshot()必须在变更发生前调用,快照里是“旧状态”。

private Stack<FlowDocument> _undoStack = new Stack<FlowDocument>(); private Stack<FlowDocument> _redoStack = new Stack<FlowDocument>(); private bool _isRestoring; public void PushUndoSnapshot() { if (_isRestoring) { return; } string json = JsonConvert.SerializeObject(_doc, _settings); FlowDocument snapshot = JsonConvert.DeserializeObject<FlowDocument>(json); _undoStack.Push(snapshot); _redoStack.Clear(); if (_undoStack.Count > 50) { FlowDocument[] items = _undoStack.ToArray(); _undoStack = new Stack<FlowDocument>(items.Take(50).Reverse()); } } public void Undo() { if (_undoStack.Count == 0) { return; } _isRestoring = true; FlowDocument redoSnapshot = CloneDoc(_doc); _redoStack.Push(redoSnapshot); _doc = _undoStack.Pop(); OnDocChanged(); _isRestoring = false; } public void Redo() { if (_redoStack.Count == 0) { return; } _isRestoring = true; FlowDocument undoSnapshot = CloneDoc(_doc); _undoStack.Push(undoSnapshot); _doc = _redoStack.Pop(); OnDocChanged(); _isRestoring = false; }

这里的关键参数是栈深 50。撤销栈不是越深越好,太深了老操作意义不大,而且每次撤销都要全量刷新画布,栈深 100 和栈深 20 的响应速度差别很明显。_isRestoring是防重入保护:OnDocChanged()内部可能会触发界面刷新、重算滚动范围,这些动作不要再压栈。CloneDoc(_doc)就是序列化再反序列化的封装,和PushUndoSnapshot共用同一套_settings,保证序列化一致性。

5.3 常见问题排查:引用类型、属性面板和批量操作

我在这里记录几条自己实际踩过的坑,每一条都是“现象 → 原因 → 解决”,希望对你有用。

现象一:撤销后发现节点回到旧位置,但连线还是跟在新节点旁边。原因是LineElement里提前缓存了端口坐标,而不是通过StartNodeId动态去查节点位置。解决:连线绘制时永远实时从节点端口计算,不在LineElement里存坐标。

现象二:在属性面板里把节点宽度从 100 改成 200,按撤销没反应。原因是PropertyGrid的PropertyValueChanged事件发生在值已经写入目标对象之后,你在这个事件里再PushUndoSnapshot()拿到的是新状态。解决:在属性赋值类的set方法开头调用PushUndoSnapshot(),或者干脆在PropertyGrid开始编辑前压一次快照。

现象三:连续拖动十个节点后想撤销一次,结果十步回到了最初,每一步撤销都只回退一个位置。原因是拖动过程中MouseMove一直在压栈。解决:用_dragging标志,只在MouseDown压一次快照,MouseMove只改坐标。

现象四:删除多个选中图元后撤销,只恢复了一个。原因是删除逻辑里把_doc.Nodes.Remove和_doc.Lines.Remove分开写,中间不小心调用了Invalidate()导致界面刷新,但这不是根因。根因通常是每次删除都调了一次PushUndoSnapshot()。解决:把“删除全部选中图元”作为一个整体操作,进入方法时压一次快照,删除完再统一刷新。

现象五:撤销本身没崩,但图元选中状态丢了。原因是FlowDocument里没有保存SelectedNodeId。解决:_selectedNode是画布运行时状态,不应该进文件,但撤销后要按 ID 重新找一次,找不到就置空。我在OnDocChanged()里总写这么一段:_selectedNode = _doc.Nodes.FirstOrDefault(n => n.Id == _selectedNode?.Id);,别偷懒。

6. 图元属性调节:PropertyGrid、参数校验与 WinForm 界面美化

图元属性调节我直接用 WinForm 自带的PropertyGrid,省时间而且和 VS 属性面板长得一样,用户不陌生。核心不是怎么显示,而是怎么让属性修改接入撤销栈。

6.1 用 PropertyGrid 绑定选中图元,但不直接绑定数据类

如果直接把NodeElement扔给propertyGrid.SelectedObject,你会看到 Id、Type、StartNodeId 这些不该给用户看的字段。我在中间加一层 ViewModel,只暴露需要修改的属性,并在 setter 里做校验。

public class NodePropertyViewModel { private readonly NodeElement _node; private readonly FlowEditorControl _editor; public NodePropertyViewModel(NodeElement node, FlowEditorControl editor) { _node = node; _editor = editor; } [Category("布局")] [DisplayName("X 坐标")] public float X { get { return _node.X; } set { _editor.PushUndoSnapshot(); _node.X = Math.Max(0f, value); _editor.RefreshCanvas(); } } [Category("外观")] [DisplayName("节点名称")] public string Name { get { return _node.Name; } set { _editor.PushUndoSnapshot(); _node.Name = value; _editor.RefreshCanvas(); } } }

ViewModel 不是数据模型,它只是属性面板的投影。这样 User 改属性时,走的是你控制的 setter,而不是让PropertyGrid随便改原始对象。[Category]和[DisplayName]是 WinForm 内置的,配好后属性面板会自动分组,界面看起来专业很多,这也是很多 WinForm 界面美化范例里经常用的方式。

6.2 属性变更触发撤销的时机:setter 里压栈

我见过很多人在propertyGrid.PropertyValueChanged += ...里压撤销快照,但那个事件触发时属性值已经写进去了,快照存的是新值,撤销自然失效。正确时机是在 setter 里,值还没真正写进_node之前压栈。

if (!_editor.PushUndoSnapshot()) { return; } _node.X = Math.Max(0f, value); _editor.RefreshCanvas();

PushUndoSnapshot()返回 bool 是顺便做的一件事:当_isRestoring为 true 时,方法直接返回 false,防止撤销还原过程中属性面板又触发一遍 setter,把刚还原的值再次压进撤销栈。这个防重入开关是整个撤销系统的安全阀。

6.3 参数校验和最后的验证习惯

属性面板里的数值不是用户随便填什么都行的。宽度小于 0、连线指向不存在的节点、名称超过显示宽度,这些在流程编辑工具里都应该被拦住。我的习惯是在 setter 里做最小校验,比如宽度最小 20、最大 2000;名称长度为 0 时自动转成默认名,不让它进数据模型。颜色这类属性用原生Color类型,PropertyGrid 会直接出现颜色选择器,不需要额外写编辑器。

最后分享一个验证习惯:每做完一个功能,我会新建一个窗体重放一遍最常用的操作链。拖入三个节点、连两条线、放大缩小、改一个节点颜色、再连续撤销五步,然后保存文件、关掉程序、重新打开。只要这条链不断,这版功能基本可以交付。WinForm 流程图最怕的往往不是某个酷炫功能没实现,而是每个功能都能用,串起来按用户顺序操作时状态对不上。希望这些落地做法能帮到你,少走我踩过的那些坑。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询