简介:这是一份基于.NET Framework 2.0环境、使用C#编写的Winform流程图设计工具源码,面向需要实现类似Visio拖拽绘图功能的.NET开发者,尤其适合Winform初学者研究图形交互与对象建模。压缩包共41个文件,其中10个cs源码文件涵盖主窗体、流程节点控件与事件流编辑等核心逻辑,配套resx资源、png图标、resources资源及dll、pdb等构建产物,并附有sln解决方案和csproj工程文件,整体仅261KB,可直接编译运行。目前已有4636人学习下载。源码完整实现了图形对象管理、工具箱拖放、鼠标选择移动、连接线动态绘制、XML/JSON保存加载等关键功能,同时包含双击编辑形状属性、属性网格展示、节点连线自动更新、双缓冲优化绘制等实用细节。整体代码结构清晰、文件组织规范,既可作为课程设计或毕业设计的参考,也能在此基础上扩展成更专业的流程图编辑器。
1. 为什么我在2024年还用Winform做流程图编辑器
先交代一下背景:我最近接了个内部工具的需求,要给产线做一个简单的流程图编辑器,用来配置设备节点和流转路径。团队里有人提议用前端做,套一个现成的vue-flow或者React Flow,放浏览器里跑。但需求方明确要求桌面端、离线可用、最好双击就能打开,还要能直接对接我们现有的C#工控系统。绕了一圈,最后还是回到了Winform。
说实话,在做之前我也纠结过:Winform做流程图,是不是有点"用马车跑高速"?市面上的流程图方案要么走Web路线,要么上WPF,再要么直接买商业控件。但仔细评估下来,Winform还真没到被淘汰的地步——尤其是这种"内部工具、节点数量不多、交互相对固定"的场景,Winform的开发效率非常高,部署也省心,一个exe拷过去就完事。
这篇文章就把我这次实现"类似Visio的简单流程图"的完整思路写出来,包括架构设计、绘制机制、交互命中、缩放平移、序列化这些核心环节。不需要引入任何第三方绘图库,纯GDI+和原生控件就能实现一个够用的版本。我会把关键的代码结构和我踩过的坑都直接贴出来,适合有Winform基础、想自己做绘图工具或流程编辑器的朋友参考。
2. 别一上来就画线:先想清楚数据和视图的关系
做流程图最容易犯的错,就是一下子钻进"怎么画矩形、怎么画箭头"的细节里。我一开始也这么干过,结果画到一半发现连线根本存不住,拖动节点后线条不会跟着动,整个代码变成了一坨不可维护的状态堆积。
后来我想明白了一件事:流程图的核心不是"画",而是"数据"。Visio、draw.io这些工具之所以看起来复杂,是因为它们把图形对象建模成了完整的文档对象模型。我们做一个简化版,不需要那么重,但至少要有一个清晰的"数据层"和"视图层"的分离。
2.1 定义图形对象模型
我这里定义了两个核心类:FlowNode(节点)和FlowConnection(连接线)。
public class FlowNode { public string Id { get; set; } = Guid.NewGuid().ToString(); public string Text { get; set; } public RectangleF Bounds { get; set; } public Color BackColor { get; set; } = Color.FromArgb(220, 240, 220); public Color BorderColor { get; set; } = Color.FromArgb(80, 120, 80); public int ZOrder { get; set; } } public class FlowConnection { public string Id { get; set; } = Guid.NewGuid().ToString(); public string StartNodeId { get; set; } public string EndNodeId { get; set; } public string Label { get; set; } public PointF StartAnchor { get; set; } // 锚点,相对画布坐标 public PointF EndAnchor { get; set; } }为什么锚点要存坐标而不是让程序实时计算?因为连线可能被手动调整过,比如拉了一个贝塞尔曲线控制点,如果纯靠"从节点A中心到节点B中心"去算,用户调整过的连线形态就会丢失。简化版可以只存StartAnchor和EndAnchor两个绝对坐标,但注意:节点移动的时候,如果锚点存的是绝对坐标,线条就不会跟着走。所以我的处理方式是:连接线里只存节点Id,每次绘制时根据节点当前Bounds动态计算锚点。这样拖动节点时连线自动跟随。
public PointF GetAnchorPoint(FlowNode node) { // 我习惯取节点右边中心作为输出锚点,左边中心作为输入锚点 // 具体取哪个方向根据实际需要调整 return new PointF( node.Bounds.Right, node.Bounds.Top + node.Bounds.Height / 2 ); }2.2 画布控件的职责边界
我新建了一个自定义控件FlowDesignerControl,它负责三件事:
- 持有数据:
List<FlowNode> Nodes和List<FlowConnection> Connections - 处理交互:鼠标事件、键盘事件
- 主动重绘:把数据渲染到屏幕上
这个控件不负责业务逻辑,比如"保存时校验是否有孤立节点"这种判断放在外部调用方做。数据动不动,画布就怎么画,这样职责单一,后面加撤销重做也会容易很多。
事件处理方面,我用了一个常用的做法:让FlowDesignerControl暴露一些事件,比如NodeMoved、ConnectionAdded,外部表单订阅这些事件去更新属性面板,不直接操作控件内部的集合。
3. 绘制的核心机制:GDI+与双缓冲,以及为什么不能直接用控件
既然决定不用第三方库,画图就绕不开GDI+。很多人一提到GDI+就觉得性能差,绘制的多边形一多就闪烁。其实在流程图这种场景下(节点几十个、连线上百条),GDI+只要用对了,帧率完全足够。
我先说结论:不要在窗体上贴一堆Panel或Button当节点,然后靠鼠标拖来拖去。那是最笨的方案,widget一多,内存占用大,移动一卡,而且线条根本没法画——因为控件是窗体的子窗口,不能跨控件画线。
正确做法是:整个画布只有一个控件,所有图形都用GDI+在OnPaint里绘制。这样无论是节点矩形、圆角、文字、线条、箭头,都是画上去的,数据和显示完全分离。
3.1 绘制一个节点的完整代码
protected override void OnPaint(PaintEventArgs e) { base.OnPaint(e); Graphics g = e.Graphics; g.SmoothingMode = System.Drawing.Drawing2D.SmoothingMode.AntiAlias; g.CompositingQuality = System.Drawing.Drawing2D.CompositingQuality.HighQuality; // 先画连接线(在节点下层) foreach (var conn in Connections) { DrawConnection(g, conn); } // 再画节点 foreach (var node in Nodes) { DrawNode(g, node); } } private void DrawNode(Graphics g, FlowNode node) { using (var fillBrush = new SolidBrush(node.BackColor)) using (var borderPen = new Pen(node.BorderColor, 2f)) using (var textBrush = new SolidBrush(Color.FromArgb(60, 60, 60))) { // 画一个圆角矩形作为节点外观 using (var path = CreateRoundedRectangle(node.Bounds, 8)) { g.FillPath(fillBrush, path); g.DrawPath(borderPen, path); } // 文字居中 var format = new StringFormat { Alignment = StringAlignment.Center, LineAlignment = StringAlignment.Center }; g.DrawString(node.Text, this.Font, textBrush, node.Bounds, format); } }CreateRoundedRectangle是封装的GDI+圆角矩形路径生成方法,用GraphicsPath加圆弧,网上很多现成实现,我直接复用了。
注意:GDI+的
Pen和Brush一定要记得Dispose。我一开始图省事每次都new,跑几分钟GDI句柄数暴涨,直接报"内存不足"。后来统一改成using或者缓存静态画刷,问题解决。
3.2 双缓冲怎么开
纯GDI+绘图最明显的问题是闪烁。尤其在拖动节点时,OnPaint里画几十个圆角矩形和线条,刷新频率一高,画面就会闪。
解决方案有二:一是给控件设置DoubleBuffered = true,二是重写OnPaintBackground不再用默认的背景擦除。
public FlowDesignerControl() { InitializeComponent(); SetStyle(ControlStyles.AllPaintingInWmPaint | ControlStyles.UserPaint | ControlStyles.OptimizedDoubleBuffer, true); this.ResizeRedraw = true; this.BackColor = Color.White; }OptimizedDoubleBuffer是Winform内置的双缓冲机制,对GDI+绘制场景非常有用。实测发现,开启之后闪烁几乎消失。如果节点数量上千、绘制复杂,可能还得搭配BufferedGraphicsContext手动管理缓冲,但在这个项目里内置的就够了。
3.3 绘制连线与箭头
连线分两层:线条本身和箭头。线条我用DrawLine,因为简化版不需要贝塞尔曲线;如果以后要曲线,把DrawLine换成DrawBezier即可。
箭头的几何计算是一个小坑:箭头要正好落在节点边界上,而不是节点中心。如果用两个节点中心连线画箭头,箭头会被节点盖住一半,视觉上很丑。我的做法是取的锚点在节点边界上:
private void DrawConnection(Graphics g, FlowConnection conn) { var startNode = GetNodeById(conn.StartNodeId); var endNode = GetNodeById(conn.EndNodeId); if (startNode == null || endNode == null) return; PointF start = GetAnchorPoint(startNode); PointF end = GetAnchorPoint(endNode); using (var pen = new Pen(Color.FromArgb(80, 100, 140), 2f)) { g.DrawLine(pen, start, end); DrawArrow(g, pen, start, end); } } private void DrawArrow(Graphics g, Pen pen, PointF start, PointF end) { const float arrowSize = 10f; double angle = Math.Atan2(end.Y - start.Y, end.X - start.X); PointF arrowP1 = new PointF( end.X - (float)Math.Cos(angle - Math.PI / 6) * arrowSize, end.Y - (float)Math.Sin(angle - Math.PI / 6) * arrowSize ); PointF arrowP2 = new PointF( end.X - (float)Math.Cos(angle + Math.PI / 6) * arrowSize, end.Y - (float)Math.Sin(angle + Math.PI / 6) * arrowSize ); g.DrawLine(pen, end, arrowP1); g.DrawLine(pen, end, arrowP2); }注意,这里的end已经是节点边界上的锚点,所以箭头不会和节点重叠。
4. 交互与人机细节:拖动、选中、连线、命中测试
流程图的价值在于"可编辑",不是看一张静态图。所以我在这里花了很多时间。核心是鼠标事件的完整链路:按下(MouseDown)、移动(MouseMove)、抬起(MouseUp),以及在这期间的状态机切换。
4.1 命中最优先级的判定
鼠标在画布上点击,可能命中的对象有:节点、连线、空白区域。我设定了一个优先级:节点 > 连线 > 空白。
private FlowNode HitTestNode(PointF point) { // 注意倒序遍历,保证ZOrder高的节点先被命中 for (int i = Nodes.Count - 1; i >= 0; i--) { if (Nodes[i].Bounds.Contains(point)) return Nodes[i]; } return null; } private FlowConnection HitTestConnection(PointF point) { foreach (var conn in Connections) { var startNode = GetNodeById(conn.StartNodeId); var endNode = GetNodeById(conn.EndNodeId); if (startNode == null || endNode == null) continue; PointF p1 = GetAnchorPoint(startNode); PointF p2 = GetAnchorPoint(endNode); if (DistanceToSegment(point, p1, p2) < 5) return conn; } return null; }DistanceToSegment是点到线段的最小距离,用来判断鼠标点是否落在连线附近。这个函数涉及向量运算,自己实现也不难:
private double DistanceToSegment(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 nearestX = a.X + t * dx; float nearestY = a.Y + t * dy; return Math.Sqrt((p.X - nearestX) * (p.X - nearestX) + (p.Y - nearestY) * (p.Y - nearestY)); }4.2 拖动节点的状态机
拖动交互最怕的是"拖出残影"和"拖飞"。残影可以通过双缓冲解决,"拖飞"则是因为MouseMove里直接改节点坐标,导致节点跳到了鼠标下面——应该保留按下时鼠标相对节点的偏移量。
private PointF _dragOffset; private FlowNode _draggingNode; protected override void OnMouseDown(MouseEventArgs e) { base.OnMouseDown(e); PointF canvasPoint = ScreenToCanvas(e.Location); var node = HitTestNode(canvasPoint); if (node != null) { _draggingNode = node; _dragOffset = new PointF( canvasPoint.X - node.Bounds.X, canvasPoint.Y - node.Bounds.Y ); SelectedNode = node; this.Invalidate(); } } protected override void OnMouseMove(MouseEventArgs e) { base.OnMouseMove(e); PointF canvasPoint = ScreenToCanvas(e.Location); if (_draggingNode != null) { _draggingNode.Bounds = new RectangleF( canvasPoint.X - _dragOffset.X, canvasPoint.Y - _dragOffset.Y, _draggingNode.Bounds.Width, _draggingNode.Bounds.Height ); this.Invalidate(); } } protected override void OnMouseUp(MouseEventArgs e) { base.OnMouseUp(e); _draggingNode = null; }ScreenToCanvas是坐标转换的关键函数,后面讲缩放平移的时候会展开。在没做缩放平移之前,它只是一个原样返回的方法。
4.3 连线怎么创建
连线我采取的做法是:按住鼠标左键从一个节点的边缘拖出,松开时如果落在另一个节点上,就创建一条连接。整体上模仿Visio的功能,但不做"连接点吸附"这种细节,够用就好。
实现方式:在MouseDown时如果命中节点,记录_pendingConnectionStart,进入"连线模式";MouseMove时显示临时线;MouseUp时如果命中另一个节点,就写入Connections集合。
protected override void OnMouseMove(MouseEventArgs e) { // ...省略拖动节点逻辑... if (_pendingConnectionStart != null) { _pendingConnectionEnd = ScreenToCanvas(e.Location); this.Invalidate(); } } protected override void OnMouseUp(MouseEventArgs e) { // ...省略拖动节点结束逻辑... if (_pendingConnectionStart != null) { var endNode = HitTestNode(ScreenToCanvas(e.Location)); if (endNode != null && endNode != _pendingConnectionStart) { Connections.Add(new FlowConnection { StartNodeId = _pendingConnectionStart.Id, EndNodeId = endNode.Id }); } _pendingConnectionStart = null; this.Invalidate(); } }这里有个需要注意的点:MouseDown里先判断是不是在已有节点上按下,如果是连线模式就不进入拖动模式。两者用一个标志位区分,不能同时为真。
4.4 选中与属性面板的联动
选中节点后,外部属性面板(我用PropertyGrid)需要显示这个节点的属性。做法是在SelectedNode的setter里触发事件:
public FlowNode SelectedNode { get => _selectedNode; set { _selectedNode = value; OnSelectionChanged?.Invoke(this, new SelectionChangedEventArgs(value)); this.Invalidate(); } }为了区分选中态,绘制节点时我加了一个判断:如果节点是_selectedNode,边框颜色换成高亮的蓝色,线宽设为3f。这个视觉反馈非常重要,不然用户会以为点击没反应。
5. 缩放和平移:流程图体验的分水岭
没有缩放平移的流程图,只能算"节点编辑器",算不上"流程图工具"。Visio里最常用的操作就是滚轮缩放和按住空格键拖动画布。这个功能看似简单,其实牵涉到坐标体系转换,一旦处理不好,所有交互都会错位。
5.1 为什么要做双坐标转换
Winform的鼠标事件e.Location是控件坐标,而节点的Bounds是画布坐标。当画布发生了缩放和平移,这两个坐标就不相等了。
我维护了一个ViewTransform,包含两个参数:缩放比例Scale和平移偏移量Offset。
public class ViewTransform { public float Scale = 1.0f; public PointF Offset = PointF.Empty; public PointF ToCanvas(PointF screenPoint) { return new PointF( (screenPoint.X - Offset.X) / Scale, (screenPoint.Y - Offset.Y) / Scale ); } public PointF ToScreen(PointF canvasPoint) { return new PointF( canvasPoint.X * Scale + Offset.X, canvasPoint.Y * Scale + Offset.Y ); } }所有鼠标事件里拿到的坐标,一律先通过ToCanvas转成画布坐标再去命中测试。而绘制时,用Graphics.ScaleTransform和TranslateTransform把画布坐标系直接映射到屏幕。
5.2 缩放时的锚点体验
滚轮缩放时有两个方案:
- 方案A:以画布中心为锚点缩放
- 方案B:以鼠标所在位置为锚点缩放(像地图应用那样)
方案B体验好得多,但实现上多一步数学计算。核心目的是:鼠标指向的那个画布坐标,在缩放前后保持"屏幕位置不变"。
protected override void OnMouseWheel(MouseEventArgs e) { base.OnMouseWheel(e); const float scaleStep = 1.1f; float newScale = e.Delta > 0 ? Math.Min(_viewTransform.Scale * scaleStep, 3.0f) : Math.Max(_viewTransform.Scale / scaleStep, 0.3f); PointF mouseCanvas = _viewTransform.ToCanvas(e.Location); _viewTransform.Scale = newScale; // 调整偏移,使鼠标下的画布坐标不移动 PointF newScreenOfMouseCanvas = _viewTransform.ToScreen(mouseCanvas); _viewTransform.Offset.X += e.Location.X - newScreenOfMouseCanvas.X; _viewTransform.Offset.Y += e.Location.Y - newScreenOfMouseCanvas.Y; this.Invalidate(); }这段代码看起来不多,但逻辑密度很高。折算下来就是:缩放前算一次鼠标对应的画布坐标,缩放后用偏移量修正让这个坐标保持原位。
5.3 平移的两种常见实现
平移有两种触发方式:
- 按住鼠标中键拖动画布
- 按住空格键+左键拖动画布
我两个都做了。实现上有细微差别,核心都是修改Offset:
protected override void OnMouseDown(MouseEventArgs e) { if (e.Button == MouseButtons.Middle) { _isPanning = true; _lastPanPoint = e.Location; this.Cursor = Cursors.SizeAll; return; } // 其他原有逻辑... } protected override void OnMouseMove(MouseEventArgs e) { if (_isPanning) { _viewTransform.Offset.X += e.Location.X - _lastPanPoint.X; _viewTransform.Offset.Y += e.Location.Y - _lastPanPoint.Y; _lastPanPoint = e.Location; this.Invalidate(); return; } // 其他原有逻辑... }注意:平移发生时,绘制内容不需要变化,只是坐标映射关系变了。所以这里只需要重新绘制,不需要改任何节点数据。
5.4 缩放平移后的绘制代码
在OnPaint开头加入变换:
protected override void OnPaint(PaintEventArgs e) { Graphics g = e.Graphics; g.SmoothingMode = System.Drawing.Drawing2D.SmoothingMode.AntiAlias; g.Clear(this.BackColor); g.TranslateTransform(_viewTransform.Offset.X, _viewTransform.Offset.Y); g.ScaleTransform(_viewTransform.Scale, _viewTransform.Scale); // 下面所有绘制代码使用的都是画布坐标 DrawConnections(g); DrawNodes(g); }有了这个变换,绘制代码就不需要关心缩放平移了,节点坐标和鼠标转换后的坐标都在同一套坐标系里,天然一致。
5.5 网格背景的实现
流程图工具一般都会画网格点或网格线,这个对对齐很有帮助。在绘制背景时,我先算了一下可见区域,再沿着可见区域画点阵:
private void DrawGrid(Graphics g) { const float gridSize = 20f; float scaledGridSize = gridSize * _viewTransform.Scale; if (scaledGridSize < 8f) return; // 太密就不画了,否则全是点,看起来像噪点 using (var brush = new SolidBrush(Color.FromArgb(200, 200, 200))) { int startX = (int)(_viewTransform.Offset.X % scaledGridSize); int startY = (int)(_viewTransform.Offset.Y % scaledGridSize); for (float x = startX; x < this.Width; x += scaledGridSize) { for (float y = startY; y < this.Height; y += scaledGridSize) { g.FillEllipse(brush, x - 1f, y - 1f, 2f, 2f); } } } }这个网格的替代思想是:在屏幕坐标系里画点,跟画布坐标系无关。这样网格始终对应的屏幕像素,缩放时点距会变化,能直观看到缩放级别。如果希望网格跟着画布走(比如1格=20px画布单位),实现方式会不一样。我选择了在屏幕坐标系画,因为用户拖动画布时网格跟着动,对齐效果更直观。
6. 序列化:让画好的图能存能读
没有持久化能力的流程图工具等于白做。Winform里自然的方案是XmlSerializer或JsonSerializer。.NET Framework下我推荐XmlSerializer,.NET Core/.NET 5以上推荐System.Text.Json或Newtonsoft.Json。两者差别不大,关键是不要把控件对象直接序列化,而是定义一个独立的DTO类。
6.1 DTO与导出结构
public class FlowDocumentDto { public List<FlowNodeDto> Nodes { get; set; } public List<FlowConnectionDto> Connections { get; set; } public string Version { get; set; } = "1.0"; } public class FlowNodeDto { 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 int ZOrder { get; set; } } public class FlowConnectionDto { public string StartNodeId { get; set; } public string EndNodeId { get; set; } public string Label { get; set; } }序列化保存的时候,遍历内存中的节点、连线,转成DTO后写入文件。加载的时候反向转换。这里有个坑:ID在节点被加载后不能变化,否则连线就找不到端点了。所以DTO里必须保留Id字段,加载时要原样恢复,不能NewGuid重新生成。
6.2 加载后的校验
加载文件后,我发现了一个实际问题:手工编辑JSON文件时(是的,总会有人这么干),连线引用了不存在的节点Id。如果不校验,加载后画布上会出现"幽灵连线"——线条一端没有节点,直接飘着。
加载后我加了一个清理逻辑:
private void ValidateConnections() { var validIds = new HashSet<string>(Nodes.Select(n => n.Id)); Connections = Connections.Where(c => validIds.Contains(c.StartNodeId) && validIds.Contains(c.EndNodeId) ).ToList(); }这个逻辑虽然简单,但避免了大量因为文件被篡改或老版本遗留数据导致的诡异问题。
6.3 导出图片功能
除了保存源文件,我加了一个"导出PNG"功能。实现方式是把画布内容渲染到一个Bitmap上,然后用Graphics做同样的绘制调用:
public Bitmap ExportToImage(int width, int height) { var bmp = new Bitmap(width, height); using (var g = Graphics.FromImage(bmp)) { g.SmoothingMode = System.Drawing.Drawing2D.SmoothingMode.AntiAlias; g.TranslateTransform(_viewTransform.Offset.X, _viewTransform.Offset.Y); g.ScaleTransform(_viewTransform.Scale, _viewTransform.Scale); DrawConnections(g); DrawNodes(g); } return bmp; }注意:绘制逻辑要从OnPaint里抽出来,成为一个独立方法。否则OnPaint和ExportToImage各写一套,后续改样式时只改了一处,两个地方显示不一致,很难排查。
7. 性能优化与那些容易被忽略的小坑
流程图工具的性能问题,卡就卡在重绘上。我实测了两种极端情况:一种是节点多但都不动,另一种是拖动一个节点时全画布重绘。前者没问题,后者如果节点有200个以上,还是会感觉到卡顿。
7.1 脏矩形重绘
Winform的Invalidate()有一个重载,可以指定一个矩形区域,只重绘该区域。我改进了一下:拖动节点时,不Invalidate()整个画布,只重绘这个节点的原区域+新区域+连线影响到的区域:
protected override void OnMouseMove(MouseEventArgs e) { if (_draggingNode != null) { RectangleF oldBounds = _draggingNode.Bounds; _draggingNode.Bounds = new RectangleF( canvasPoint.X - _dragOffset.X, canvasPoint.Y - _dragOffset.Y, _draggingNode.Bounds.Width, _draggingNode.Bounds.Height ); // 合并旧区域与新区域,加一点余量(因为有箭头) RectangleF invalidRect = RectangleF.Union(oldBounds, _draggingNode.Bounds); invalidRect.Inflate(20f, 20f); this.Invalidate(Rectangle.Round(invalidRect)); return; } }不过,这个优化在缩放平移状态下要注意:Invalidate(rect)的rect是控件坐标,不是画布坐标。我在这里踩过坑:缩放0.5倍时,实际影响区域是画布区域的0.5倍,如果不做转换,会出现区域遗漏、拖出来的线还剩半边没擦干净的"鬼影"。
实际情况是:如果只是内部工具,节点数少于50个,整画布重绘完全够用。性能优化是最后一步,不是第一步,别把事情搞复杂了。
7.2 Font和Pen的缓存
频繁创建Pen、SolidBrush、Font对象,不仅慢,还会导致GDI+句柄泄漏。我的做法是:
private Pen _penBorder = new Pen(Color.FromArgb(80, 120, 80), 2f); private Pen _penSelected = new Pen(Color.FromArgb(65, 105, 225), 3f); private Pen _penLine = new Pen(Color.FromArgb(80, 100, 140), 2f); private SolidBrush _brushText = new SolidBrush(Color.FromArgb(60, 60, 60));在控件Dispose时统一释放。这样不仅减少GC压力,还让绘制代码更简洁。
7.3 高DPI分辨率的适配
现在很多笔记本默认都是150%缩放,Winform在高DPI下如果不做适配,界面会糊成一片,鼠标坐标也会偏移。绘制流程图的坐标精准度要求高,这里必须处理。
第一步,修改app.manifest:
<application xmlns="urn:schemas-microsoft-com:asm.v3"> <windowsSettings> <dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true</dpiAware> <dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2</dpiAwareness> </windowsSettings> </application>第二步,在Main里调用Application.EnableVisualStyles()后,设置Application.SetHighDpiMode(HighDpiMode.PerMonitorV2)。
如果漏掉这两步,在4K屏+125%缩放下,你会发现画布白边不对、鼠标点击位置和图形对不上,特别崩溃。而且测试时不能只看本机,要多拿几台不同缩放比例的机器试。
7.4 画布大小和滚动条
Visio可以画一页,也可以无限延伸。做简化版,我建议给画布一个固定的虚拟大小,比如4000x4000,超出这个范围就不允许拖动了。这样实现简单,还不用考虑滚动条和性能边界。
我的做法是,在OnMouseMove的拖动逻辑里加一个边界钳制:
float maxX = _virtualCanvasWidth - _draggingNode.Bounds.Width; float maxY = _virtualCanvasHeight - _draggingNode.Bounds.Height; float newX = Math.Max(0, Math.Min(canvasPoint.X - _dragOffset.X, maxX)); float newY = Math.Max(0, Math.Min(canvasPoint.Y - _dragOffset.Y, maxY));固定画布还有一个好处:存储的时候坐标范围是确定的,不会出现节点被拖到坐标上万像素之外、然后画布空白一片找不到图的情况。
8. 从工具到产品:我的扩展路线和总结
目前这个流程图控件我已经用在了内部产线配置工具里,整体体验接近一个"简化版Visio"。但如果接下来要继续演进,我大概会按这个优先级去加功能:
- 撤销/重做:这是使用频率最高的功能,也是当前最缺的。实现思路是命令模式,每一步操作记录一个可以逆操作的命令对象,栈式管理。这个在纯数据驱动的架构下很好加,因为操作对象是数据而不是画布。
- 对齐参考线:拖动节点时,当节点边缘或其他节点中心接近时,显示一条辅助虚线并吸附。这是从"能用到好用"的关键一步,实现上需要遍历所有其他节点,做阈值判断。
- 迷你地图:画布右下角显示缩略全貌,对定位大流程图很有帮助。逻辑不复杂,需要单独用一个小的
Paint事件,按比例把节点画成小矩形。 - 自定义形状:节点从矩形扩展为菱形(决策)、六边形(过程)、椭圆(开始/结束)等。我推荐的抽象方式是把节点形状抽象成一个枚举,绘制时switch不同形状,而命中测试统一用矩形边界。这样命中测试逻辑不用改动。
如果你也要做一个类似的工具,我最大的建议是:先定数据模型,再写绘制代码。数据模型设计好了,后面的交互、存储、撤销重做全都是顺水推舟的事情。反过来,先画图再补数据,会越写越痛苦——因为你会不断发现"这个图形信息不知道该塞在哪个字段里"。
另外,不用害怕Winform老。它虽然不算新潮,但对于内部工具、工控上位机、桌面小工具这些场景,成熟稳定、部署简单、不依赖运行时,你很难找到比它更省心的方案。做工具,核心是解决问题,不是追逐技术热点。这个简化版流程图编辑器,我用GDI+和原生控件实现了节点绘制、连线、拖动、缩放平移、序列化、导出图片,代码量控制在两千行左右,维护成本完全可接受。
最后说一个我实际用下来的体会:流程图工具的价值不在于"画得好看",在于"数据结构被业务复用"。我的这个控件,导出的JSON可以直接被上位的执行引擎读取——节点变成工位,连线变成流转路径,流程图编辑器本质上变成了一种可视化的业务配置工具。如果你也是这个方向,数据层的设计一定不要偷懒,多花点时间把ID、锚点、ZOrder这些概念理清楚,后面会省很多事。
本文还有配套的精品资源,点击获取