首页 / 资讯中心 / 文章详情

用WPF+OpenCvSharp构建仿VisionMaster拖拉拽视觉框架:交互内核与流程调度拆解

用WPF+OpenCvSharp构建仿VisionMaster拖拉拽视觉框架:交互内核与流程调度拆解 ★ FEATURED ARTICLE
简介面向工业视觉与上位机开发者的仿VisionMaster通用视觉框架源码包基于WPF、OpenCvSharp与C#实现流程图式拖拉拽流程编排开箱即用且便于二次开发。压缩包共2000个文件约335.39MB以C#源码(.cs)为核心配以JSON配置、XAML界面、XML工程配置、DLL依赖库及csproj项目文件涵盖界面、逻辑与工程配置可直接编译运行。工程按Views、ViewModel、Models、Service四层组织清晰体现MVVM模式与界面、数据、存储、服务分离的设计思路内部已集成深度学习、线程控制、延时跳转、AI YOLOv等视觉工具插件可理解插件式功能扩展机制也便于按需替换或新增算法模块。适合需要快速搭建视觉检测原型或研究视觉框架架构的开发者无论用于课程设计、毕业课题还是实际产线视觉定位都有较强参考价值既可用作学习参考也能基于源码替换与新增工具模块投入实际项目。目前已有119人学习下载。1. 用 WPF OpenCvSharp 拆一个仿 VisionMaster 的拖拉拽视觉框架最值得先读的是交互内核做视觉项目的人大多在流程 IDE 上耗过时间读图、找特征、算结果每换一个项目就要重排一遍逻辑代码写到一半又发现调参界面和算法逻辑耦合得太死。这个资源想解决的就是这件事——一套用 WPF C# OpenCvSharp 写的仿 VisionMaster 的拖拉拽通用视觉框架软件不是 Demo是整套源码开箱即用。你会在里面看到真正可落地的流程编辑交互左侧工具箱把算子拖到画布连线决定数据流向右侧属性面板直接改参数整套流程能存成文件再打开接着改。适合两类人一类是要快速搭自家视觉软件原型的工程师一类是刚入视觉岗位、想弄明白这类 IDE 内部机制的新人。我拆完的结论是界面反而不是难点把节点的数据流和序列化想清楚整个项目就立住了一半。2. 源码结构与第一次跑通先别急着看算法把工程的四条边界理清这一章不做代码逐行讲解先带你把这套源码当成一个解决方案来读。否则你打开 .sln 面对几百个文件很容易迷失在 XAML 和算子的细节里。2.1 解决方案里的工程边界谁负责界面谁负责调度谁负责干活项目给我的第一印象是工程边界分得清楚。一个完整的流程 IDE如果所有代码堆在一个 WPF 工程里后面加算子就是一场灾难。这套源码把功能拆成了四个可独立编译的工程大致划分如下VisionFramework.sln ├─ App.UI // WPF 主程序MainWindow、画布交互、工具箱、属性面板 ├─ Flow.Core // 流程内核节点抽象、端口、调度器、序列化 ├─ Algorithm.Library // 算子实现读图、灰度、二值化、找圆、模板匹配 └─ Controls.Common // 通用控件节点样式、连线段、端口拖拽辅助类App.UI 引用 Flow.Core 和 Controls.Common负责把用户操作翻译成流程模型的变化。Flow.Core 不依赖 OpenCvSharp它只管节点的执行和数据的搬运。Algorithm.Library 引用 Flow.Core 和 OpenCvSharp具体算法在这里实现。这种分层的好处是你要加一个新算法只需要在 Algorithm.Library 里加一个类App.UI 几乎不用改。如果以后想把调度内核换成别的界面框架Flow.Core 也可以独立复用。我接触过的很多项目源码问题恰恰出在 UI 和业务逻辑揉在一起这版能分开说明设计者的思路是经过实际项目打磨过的。2.2 启动链路从 App.xaml.cs 到流程画布的第一帧看启动顺序能最快摸清一个 WPF 项目的骨架。项目采用的是常见的依赖注入方式做初始化入口在 App.xaml.cs 的 OnStartup 里protected override void OnStartup(StartupEventArgs e) { base.OnStartup(e); // 1. 构建服务容器注册流程内核、算子注册表、主窗口 ViewModel _bootstrapper new Bootstrapper(); _container _bootstrapper.BuildContainer(); // 2. 读取上次退出时保存的流程文件如果存在 var recentFile LoadRecentFlowFile(); var flowEngine _container.ResolveFlowEngine(); if (!string.IsNullOrEmpty(recentFile)) { flowEngine.Load(recentFile); } // 3. 主窗口交给容器管理ViewModel 负责把流程模型映射到画布 var mainWindow new MainWindow { DataContext _container.ResolveMainWindowViewModel() }; mainWindow.Show(); }这里有两个关键选择值得注意。第一主窗口的 ViewModel 是从容器里解析出来的而不是在 XAML 里 new 出来这样 FlowEngine 实例会被共享注入画布上的所有节点和核心引擎拿到的是同一个实例。第二流程文件的加载发生在窗口显示之前实现的是“打开软件就恢复到上次编辑现场”的效果这个细节对工业软件来说很实用。如果你之前写 WPF 是直接在 MainWindow 的构造函数里 new 一堆对象这个启动结构就是很好的升级方向。整套注册逻辑都集中在 Bootstrapper 里找依赖关系一目了然。2.3 跑通第一个流程拖两个节点连起来按下运行源码里通常自带一个示例流程但新手还是应该亲手拖一遍流程。操作路径是这样用 Visual Studio 打开解决方案先还原 NuGet 包。OpenCvSharp 的包会比较大首次编译耐心等。把 App.UI 设为启动项目直接 F5 运行。主界面出现后左侧是工具箱中间是画布右侧是属性面板。从工具箱拖一个“图像读取”节点到画布再拖一个“二值化”节点。把前者的输出端口拖动到后者的输入端口松手后出现一条连线。在属性面板里设置图像路径和阈值参数。点运行按钮观察节点状态。节点变成绿色表示执行成功红色表示抛异常。第一次跑通的重点不是算法效果而是确认事件链路是完整的拖拽能创建节点连线能建立数据通道运行能触发执行。如果到这一步都顺畅说明这套 IDE 的骨架是健康的。后续你要做二次开发基本就是在这个链路上加新的节点类型。3. 拖拉拽交互的关键实现工具箱、画布放置、连线判定与流程存档很多视觉工程师拿到这类源码第一反应是找算法在哪但实际最花时间的是交互层。VisionMaster 这类产品用起来顺手核心就在于“拖、连、调”三个动作的响应足够自然。这一章拆解这套源码里对应的实现方式。3.1 工具箱的数据模型为什么用 Factory 而不是 Type工具箱不是把字符串显示在 ListBox 里那么简单关键是要让画布在 Drop 时能拿到“该创建哪种节点”的信息。源码里的 ToolboxItem 设计了这样一组属性public class ToolboxItem { // 左侧列表中显示的名称 public string DisplayName { get; set; } // 缩略描述鼠标悬停时提示用途 public string Description { get; set; } // 关键点这里保存的不是 Type而是一个工厂委托 public FuncFlowNode Factory { get; set; } }这里用 Factory 而不是 Type是一个值得借鉴的设计。如果直接存 Type画布 Drop 时就要用反射 Activator.CreateInstance 创建实例效率尚可但类型如果被裁剪或程序集改变反射就会失败。而把创建逻辑收敛到注册阶段工具箱绑定数据源的时候每个节点就自带创建能力。工具箱的 ViewModel 通常长这样public class ToolboxViewModel { public ObservableCollectionToolboxItem Items { get; } new(); public void RegisterNodeT(string displayName, string description) where T : FlowNode, new() { Items.Add(new ToolboxItem { DisplayName displayName, Description description, Factory () new T() }); } }拖拽开始则是在 ListBox 的 MouseLeftButtonDown 事件里调用 DoDragDropprivate void ToolboxItem_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e) { var item (sender as FrameworkElement)?.DataContext as ToolboxItem; if (item null) return; var dataObject new DataObject(typeof(ToolboxItem), item); DragDrop.DoDragDrop(toolboxListBox, dataObject, DragDropEffects.Copy); }注意 DataObject 里装的是 ToolboxItem 对象本身而不是字符串。这是很多翻车案例的起点——如果传的是 DisplayName画布 Drop 时还得再做一次名称到类型的映射多一层不必要的维护。直接传对象配合 DragDropEffects.Copy语义上表示“我是创建一个新副本而不是移动原节点”。3.2 画布的 Drop 逻辑坐标基准和鼠标偏移是第一个坑画布用的是标准 Canvas。节点落位的关键在 Drop 事件里的坐标转换。源码里的处理逻辑大致如下private void FlowCanvas_Drop(object sender, DragEventArgs e) { if (!e.Data.GetDataPresent(typeof(ToolboxItem))) return; var item e.Data.GetData(typeof(ToolboxItem)) as ToolboxItem; var pos e.GetPosition(flowCanvas); // 用工厂创建节点实例并指定初始位置 var node item.Factory(); node.NodeId Guid.NewGuid().ToString(); node.X pos.X - 60; // 减去节点宽度的一半 node.Y pos.Y - 20; // 减去节点标题栏高度的一半 FlowNodes.Add(node); }这里的 60 和 20 不是随便写的节点模板宽度约 120高度约 40做一次粗略偏移后节点中心能基本落在鼠标释放的位置。如果你自己改造节点样式导致尺寸变化这两项必须同步调整否则视觉上会有明显的鼠标和节点错位感。还有一点e.GetPosition 的参数是 flowCanvas 而非主窗口。让新手容易踩坑的就是这里如果对主窗口取坐标画布左上角不在 (0,0) 时所有节点都会偏离一段距离。只要画布外层还有边框、工具栏、滚动条这个偏移就一定会出现。所以拿到坐标时务必传入放置目标容器。3.3 节点移动与三连击式的事件序列节点在画布上拖动的实现没有直接上 Thumb 控件而是手动处理鼠标事件三连击MouseLeftButtonDown、MouseMove、MouseLeftButtonUp。为什么这么选因为 Thumb 控件在 Canvas 里虽然拖动逻辑简单但样式定制起来比较受限制而且后续要支持缩放平移时事件处理会更麻烦。手动实现的核心逻辑如下private void NodeControl_MouseLeftButtonDown(object sender, MouseButtonEventArgs e) { var node (sender as FrameworkElement).DataContext as FlowNode; _draggingNode node; _dragOffset e.GetPosition(flowCanvas) - new Point(node.X, node.Y); node.IsSelected true; (sender as UIElement).CaptureMouse(); e.Handled true; } private void NodeControl_MouseMove(object sender, MouseEventArgs e) { if (_draggingNode null) return; var pos e.GetPosition(flowCanvas); _draggingNode.X pos.X - _dragOffset.X; _draggingNode.Y pos.Y - _dragOffset.Y; _draggingNode.RefreshConnections(); // 更新连线端点位置 } private void NodeControl_MouseLeftButtonUp(object sender, MouseButtonEventArgs e) { _draggingNode null; (sender as UIElement).ReleaseMouseCapture(); }_dragOffset 的保存是保证节点移动时不会被“瞬移”到鼠标正下方。如果没有这个偏移量按下鼠标那一刻节点就会跳到鼠标位置体验很差。还有一点要注意MouseDown 里要调用 CaptureMouse否则鼠标移出节点控件范围后Move 和 Up 事件就会断掉。拖动结束后必须调用 RefreshConnections把与之相连的连接线端点同步过去。这一点很容易遗漏如果你发现节点拖动后连线不跟着走基本是这个方法没有被调用。3.4 连线的判定HitTest 与端口类型校验连线是这类 IDE 里最微妙的部分。用户从一个输出端口拖出一根临时线松手时系统需要判断是否落在合法的输入端口上。源码里的实现思路是拖动端口时把端口对象放进 DataObject画布 Drop 时做 VisualTreeHelper 命中测试再向上查找最近的输入端口。private void FlowCanvas_Drop(object sender, DragEventArgs e) { if (e.Data.GetData(typeof(OutputPort)) is OutputPort fromPort) { var hitElement VisualTreeHelper.HitTest(flowCanvas, e.GetPosition(flowCanvas)) as DependencyObject; var toPort FindParentInputPort(hitElement); if (toPort ! null CanConnect(fromPort, toPort)) { Connections.Add(new ConnectionViewModel(fromPort, toPort)); } } }CanConnect 这个方法承担了类型校验。它能避免用户把一个图像输出端口接在数值输入端口上也避免了一个输出重复连接到同一个输入。三个校验逻辑一般如下输入端口未被占用一个输入端口只能连一条线。端口数据类型兼容图像流只能接图像流数值流只能接数值流。不是连接到自身节点。这套校验放在 Drop 时做比放在执行时做要友好得多。错误暴露得越早用户花费的试错成本越低。3.5 流程序列化把流程图存成 JSON 结构流程保存功能是这类软件区别于普通调参工具的重要分水岭。源码里采用 JSON 保存结构清楚便于手工修改和调试{ version: 1, nodes: [ { id: node-001, type: ImageReadNode, x: 120, y: 80, params: { ImagePath: C:/demo.bmp, IsGray: false } }, { id: node-002, type: BinaryThresholdNode, x: 320, y: 80, params: { Threshold: 128, MaxValue: 255 } } ], connections: [ { fromNodeId: node-001, fromPortIndex: 0, toNodeId: node-002, toPortIndex: 0 } ] }保存时要把节点内部的 params 转换为普通字典避免直接序列化控件的依赖属性。加载时按 type 字段创建节点实例再给节点属性赋值。这里远比看起来复杂因为反序列化会遇到类型映射丢失的问题具体排错方案放在第 5 章展开。4. 流程调度内核数据如何从读图节点一路流到结果输出交互层是皮调度内核才是这套框架的里子。这一章聚焦一个核心问题当用户按下运行系统如何确保每个节点按连线方向被正确执行。4.1 抽象节点带端口、带坐标、带执行的统一基类在 Flow.Core 里节点并不直接等同于某个算法而是一个带执行能力的模型。核心接口的常见形态如下public abstract class FlowNode { public string NodeId { get; set; } public double X { get; set; } public double Y { get; set; } // 输入端口和输出端口 public ListInputPort Inputs { get; } new(); public ListOutputPort Outputs { get; } new(); // 执行逻辑由具体算法节点实现 public abstract object Run(FlowContext context); }InputPort 和 OutputPort 本质上是带数据缓存的数据槽public class InputPort { public string Name { get; set; } public object Value { get; set; } // 记录这个输入值来自哪个节点便于界面显示调试信息 public string SourceNodeId { get; set; } } public class OutputPort { public string Name { get; set; } // 下游订阅者用于通知式调度 public ListPortSubscription Subscribers { get; } new(); }把端口从节点中独立出来是为了让连线和数据流动不依赖具体的节点类型。任何节点都具有一组输入和一组输出调度器只和端口打交道不需要知道内部是阈值分割还是模板匹配。节点执行时典型模式是先从输入端取值运算后再写到输出端的 Value。这样数据流方向永远是从 Output 到 Input和连线的视觉方向保持一致。4.2 调度器不做全局拓扑排序而是让数据沿着连线流起来有一种典型的错误设计是每次运行前对整个流程图做一次完整的拓扑排序然后在循环里按序执行所有节点。这听起来很严谨但实际并不匹配交互式流程编辑器的需求。用户可能只想运行从某个节点开始的后半段也可能流程中存在一个分支结构。更贴近 VisionMaster 的调度方式是“数据流驱动”节点的执行由连线上游的输出触发。源码里对应的执行器逻辑模糊掉细节后是这样的public void RunFrom(FlowNode startNode, FlowContext context) { var queue new QueueFlowNode(); queue.Enqueue(startNode); while (queue.Count 0) { var current queue.Dequeue(); // 1. 执行当前节点 var outputs current.Run(context); // 2. 把每个输出值派发给订阅了该端口的下游 for (int i 0; i current.Outputs.Count; i) { var value outputs[i]; foreach (var subscription in current.Outputs[i].Subscribers) { var inputPort subscription.InputPort; inputPort.Value value; inputPort.SourceNodeId current.NodeId; // 3. 将下游节点加入队列实现层递推进 queue.Enqueue(subscription.OwnerNode); } } } }这个设计有一个重要特性节点只会在“上游已经执行完毕”的情况下被触发因为下游入队的动作发生在输出值写入之后这天然保证了执行的依赖顺序。流程图只要不存在环形连接这个队列就一定能跑完。如果用户在流程里人为定义了循环调度器会进入死循环。所以工业级的实现通常还要加一个“最大执行步数”的保护或者对节点执行次数做统计超过阈值时弹出提示并终止。这个边界细节二次开发的时候一定要保留。4.3 OpenCvSharp 图像数据的流转Mat 的引用计数与释放策略在具体算子层节点间传递最多的并不是普通数值而是图像。用 OpenCvSharp 时图像类型自然是 Mat。这里有一个必须理解的内存特征Mat 在 OpenCvSharp 里是托管壳它内部引用了一块原生内存。当 Mat 被复制到另一个变量时原生内存的引用计数会增加只有所有引用都被 Dispose内存才会真正释放。新手最常见的翻车方式是在每个算子节点里直接 new Mat然后在节点结束时调用 Disposepublic override object Run(FlowContext context) { var srcMat context.GetImageInputMat(0); // 危险写法这里新建了一个 Mat释放时机无法控制 var resultMat new Mat(); Cv2.CvtColor(srcMat, resultMat, ColorConversionCodes.BGR2GRAY); return resultMat; }如果流程只有一个下游这个写法问题不大。但流程存在分支时同一个输出会被两个下游订阅。此时返回的 Mat 如果在上游节点执行结束时被释放分支下游拿到的就是一个已释放的悬空对象。更可靠的做法是引入一个图像池或者引用管理集合public class MatPool { private readonly ListMat _allocated new(); public Mat Rent() { var mat new Mat(); lock (_allocated) { _allocated.Add(mat); } return mat; } public void DisposeAll() { lock (_allocated) { foreach (var mat in _allocated) { if (!mat.IsDisposed) mat.Dispose(); } _allocated.Clear(); } } }MatPool 一次性管理所有节点新创建的 Mat在流程结束或者用户重新运行前统一释放。这样简化了每个节点的内存职责算法节点只负责把结果交给 MatPool不负责单独释放。代价是内存占用峰值会略高但对桌面视觉软件来说这个取舍通常是值得的。如果在实际项目里遇到长时间运行后内存持续增长第一排查点就是哪些 Mat 没有被纳入池中管理。4.4 属性面板用反射驱动参数编辑而不是手工写死控件右侧属性面板在源码里不是为每个节点单独写一组 TextBox而是基于节点暴露的公共属性通过反射自动生成编辑行。这个机制极大降低了新增算子的工作量。核心思路如下public class PropertyPanelBuilder { public IEnumerablePropertyRowViewModel BuildFor(FlowNode node) { foreach (var prop in node.GetType().GetProperties()) { // 只收集标了编辑特性的属性 var display prop.GetCustomAttributeDisplayNameAttribute(); var category prop.GetCustomAttributeCategoryAttribute(); if (display null) continue; yield return new PropertyRowViewModel { Category category?.Category ?? 默认, PropertyName prop.Name, DisplayName display.DisplayName, PropertyType prop.PropertyType, Value prop.GetValue(node), SetValue v prop.SetValue(node, v) }; } } }设置值的 SetValue 闭包直接指向节点的原始属性这样改完参数后不需要额外同步回节点对象。在这一层需要注意如果属性类型是枚举界面就要生成 ComboBox 而不是 TextBox如果是布尔类型则生成 CheckBox。通常的做法是根据 PropertyType 在 View 层选择不同的编辑控件模板。5. 避坑与排查五个高频问题每一个我都踩过这一章整理的五个问题是我在阅读这类 WPF 流程框架源码后最想留下的笔记。前三个和交互相关后两个与运行时相关。每条都按现象、原因、解决三个角度展开。5.1 拖放后节点出现在画布外或者和鼠标错位一大截现象从工具箱拖出一个算子松手后节点没有出现在鼠标附近而是跑到画布左上角或直接被滚动条遮住。原因Drop 事件里取坐标时用了错误的参考元素。常见错误是把 e.GetPosition 的参数传给 MainWindow 或根 Grid导致坐标没有减去画布的相对偏移。另外节点模板尺寸和落位时的减半偏移量不一致也会导致视觉上错位。解决一律使用真正承载节点的容器作为坐标基准e.GetPosition(flowCanvas)。同时把节点中心落位的偏移量从固定数值改为动态获取比如从节点模板的 ActualWidth 和 ActualHeight 计算这样即使换了节点样式也不会失准。5.2 OpenCvSharp 的 Mat 在多次运行流程后出现内存持续上涨现象同一张图反复运行流程内存占用一次比一次高最后系统卡顿甚至出现 OpenCvSharpException 提示原生内存分配失败。原因每个算子节点执行时都新建了 Mat又没有在流程结束时统一释放。GC 只能回收托管壳原生内存只有引用计数归零才会释放而节点对象一旦被界面引用里面的 Mat 字段就可能一直存活。解决不要让每个节点自行管理 Mat 生命周期。改为由执行器统一维护一个 MatPool流程运行结束或重新运行前调用 DisposeAll。如果某个中间 Mat 还需要保留用于显示需要把它从池里摘出来单独管理避免释放后图像控件显示异常。5.3 连线删除后下游节点还在重复执行上一次的旧数据现象把一条连线从画布上删除但再次运行时下游节点仍然读取到上一次的输入值结果看起来像是断线无效。原因InputPort.Value 字段在节点执行时被赋了新值但在连线删除后没有任何代码把输入值置空。调度器只通过 Subscribers 触发节点而 InputPort 仍保留着历史缓存节点执行时直接取出旧值。解决删除连线时除了从 Connections 集合移除记录还要同步执行 inputPort.Value null、inputPort.SourceNodeId null。更保险的做法是在每次流程开始前遍历所有节点的所有 InputPort统一清空缓存确保每次运行都是从干净状态开始。5.4 反序列化流程文件时报类型错误节点创建为空现象保存流程文件后重新打开程序加载时报异常提示找不到某个节点类型或者节点创建出来是 null。原因反序列化时直接调用 Type.GetType 去解析 JSON 里的 type 字段。Type.GetType 默认只在当前程序集和 mscorlib 里查找而具体的算法节点都在 Algorithm.Library 这个单独程序集里程序集名称不匹配就会失败。解决维护一个类型注册表在程序启动时用 GetType().Assembly 扫描所有 FlowNode 子类并登记到一个字典。反序列化时用这个字典做映射而不是直接走 Type.GetTypeprivate static readonly Dictionarystring, Type _nodeTypeMap new(); public static void ScanAndRegister() { var assembly typeof(FlowNode).Assembly; foreach (var type in assembly.GetTypes()) { if (!type.IsAbstract typeof(FlowNode).IsAssignableFrom(type)) { _nodeTypeMap[type.Name] type; } } } public static FlowNode Create(string typeName) { if (!_nodeTypeMap.TryGetValue(typeName, out var type)) throw new NotSupportedException($未注册的节点类型: {typeName}); return Activator.CreateInstance(type) as FlowNode; }这样即使节点类型挪了程序集只要启动阶段扫描逻辑还在加载流程文件就不会丢类型。5.5 端口之间连不上线拖出线条后松手没反应现象从输出端口拖到输入端口松手后没有生成连线也没有任何错误提示。原因最常见的是 HitTest 命中的元素是端口内部的某个子控件比如 Border 或 TextBlock而 FindParent 向上寻找 InputPort 时因为层级不对而失败。另外如果端口外层被某个 Grid 设置了 IsHitTestVisiblefalse整个端口都会失去命中响应。解决连线的目标查找不要依赖单一 HitTest更稳妥的做法是遍历所有输入端口计算鼠标位置和端口控件中心位置的距离取最小距离且在阈值范围内的端口作为连接目标。这样比 VisualTree 的层级查找要稳定也便于后期做连接器自动吸附。6. 二次开发实战自己写一个算子节点并接入面板四步走理解这套框架最快的方式是亲手往里面加一个节点。下面以添加一个简单的“图像锐化”算子为例演示完整接入过程。第一步在 Algorithm.Library 新建一个类继承 FlowNode 并实现 Run[DisplayName(锐化滤镜)] public class SharpenFilterNode : FlowNode { [Category(参数), Description(锐化强度)] public double Amount { get; set; } 1.0; public override object Run(FlowContext context) { var inputMat context.GetInputMat(0); // 取第一路图像输入 var kernel new Mat(3, 3, MatType.CV_32F); // 用固定核做一次自定义滤波演示核心逻辑 Cv2.Filter2D(inputMat, outputMat, -1, kernel); return outputMat; } }第二步在工具箱初始化时完成注册toolboxViewModel.RegisterNodeSharpenFilterNode(锐化滤镜, 对灰度图像做锐化增强);第三步在类型注册表中补充该类型。因为 ScanAndRegister 已自动扫描 FlowNode 子类一般无需额外处理。第四步编译运行新节点会自动出现在工具箱属性面板也会通过反射识别 Amount 参数。整个过程不需要碰主窗口的 XAML也不需要改任何交互代码核心优势就在这里。从那以后我每接到一个新的视觉需求都会先按这套框架的模板把算法跑通再分支出参数和类型校验这样既不会被 UI 拖累也不会让算法代码和界面代码纠缠不清。希望这套源码和这篇拆解能帮你省掉重建轮子的时间。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?
咨询建站