Skip to main content

界面程序

循环事件结构 在处理用户界面操作时,有明显的优势,因此被应用到了几乎所有的用户界面程序开发中。LabVIEW 新建对话框中的基于模板中几个关于界面的 VI:"用户界面事件处理器"、"使用事件的顶层应用程序" 和 "使用事件的对话框",所使用的都是循环事件结构。

上一章在介绍事件结构时,已经简要介绍了循环事件结构特点和使用。在这里我们再详细讨论一下如何更有效地使用循环事件结构。

界面程序的程序框图设计

图 3.51 是一个基本的用户界面应用程序,实际应用中的程序要比它复杂得多。程序在开始处理用户界面操作之前,可能有一些初始化的工作需要做;程序结束前又有一些收尾工作。所以一般的界面 VI 的程序框图都如图 4.34 所示。

图 .34 一般的界面 VI 程序框图

这种程序框图最大的问题在于,程序几乎所有的事件都在循环事件结构中运行,而框图上却又有大量处理初始化和收尾工作的代码。程序没有突出循环事件结构这个主体。程序框图的结构应该是越简单、越单一,越容易被人理解。

对于界面 VI 的程序框图而言,在循环事件结构外,应该只保留极少量必不可少的节点,其它的代码,统统移至循环事件结构内。其实,初始化和收尾完全可以被看作是两个自定义事件。需要时,发出事件,然后跳至事件结构中相应的分支去处理即可。

改进后的程序见图 4.35。

图 .35 改进的界面程序

改进后的程序只有一个 VI 游离在循环事件结构之外,程序的主体一目了然。而原本在循环事件结构之外的初始化、收尾工作,作为新的用户事件,被移至结构内。

结构之外的唯一 VI 用于创建程序所需的自定义事件,比如 "初始化","结束" 这样的事件。它的程序框图如图 4.36 所示。

图 .36 初始化事件 VI 的程序框图

在这个初始化事件 VI 中,除了注册两个用户自定义事件之外,程序还抛出了一个 "初始化" 事件。这样,在界面 VI 运行到循环事件结构时,首先会进入 "初始化" 分支,执行初始化相关的代码。

程序把新创建的两个用户自定义事件,赋予了全局变量,这是为了将来在程序中使用方便。程序有可能在任何地方抛出某个自定义事件,为了不使程序框图上的连线过于凌乱,可以使用全局变量输出新创建出的用户自定义事件。因为记录用户自定义事件的全局变量,唯有在此处是写入数据的,在程序其它地方都是只读的。因此,这里使用全局变量来记录用户自定义事件不会导致程序的可读性下降。当然,如果界面程序比较简单,数据连线较少,也可以直接用连线将用户事件接入事件框内。

图 4.37 是抛出 "停止" 用户自定义事件的例子。用户在界面上点击 "停止" 按钮,表示要求退出程序。但是,在停止 while 循环之前,还有一些收尾工作要做。这些收尾工作是在 "结束" 事件分支中实现的。程序对 "停止" 按钮按下的处理仅仅是抛出一个 "结束" 事件。

图 .37 抛出 "结束" 事件

实际界面程序中,往往不仅是 "停止" 按钮按下需要抛出 "结束" 事件。当用户点击了界面右上方的关闭窗口按钮,也需要按正常途径退出程序。也就是说,当 "前面板关闭" 事件发生后,程序也需要抛出一个 "结束" 事件。

"结束" 事件的处理分支中,包括了所有收尾工作的代码。主要是释放程序曾经创建或打开的各种资源,如销毁被创建的用户自定义事件、关闭打开的文件等等。最后,传 "真" 值给 while 循环的停止条件接线端,退出整个程序(图 4.38)。

图 .38"结束" 事件处理分支

通用用户自定义事件的设计

大型的程序中往往需要多个用户自定义事件,并且有可能随着程序的开发,发现需要增加一些新的事件。当需要增加新的用户自定义事件数量时,如果采用上一节提到的方案,就很不方便:需要为每个用户自定义事件创建一个全局变量来保存它,添加一个事件就要创建相应的全局变量;每多一个用户自定义事件,"注册事件" 节点返回的 "事件注册引用句柄" 的类型就会发生变化,要更换新的 "事件注册引用句柄" 控件,并更新相应的连线。

