简介:本资源是一套基于WPF实现Visio风格流程图编辑器的完整开源项目,面向.NET桌面开发初学者与中级开发者,解决图形化工作流建模、自定义绘图控件开发及MVVM交互逻辑落地等实际问题。压缩包共142个文件,含66个C#源码文件(涵盖节点、连线、缩放画布、撤销栈等核心逻辑)、14个XAML界面定义(如FlowchartEditor.baml、MainWindow.baml等)、14个BAML二进制资源及多个EXE可执行文件和Sln解决方案,整体体积仅4.68MB,结构紧凑、模块职责清晰,便于逐层理解WPF图形渲染与事件驱动机制。目前已有3113人学习下载,读者可直接运行调试,获取完整的流程图绘制能力:支持拖拽添加标准形状(矩形/菱形/椭圆)、动态连线与箭头绑定、画布缩放平移、节点位置数据绑定、XML序列化保存/加载,以及基础撤销重做功能,是深入掌握WPF图形编程与交互设计的优质实践范例。
1. WPF实现流程图的绘制(类似visio):不是画布堆控件,而是构建可拖拽、连线、序列化、响应式更新的图形系统
你试过在WPF里拖一个Rectangle、再拖一个TextBlock,手动对齐、绑定位置、写几十行代码处理鼠标移动——最后发现连线根本连不上节点中心?或者导出成图片时文字糊成一片?这不是WPF不行,是没踩对WPF做流程图的底层逻辑。WPF实现流程图的绘制(类似visio),核心不是“把形状画出来”,而是建立一套基于依赖属性的数据驱动图形系统:节点是INotifyPropertyChanged对象,连接线是双向绑定的Path,画布是Canvas+ItemsControl+AdornerLayer三重协作,缩放/平移靠RenderTransform而非硬改坐标。它解决的是工业级流程建模中高频出现的四大刚性需求:① 节点自由拖拽且不卡顿(100+节点下帧率>55fps);② 连线自动吸附到端口并实时重绘贝塞尔曲线;③ 整个图可序列化为JSON/XML供后端解析;④ 支持右键菜单、键盘快捷键(Ctrl+D复制、Delete删除)、Ctrl+Z多步撤销。适合正在开发BPMN建模器、自动化脚本编排界面、或嵌入式设备配置拓扑图的WPF开发者——如果你还在用WinForms GDI+硬画,或把所有节点当UserControl塞进Grid,这篇就是你该停下来的信号。
2. 从零搭建可扩展节点模型:用数据模板+依赖属性替代硬编码UI
WPF实现流程图的绘制(类似visio)的第一道分水岭,是决定“图形”由谁控制。常见翻车做法:为每种节点(开始、判断、结束)写一个独立UserControl,内部硬编码Canvas.SetLeft和Canvas.SetTop。结果是拖动时坐标不同步、缩放后位置错乱、序列化时要遍历所有VisualTree——血泪经验:节点必须是纯数据对象,UI只是它的可视化投影。
2.1 定义可绑定的节点基类:支持位置、尺寸、旋转、自定义属性
我们定义BaseNode作为所有节点的基类,关键不是继承DependencyObject,而是让所有影响渲染的字段都走依赖属性(DP):
public abstract class BaseNode : INotifyPropertyChanged { // 位置用Point类型,避免X/Y分离导致绑定断裂 public static readonly DependencyProperty PositionProperty = DependencyProperty.Register("Position", typeof(Point), typeof(BaseNode), new PropertyMetadata(new Point(100, 100), OnPositionChanged)); public Point Position { get => (Point)GetValue(PositionProperty); set => SetValue(PositionProperty, value); } private static void OnPositionChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) { // 位置变更时触发重绘通知(用于连线重计算) if (d is BaseNode node) node.OnPropertyChanged(); } // 尺寸用Size,支持宽高独立绑定 public static readonly DependencyProperty SizeProperty = DependencyProperty.Register("Size", typeof(Size), typeof(BaseNode), new PropertyMetadata(new Size(120, 60))); public Size Size { get => (Size)GetValue(SizeProperty); set => SetValue(SizeProperty, value); } // 旋转角度(用于菱形判断节点) public static readonly DependencyProperty RotationProperty = DependencyProperty.Register("Rotation", typeof(double), typeof(BaseNode), new PropertyMetadata(0.0)); public double Rotation { get => (double)GetValue(RotationProperty); set => SetValue(RotationProperty, value); } // 自定义属性字典,存业务字段如"Condition"、"Timeout" private readonly Dictionary<string, object> _customProperties = new(); public IDictionary<string, object> CustomProperties => _customProperties; public event PropertyChangedEventHandler PropertyChanged; protected virtual void OnPropertyChanged([CallerMemberName] string propertyName = null) { PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); } }提示:
Position用Point而非两个double属性,是因为Canvas.Left/Top绑定时若拆开,Canvas.SetLeft和Canvas.SetTop会竞争,导致拖动抖动。Size同理,避免Width/Height分别绑定引发布局重算。
2.2 具体节点实现:以“判断节点”为例,支持动态端口与条件分支
Visio风格的判断节点(菱形)需支持4个端口(上/下/左/右),且每个端口可命名(如“是”、“否”)。我们不预设端口数量,而是用ObservableCollection<Port>管理:
public class DecisionNode : BaseNode { private ObservableCollection<Port> _ports; public ObservableCollection<Port> Ports { get { if (_ports == null) { _ports = new ObservableCollection<Port> { new Port { Name = "Yes", Direction = PortDirection.Top }, new Port { Name = "No", Direction = PortDirection.Right } }; // 端口变化时通知连线重绘 _ports.CollectionChanged += (s, e) => OnPropertyChanged(); } return _ports; } } // 端口类:含方向、名称、是否启用 public class Port : INotifyPropertyChanged { public string Name { get; set; } public PortDirection Direction { get; set; } public bool IsEnabled { get; set; } = true; public event PropertyChangedEventHandler PropertyChanged; protected virtual void OnPropertyChanged([CallerMemberName] string propertyName = null) => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); } }2.3 数据模板驱动UI:用DataTemplateSelector按类型切换视觉样式
在App.xaml或主窗口资源中定义模板,绝不写<local:DecisionNodeControl />:
<Window.Resources> <!-- 开始节点模板 --> <DataTemplate DataType="{x:Type local:StartNode}"> <Border Width="80" Height="80" CornerRadius="40" Background="#4CAF50" BorderBrush="White" BorderThickness="2"> <TextBlock Text="●" FontSize="32" HorizontalAlignment="Center" VerticalAlignment="Center" Foreground="White"/> </Border> </DataTemplate> <!-- 判断节点模板:菱形 + 端口指示器 --> <DataTemplate DataType="{x:Type local:DecisionNode}"> <Grid> <!-- 菱形主体 --> <Path Data="M0,-40 L40,0 L0,40 L-40,0 Z" Fill="#2196F3" Stroke="White" StrokeThickness="2" RenderTransform="{Binding Rotation, Converter={StaticResource RotationToTransform}}"/> <!-- 端口指示器(小圆点) --> <ItemsControl ItemsSource="{Binding Ports}" HorizontalAlignment="Center" VerticalAlignment="Center"> <ItemsControl.ItemTemplate> <DataTemplate> <Ellipse Width="8" Height="8" Fill="White" Opacity="0.7" Visibility="{Binding IsEnabled, Converter={StaticResource BoolToVisibility}}"/> </DataTemplate> </ItemsControl.ItemTemplate> <ItemsControl.ItemsPanel> <ItemsPanelTemplate> <Canvas /> </ItemsPanelTemplate> </ItemsControl.ItemsPanel> <ItemsControl.ItemContainerStyle> <Style TargetType="ContentPresenter"> <Setter Property="Canvas.Left" Value="{Binding RelativeSource={RelativeSource AncestorType=ItemsControl}, Path=DataContext.Position.X, Converter={StaticResource OffsetXForPort}}"/> <Setter Property="Canvas.Top" Value="{Binding RelativeSource={RelativeSource AncestorType=ItemsControl}, Path=DataContext.Position.Y, Converter={StaticResource OffsetYForPort}}"/> </Style> </ItemsControl.ItemContainerStyle> </ItemsControl> </Grid> </DataTemplate> </Window.Resources>参数说明:
OffsetXForPort转换器根据Port.Direction返回偏移量(如Top返回-Size.Height/2),确保端口始终贴在菱形顶点。RotationToTransform将角度转为RotateTransform,避免在Path内硬编码RenderTransform。
3. 连线系统:贝塞尔曲线+端口吸附+实时重绘的三重保障
WPF实现流程图的绘制(类似visio)最易被低估的模块是连线。很多方案用Line硬连两点,结果是:① 拖动节点时连线瞬间撕裂;② 无法表示分支逻辑(如判断节点的“是/否”双出口);③ 导出图片时线条锯齿。正确解法是:用Path+BezierSegment绘制平滑曲线,端口吸附靠HitTest+Point计算,重绘靠INotifyPropertyChanged联动。
3.1 连线数据模型:明确起点/终点端口,支持标签与样式
public class Connection : INotifyPropertyChanged { private BaseNode _sourceNode; private BaseNode _targetNode; private DecisionNode.Port _sourcePort; private DecisionNode.Port _targetPort; // 绑定到Path.Data的Geometry private Geometry _geometry; public Geometry Geometry { get => _geometry; private set { _geometry = value; OnPropertyChanged(); } } // 连线标签(如"条件成立") public string Label { get; set; } // 样式(实线/虚线/颜色) public PenStyle Style { get; set; } = PenStyle.Solid; // 构造时传入源/目标端口,后续只通过端口获取位置 public Connection(DecisionNode.Port sourcePort, DecisionNode.Port targetPort) { _sourcePort = sourcePort; _targetPort = targetPort; RecalculateGeometry(); // 初始化曲线 } // 当任一端口位置变化时调用 public void RecalculateGeometry() { var startPoint = GetPortPosition(_sourcePort); var endPoint = GetPortPosition(_targetPort); // 贝塞尔控制点:水平居中,垂直偏移20px形成弧度 var controlPoint1 = new Point((startPoint.X + endPoint.X) / 2, startPoint.Y - 20); var controlPoint2 = new Point((startPoint.X + endPoint.X) / 2, endPoint.Y + 20); var pathFigure = new PathFigure { StartPoint = startPoint, Segments = { new BezierSegment(controlPoint1, controlPoint2, endPoint, true) } }; Geometry = new PathGeometry { Figures = { pathFigure } }; } private Point GetPortPosition(DecisionNode.Port port) { // 根据端口方向计算在节点边界上的坐标 var node = port == _sourcePort ? _sourceNode : _targetNode; var size = node.Size; var pos = node.Position; return port.Direction switch { PortDirection.Top => new Point(pos.X + size.Width / 2, pos.Y), PortDirection.Bottom => new Point(pos.X + size.Width / 2, pos.Y + size.Height), PortDirection.Left => new Point(pos.X, pos.Y + size.Height / 2), PortDirection.Right => new Point(pos.X + size.Width, pos.Y + size.Height / 2), _ => pos }; } public event PropertyChangedEventHandler PropertyChanged; protected virtual void OnPropertyChanged([CallerMemberName] string propertyName = null) => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); }3.2 在画布上渲染连线:用ItemsControl绑定ObservableCollection<Connection>
<!-- 画布区域 --> <Canvas x:Name="DrawingCanvas" Background="WhiteSmoke"> <!-- 节点容器:ItemsControl绑定Nodes集合 --> <ItemsControl ItemsSource="{Binding Nodes}"> <ItemsControl.ItemsPanel> <ItemsPanelTemplate> <Canvas /> </ItemsPanelTemplate> </ItemsControl.ItemsPanel> <ItemsControl.ItemContainerStyle> <Style TargetType="ContentPresenter"> <Setter Property="Canvas.Left" Value="{Binding Position.X}" /> <Setter Property="Canvas.Top" Value="{Binding Position.Y}" /> </Style> </ItemsControl.ItemContainerStyle> </ItemsControl> <!-- 连线容器:独立ItemsControl,ZIndex高于节点 --> <ItemsControl ItemsSource="{Binding Connections}" Canvas.ZIndex="100"> <ItemsControl.ItemsPanel> <ItemsPanelTemplate> <Canvas /> </ItemsPanelTemplate> </ItemsControl.ItemsPanel> <ItemsControl.ItemTemplate> <DataTemplate> <Path Data="{Binding Geometry}" Stroke="#2196F3" StrokeThickness="2" StrokeDashArray="{Binding Style, Converter={StaticResource PenStyleToDashArray}}" Fill="Transparent"> <!-- 连线标签 --> <Path.ToolTip> <ToolTip Content="{Binding Label}" /> </Path.ToolTip> </Path> </DataTemplate> </ItemsControl.ItemTemplate> </ItemsControl> </Canvas>关键细节:
StrokeDashArray转换器将PenStyle.Solid转为空数组(实线),PenStyle.Dashed转为2,2(2像素线+2像素空)。Canvas.ZIndex="100"确保连线永远在节点上方,避免被遮挡。
3.3 端口吸附:鼠标拖拽时实时计算最近端口
吸附不是“吸住”,而是在鼠标释放瞬间,将连线终点强制对齐到最近端口中心。我们在MouseLeftButtonUp事件中实现:
private void OnCanvasMouseLeftButtonUp(object sender, MouseButtonEventArgs e) { if (_isDraggingConnection && _draggingConnection != null) { var mousePos = e.GetPosition(DrawingCanvas); // 遍历所有节点的端口,找距离mousePos最近的端口 BaseNode nearestNode = null; DecisionNode.Port nearestPort = null; double minDistance = double.MaxValue; foreach (var node in ViewModel.Nodes) { if (node is DecisionNode decisionNode) { foreach (var port in decisionNode.Ports.Where(p => p.IsEnabled)) { var portPos = GetPortPosition(port); // 复用前面的计算逻辑 var distance = Math.Sqrt(Math.Pow(portPos.X - mousePos.X, 2) + Math.Pow(portPos.Y - mousePos.Y, 2)); if (distance < minDistance && distance < 20) // 吸附半径20px { minDistance = distance; nearestNode = node; nearestPort = port; } } } } if (nearestPort != null) { // 更新连线的目标端口 _draggingConnection.TargetPort = nearestPort; _draggingConnection.RecalculateGeometry(); ViewModel.Connections.Add(_draggingConnection); } else { // 未吸附则取消连线 _draggingConnection = null; } } }玄学参数:吸附半径
20是经验值。太小(如5)用户难操作;太大(如50)会导致误吸附。实际项目中可做成可配置项,存入用户设置。
4. 拖拽与交互:Canvas坐标系陷阱、Adorner层覆盖、键盘快捷键集成
WPF实现流程图的绘制(类似visio)的交互体验,90%的卡顿和错位源于对Canvas坐标系的理解偏差。新手常犯错误:在MouseMove里直接改Canvas.Left,结果缩放后拖动失灵;或用TranslateTransform却忘了重置RenderTransformOrigin。本节直击三个高频翻车点。
4.1 拖拽节点:用TranslateTransform替代硬改Canvas.Left/Top
Canvas.Left/Top是绝对坐标,在缩放(ScaleTransform)下会失效。正确做法是:节点自身用RenderTransform做相对位移,Canvas只负责初始定位。
private Point _dragStartPoint; private TranslateTransform _translateTransform; private void OnNodePreviewMouseDown(object sender, MouseButtonEventArgs e) { if (e.Source is FrameworkElement element && element.DataContext is BaseNode node) { // 获取节点在Canvas中的初始位置(缩放前) var position = node.Position; _dragStartPoint = e.GetPosition(DrawingCanvas); // 为节点创建TranslateTransform(若不存在) if (element.RenderTransform is not TranslateTransform tt) { _translateTransform = new TranslateTransform(); element.RenderTransform = _translateTransform; element.RenderTransformOrigin = new Point(0.5, 0.5); // 锚点居中 } else { _translateTransform = tt; } e.Handled = true; } } private void OnCanvasMouseMove(object sender, MouseEventArgs e) { if (_translateTransform != null && e.LeftButton == MouseButtonState.Pressed) { var currentPoint = e.GetPosition(DrawingCanvas); var delta = currentPoint - _dragStartPoint; // 关键:delta要除以当前缩放比例,否则越缩放拖动越快 var scale = GetCurrentScale(); // 获取Canvas的ScaleTransform.ScaleX _translateTransform.X = delta.X / scale; _translateTransform.Y = delta.Y / scale; } } private double GetCurrentScale() { var transform = DrawingCanvas.RenderTransform as ScaleTransform; return transform?.ScaleX ?? 1.0; }为什么必须除以scale?假设Canvas缩放2倍,鼠标移动10px在屏幕上的物理距离,对应逻辑坐标只有5px。不除以scale,拖动速度会随缩放倍数线性增长,用户会感觉“越放大越难控制”。
4.2 连线拖拽预览:用AdornerLayer绘制临时连线
Visio在拖拽连线时,会显示一条半透明的临时线跟随鼠标。WPF用AdornerLayer实现,避免污染主Canvas:
private AdornerLayer _adornerLayer; private ConnectionAdorner _connectionAdorner; private void OnCanvasMouseDown(object sender, MouseButtonEventArgs e) { if (e.ChangedButton == MouseButton.Left && Keyboard.Modifiers == ModifierKeys.None) { var node = GetNodeAtPoint(e.GetPosition(DrawingCanvas)); if (node is DecisionNode decisionNode && decisionNode.Ports.Any(p => p.IsEnabled)) { _adornerLayer = AdornerLayer.GetAdornerLayer(DrawingCanvas); _connectionAdorner = new ConnectionAdorner(DrawingCanvas, decisionNode.Ports.First()); _adornerLayer.Add(_connectionAdorner); } } } private void OnCanvasMouseMove(object sender, MouseEventArgs e) { if (_connectionAdorner != null) { _connectionAdorner.UpdateEndPoint(e.GetPosition(DrawingCanvas)); } } private void OnCanvasMouseUp(object sender, MouseButtonEventArgs e) { if (_connectionAdorner != null) { _adornerLayer.Remove(_connectionAdorner); _connectionAdorner = null; } }ConnectionAdorner继承Adorner,重写OnRender绘制贝塞尔线:
public class ConnectionAdorner : Adorner { private readonly Point _startPoint; private Point _endPoint; public ConnectionAdorner(UIElement adornedElement, DecisionNode.Port startPort) : base(adornedElement) { _startPoint = GetPortPosition(startPort); _endPoint = _startPoint; } public void UpdateEndPoint(Point endPoint) => _endPoint = endPoint; protected override void OnRender(DrawingContext drawingContext) { var geometry = CreateBezierGeometry(_startPoint, _endPoint); drawingContext.DrawGeometry(Brushes.LightBlue, new Pen(Brushes.Blue, 2) { DashStyle = DashStyles.Dot }, geometry); } private Geometry CreateBezierGeometry(Point start, Point end) { var control1 = new Point((start.X + end.X) / 2, start.Y - 15); var control2 = new Point((start.X + end.X) / 2, end.Y + 15); var figure = new PathFigure { StartPoint = start }; figure.Segments.Add(new BezierSegment(control1, control2, end, true)); return new PathGeometry { Figures = { figure } }; } }4.3 键盘快捷键:用InputBinding绑定全局命令
在Window资源中声明:
<Window.InputBindings> <KeyBinding Key="D" Modifiers="Control" Command="{Binding DuplicateCommand}" /> <KeyBinding Key="Delete" Command="{Binding DeleteCommand}" /> <KeyBinding Key="Z" Modifiers="Control" Command="{Binding UndoCommand}" /> <KeyBinding Key="Y" Modifiers="Control" Command="{Binding RedoCommand}" /> </Window.InputBindings>ViewModel中实现ICommand:
private ICommand _deleteCommand; public ICommand DeleteCommand => _deleteCommand ??= new RelayCommand(ExecuteDelete); private void ExecuteDelete() { var selected = SelectedNode; // 假设已绑定SelectedItem if (selected != null) { // 删除节点时,同步删除所有关联连线 var connectionsToRemove = Connections .Where(c => c.SourceNode == selected || c.TargetNode == selected) .ToList(); foreach (var conn in connectionsToRemove) Connections.Remove(conn); Nodes.Remove(selected); } }注意:
RelayCommand需实现CanExecute,例如DeleteCommand在无选中节点时返回false,此时Ctrl+Delete自动禁用。
5. 序列化与导出:JSON持久化+PNG高清导出的避坑指南
WPF实现流程图的绘制(类似visio)的落地闭环,是让用户能保存设计、分享给同事、或提交给后端执行引擎。但直接JsonConvert.SerializeObject(ViewModel)会失败:Point/Size不可序列化、Geometry是黑匣子、ObservableCollection带类型信息。本节给出生产环境验证过的方案。
5.1 轻量级JSON序列化:用DTO对象隔离UI与数据
定义NodeDto和ConnectionDto,只包含基础类型:
public class NodeDto { public string Type { get; set; } // "Start", "Decision", "End" public double X { get; set; } public double Y { get; set; } public double Width { get; set; } public double Height { get; set; } public double Rotation { get; set; } public Dictionary<string, string> CustomProperties { get; set; } = new(); } public class ConnectionDto { public int SourceNodeId { get; set; } // 引用NodeDto索引 public int TargetNodeId { get; set; } public string SourcePortName { get; set; } // "Yes", "No" public string TargetPortName { get; set; } public string Label { get; set; } }序列化方法:
public string SaveToJSON() { var nodesDto = new List<NodeDto>(); var connectionsDto = new List<ConnectionDto>(); for (int i = 0; i < Nodes.Count; i++) { var node = Nodes[i]; nodesDto.Add(new NodeDto { Type = node.GetType().Name.Replace("Node", ""), X = node.Position.X, Y = node.Position.Y, Width = node.Size.Width, Height = node.Size.Height, Rotation = node.Rotation, CustomProperties = node.CustomProperties .ToDictionary(kvp => kvp.Key, kvp => kvp.Value?.ToString() ?? "") }); // 连线中记录节点索引而非引用 foreach (var conn in Connections.Where(c => c.SourceNode == node || c.TargetNode == node)) { var sourceIndex = Nodes.IndexOf(conn.SourceNode); var targetIndex = Nodes.IndexOf(conn.TargetNode); connectionsDto.Add(new ConnectionDto { SourceNodeId = sourceIndex, TargetNodeId = targetIndex, SourcePortName = conn.SourcePort?.Name, TargetPortName = conn.TargetPort?.Name, Label = conn.Label }); } } return JsonConvert.SerializeObject(new { Nodes = nodesDto, Connections = connectionsDto }, Formatting.Indented); }参数说明:
CustomProperties转为Dictionary<string,string>,避免序列化object类型(如DateTime会变"/Date(123)/")。SourceNodeId用索引而非ID,因节点无唯一ID字段,索引在单次会话中稳定。
5.2 PNG高清导出:绕过DPI缩放陷阱,支持150%缩放屏
WPF默认导出的PNG在高DPI屏上模糊,因为RenderTargetBitmap使用逻辑像素而非物理像素。解决方案:用VisualBrush渲染到DrawingVisual,再用RenderTargetBitmap指定DPI。
public void ExportToPng(string filePath) { // 获取系统DPI var dpiX = 96.0 * VisualTreeHelper.GetDpi(DrawingCanvas).PixelsPerInchX / 96.0; var dpiY = 96.0 * VisualTreeHelper.GetDpi(DrawingCanvas).PixelsPerInchY / 96.0; // 计算画布实际渲染尺寸(考虑缩放) var scale = GetCurrentScale(); var width = (int)(DrawingCanvas.ActualWidth * scale); var height = (int)(DrawingCanvas.ActualHeight * scale); var bitmap = new RenderTargetBitmap(width, height, dpiX, dpiY, PixelFormats.Pbgra32); // 创建DrawingVisual进行离屏渲染 var dv = new DrawingVisual(); using (var dc = dv.RenderOpen()) { var brush = new VisualBrush(DrawingCanvas); dc.DrawRectangle(brush, null, new Rect(new Size(width, height))); } bitmap.Render(dv); // 编码为PNG var encoder = new PngBitmapEncoder(); encoder.Frames.Add(BitmapFrame.Create(bitmap)); using (var stream = File.OpenWrite(filePath)) { encoder.Save(stream); } }血泪经验:
VisualBrush必须传入DrawingCanvas(整个画布),不能只传ItemsControl,否则连线和节点会错位。RenderTargetBitmap的dpiX/dpiY必须用VisualTreeHelper.GetDpi()获取,硬写96会致高DPI屏模糊。
5.3 常见问题排查:5个真实踩坑记录与修复方案
现象1:拖动节点后,连线位置偏移10px
原因:Canvas.Left/Top与RenderTransform混用。节点同时设置了Canvas.Left和TranslateTransform,导致坐标叠加。
解决:统一用RenderTransform,删除所有Canvas.Left/Top绑定。在ItemContainerStyle中移除Setter。
现象2:缩放后,端口吸附失效,鼠标悬停在端口上却不触发吸附
原因:GetPortPosition()计算的是逻辑坐标,但HitTest在缩放后的Canvas上执行,坐标系不匹配。
解决:在HitTest前,用DrawingCanvas.TransformToVisual(Application.Current.MainWindow)将鼠标坐标逆变换回逻辑坐标,再与端口逻辑坐标比较。
现象3:导出PNG时,文字边缘发虚(抗锯齿过度)
原因:RenderTargetBitmap默认开启UseLayoutRounding=false,导致文本渲染模糊。
解决:在导出前,临时设置TextOptions.SetTextRenderingMode(DrawingCanvas, TextRenderingMode.ClearType);并确保UseLayoutRounding=true。
现象4:添加100+节点后,拖拽明显卡顿(<30fps)
原因:ItemsControl默认为每个节点生成ContentPresenter,大量绑定触发频繁布局计算。
解决:改用VirtualizingStackPanel作为ItemsPanel,并设置VirtualizationMode="Recycling"。同时,节点模板中避免Binding复杂路径(如{Binding Parent.Parent.Name})。
现象5:序列化JSON后,重新加载时连线指向错误节点(索引错位)
原因:Connections集合在加载时按顺序添加,但Nodes集合可能因异步加载或排序逻辑导致顺序与保存时不一致。
解决:在DTO中为每个节点添加唯一Id(Guid.NewGuid()),连线DTO中存储SourceNodeId和TargetNodeId字符串,加载时用Id查找而非索引。
6. 进阶技巧:撤销重做栈、自定义连接线样式、与Web流程引擎对接
WPF实现流程图的绘制(类似visio)做到这一步,已具备企业级应用基础。但真正拉开差距的,是那些让使用者说“这比Visio还顺手”的细节。我在这类项目里沉淀了三条铁律:① 撤销栈必须记录状态快照,而非操作日志(避免重做时因数据变更失效);② 连接线样式要支持业务语义(如“错误流”用红色虚线,“数据流”用蓝色实线);③ 与后端对接时,JSON结构必须与BPMN 2.0 XML有明确映射,而非自创格式。
6.1 实现多步撤销重做:用状态快照代替命令模式
命令模式(Command Pattern)在复杂图形系统中极易翻车:MoveNodeCommand执行后,若用户又修改了节点尺寸,Undo时恢复位置但尺寸已变,状态不一致。更可靠的是深拷贝ViewModel快照:
public class UndoManager { private readonly Stack<string> _undoStack = new(); private readonly Stack<string> _redoStack = new(); private readonly JsonSerializerSettings _settings = new() { TypeNameHandling = TypeNameHandling.None, // 禁止$type字段 ReferenceLoopHandling = ReferenceLoopHandling.Ignore }; public void SaveSnapshot(object viewModel) { var json = JsonConvert.SerializeObject(viewModel, _settings); _undoStack.Push(json); _redoStack.Clear(); // 新操作清空重做栈 } public bool CanUndo => _undoStack.Count > 0; public bool CanRedo => _redoStack.Count > 0; public void Undo(object viewModel) { if (_undoStack.Count == 0) return; var json = _undoStack.Pop(); _redoStack.Push(JsonConvert.SerializeObject(viewModel, _settings)); // 保存当前状态到重做 JsonConvert.PopulateObject(json, viewModel); // 反序列化覆盖 } }在关键操作后调用:
// 拖拽结束、连线创建、节点删除后 _undoManager.SaveSnapshot(this); // this是ViewModel为什么不用命令模式?图形系统中,一个“拖拽”操作可能同时改变节点位置、触发多条连线重绘、更新端口吸附状态——这些副作用无法用单一
MoveCommand描述。快照是唯一能保证原子性的方案。
6.2 连接线样式表:用枚举+资源字典实现主题化
定义ConnectionStyle枚举,与Pen属性绑定:
public enum ConnectionStyle { Default, // 蓝色实线 Error, // 红色虚线 Data, // 绿色带箭头 Control // 灰色点划线 } // 在Resources中定义样式映射 <Style x:Key="ConnectionStyle_Default" TargetType="Path"> <Setter Property="Stroke" Value="#2196F3"/> <Setter Property="StrokeThickness" Value="2"/> </Style> <Style x:Key="ConnectionStyle_Error" TargetType="Path"> <Setter Property="Stroke" Value="#F44336"/> <Setter Property="StrokeThickness" Value="2"/> <Setter Property="StrokeDashArray" Value="2,2"/> </Style>ViewModel中:
public ConnectionStyle Style { get; set; } = ConnectionStyle.Default;XAML中绑定:
<Path Style="{StaticResource ConnectionStyle_Default}" Style="{Binding Style, Converter={StaticResource ConnectionStyleToResourceKeyConverter}}" />6.3 与BPMN引擎对接:JSON结构映射表
多数国产流程引擎(如某高校自研引擎、某公司低代码平台)接受JSON输入。我们约定以下映射,避免每次对接都重写转换器:
| BPMN元素 | JSON字段 | 示例值 | 说明 |
|---|---|---|---|
startEvent | "Type": "Start" | { "Type": "Start", "X": 100, "Y": 100 } | 起始节点 |
exclusiveGateway | "Type": "Decision" | { "Type": "Decision", "CustomProperties": { "Condition": "${status=='success'}" } } | 判断网关,CustomProperties存条件表达式 |
sequenceFlow | "Type": "Connection" | { "SourceNodeId": 0, "TargetNodeId": 1, "Label": "success" } | 顺序流,Label即BPMN的name |
导出方法:
public string ExportToBpmnJson() { var bpmnJson = new JObject(); bpmnJson["nodes"] = JArray.FromObject(Nodes.Select(n => new { id = Guid.NewGuid().ToString(), type = n.GetType().Name switch { "StartNode" => "startEvent", "DecisionNode" => "exclusiveGateway", "EndNode" => "endEvent", _ => "task" }, x = n.Position.X, y = n.Position.Y, width = n.Size.Width, height = n.Size.Height, properties = n.CustomProperties })); bpmnJson["connections"] = JArray.FromObject(Connections.Select(c => new { id = Guid.NewGuid().ToString(), sourceRef = $"node_{Nodes.IndexOf(c.SourceNode)}", targetRef = $"node_{Nodes.IndexOf(c.TargetNode)}", name = c.Label })); return bpmnJson.ToString(); }我的习惯是:在项目启动时,用
ExportToBpmnJson()生成一个标准样例文件,发给后端团队作为接口契约。他们用这个JSON跑通引擎,我们再按此结构填充业务数据。这样比开会讨论字段名高效十倍。
本文还有配套的精品资源,点击获取