你在做测试系统上位机的时候,一定碰过这种场面:代码里到处是“如果型号是A,就创建A设备,如果型号是B,就创建B设备”的Case结构,每加一款新仪器,主程序就要跟着改一遍,界面逻辑和驱动逻辑搅成一锅粥。这个问题的答案,就是设计模式里的工厂模式。这篇是LabVIEW OOP系列第四篇,我打算把工厂模式怎么在LabVIEW里真正落地,一次讲透。
这篇文章适合已经会建类、知道继承和动态分发,但还没系统用过设计模式的LabVIEW开发者。看完你能搞懂两件事:工厂模式到底解决什么问题;在LabVIEW这种图形化语言里,怎么用最自然的方式实现简单工厂和工厂方法模式。我还会带一个完整的“多型号万用表管理”案例,以及我在实际项目中踩过的坑,争取让你少走几个月的弯路。
1. 为什么LabVIEW里的“对象创建”比别的语言更值得讲清楚?
1.1 没有构造函数,类是值类型
很多从C++、Java转过来的朋友,最开始会被LabVIEW的类搞懵。Java里new DMM()会返回一个对象,LabVIEW里没有这种语法,类的实例不是靠“调用构造函数”产生的,而是靠数据流本身。你往框图上拖一个DMM.lvclass的类常量,这个常量就是一个对象实例;你把一个类控件放到前面板或者自定义控件里,这也是一种对象。
这带来一个很关键的差异:类在LabVIEW里默认是值类型。除非你内部使用引用句柄、队列、用户事件这些机制来承载重数据,否则一个对象从子VI传到调用方,涉及的是数据拷贝。这对于我们设计工厂方法很重要。因为工厂返回的对象,其实是一个“父类类型”的输出端子上挂着“子类类型”的数据,系统会在运行时保留它真正的类型身份。
1.2 动态分发 + 重写,是多态的基础
要让工厂模式起作用,必须依赖LabVIEW动态分发(Dynamic Dispatch)机制。父类里定义一个动态方法,比如Read.vi,子类继承后右键选择“重写(Override)”,在子类里写自己的行为。当调用方持有一个父类类型的对象,调用Read.vi时,LabVIEW会根据这个对象运行时真正的类型去调用子类版本的方法。
这个机制有一个前提:方法必须是动态分发的,而不是静态调用(Static Dispatch)。静态调用会在编译期就绑定到父类方法,不管你传入的是什么子类。所以用工厂模式时,第一步要回去检查一下你那个基类方法,是不是右键选择了“支持动态分发(Dynamic Dispatch)”。很多人的工厂“没生效”,第一反应是找模式问题,其实根子在这上面。
1.3 工厂模式解决的核心痛点
没有工厂的时候,调用方同时承担三件事:知道有哪些具体型号、负责创建具体对象、再调用业务接口。第二件事最恶心,因为“创建哪一类对象”的判断逻辑会散落在界面事件、初始化代码、配置读取各处。每加一个新型号,你至少要改三四处。
工厂模式做的,就是把“创建对象”这个动作收口,让调用方只面对一个统一的父类接口。你要加新型号,只加一个子类和一个工厂分支,主流程基本不动。用一句话概括:它把“变化”隔离在工厂这一层,不让变化扩散到整个系统。
2. 简单工厂模式:最快落地的LabVIEW写法
2.1 搭一个抽象基类和两个具体子类
简单工厂是工厂模式里最朴素的一种。先设计一个基类,我这里拿万用表举例。项目浏览器里新建一个DMM.lvclass,作为父类;再新建Agilent34401.lvclass、Keithley2000.lvclass两个子类,都继承自DMM。
父类里定义动态方法:Initialize.vi、Configure.vi、Read.vi、Close.vi。子类里分别右键这些方法,选择“重写”。比如Keithley2000的Read.vi里面,可能发的是MEAS:VOLT:DC?;Agilent34401的Read.vi,可能走的是另一套命令。这些细节全部封装到子类里,调用方完全不需要关心你用的是SCPI还是VISA。
2.2 用“转换为更通用类”节点实现父类输出
接下来是最关键的一步:写工厂VI。不一定要建工厂类,简单工厂可以直接用一个独立VI实现,比如Create DMM.vi。
打开VI,放置一个条件结构(Case Structure),输入选一个枚举DeviceType,枚举里包含你支持的仪器型号。然后在这个条件结构的每个分支里,把你需要的子类常量拖进来。比如在Agilent34401这个分支里,放一个Agilent34401.lvclass的常量,这个常量就是一个该类的实例。
然后从函数面板找到“编程 -> 类 -> 转换为更通用类(To More Generic Class)”,把它放在框图上,选择目标类型为DMM。子类常量连到转换节点的输入,转换节点的输出再连到条件结构外部的输出隧道。这个输出隧道在连线板上,对应的输出控件类型要设成DMM父类类型。这样一来,不管内部Case进了哪个分支,外部拿到统一都是“WMM父类”的对象,但运行时类型还是各自具体的子类。
注意:不要在Case结构里直接连线子类常量到输出隧道。各个分支的输出类型不一致,必然断线。LabVIEW不会像C++那样允许子类直接作为父类引用返回,必须显式做一次向上转换。这是LabVIEW实现工厂模式时最容易卡住的地方。
2.3 调用侧写起来有多舒服
调用侧代码会清爽得多。假设前面板有个下拉框,用户选了KEITHLEY_2000,你只需要把枚举喂给Create DMM.vi,拉回来的DMM对象直接连到后面所有测流程方法上。
DeviceType枚举 -> Create DMM.vi -> DMM对象 DMM对象 -> Initialize.vi DMM对象 -> Configure.vi DMM对象 -> Read.vi以后用户要换型号,界面下拉框换个选项就行,主流程代码一行都不用动。我再强调一下:在这个流程里,调用方接触的永远是父类公开出来的方法,它甚至不需要知道具体返回的是哪个子类。这不仅仅是“好维护”,更重要的是能让你把测试流程的代码稳定下来,流程才是业务的核心资产,仪器型号只是随时会变的配置项。
2.4 简单工厂的边界在哪
简单工厂好写、直观,但有个缺点:所有产品的创建逻辑都集中在一个VI里,分支多了以后这个VI会膨胀。每加一个型号,除了建新的子类,你还要打开Create DMM.vi去增加一个Case分支,这个动作本质上是对已有代码的修改。虽然只是小改,但如果不同的设备在创建阶段的初始化逻辑特别复杂,比如有些要校准,有些要加载预设配置,有些要启动单独的监控线程,那这个简单的Case结构很快就会变成一坨乱麻。
所以简单工厂更适合:型号数量不多、创建逻辑差异不大、且短期内不会频繁扩展的场景。如果你的设备类型已经开始呈现出明显的“族”的特征,下一节这种工厂方法模式会是更好的选择。
3. 工厂方法模式:把“生产决策”拆到子工厂
3.1 思路转换:每个产品配一个工厂
工厂方法模式不再用一个“万能开关”去判断该创建谁,而是把“创建产品”这个动作定义在抽象工厂类里,让每个具体工厂子类去决定到底创建哪种产品。调用方拿到的是一个工厂对象,它不需要知道工厂内部在造什么,只管调用工厂的“Create”方法拿产品。
对应到LabVIEW,结构是这样的:
- 新建
DMMFactory.lvclass抽象工厂类,定义一个动态方法Create DMM.vi,输出类型是DMM父类。 - 新建
AgilentFactory.lvclass和KeithleyFactory.lvclass,都继承自DMMFactory。 - 两个具体工厂类重写
Create DMM.vi:前者返回Agilent34401实例,后者返回Keithley2000实例。
如果把类结构用文本列出来,大概是这样:
DMM(产品抽象类) |-- Agilent34401 |-- Keithley2000 DMMFactory(工厂抽象类) |-- AgilentFactory |-- KeithleyFactory3.2 LabVIEW里的具体实现步骤
第一步,创建DMMFactory.lvclass,在类里新建VICreate DMM.vi。记得把这个方法设为动态分发,并且输出端类型选DMM父类,而不是具体子类。
第二步,新建AgilentFactory.lvclass。右键父类DMMFactory.lvclass,选择“新建子类”,让新类继承父类。然后在项目浏览器里选中Create DMM.vi,右键选“重写”,会自动生成一个同名重写VI。
第三步,在重写VI里,把一个Agilent34401.lvclass常量拖到框图上,连到To More Generic Class节点,目标类型选DMM,再连到输出接线端。KeithleyFactory同理。
调用的方式也变了。主程序不再直接依赖Create DMM.vi,而是先持有一个工厂对象。这个工厂对象从哪来?可以来自配置文件,可以来自一个简单的初始化函数,也可以在最外层用简单工厂来创建工厂。然后调用工厂对象的Create DMM.vi:
工厂对象 -> Create DMM.vi -> DMM产品对象这样一层套一层,核心测试代码完全没有Case判断,真正把“变化”推到了系统最边缘。
3.3 简单工厂还是工厂方法?我的判断标准
有些文章喜欢把它俩分开,说工厂方法要优于简单工厂。我的观点更务实:按项目规模来。
如果你只有两三种设备,创建过程又差不多,老老实实用简单工厂。多建四五个类文件,代价大于收益。如果设备型号开始向十几个发展,并且型号之间创建逻辑差异很大,比如有的设备连接以后需要做固件升级检查,有的是网口通信需要先建立TCP连接,有的是GPIB需要设置主从地址,那必须考虑工厂方法模式,把每种设备完整的“装配过程”收进各自工厂里。
还有一个信号:当你发现简单工厂的条件结构里每个Case分支超过20行,并且有大量分支间的共性逻辑想复用的时候,赶紧切换成工厂方法模式。工厂方法天然支持每个子类工厂在父类基础上扩展,你可以把公共流程放到父类工厂里,把型号差异留在子类重写方法里,这其实就是模板方法模式的一种变体。
4. 实战案例:一套支持多种万用表的上位机
4.1 需求与类结构设计
假设我们在做一个产测上位机,被测产品需要测量电压、电阻、电流,硬件配置选了两种万用表:Agilent 34401 和 Keithley 2000。程序启动后,从配置文件里读取当前用的型号,用户界面也能手动切换。后续测试流程,包括校准、检定、数据记录,全部走同一个测量接口。
我设计的类结构就是前面讲的那套:
DMM.lvclass父类,动态方法有Initialize、Configure、Read、CloseAgilent34401.lvclass继承DMMKeithley2000.lvclass继承DMMCreate DMM.vi作为简单工厂入口,根据枚举创建具体的DMM对象
这里我先用简单工厂,因为两种仪器创建过程都是“调Initialize”,差异不大,没必要上工厂方法。
4.2 工厂VI内部的连线细节
打开Create DMM.vi,前面板连接板放两个端子:一个是枚举输入Device Type,一个是DMM类输出。
框图上放一个条件结构,Case选择器接到枚举输入。两个分支里分别做这样的事:
Keihtley2000分支:放置Keithley2000.lvclass常量,连接To More Generic Class节点(目标类型DMM),输出到隧道。Agilent34401分支:放置Agilent34401.lvclass常量,同样转换成DMM,输出到隧道。
这个VI本身不包含任何仪器通信逻辑,它只负责“根据枚举返回正确的对象”。仪器通信逻辑全部在子类重写的Initialize.vi/Read.vi里。所以从职责上看,这个工厂VI可以被当作整个系统的“注册表”来理解:系统里支持哪些设备、每个设备返回什么对象,在这里一目了然。
4.3 主界面和测试流程里的用法
主界面可以做成一个状态机。初始化状态里,读配置文件拿到Device Type,调Create DMM.vi,把返回对象放到移位寄存器里。后面的测量状态,直接把这个对象连到Read.vi,拿到的电压值显示到前面板,同时写入TDMS文件。
事件结构里处理“切换设备型号”这个事件时,也只需要做一件事:重新调用Create DMM.vi,把新返回的对象替换掉旧对象,再调用一次Initialize.vi。至于仪器是GPIB还是串口,子类内部自己处理,主界面完全不知道。
如果你在做labview控制6221与2182同步采集这类多设备同步的软件,工厂模式一样有用。6221是电流源,2182是纳伏表,你完全可以把它们各自封装成产品子类,然后由一个同步控制类去协调。初始化时用工厂批量创建对象,再统一调用Initialize,等到所有设备都Ready了再统一触发采集。这样同步逻辑不用关心具体仪器命令,异常处理也集中在各自的子类里,查问题的时候会节省大量时间。
5. 进阶扩展:注册表式工厂与按需动态加载
5.1 为什么要走向注册表式
简单工厂和工厂方法有一个共同的隐含前提:所有产品类型在编译期就是已知的。但有些系统做得比较大,比如一个通用测试平台,会允许第三方模块通过配置文件或插件的方式接入新设备。你在编译的时候根本不知道插件里有哪些类,怎么让工厂也支持这种扩展?
LabVIEW不像Java有反射,但只要思路转换一下,依然可以做到一种“注册表式”的动态工厂。做法是:维护一个全局的数据结构,比如功能全局变量(FGV)或者队列,里面存的是“设备类型字符串 -> 创建VI引用”的映射关系。各个设备模块启动时,把自己的创建VI引用注册进去;工厂要创建对象时,先去查这个表,再通过“按引用调用(Call by Reference)”来执行对应的创建VI。
5.2 用VI引用实现键值对工厂
具体做法我先说个大概。定义一个DeviceRegistry.lvclass,内部有一个私有数据,可以是Map<String, VI Ref>或者自定义的“名称-引用”簇数组。类方法包括Register.vi和Lookup.vi。Register.vi输入一个设备名字字符串和一个VI引用,把引用保存进去;Lookup.vi输入设备名字,返回对应的VI引用。
创建VI引用的时候,注意你是调用不连线的VI,所以要把“创建VI引用”的连线板配置成输入一个枚举或字符串,输出一个DMM父类对象。这样调用方只要拿到引用,就能统一用某个传入参数去执行,最终拿到一个DMM对象。
DeviceType枚举 -> Lookup.vi -> VI引用 -> Call by Reference -> DMM对象这个方案的好处是,真正做到了“开闭原则”:新增设备不需要改动任何已有的Case结构,只需要新写一个创建VI,然后在启动时调一次Register.vi把它注册进去。整个工厂核心逻辑从此不需要再动。
5.3 这块的适用边界,我必须泼点冷水
注册表式工厂很灵活,但在LabVIEW里的代价也不小。VI引用创建时要求连线板签名严格匹配,一旦输入输出类型不一致,只能在运行时才发现,错误信息还往往不够直观。另外,VI引用管理不当容易造成内存泄漏,特别是在频繁创建引用、又不关闭引用的场景下,内存涨得很快。我也见过团队为了追求“完全可插拔”,把项目搞得异常复杂,最后新同事接手根本不敢动那个全局注册表。
所以我的建议是:常规的工控上位机、产测软件,用简单工厂和工厂方法组合就足够了。只有当你确实面临多套独立开发的模块需要集成到同一平台,而且模块边界稳定、团队规模也足够支撑这种抽象时,再考虑注册表式工厂。设计模式是解决问题的手段,不是用来炫耀的装饰品。
6. 常见问题与排查技巧实录
6.1 常见问题速查表
| 症状 | 可能原因 | 解决办法 |
|---|---|---|
| 接线端断线,错误提示“类型不匹配” | Case分支里子类常量没有转成父类类型 | 在分支里加To More Generic Class,目标选父类 |
| 方法调了半天,执行的都是父类逻辑 | 方法不是动态分发,或子类没重写 | 右键方法勾选“动态分发”,子类里右键选“重写” |
| 拿到了对象,但看不到子类特有方法 | 这是多态的正常限制,变量静态类型是父类 | 先用运行时类型判断,再使用To More Specific Class转成具体类 |
| 反复调用工厂,内存涨得厉害 | LVClass是值类型,对象内部含大数组/大字符串 | 大数据用队列、用户事件、引用句柄承载,不要直接放类里 |
| Case结构单个分支标签不匹配,运行进默认分支 | 枚举不是严格类型定义,标签值不同步 | 用type_def.ctl严格类型定义枚举 |
| 子类在项目里明明存在,但框图上找不到类常量 | 类文件没保存,或者项目浏览器未刷新 | 保存全部,右键项目浏览器“刷新” |
6.2 关于动态分发、转换节点的几个细节
使用To More Generic Class时,如果数据到达这个节点的类型已经是父类,那么转换节点不会报错,但也起不了什么作用。真正的场景,一定是Case分支里的局部数据是子类,需要向上统一。反过来,向下转换To More Specific Class更危险,如果源数据实际运行时不是目标子类,是会直接报错的。所以向下转换前一定要先做类型判断,可以在父类里设计一个动态方法Get Class Name.vi,子类重写,返回自己的类名,判断完再转换。
还有一个细节容易被忽视:子类重写后的方法,要保持输入输出端子的数量、类型、顺序与父类一致。LabVIEW虽然允许子类重写版本增加自己的其他输入输出,但一旦不一致,很容易在调用时出现无法匹配的问题。我的习惯是重写时直接复制父类方法作为模板,只改内部逻辑,不断动连线板签名。
6.3 工程架构层面的建议
一个工厂模式用的舒服的项目,通常类结构非常稳定。我在实际项目里有个坚持:工厂类里不放业务逻辑,它只负责创建对象;类的私有数据只放这个对象自己的状态,比如VISA会话、设备名称、量程配置;业务流程,比如“先测电压再测电流然后计算功率”,放在状态机或者独立的业务VI里,不要塞进类方法里。这样类才能保持职责单一,工厂模式才有意义。
另外,别在初始化的时候把所有设备资源都打开一套。工厂的好处是你可以延迟创建对象,等真正用到某台设备的时候再去创建、占用资源。对工控上位机来说,这种按需创建的思路特别重要。我见过一个程序,一启动就把所有支持的仪器型号都初始化了一遍,结果某台仪器没开机,程序直接卡死在等待响应上。换成工厂模式以后,设置为“只初始化配置里启用的设备”,问题彻底消失。
在具体编码过程中,我还会把所有设备型号枚举定义成严格类型定义,并在枚举的“标签”和“数值”保持一致。这样在Case结构选择器上写分支时,不会出现同名不同值的情况。严格类型定义还能保证调用方和工厂VI用的是同一个枚举,避免将来界面下拉框和工厂内部Case各维护一套数值。
最后一个建议:给类方法命名要克制。Create DMM、Initialize、Read、Close这种一看就懂;别起什么ExecutePrecisionMeasurementProcess这种又长又模糊的名字。命名清晰了,工厂模式的阅读成本能再降一半。
我个人在实际项目里最直观的体会是:从用了工厂模式以后,我再也没有在下位机固件版本变更、仪器型号调整这件事上熬夜加班过。以前改一次型号要动界面、动流程、动驱动三个地方,现在只动配置文件和一个新子类。这才是设计模式应该带来的改变。