一种扩展性更好的方案是,在程序中仅使用一个用户自定义事件,利用它的事件数据参数来区分事件的不同用途。自定义事件数据类型为一个簇,簇中包含两个元素:"事件名" 和 "事件数据"。"事件名" 用于标明事件的用途,比如在抛出事件时,可以将其定名为 "初始化" 或 "结束" 等。"事件数据" 用于携带事件相关的一些数据,因为不同用途的事件需要携带的数据类型可能各不相同,所以这个 "事件数据" 的类型可以使用变体,以存放各种数据。

图 .39 一个通用的用户自定义事件方案,利用事件数据区分其用途

图 4.39 是对图 4.36 初始化事件程序的改进方案。LabVIEW 也自带有实现了类似功能的 VI,程序中也可直接使用 LabVIEW 自带的 VI。

与图 4.39 类似的 VI 在路径 "[LabVIEW]\resource\importtools\Common\Event\Method" 中。图 4.40 是应用这种单用户事件的程序,它使用了上述 LabVIEW 自带的事件管理 VI,与使用图 4.39 中的 VI 的思路是完全相同的。图中与 "Create Event.vi"(发生事件 VI)输入参数相连的 "Event in" 是 "类" 常量。在这里,我们只要点击 "Create Event.vi" 的 "Event in" 输入接线端,为它创建一个常量即可。有关 "类" 的详细内容将在面向对象编程一章中做介绍。

用户在抛出 "用户事件" 时,需要给这个事件指定一个 "事件名"。循环事件结构捕获到这个事件后,跳入 "用户事件" 处理分支。在处理这个事件之前,首先查看一下 "事件名",然后再根据 "事件名" 对事件做不同的处理。

图 .40 单用户事件的处理方法

对耗时代码的处理

编写界面程序时,需要注意不要把耗时较长的代码放在循环事件结构内。每个事件处理分支内的代码的执行时间尽量不要超过 200 毫秒。否则,当程序停留在某一事件处理分支内,长时间执行这段耗时代码时,程序是无法立即响应其他事件的。此时,用户会觉得程序界面对操作毫无反应,可能会误认为是程序已经崩溃或死锁,并且极可能会在界面上胡乱地点击鼠标,导致更严重的问题。

在默认情况下,当执行某一事件时,用户界面是被锁住的。在图 3.47 中,编辑事件对话框最下方的 "锁定前面板直至本事件分支完成" 选项,默认是被选中的。程序在处理某事件时不应该再对用户的界面操作产生新的事件,这一点是十分必要的。否则,当程序正忙于执行耗时代码时,用户在界面上的胡乱操作所触发的事件虽然不能被立即执行,但却会被程序记录下来。之后程序再处理这些随意产生的事件,不但毫无用处,还很可能引发意想不到的错误。

但是仅仅锁住用户界面是不够的,用户还是会对界面无反应产生疑惑。其实,还有一些更好的方法可以用来解决某些分支耗时过长的情况。

最简单的方法就是提示用户:程序并没有出现问题,只是暂时忙于处理某事件,无法处理用户的界面操作。最基本的做法就是把光标设为沙漏状。这是 Windows 比较常见的处理方法,当我们在运行某些程序时,发现光标变为沙漏状,就会意识到当前程序正忙,需要等一会儿再继续操作电脑。

LabVIEW 对光标进行操作的 VI 在 "编程 -> 对话框与用户界面 -> 光标" 中。其中有两个专门的 VI,用于设置和取消光标的忙碌状态。在事件结构的分支中,执行一段耗时较长的程序前,先把光标设置为忙碌。这样,用户看到沙漏状的光标,就会意识到程序正忙,无法反应自己的操作。耗时任务完成后,再把光标恢复为正常状态(图 4.41)。

图 .41 在程序运算繁忙时,将鼠标设置为忙碌

