ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

C# WinForm流程图编辑器实现:数据模型、缩放与撤销重做

C# WinForm流程图编辑器实现:数据模型、缩放与撤销重做 简介面向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 ListPortPoint 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 ListNodeElement Nodes { get; set; } public ListLineElement 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.DeserializeObjectFlowDocument(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. 操作步骤可撤销命令栈实现与常见问题排查可撤销是这整套功能里最容易被做成“半成品”的部分。很多实现只对移动做了撤销对“改属性”没做结果用户把节点颜色改了之后按 CtrlZ 没反应直接骂人。我最后采用的是快照式撤销每次操作前把整个FlowDocument深拷贝一份压栈。原理虽然粗暴但对“功能超完整”的工具类应用最可靠。5.1 快照式撤销 vs 命令式撤销命令式撤销是把每个操作封装成ICommand比如移动节点的命令保存旧坐标撤销时把坐标写回去。好处是省内存、指令明确坏处是每增加一种图元操作就要多写一个命令类尤其属性调节、连线、删除这类操作组合起来非常繁琐。快照式撤销则简单很多任何操作前把当前文档完整序列化一份存进栈。撤销时把栈顶文档拿出来替换当前文档。内存占用确实高一点但现代机器跑内部流程图工具完全没有压力。我在项目里把撤销栈深度限制为 50 步并且对单个文档节点数超过 500 的情况才考虑命令式。做工具类软件稳定可预期的撤销行为比节省几十 MB 内存更重要。5.2 快照栈的代码实现深拷贝靠 JSON 做兜底我选择用同一套 Json.NET 设置对文档序列化再做一次反序列化得到全新对象。这样不会因为引用类型共享导致修改一个节点时快照里的节点也跟着变。写代码前要先想清楚PushUndoSnapshot()必须在变更发生前调用快照里是“旧状态”。private StackFlowDocument _undoStack new StackFlowDocument(); private StackFlowDocument _redoStack new StackFlowDocument(); private bool _isRestoring; public void PushUndoSnapshot() { if (_isRestoring) { return; } string json JsonConvert.SerializeObject(_doc, _settings); FlowDocument snapshot JsonConvert.DeserializeObjectFlowDocument(json); _undoStack.Push(snapshot); _redoStack.Clear(); if (_undoStack.Count 50) { FlowDocument[] items _undoStack.ToArray(); _undoStack new StackFlowDocument(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 流程图最怕的往往不是某个酷炫功能没实现而是每个功能都能用串起来按用户顺序操作时状态对不上。希望这些落地做法能帮到你少走我踩过的那些坑。本文还有配套的精品资源点击获取
返回列表