如果耗时任务所需的时间特别长,比如几秒甚至几分钟,那么最好在任务开始前提示用户。比如,在界面上显示一段文字,或弹出对话框显示:"本操作需要耗时数秒钟,请耐心等待" 等类似的提示信息。

给用户足够的提示信息是一个简单的方法,但却不是最完美的解决方案。某些用户操作应当可以打断一个任务,比如用户打算中断或取消这个任务等。若要程序在执行任务的同时又对界面操作及时反应,就必须把任务放到界面线程之外的其它线程去执行。不过这个实现方法比较麻烦,我们将留到第 6.3.5 节再来讨论这个问题。

其他注意事项

事件结构给 LabVIEW 编程带来了很大的灵活性,但是滥用事件结构也可能给程序带来很大的问题。以下是一些使用事件结构的经验,供读者参考。

检测界面的按钮是否被按下,应当使用 "值改变" 事件。"鼠标按下" 和 "鼠标释放" 有时会起到同样的效果,但有时(比如鼠标在按下后又移动)往往就不能真实反映按钮的状态了。此外,用作按钮的布尔控件应当选择 "释放时触发" 机械动作。

应尽量避免在一个分支内处理多个事件。在程序较为复杂时,它会降低程序的可读性以及可维护性。

在一个 VI 内,最多只能有一个事件结构。虽然 LabVIEW 并不禁止在一个 VI 内使用多个事件结构,但是多事件结构极易造成程序逻辑错误,而且也没有任何必要。在一个 VI 内,完全可以把所有的事件都放到一个事件结构中去处理。

通常只有当用户在界面上改变了一个控件的值,LabVIEW 才会产生值改变事件。在程序中,直接赋值给控件的接线端或局部变量是不会产生值改变事件的。如果希望在程序中改变控件的值的同时也让它发出值改变事件,可以把值赋给控件的 "值 (信号)" 属性。如图 4.42 所示,该数值控件会产生一个值改变事件。

图 .42 控件的值 (信号) 属性

回调 VI

LabVIEW 界面程序最常用的结构就是循环事件结构。用事件结构截获用户在界面上对控件的操作,然后做出相应处理。而在文本语言中,常用的事件处理方法与 LabVIEW 是不同的。文本语言常常使用回调函数来处理界面事件。比如,某个按钮按下时,需要进行一个 fft 运算。那么就写一段函数来完成 fft 运算,再把这个函数与按钮按下事件关联起来。开发语言通常已经做好了对事件的监控,一旦发现发生了按钮按下事件,就去调用与它关联的 fft 运算函数。这个由程序员自己编写,被系统调用的函数就叫做回调函数。

LabVIEW 也可以采用与文本语言相类似的方法来处理事件:不是在事件结构内处理事件,而是在程序开始时,就为某事件注册一个回调 VI。在回调 VI 内编写相应代码,一旦事件发生,就会执行这段代码。

与事件结构相比,回调 VI 编写起来稍微麻烦一点。但回调 VI 与主 VI 是并行运行的。如果某个事件处理过程比较耗时,把它放在事件结构中就会阻塞整个程序,使得程序界面暂时失去响应;而把它放在回调 VI 中,则不会影响程序其它部分的运行。动态调用也可以达到这一效果,但回调 VI 编写起来少许简便一点。

比如图 4.42 所示的这个例子。程序界面上有两个仪表盘:左表始终在运转,每 10 秒钟旋转一圈;右表由按钮控制,按下按钮才旋转一圈。若把旋转右表这段程序作为子 VI 放到事件结构的按钮值改变事件处理分支中,它势必会打断左表的旋转。因此,考虑把它放到回调 VI 中。(当然,也可在事件处理分支中采用动态调用子程序的方式处理)。

\ 图 4.42 分别控制左右两块表的主程序界面

\ 图 4.43 分别控制左右两块表的程序框图

程序的代码也比较简单。先看代码的右半部份:这是一个典型的循环事件结构,用来控制左表的旋转。但是注意,右表的控制并不是在这个结构中实现的。

再看程序左半部分:它为按钮 "右表旋转一圈" 的值改变事件注册了一个回调 VI。

注册回调 VI 用的是 "事件回调注册" 节点,它位于函数选板 " 互联接口 --> ActiveX" 上。这个节点主要是为了给 ActiveX 和.NET 控件的事件注册回调 VI。因为 LabVIEW 的事件结构无法截获 ActiveX 与.NET 控件的事件,只能通过回调 VI 的方式来处理这些控件的事件。但是,这个节点也可以用于给 LabVIEW 自带的控件注册回调 VI。

注册回调 VI 节点,有三个输入参数。从上至下分别是:事件源、回调 VI 引用、用户参数。

在这个例子中,需要截获的是按钮 "右表旋转一圈" 的值改变事件,因此需要把该按钮的引用作为第一个参数传递给事件回调注册节点。接下来需要选择事件的类型。鼠标右击该参数右侧的向下箭头,可以看到 "右表旋转一圈" 按钮的所有事件都已经列在其中了。选择 "值改变" 事件。

第三个参数是用户自定义数据,它可以是任意类型的数据,在回调 VI 中需要用到的数据都可以通过它来传递。该示例是在回调 VI 中旋转控件 "右表",因此把 "右表" 的引用作为数据传递给回调 VI。

第二个参数是回调 VI 的引用,如果已经编写好了回调 VI,把引用传进去就行了。如果还没有编写回调 VI,则可以在参数的接线端上点击鼠标右键,选择 "创建回调 VI",创建一个空白的回调 VI。

在回调 VI 中编写一小段代码,让右表旋转一圈,整个程序就完成了。运行该程序,左右表可以各自运行,互不影响。

\ 图 4.44 回调 VI 的程序框图

读者调试图 4.42 所示的例子时,也许会发现这样一个问题:若右表尚在旋转中,就按下停止按钮,VI 虽然停止了运行,但右表仍然会继续旋转到底才停止。这是因为回调 VI 是被系统调用的,main.vi 停止后,回调 VI 并不会同时停止运行,它只有等待自身运行结束才会停止。

同一功能对应多种不同界面的应用程序

如果需要开发一套销售给多个用户的应用程序:每个用户对程序的功能需求是一致的,但对程序界面的需求却各自不同。比如,一个对同一产品进行测试的程序,但每个版本对界面的语言,以及控件的位置、尺寸、颜色等各有不同的需求。对于软件开发人员来说,最好的解决方案当然是一份程序的代码(程序框图)能够对应于多份的界面(VI 前面板)。利用常规的方法是无法解决这个问题的,因为 LabVIEW 中每个 VI 只能拥有一个前面板和一个程序框图。

一般来说,程序中实现主界面的那个 VI 就是主 VI,主 VI 中的代码都是比较复杂的。在一个项目中设置维护多份复杂、而功能又相同的主 VI 显然不是一种优化的方法:大量的代码重复,不仅程序变得庞大;一旦在某一版本的主 VI 代码中发现一个错误或功能需求有改动,其它版本的主 VI 代码也必须要做同样的修改。

利用动态注册事件就可以优化上述的程序结构。动态注册事件的特性之一就是把界面和程序代码完全分离开来了。遇到上述需求,可以编写多个实现不同风格界面的 VI(以下简称为 "界面 VI" );以及一个无需显示界面、而专门负责完成各项功能的 VI(以下简称为 "功能 VI")。界面 VI 的程序框图极其简单,只需维持程序持续运行(或许需要有一个空循环)以及能够把控件引用传递给功能 VI 即可;而功能 VI 的界面无需显示,项目仅需利用其程序框图。这样一来,项目中每个 VI 只负责一件任务:或者负责界面,或者负责实现程序功能。需要对程序功能进行修改,或者调整某一风格的界面时,只需改动其对应的那一个 VI 即可。程序中也不再有代码重复的 VI,可维护性大大加强。

下面举一个简单的示例:分别在两个风格完全不同的界面上,实现同一个简单的功能,即在界面上点一下按钮,就返回一个随机数值。这个示例的项目由三个 VI 组成(图 4.45):Main.vi 为该程序的功能 VI;Interface1,和 Interface2 分别是两个不同风格的界面 VI。程序的功能是在 Main.vi 中实现的,它采用了经典的事件结构。

\ 图 4.45 演示同一功能多个界面的项目

实现上述项目,可以使用两种不同的方法。第一种方法简单易行,适合不太复杂的界面;第二种方法更为通用,可以应对复杂需求。下面分别介绍一下两种实现方法。

第一种方式:

在第一种方法中,程序的启动 VI 是 interface1.vi 或 interface2.vi。图 4.46 是 interface1.vi 的前面板,由三个简单控件组成。

\ 图 4.46 interface1.vi 的前面板

interface1.vi 的程序框图并不进行任何具体工作,而仅是把界面上所有控件的引用传递给 Main.vi,并将 Main.vi 作为子 VI 调用起来。

\ 图 4.47 interface1.vi 的程序框图

Main.vi 并无需要显示的界面,它的前面板上的控件仅用于输入数据。

\ 图 4.48 Main.vi 的前面板

在 Main.vi 中,首先通过动态注册的方式,让事件结构接收 interface1.vi 中控件的事件;然后,当用户按下 interface1.vi 中的 "get value" 按钮时,就产生一个随机数赋值给 numeric 控件。

\ 图 4.49 Main.vi 的程序框图

interface2.vi 的前面板具有与 interface1.vi 完全相同的控件,仅是它们的风格与布局完全不同。

\ 图 4.50 interface2.vi 的前面板

interface2.vi 的程序框图则与 interface1.vi 完全相同。

\ 图 4.51 interface2.vi 的程序框图

由于程序的功能完全是在 main.vi 中实现的,所以程序功能需要变动时,只要更新这一个 VI 就可以了。

第二种方式:

在第二种方法中,程序的启动 VI 是 Main.vi,因此 interface1.vi 和 interface2.vi 的程序框图就更加简单了,它甚至不需要调用任何子 VI,只要有一个 while 循环,能够让 VI 持续运行就可以(图 4.52)。

\ 图 4.52 第二种实现方式中,interface1.vi 的程序框图

但是 Main.vi 中的程序变得相对复杂了(图 4.53),它要负责把界面 VI 打开运行,并显示出界面。在这个方法中,界面 VI 没有主动把控件引用传递个 Main.vi,因此 Main.vi 需要自己打开这些控件的引用。程序在打开控件引用时,使用了 "打开 VI 对象引用" 这一函数,这个函数可以根据控件的名称来找到它的引用。这个函数没有在 LabVIEW 默认的函数选板中,本书会在 6.4.1 节中介绍如何得到这个函数。

在得到控件的引用之后,程序其它部分与第一种方式都是相同的。这样一来,该程序就具备了和传统的主 VI 一样的能力了:如读写界面上控件的值,接收控件发出的事件等等。

\ 图 4.53 第二种实现方式中,Main.vi 的程序框图

在这个实现方案中 interface2.vi 的程序框图与 interface1.vi 完全相同。需要注意的是,这个方案是通过控件的标签来找到每一个控件的。这就要求每个界面 VI 上,相对应的控件必须都具有相同的标签。

通过这种设计,把程序的界面与功能完全分离到了不同的 VI 中。因此,可以做到只改变程序的界面而又完全不改动程序的功能代码部分,反之亦可。

两种实现界面程序方法的对比

在主程序中,避免不了对用户界面操作的处理,因此事件结构是必不可少的。此外,为了处理一些非界面上的任务,程序还必须有一个选择结构以应对其它工作。这样一来,就有两种程序结构可供选择了:1. 选择结构在外,事件结构在内,在 Labview 中称之为 "队列消息驱动" 结构;2. 事件结构在外,选择结构在内,本书称之为 "事件驱动" 结构(参见图 4.40)。下表是对这两种结构的原理、性能等多方面的比较,供读者参考。

名称队列消息驱动事件驱动
示意图
工作原理这是一种典型的状态机结构。使用队列记录消息,控制状态的跳转。 选择结构根据每次发来的不同消息,选择一个分支进行处理。这里有一个特殊的状态 “No Action”,在没有其它消息的时候,选择结构进入此分支。这个分支内嵌一个事件结构,用于接收用户的界面操作。使用事件(包括 LabVIEW 自带的和用户自定义的事件)来控制程序的运行,使其在不同分支之间跳转。事件结构有一个特殊分支 “用户事件”,用于处理所有的非界面事件。其内部嵌套一个选择结构,以处理不同的事件。实际上,也可以为程序的每一个处理分支都定义一个用户事件,程序就不需要选择结构了。但定义太多的用户事件,比较麻烦,程序显得凌乱。而定义一个统一的用户事件,在其事件处理分支中再根据事件传来的数据区分具体是哪一个事件,这种方法使得程序更加简明易读易懂。
发展历史早期的 LabVIEW 版本还没有事件结构,因此状态机结构是最强大的界面程序模式。图中的程序结构是在状态机的基础上一步步改进演化而来的。类似的程序结构相当多,上图所示的这个版本取自 NI 官方的 LabVIEW 社区,是 NI 系统工程师所开发的。这是笔者在编程过程中不断改进演化而来的。笔者开始编写界面程序时,LabVIEW 已经具有了事件结构。因此,没有按照状态机的惯例,而是采用了自己认为最简洁的方式来设计编写界面程序。
封装性这个架构比较复杂,架构中包含多个子 VI,用于管理消息队列(队列的创建、销毁、消息的入队、出队等)。网络上可以找到一些已经编写好的子 VI 和模板,但是用起来还是稍显复杂。LabVIEW 自带的事件处理函数比较简单,不必对其封装就可直接使用。但是为了让程序更简洁,笔者还是对其作了进一步的封装,把主要功能放在几个子 VI 中。LabVIEW 中某些对话框就是采用此架构编写的,因此,图 4.40 程序中用到的事件相关的子 VI 都是随 LabVIEW 一起发布的。无需下载就可以直接使用。
代码可读性、可维护性、扩展性连线和子 VI 较多一些,程序复杂度高,使得这几项指标较差。此外,界面程序最主要的工作是应对界面事件的响应,其它任务居次要地位。而这个架构中把主要对象放在次要对象中的某一分支内,主次颠倒,看起来比较别扭。连线和子 VI 较少,程序复杂度低,这几项指标更好。
调整未被处理的任务由于程序中用于控制程序流程的消息是由用户自己管理的,所以比较灵活。用户可以在任何时候对还没有处理到的消息进行调整,比如删除某些消息、改变其顺序等。但实际上,这种应用比较罕见。对事件的管理是在 LabVIEW 内部进行的。用户不能对其做调整。
其它 VI 对程序流程的控制其它 VI 也可插入新的消息到队列中来,从而控制主 VI 的运行。队列有个优点,就是可以通过队列的名字来得到一个队列,而不需要连接队列的数据线。这个优势使得有些程序编写稍许简化。其它 VI 也可抛出一个事件,来控制主 VI 的运行。与队列中的消息相比,事件有个额外的优势:事件被抛出后,任何 VI 都可以接收到。这样,其它 VI 不但可以控制主 VI 的运行,也可以监视主 VI 发出了哪些事件。与队列相比,事件的缺陷在于它不能通过名字来获取,一定要连接数据线(或使用全局变量)。
使用事件结构定时这种模式中的事件结构的延时设置具有特殊用途:必须设定一个 100~300ms 的超时事件,超时事件发生后,程序继续向下执行。没有这个超时事件,程序可能会被阻塞在事件结构里,而失去了对其它状态跳转的响应。所以,不能再把超时事件用来作定时用。任何用户自定义事件都会触发事件结构,因此它不存在阻塞的问题。程序中如有定时需求(比如每 1 秒采集一个数据),可以把超时事件当作定时器用。
状态跳转时的滞后程序每次运行至事件结构时,都需要等待 100~300ms,等超时事件产生后才能继续运行下去,如果这时有新的消息加入到队列中,程序不会立刻响应。没有响应滞后的问题。
适用场合适用各种场合,尤其是需要灵活改变消息的顺序时。各种场合皆可。